package server import ( "context" "fmt" "io/fs" "net/http" "strconv" "strings" "codeberg.org/maxwelljensen/weimar/internal/api" "codeberg.org/maxwelljensen/weimar/internal/auth" "codeberg.org/maxwelljensen/weimar/internal/config" "codeberg.org/maxwelljensen/weimar/internal/db" "codeberg.org/maxwelljensen/weimar/internal/storage" ) func New(cfg *config.Config, webFS fs.FS, database *db.DB) (*Server, error) { sm := auth.NewSessionManager(database) st, err := storage.New(cfg.Storage.Path) if err != nil { return nil, err } h := api.New(database, sm, st, cfg) // Load the default favicon from the embedded web build. api.InitDefaultFavicon(webFS) mux := http.NewServeMux() // Health check (public). mux.HandleFunc("GET /api/health", handleHealth) // Public config. mux.HandleFunc("GET /api/config", h.HandleConfig) // Favicon and logo (public). mux.HandleFunc("GET /favicon", h.HandleFavicon) mux.HandleFunc("GET /logo", h.HandleLogo) spaH := spaHandler(webFS) s := &Server{ cfg: cfg, mux: mux, auth: sm, db: database, webFS: webFS, spaH: spaH, } // Auth (public). mux.HandleFunc("POST /api/auth/login", h.HandleLogin) mux.HandleFunc("POST /api/auth/register", h.HandleRegister) // Auth (authenticated). mux.HandleFunc("POST /api/auth/logout", s.requireAuth(h.HandleLogout)) // Images (conditionally public). mux.HandleFunc("POST /api/upload", s.requireAuth(h.HandleUpload)) mux.HandleFunc("GET /api/images", s.requireAuthOrPublic(h.HandleListImages)) mux.HandleFunc("GET /api/images/{id}", s.requireAuthOrPublic(h.HandleGetImage)) mux.HandleFunc("GET /api/images/{id}/download", s.requireAuthOrPublic(h.HandleDownload)) mux.HandleFunc("GET /api/images/{id}/thumbnail", s.requireAuthOrPublic(h.HandleThumbnail)) mux.HandleFunc("POST /api/images/{id}/tags", s.requireAuth(h.HandleAddTags)) mux.HandleFunc("DELETE /api/images/{id}/tags", s.requireAuth(h.HandleRemoveTags)) // Gems (authenticated). mux.HandleFunc("POST /api/images/{id}/gem", s.requireAuth(h.HandleToggleGem)) // Tags (public for browse suggestions). mux.HandleFunc("GET /api/tags/suggest", s.requireAuthOrPublic(h.HandleTagSuggest)) // Users (public for browse suggestions). mux.HandleFunc("GET /api/users/suggest", s.requireAuthOrPublic(h.HandleUserSuggest)) // Users (authenticated). mux.HandleFunc("GET /api/users/me", s.requireAuth(h.HandleGetMe)) mux.HandleFunc("POST /api/users/me/avatar", s.requireAuth(h.HandleUploadAvatar)) mux.HandleFunc("GET /api/users/{id}/avatar", h.HandleServeAvatar) // Open Graph preview for social crawlers (image/video pages). mux.HandleFunc("GET /image/{id}", s.handleOGPreview) // SPA — serve index.html for all other routes (client-side routing). mux.Handle("/", spaH) return s, nil } type Server struct { cfg *config.Config mux *http.ServeMux auth *auth.SessionManager db *db.DB webFS fs.FS spaH http.HandlerFunc } func (s *Server) Start(ctx context.Context) error { addr := s.cfg.Server.Host + ":" + itoa(s.cfg.Server.Port) // Wrap the mux with a handler that sets security headers. handler := securityHeaders(s.mux) srv := &http.Server{Addr: addr, Handler: handler} go func() { <-ctx.Done() srv.Shutdown(context.Background()) }() if err := srv.ListenAndServe(); err != http.ErrServerClosed { return err } return nil } // ReloadConfig atomically swaps the server config. Handlers and middleware // read from the pointer at request time and will pick up the new values. func (s *Server) ReloadConfig(cfg config.Config) { *s.cfg = cfg } func itoa(n int) string { if n == 0 { return "0" } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) } // htmlEsc escapes HTML special characters for safe inclusion in HTML output. func htmlEsc(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "\"", """) s = strings.ReplaceAll(s, "'", "'") return s } // formatFileSize returns a human-readable file size string. func formatFileSize(bytes int64) string { switch { case bytes >= 1_000_000_000: return fmt.Sprintf("%.1f GB", float64(bytes)/1_000_000_000) case bytes >= 1_000_000: return fmt.Sprintf("%.1f MB", float64(bytes)/1_000_000) case bytes >= 1_000: return fmt.Sprintf("%.1f KB", float64(bytes)/1_000) default: return fmt.Sprintf("%d B", bytes) } } // spaHandler serves static files for SPA client-side routing. // For routes that don't match an existing file (e.g. /browse, /image/123), // it falls back to serving index.html so the SPA router can handle them. func spaHandler(webFS fs.FS) http.HandlerFunc { fsHandler := http.FileServer(http.FS(webFS)) return func(w http.ResponseWriter, r *http.Request) { // API routes should not be served by the SPA handler. if strings.HasPrefix(r.URL.Path, "/api/") { http.NotFound(w, r) return } // Check if the requested file exists in the embedded FS. path := strings.TrimPrefix(r.URL.Path, "/") if _, err := fs.Stat(webFS, path); err != nil { // File doesn't exist — serve index.html for SPA routing. r.URL.Path = "/" fsHandler.ServeHTTP(w, r) return } // File exists — serve it directly. fsHandler.ServeHTTP(w, r) } } // handleOGPreview serves a static HTML page with Open Graph meta tags at // /image/{id}. All requests get the OG page so crawlers always see the // preview tags. Real browsers are redirected to the SPA via a script tag // (crawlers don't execute JavaScript). func (s *Server) handleOGPreview(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { s.spaH.ServeHTTP(w, r) return } img, err := s.db.GetImageByID(r.Context(), id) if err != nil { s.spaH.ServeHTTP(w, r) return } // Determine scheme — check X-Forwarded-Proto for reverse proxy setups. scheme := "http" if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" } base := scheme + "://" + r.Host thumbURL := base + "/api/images/" + idStr + "/thumbnail" downloadURL := base + "/api/images/" + idStr + "/download" pageURL := base + "/image/" + idStr // Look up uploader info. uploaderUsername := "" user, err := s.db.GetUserByID(r.Context(), img.UploadedBy) if err == nil { uploaderUsername = user.Username } title := img.Filename description := "Uploaded by " + uploaderUsername if img.Width > 0 && img.Height > 0 { description += fmt.Sprintf(" · %d×%d", img.Width, img.Height) } if img.FileSize > 0 { description += fmt.Sprintf(" · %s", formatFileSize(img.FileSize)) } isVideo := strings.HasPrefix(img.MimeType, "video/") // For images (including GIFs), use the full-resolution download URL as the // og:image so platforms display a high-quality preview. For videos, use the // thumbnail as a static poster frame since the download URL is a video file. ogImageURL := thumbURL if !isVideo { ogImageURL = downloadURL } w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `