all repos — weimar @ fe3bf26c76d0c554ef9e8f0c9f52038b37ea886e

Unnamed repository; edit this file 'description' to name the repository.

internal/server/server.go (view raw)

  1package server
  2
  3import (
  4	"context"
  5	"io/fs"
  6	"net/http"
  7	"strings"
  8
  9	"github.com/mjensen/weimar/internal/api"
 10	"github.com/mjensen/weimar/internal/auth"
 11	"github.com/mjensen/weimar/internal/config"
 12	"github.com/mjensen/weimar/internal/db"
 13	"github.com/mjensen/weimar/internal/storage"
 14)
 15
 16func New(cfg *config.Config, webFS fs.FS, database *db.DB) (*Server, error) {
 17	sm := auth.NewSessionManager(database)
 18	st, err := storage.New(cfg.Storage.Path)
 19	if err != nil {
 20		return nil, err
 21	}
 22	h := api.New(database, sm, st, cfg)
 23
 24	// Load the default favicon from the embedded web build.
 25	api.InitDefaultFavicon(webFS)
 26
 27	mux := http.NewServeMux()
 28
 29	// Health check (public).
 30	mux.HandleFunc("GET /api/health", handleHealth)
 31
 32	// Public config.
 33	mux.HandleFunc("GET /api/config", h.HandleConfig)
 34
 35	// Favicon and logo (public).
 36	mux.HandleFunc("GET /favicon", h.HandleFavicon)
 37	mux.HandleFunc("GET /logo", h.HandleLogo)
 38
 39	s := &Server{
 40		cfg:  cfg,
 41		mux:  mux,
 42		auth: sm,
 43	}
 44
 45	// Auth (public).
 46	mux.HandleFunc("POST /api/auth/login", h.HandleLogin)
 47	mux.HandleFunc("POST /api/auth/register", h.HandleRegister)
 48
 49	// Auth (authenticated).
 50	mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout))
 51
 52	// Images (conditionally public).
 53	mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload))
 54	mux.HandleFunc("GET /api/images", s.requireAuthOrPublic(h.HandleListImages))
 55	mux.HandleFunc("GET /api/images/{id}", s.requireAuthOrPublic(h.HandleGetImage))
 56	mux.HandleFunc("GET /api/images/{id}/download", s.requireAuthOrPublic(h.HandleDownload))
 57	mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail))
 58	mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags))
 59	mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags))
 60
 61	// Tags (public for browse suggestions).
 62	mux.HandleFunc("GET /api/tags/suggest", s.requireAuthOrPublic(h.HandleTagSuggest))
 63
 64	// Users (public for browse suggestions).
 65	mux.HandleFunc("GET /api/users/suggest", s.requireAuthOrPublic(h.HandleUserSuggest))
 66
 67	// Users (authenticated).
 68	mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
 69	mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar))
 70	mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar)
 71
 72	// SPA — serve index.html for all non-API routes (client-side routing).
 73	mux.Handle("/", s.spaHandler(webFS))
 74
 75	return s, nil
 76}
 77
 78type Server struct {
 79	cfg  *config.Config
 80	mux  *http.ServeMux
 81	auth *auth.SessionManager
 82}
 83
 84func (s *Server) Start(ctx context.Context) error {
 85	addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port)
 86
 87	// Wrap the mux with a handler that sets security headers.
 88	handler := securityHeaders(s.mux)
 89
 90	srv := &http.Server{Addr: addr, Handler: handler}
 91
 92	go func() {
 93		<-ctx.Done()
 94		srv.Shutdown(context.Background())
 95	}()
 96
 97	if err := srv.ListenAndServe(); err != http.ErrServerClosed {
 98		return err
 99	}
100	return nil
101}
102
103// ReloadConfig atomically swaps the server config. Handlers and middleware
104// read from the pointer at request time and will pick up the new values.
105func (s *Server) ReloadConfig(cfg config.Config) {
106	*s.cfg = cfg
107}
108
109func itoa(n int) string {
110	if n == 0 {
111		return "0"
112	}
113	var buf [20]byte
114	i := len(buf)
115	for n > 0 {
116		i--
117		buf[i] = byte('0' + n%10)
118		n /= 10
119	}
120	return string(buf[i:])
121}
122
123// spaHandler serves static files for SPA client-side routing.
124// For routes that don't match an existing file (e.g. /browse, /image/123),
125// it falls back to serving index.html so the SPA router can handle them.
126func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc {
127	fsHandler := http.FileServer(http.FS(webFS))
128	return func(w http.ResponseWriter, r *http.Request) {
129		// API routes should not be served by the SPA handler.
130		if strings.HasPrefix(r.URL.Path, "/api/") {
131			http.NotFound(w, r)
132			return
133		}
134
135		// Check if the requested file exists in the embedded FS.
136		path := strings.TrimPrefix(r.URL.Path, "/")
137		if _, err := fs.Stat(webFS, path); err != nil {
138			// File doesn't exist — serve index.html for SPA routing.
139			r.URL.Path = "/"
140			fsHandler.ServeHTTP(w, r)
141			return
142		}
143
144		// File exists — serve it directly.
145		fsHandler.ServeHTTP(w, r)
146	}
147}
148
149func handleHealth(w http.ResponseWriter, r *http.Request) {
150	w.Header().Set("Content-Type", "application/json")
151	w.Write([]byte(`{"status":"ok"}`))
152}
153
154// securityHeaders wraps a handler with security-related HTTP headers.
155func securityHeaders(next http.Handler) http.Handler {
156	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157		w.Header().Set("X-Content-Type-Options", "nosniff")
158		w.Header().Set("X-Frame-Options", "DENY")
159		w.Header().Set("Referrer-Policy", "same-origin")
160		next.ServeHTTP(w, r)
161	})
162}