internal/server/server.go (view raw)
1package server
2
3import (
4 "context"
5 "fmt"
6 "io/fs"
7 "net/http"
8 "strconv"
9 "strings"
10
11 "codeberg.org/maxwelljensen/weimar/internal/api"
12 "codeberg.org/maxwelljensen/weimar/internal/auth"
13 "codeberg.org/maxwelljensen/weimar/internal/config"
14 "codeberg.org/maxwelljensen/weimar/internal/db"
15 "codeberg.org/maxwelljensen/weimar/internal/storage"
16)
17
18func New(cfg *config.Config, webFS fs.FS, database *db.DB) (*Server, error) {
19 sm := auth.NewSessionManager(database)
20 st, err := storage.New(cfg.Storage.Path)
21 if err != nil {
22 return nil, err
23 }
24 h := api.New(database, sm, st, cfg)
25
26 // Load the default favicon from the embedded web build.
27 api.InitDefaultFavicon(webFS)
28
29 mux := http.NewServeMux()
30
31 // Health check (public).
32 mux.HandleFunc("GET /api/health", handleHealth)
33
34 // Public config.
35 mux.HandleFunc("GET /api/config", h.HandleConfig)
36
37 // Favicon and logo (public).
38 mux.HandleFunc("GET /favicon", h.HandleFavicon)
39 mux.HandleFunc("GET /logo", h.HandleLogo)
40
41 spaH := spaHandler(webFS)
42
43 s := &Server{
44 cfg: cfg,
45 mux: mux,
46 auth: sm,
47 db: database,
48 webFS: webFS,
49 spaH: spaH,
50 }
51
52 // Auth (public).
53 mux.HandleFunc("POST /api/auth/login", h.HandleLogin)
54 mux.HandleFunc("POST /api/auth/register", h.HandleRegister)
55
56 // Auth (authenticated).
57 mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout))
58
59 // Images (conditionally public).
60 mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload))
61 mux.HandleFunc("GET /api/images", s.requireAuthOrPublic(h.HandleListImages))
62 mux.HandleFunc("GET /api/images/{id}", s.requireAuthOrPublic(h.HandleGetImage))
63 mux.HandleFunc("GET /api/images/{id}/download", s.requireAuthOrPublic(h.HandleDownload))
64 mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail))
65 mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags))
66 mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags))
67
68 // Gems (authenticated).
69 mux.HandleFunc("POST /api/images/{id}/gem", s.requireAuth(h.HandleToggleGem))
70
71 // Tags (public for browse suggestions).
72 mux.HandleFunc("GET /api/tags/suggest", s.requireAuthOrPublic(h.HandleTagSuggest))
73
74 // Users (public for browse suggestions).
75 mux.HandleFunc("GET /api/users/suggest", s.requireAuthOrPublic(h.HandleUserSuggest))
76
77 // Users (authenticated).
78 mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe))
79 mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar))
80 mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar)
81
82 // Open Graph preview for social crawlers (image/video pages).
83 mux.HandleFunc("GET /image/{id}", s.handleOGPreview)
84
85 // SPA — serve index.html for all other routes (client-side routing).
86 mux.Handle("/", spaH)
87
88 return s, nil
89}
90
91type Server struct {
92 cfg *config.Config
93 mux *http.ServeMux
94 auth *auth.SessionManager
95 db *db.DB
96 webFS fs.FS
97 spaH http.HandlerFunc
98}
99
100func (s *Server) Start(ctx context.Context) error {
101 addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port)
102
103 // Wrap the mux with a handler that sets security headers.
104 handler := securityHeaders(s.mux)
105
106 srv := &http.Server{Addr: addr, Handler: handler}
107
108 go func() {
109 <-ctx.Done()
110 srv.Shutdown(context.Background())
111 }()
112
113 if err := srv.ListenAndServe(); err != http.ErrServerClosed {
114 return err
115 }
116 return nil
117}
118
119// ReloadConfig atomically swaps the server config. Handlers and middleware
120// read from the pointer at request time and will pick up the new values.
121func (s *Server) ReloadConfig(cfg config.Config) {
122 *s.cfg = cfg
123}
124
125func itoa(n int) string {
126 if n == 0 {
127 return "0"
128 }
129 var buf [20]byte
130 i := len(buf)
131 for n > 0 {
132 i--
133 buf[i] = byte('0' + n%10)
134 n /= 10
135 }
136 return string(buf[i:])
137}
138
139// htmlEsc escapes HTML special characters for safe inclusion in HTML output.
140func htmlEsc(s string) string {
141 s = strings.ReplaceAll(s, "&", "&")
142 s = strings.ReplaceAll(s, "<", "<")
143 s = strings.ReplaceAll(s, ">", ">")
144 s = strings.ReplaceAll(s, "\"", """)
145 s = strings.ReplaceAll(s, "'", "'")
146 return s
147}
148
149// formatFileSize returns a human-readable file size string.
150func formatFileSize(bytes int64) string {
151 switch {
152 case bytes >= 1_000_000_000:
153 return fmt.Sprintf("%.1f GB", float64(bytes)/1_000_000_000)
154 case bytes >= 1_000_000:
155 return fmt.Sprintf("%.1f MB", float64(bytes)/1_000_000)
156 case bytes >= 1_000:
157 return fmt.Sprintf("%.1f KB", float64(bytes)/1_000)
158 default:
159 return fmt.Sprintf("%d B", bytes)
160 }
161}
162
163// spaHandler serves static files for SPA client-side routing.
164// For routes that don't match an existing file (e.g. /browse, /image/123),
165// it falls back to serving index.html so the SPA router can handle them.
166func spaHandler(webFS fs.FS) http.HandlerFunc {
167 fsHandler := http.FileServer(http.FS(webFS))
168 return func(w http.ResponseWriter, r *http.Request) {
169 // API routes should not be served by the SPA handler.
170 if strings.HasPrefix(r.URL.Path, "/api/") {
171 http.NotFound(w, r)
172 return
173 }
174
175 // Check if the requested file exists in the embedded FS.
176 path := strings.TrimPrefix(r.URL.Path, "/")
177 if _, err := fs.Stat(webFS, path); err != nil {
178 // File doesn't exist — serve index.html for SPA routing.
179 r.URL.Path = "/"
180 fsHandler.ServeHTTP(w, r)
181 return
182 }
183
184 // File exists — serve it directly.
185 fsHandler.ServeHTTP(w, r)
186 }
187}
188
189// handleOGPreview serves a static HTML page with Open Graph meta tags at
190// /image/{id}. All requests get the OG page so crawlers always see the
191// preview tags. Real browsers are redirected to the SPA via a script tag
192// (crawlers don't execute JavaScript).
193func (s *Server) handleOGPreview(w http.ResponseWriter, r *http.Request) {
194 idStr := r.PathValue("id")
195 id, err := strconv.ParseInt(idStr, 10, 64)
196 if err != nil {
197 s.spaH.ServeHTTP(w, r)
198 return
199 }
200
201 img, err := s.db.GetImageByID(r.Context(), id)
202 if err != nil {
203 s.spaH.ServeHTTP(w, r)
204 return
205 }
206
207 // Determine scheme — check X-Forwarded-Proto for reverse proxy setups.
208 scheme := "http"
209 if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
210 scheme = "https"
211 }
212 base := scheme + "://" + r.Host
213 thumbURL := base + "/api/images/" + idStr + "/thumbnail"
214 downloadURL := base + "/api/images/" + idStr + "/download"
215 pageURL := base + "/image/" + idStr
216
217 // Look up uploader info.
218 uploaderUsername := ""
219 user, err := s.db.GetUserByID(r.Context(), img.UploadedBy)
220 if err == nil {
221 uploaderUsername = user.Username
222 }
223
224 title := img.Filename
225 description := "Uploaded by " + uploaderUsername
226 if img.Width > 0 && img.Height > 0 {
227 description += fmt.Sprintf(" · %d×%d", img.Width, img.Height)
228 }
229 if img.FileSize > 0 {
230 description += fmt.Sprintf(" · %s", formatFileSize(img.FileSize))
231 }
232
233 isVideo := strings.HasPrefix(img.MimeType, "video/")
234
235 // For images (including GIFs), use the full-resolution download URL as the
236 // og:image so platforms display a high-quality preview. For videos, use the
237 // thumbnail as a static poster frame since the download URL is a video file.
238 ogImageURL := thumbURL
239 if !isVideo {
240 ogImageURL = downloadURL
241 }
242
243 w.Header().Set("Content-Type", "text/html; charset=utf-8")
244
245 fmt.Fprintf(w, `<!DOCTYPE html>
246<html>
247<head>
248<meta charset="utf-8">
249<title>%s</title>
250<meta name="description" content="%s">
251<meta property="og:locale" content="en">
252<meta property="og:title" content="%s">
253<meta property="og:description" content="%s">
254<meta property="og:url" content="%s">
255<meta property="og:image" content="%s">
256<meta property="og:image:secure_url" content="%s">
257<meta property="og:image:width" content="%d">
258<meta property="og:image:height" content="%d">
259<meta name="twitter:card" content="summary_large_image">
260<meta name="twitter:title" content="%s">
261<meta name="twitter:description" content="%s">
262<meta name="twitter:image" content="%s">
263<meta name="theme-color" content="#c62828">
264`,
265 htmlEsc(title), htmlEsc(description),
266 htmlEsc(title), htmlEsc(description),
267 htmlEsc(pageURL),
268 htmlEsc(ogImageURL), htmlEsc(ogImageURL),
269 img.Width, img.Height,
270 htmlEsc(title), htmlEsc(description), htmlEsc(ogImageURL))
271
272 if isVideo {
273 fmt.Fprintf(w, `<meta property="og:type" content="video">
274<meta property="og:video" content="%s">
275<meta property="og:video:url" content="%s">
276<meta property="og:video:secure_url" content="%s">
277<meta property="og:video:type" content="%s">
278<meta property="og:video:width" content="%d">
279<meta property="og:video:height" content="%d">
280<meta name="twitter:card" content="player">
281<meta name="twitter:player" content="%s">
282<meta name="twitter:player:width" content="%d">
283<meta name="twitter:player:height" content="%d">
284<meta name="twitter:player:stream" content="%s">
285<meta name="twitter:player:stream:content_type" content="%s">
286`,
287 htmlEsc(downloadURL),
288 htmlEsc(downloadURL), htmlEsc(downloadURL),
289 htmlEsc(img.MimeType),
290 img.Width, img.Height,
291 htmlEsc(pageURL),
292 img.Width, img.Height,
293 htmlEsc(downloadURL),
294 htmlEsc(img.MimeType))
295 } else {
296 fmt.Fprintf(w, `<meta property="og:type" content="website">
297`)
298 }
299
300 fmt.Fprintf(w, `<meta property="og:site_name" content="weimar">
301<script>location.href="/browse?image=%s"</script>
302</head>
303<body>
304<img src="%s" alt="%s" style="max-width:100%%">
305</body>
306</html>`, idStr, htmlEsc(ogImageURL), htmlEsc(title))
307}
308
309func handleHealth(w http.ResponseWriter, r *http.Request) {
310 w.Header().Set("Content-Type", "application/json")
311 w.Write([]byte(`{"status":"ok"}`))
312}
313
314// securityHeaders wraps a handler with security-related HTTP headers.
315func securityHeaders(next http.Handler) http.Handler {
316 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
317 w.Header().Set("X-Content-Type-Options", "nosniff")
318 w.Header().Set("X-Frame-Options", "DENY")
319 w.Header().Set("Referrer-Policy", "same-origin")
320 next.ServeHTTP(w, r)
321 })
322}