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}