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