all repos — weimar @ 30c984bbd9a52787565d44cc121088cf1ee25029

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