package server import ( "context" "io/fs" "net/http" "strings" "github.com/mjensen/weimar/internal/api" "github.com/mjensen/weimar/internal/auth" "github.com/mjensen/weimar/internal/config" "github.com/mjensen/weimar/internal/db" "github.com/mjensen/weimar/internal/storage" ) func New(cfg config.Config, webFS fs.FS, database *db.DB) (*Server, error) { sm := auth.NewSessionManager(database) st, err := storage.New(cfg.Storage.Path) if err != nil { return nil, err } h := api.New(database, sm, st, cfg) mux := http.NewServeMux() // Health check (public). mux.HandleFunc("GET /api/health", handleHealth) s := &Server{ cfg: cfg, mux: mux, auth: sm, } // Auth (public). mux.HandleFunc("POST /api/auth/login", h.HandleLogin) mux.HandleFunc("POST /api/auth/register", h.HandleRegister) // Auth (authenticated). mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout)) // Images (conditionally public). mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload)) mux.HandleFunc("GET /api/images", s.requireAuthOrPublic(h.HandleListImages)) mux.HandleFunc("GET /api/images/{id}", s.requireAuthOrPublic(h.HandleGetImage)) mux.HandleFunc("GET /api/images/{id}/download", s.requireAuthOrPublic(h.HandleDownload)) mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail)) mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags)) mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags)) // Tags (authenticated). mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest)) // Users (authenticated). mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe)) mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar)) mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar) // SPA — serve index.html for all non-API routes (client-side routing). mux.Handle("/", s.spaHandler(webFS)) return s, nil } type Server struct { cfg config.Config mux *http.ServeMux auth *auth.SessionManager } func (s *Server) Start(ctx context.Context) error { addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port) // Wrap the mux with a handler that sets security headers. handler := securityHeaders(s.mux) srv := &http.Server{Addr: addr, Handler: handler} go func() { <-ctx.Done() srv.Shutdown(context.Background()) }() return srv.ListenAndServe() } func itoa(n int) string { if n == 0 { return "0" } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) } // spaHandler serves static files for SPA client-side routing. // For routes that don't match an existing file (e.g. /browse, /image/123), // it falls back to serving index.html so the SPA router can handle them. func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc { fsHandler := http.FileServer(http.FS(webFS)) return func(w http.ResponseWriter, r *http.Request) { // API routes should not be served by the SPA handler. if strings.HasPrefix(r.URL.Path, "/api/") { http.NotFound(w, r) return } // Check if the requested file exists in the embedded FS. path := strings.TrimPrefix(r.URL.Path, "/") if _, err := fs.Stat(webFS, path); err != nil { // File doesn't exist — serve index.html for SPA routing. r.URL.Path = "/" fsHandler.ServeHTTP(w, r) return } // File exists — serve it directly. fsHandler.ServeHTTP(w, r) } } func handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status":"ok"}`)) } // securityHeaders wraps a handler with security-related HTTP headers. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "same-origin") next.ServeHTTP(w, r) }) }