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) // Load the default favicon from the embedded web build. api.InitDefaultFavicon(webFS) mux := http.NewServeMux() // Health check (public). mux.HandleFunc("GET /api/health", handleHealth) // Public config. mux.HandleFunc("GET /api/config", h.HandleConfig) // Favicon and logo (public). mux.HandleFunc("GET /favicon", h.HandleFavicon) mux.HandleFunc("GET /logo", h.HandleLogo) 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 (public for browse suggestions). mux.HandleFunc("GET /api/tags/suggest", s.requireAuthOrPublic(h.HandleTagSuggest)) // Users (public for browse suggestions). mux.HandleFunc("GET /api/users/suggest", s.requireAuthOrPublic(h.HandleUserSuggest)) // 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()) }() if err := srv.ListenAndServe(); err != http.ErrServerClosed { return err } return nil } // ReloadConfig atomically swaps the server config. Handlers and middleware // read from the pointer at request time and will pick up the new values. func (s *Server) ReloadConfig(cfg config.Config) { *s.cfg = cfg } 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) }) }