Add full-stack markdown editor with Go backend and React frontend. Backend: - Cobra CLI with --data-dir, --port, --host flags - REST API for markdown file CRUD operations - File storage with flat directory structure - logrus logging for all operations - Static file serving for frontend - Comprehensive tests for CRUD and static assets Frontend: - React + TypeScript + Vite + Tailwind CSS - Live markdown preview with marked (GFM) - File management: list, create, open, save, delete - Theme system: Dark/Light/System with persistence - Responsive design (320px to 1920px) - Component tests for Editor, Preview, Sidebar Build: - Makefile for build, test, and run automation - Single command testing (make test) Closes SPEC.md requirements
173 lines
4.1 KiB
Go
173 lines
4.1 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"markdown-editor-backend/handlers"
|
|
"markdown-editor-backend/storage"
|
|
)
|
|
|
|
// Server represents the HTTP server
|
|
type Server struct {
|
|
dataDir string
|
|
host string
|
|
port int
|
|
log *logrus.Logger
|
|
storage *storage.Storage
|
|
handler *handlers.Handler
|
|
staticPath string
|
|
listener net.Listener
|
|
}
|
|
|
|
// New creates a new Server instance
|
|
func New(dataDir, host string, port int, log *logrus.Logger) *Server {
|
|
store := storage.New(dataDir, log)
|
|
handler := handlers.New(store, log)
|
|
return &Server{
|
|
dataDir: dataDir,
|
|
host: host,
|
|
port: port,
|
|
log: log,
|
|
storage: store,
|
|
handler: handler,
|
|
staticPath: "./static",
|
|
}
|
|
}
|
|
|
|
// SetStaticPath sets the path for static files (used for embedding frontend)
|
|
func (s *Server) SetStaticPath(path string) {
|
|
s.staticPath = path
|
|
}
|
|
|
|
// Start initializes storage and starts the HTTP server
|
|
func (s *Server) Start() error {
|
|
if err := s.storage.Init(); err != nil {
|
|
return err
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// API routes
|
|
mux.HandleFunc("/api/files", s.handler.FilesHandler)
|
|
mux.HandleFunc("/api/files/", s.handler.FileHandler)
|
|
|
|
// Health check
|
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
})
|
|
|
|
// Static file serving - serve frontend build
|
|
fs := http.FileServer(http.Dir(s.staticPath))
|
|
mux.Handle("/", fs)
|
|
|
|
// CORS middleware
|
|
handler := s.corsMiddleware(mux)
|
|
|
|
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
|
s.log.WithField("address", addr).Info("Server starting")
|
|
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.listener = listener
|
|
|
|
return http.Serve(listener, handler)
|
|
}
|
|
|
|
// StartAsync starts the server asynchronously and returns the actual address
|
|
func (s *Server) StartAsync() (string, error) {
|
|
if err := s.storage.Init(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// API routes
|
|
mux.HandleFunc("/api/files", s.handler.FilesHandler)
|
|
mux.HandleFunc("/api/files/", s.handler.FileHandler)
|
|
|
|
// Health check
|
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
})
|
|
|
|
// Static file serving - serve frontend build
|
|
fs := http.FileServer(http.Dir(s.staticPath))
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" {
|
|
return
|
|
}
|
|
fs.ServeHTTP(w, r)
|
|
})
|
|
|
|
// CORS middleware
|
|
handler := s.corsMiddleware(mux)
|
|
|
|
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
|
if s.port == 0 {
|
|
addr = fmt.Sprintf("%s:0", s.host)
|
|
}
|
|
|
|
listener, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.listener = listener
|
|
|
|
actualAddr := listener.Addr().String()
|
|
s.log.WithField("address", actualAddr).Info("Server starting")
|
|
|
|
go http.Serve(listener, handler)
|
|
|
|
return actualAddr, nil
|
|
}
|
|
|
|
// Stop stops the server
|
|
func (s *Server) Stop() error {
|
|
if s.listener != nil {
|
|
return s.listener.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// corsMiddleware adds CORS headers for frontend development
|
|
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// GetStorage returns the storage instance (for testing)
|
|
func (s *Server) GetStorage() *storage.Storage {
|
|
return s.storage
|
|
}
|
|
|
|
// GetStaticPath returns the static files path (for testing)
|
|
func (s *Server) GetStaticPath() string {
|
|
return s.staticPath
|
|
}
|
|
|
|
// GetAddr returns the server address (for testing)
|
|
func (s *Server) GetAddr() string {
|
|
if s.listener != nil {
|
|
return s.listener.Addr().String()
|
|
}
|
|
return fmt.Sprintf("%s:%d", s.host, s.port)
|
|
}
|