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 (authenticated).
62 mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest))
63
64 // Users (authenticated).
65 mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
66 mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar))
67 mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar)
68
69 // SPA — serve index.html for all non-API routes (client-side routing).
70 mux.Handle("/", s.spaHandler(webFS))
71
72 return s, nil
73}
74
75type Server struct {
76 cfg *config.Config
77 mux *http.ServeMux
78 auth *auth.SessionManager
79}
80
81func (s *Server) Start(ctx context.Context) error {
82 addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port)
83
84 // Wrap the mux with a handler that sets security headers.
85 handler := securityHeaders(s.mux)
86
87 srv := &http.Server{Addr: addr, Handler: handler}
88
89 go func() {
90 <-ctx.Done()
91 srv.Shutdown(context.Background())
92 }()
93
94 if err := srv.ListenAndServe(); err != http.ErrServerClosed {
95 return err
96 }
97 return nil
98}
99
100// ReloadConfig atomically swaps the server config. Handlers and middleware
101// read from the pointer at request time and will pick up the new values.
102func (s *Server) ReloadConfig(cfg config.Config) {
103 *s.cfg = cfg
104}
105
106func itoa(n int) string {
107 if n == 0 {
108 return "0"
109 }
110 var buf [20]byte
111 i := len(buf)
112 for n > 0 {
113 i--
114 buf[i] = byte('0' + n%10)
115 n /= 10
116 }
117 return string(buf[i:])
118}
119
120// spaHandler serves static files for SPA client-side routing.
121// For routes that don't match an existing file (e.g. /browse, /image/123),
122// it falls back to serving index.html so the SPA router can handle them.
123func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc {
124 fsHandler := http.FileServer(http.FS(webFS))
125 return func(w http.ResponseWriter, r *http.Request) {
126 // API routes should not be served by the SPA handler.
127 if strings.HasPrefix(r.URL.Path, "/api/") {
128 http.NotFound(w, r)
129 return
130 }
131
132 // Check if the requested file exists in the embedded FS.
133 path := strings.TrimPrefix(r.URL.Path, "/")
134 if _, err := fs.Stat(webFS, path); err != nil {
135 // File doesn't exist — serve index.html for SPA routing.
136 r.URL.Path = "/"
137 fsHandler.ServeHTTP(w, r)
138 return
139 }
140
141 // File exists — serve it directly.
142 fsHandler.ServeHTTP(w, r)
143 }
144}
145
146func handleHealth(w http.ResponseWriter, r *http.Request) {
147 w.Header().Set("Content-Type", "application/json")
148 w.Write([]byte(`{"status":"ok"}`))
149}
150
151// securityHeaders wraps a handler with security-related HTTP headers.
152func securityHeaders(next http.Handler) http.Handler {
153 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154 w.Header().Set("X-Content-Type-Options", "nosniff")
155 w.Header().Set("X-Frame-Options", "DENY")
156 w.Header().Set("Referrer-Policy", "same-origin")
157 next.ServeHTTP(w, r)
158 })
159}