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 mux := http.NewServeMux()
25
26 // Health check (public).
27 mux.HandleFunc("GET /api/health", handleHealth)
28
29 s := &Server{
30 cfg: cfg,
31 mux: mux,
32 auth: sm,
33 }
34
35 // Auth (public).
36 mux.HandleFunc("POST /api/auth/login", h.HandleLogin)
37 mux.HandleFunc("POST /api/auth/register", h.HandleRegister)
38
39 // Auth (authenticated).
40 mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout))
41
42 // Images (conditionally public).
43 mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload))
44 mux.HandleFunc("GET /api/images", s.requireAuthOrPublic(h.HandleListImages))
45 mux.HandleFunc("GET /api/images/{id}", s.requireAuthOrPublic(h.HandleGetImage))
46 mux.HandleFunc("GET /api/images/{id}/download", s.requireAuthOrPublic(h.HandleDownload))
47 mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail))
48 mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags))
49 mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags))
50
51 // Tags (authenticated).
52 mux.HandleFunc("GET /api/tags/suggest", s.requireAuth(h.HandleTagSuggest))
53
54 // Users (authenticated).
55 mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
56 mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar))
57 mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar)
58
59 // SPA — serve index.html for all non-API routes (client-side routing).
60 mux.Handle("/", s.spaHandler(webFS))
61
62 return s, nil
63}
64
65type Server struct {
66 cfg config.Config
67 mux *http.ServeMux
68 auth *auth.SessionManager
69}
70
71func (s *Server) Start(ctx context.Context) error {
72 addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port)
73
74 // Wrap the mux with a handler that sets security headers.
75 handler := securityHeaders(s.mux)
76
77 srv := &http.Server{Addr: addr, Handler: handler}
78
79 go func() {
80 <-ctx.Done()
81 srv.Shutdown(context.Background())
82 }()
83
84 return srv.ListenAndServe()
85}
86
87func itoa(n int) string {
88 if n == 0 {
89 return "0"
90 }
91 var buf [20]byte
92 i := len(buf)
93 for n > 0 {
94 i--
95 buf[i] = byte('0' + n%10)
96 n /= 10
97 }
98 return string(buf[i:])
99}
100
101// spaHandler serves static files for SPA client-side routing.
102// For routes that don't match an existing file (e.g. /browse, /image/123),
103// it falls back to serving index.html so the SPA router can handle them.
104func (s *Server) spaHandler(webFS fs.FS) http.HandlerFunc {
105 fsHandler := http.FileServer(http.FS(webFS))
106 return func(w http.ResponseWriter, r *http.Request) {
107 // API routes should not be served by the SPA handler.
108 if strings.HasPrefix(r.URL.Path, "/api/") {
109 http.NotFound(w, r)
110 return
111 }
112
113 // Check if the requested file exists in the embedded FS.
114 path := strings.TrimPrefix(r.URL.Path, "/")
115 if _, err := fs.Stat(webFS, path); err != nil {
116 // File doesn't exist — serve index.html for SPA routing.
117 r.URL.Path = "/"
118 fsHandler.ServeHTTP(w, r)
119 return
120 }
121
122 // File exists — serve it directly.
123 fsHandler.ServeHTTP(w, r)
124 }
125}
126
127func handleHealth(w http.ResponseWriter, r *http.Request) {
128 w.Header().Set("Content-Type", "application/json")
129 w.Write([]byte(`{"status":"ok"}`))
130}
131
132// securityHeaders wraps a handler with security-related HTTP headers.
133func securityHeaders(next http.Handler) http.Handler {
134 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135 w.Header().Set("X-Content-Type-Options", "nosniff")
136 w.Header().Set("X-Frame-Options", "DENY")
137 w.Header().Set("Referrer-Policy", "same-origin")
138 next.ServeHTTP(w, r)
139 })
140}