all repos — weimar @ 0137493bbdbd370d56c8a26873843ff02b378485

Unnamed repository; edit this file 'description' to name the repository.

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, "&", "&amp;")
139	s = strings.ReplaceAll(s, "<", "&lt;")
140	s = strings.ReplaceAll(s, ">", "&gt;")
141	s = strings.ReplaceAll(s, "\"", "&quot;")
142	s = strings.ReplaceAll(s, "'", "&#39;")
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}