// Package server wires HTTP routes, templates and handlers together. package server import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "html/template" "io/fs" "log/slog" "net" "net/http" "net/url" "strconv" "strings" "time" "github.com/cdnutter/canon/internal/auth" "github.com/cdnutter/canon/internal/config" "github.com/cdnutter/canon/internal/imgcache" "github.com/cdnutter/canon/internal/providers" "github.com/cdnutter/canon/internal/store" "github.com/cdnutter/canon/web" ) const ( sessionCookie = "canon_session" sessionTTL = 30 * 24 * time.Hour ) type Server struct { cfg config.Config store *store.Store images *imgcache.Cache providers *providers.Registry log *slog.Logger pages map[string]*template.Template static fs.FS assetVer string loginLimit *auth.Limiter setupCode string } func New(cfg config.Config, st *store.Store, images *imgcache.Cache, log *slog.Logger, version string) (*Server, error) { s := &Server{ cfg: cfg, store: st, images: images, log: log, assetVer: version, loginLimit: auth.NewLimiter(10, 15*time.Minute), } s.providers = providers.NewRegistry(s.credentials) static, err := fs.Sub(web.FS, "static") if err != nil { return nil, err } s.static = static if err := s.parseTemplates(); err != nil { return nil, err } return s, nil } // SetSetupCode enables first-run account creation guarded by code. func (s *Server) SetSetupCode(code string) { s.setupCode = code } func (s *Server) credentials(ctx context.Context) providers.Credentials { set, err := s.store.Settings(ctx) if err != nil { s.log.Error("loading settings", "err", err) set = map[string]string{} } pick := func(env, key string) string { if env != "" { return env } return set[key] } return providers.Credentials{ TMDBKey: pick(s.cfg.TMDBKey, store.SettingTMDBKey), TVDBKey: pick(s.cfg.TVDBKey, store.SettingTVDBKey), TVDBPin: pick(s.cfg.TVDBPin, store.SettingTVDBPin), IGDBClientID: pick(s.cfg.IGDBClientID, store.SettingIGDBClientID), IGDBClientSecret: pick(s.cfg.IGDBClientSecret, store.SettingIGDBClientSecret), MusicProvider: set[store.SettingMusicProvider], TVProvider: set[store.SettingTVProvider], } } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // Public mux.HandleFunc("GET /{$}", s.handleHome) mux.HandleFunc("GET /l/{slug}", s.handleList) mux.HandleFunc("GET /l/{slug}/export", s.handleExport) mux.HandleFunc("GET /images/{name}", s.handleImage) mux.HandleFunc("GET /custom.css", s.handleCustomCSS) mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler())) mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/static/img/favicon.svg", http.StatusMovedPermanently) }) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) // Auth mux.HandleFunc("GET /login", s.handleLoginPage) mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /setup", s.handleSetupPage) mux.HandleFunc("POST /setup", s.handleSetup) // Admin pages mux.HandleFunc("GET /admin", s.requireUser(s.handleAdminLists)) mux.HandleFunc("GET /admin/lists/{id}", s.requireUser(s.handleAdminList)) mux.HandleFunc("GET /admin/settings", s.requireUser(s.handleAdminSettings)) // Admin JSON API api := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, s.requireAPI(h)) } api("GET /api/lists", s.apiLists) api("POST /api/lists", s.apiCreateList) api("POST /api/lists/order", s.apiReorderLists) api("PUT /api/lists/{id}", s.apiUpdateList) api("DELETE /api/lists/{id}", s.apiDeleteList) api("GET /api/lists/{id}/items", s.apiItems) api("POST /api/lists/{id}/items", s.apiAddItem) api("PUT /api/items/{id}", s.apiUpdateItem) api("POST /api/items/{id}/image", s.apiUploadItemImage) api("POST /api/items/{id}/move", s.apiMoveItem) api("DELETE /api/items/{id}", s.apiDeleteItem) api("GET /api/search", s.apiSearch) api("GET /api/settings", s.apiSettings) api("PUT /api/settings", s.apiUpdateSettings) api("POST /api/settings/avatar", s.apiUploadAvatar) api("DELETE /api/settings/avatar", s.apiDeleteAvatar) api("POST /api/integrations/test", s.apiTestIntegration) api("PUT /api/account", s.apiUpdateAccount) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.notFound(w, r) }) return s.recoverer(s.securityHeaders(s.logRequests(mux))) } // ---- middleware ---- type statusWriter struct { http.ResponseWriter status int } func (w *statusWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } func (s *Server) logRequests(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w, status: 200} next.ServeHTTP(sw, r) if strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/images/") { return } s.log.Debug("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "dur", time.Since(start).Round(time.Millisecond)) }) } func (s *Server) recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if v := recover(); v != nil { if v == http.ErrAbortHandler { panic(v) } s.log.Error("panic", "path", r.URL.Path, "err", v) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } func (s *Server) securityHeaders(next http.Handler) http.Handler { // Remote https images are allowed so search results can preview art // before it's cached locally. const csp = "default-src 'self'; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; " + "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", csp) h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") h.Set("X-Frame-Options", "SAMEORIGIN") next.ServeHTTP(w, r) }) } type ctxKey int const userKey ctxKey = 0 type sessionUser struct { store.User TokenHash string } func (s *Server) currentUser(r *http.Request) (sessionUser, bool) { if u, ok := r.Context().Value(userKey).(sessionUser); ok { return u, true } c, err := r.Cookie(sessionCookie) if err != nil || c.Value == "" { return sessionUser{}, false } hash := auth.HashToken(c.Value) u, err := s.store.SessionUser(r.Context(), hash) if err != nil { return sessionUser{}, false } return sessionUser{User: u, TokenHash: hash}, true } func (s *Server) requireUser(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { u, ok := s.currentUser(r) if !ok { http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther) return } h(w, r.WithContext(context.WithValue(r.Context(), userKey, u))) } } // requireAPI guards JSON endpoints. Besides the session cookie, it requires a // custom header that browsers won't send cross-site without a CORS // preflight, which blocks CSRF. func (s *Server) requireAPI(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Canon") != "1" || !s.sameOrigin(r) { writeError(w, http.StatusForbidden, "Missing request header") return } u, ok := s.currentUser(r) if !ok { writeError(w, http.StatusUnauthorized, "Your session expired. Please sign in again.") return } h(w, r.WithContext(context.WithValue(r.Context(), userKey, u))) } } // sameOrigin rejects requests whose Origin header names another site. func (s *Server) sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" || origin == "null" { return origin == "" // absent: non-browser or same-origin GET } u, err := url.Parse(origin) if err != nil { return false } host := r.Host if s.cfg.TrustProxy { if fh := r.Header.Get("X-Forwarded-Host"); fh != "" { host = fh } } return strings.EqualFold(u.Host, host) } func (s *Server) clientIP(r *http.Request) string { if s.cfg.TrustProxy { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { first, _, _ := strings.Cut(xff, ",") return strings.TrimSpace(first) } if xr := r.Header.Get("X-Real-IP"); xr != "" { return xr } } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func (s *Server) isHTTPS(r *http.Request) bool { if s.cfg.SecureCookies || r.TLS != nil { return true } return s.cfg.TrustProxy && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } // ---- static files ---- func (s *Server) staticHandler() http.Handler { fsrv := http.FileServerFS(s.static) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/") { s.notFound(w, r) return } if r.URL.Query().Get("v") != "" { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=3600") } fsrv.ServeHTTP(w, r) }) } func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !imgcache.ValidName(name) { s.notFound(w, r) return } w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") http.ServeFile(w, r, s.images.Dir()+"/"+name) } // ---- JSON helpers ---- func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // writeErr maps store/provider errors to HTTP responses. func (s *Server) writeErr(w http.ResponseWriter, err error) { var ve store.ValidationError var nc providers.NotConfiguredError switch { case errors.As(err, &ve): writeError(w, http.StatusBadRequest, ve.Msg) case errors.As(err, &nc): writeError(w, http.StatusFailedDependency, nc.Error()) case errors.Is(err, store.ErrNotFound): writeError(w, http.StatusNotFound, "Not found") case errors.Is(err, imgcache.ErrNotImage): writeError(w, http.StatusBadRequest, err.Error()) default: s.log.Error("request failed", "err", err) writeError(w, http.StatusInternalServerError, "Something went wrong: "+err.Error()) } } func decodeJSON(r *http.Request, v any) error { dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) if err := dec.Decode(v); err != nil { return store.ValidationError{Msg: "Invalid request body"} } return nil } func pathID(r *http.Request) (int64, error) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { return 0, store.ErrNotFound } return id, nil } func shortHash(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:])[:10] } func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/") { writeError(w, http.StatusNotFound, "Not found") return } s.render(w, r, http.StatusNotFound, "error", "Not found", errorPage{ Code: 404, Message: "There's nothing ranked here.", }) } type errorPage struct { Code int Message string } func (s *Server) serverError(w http.ResponseWriter, r *http.Request, err error) { s.log.Error("page failed", "path", r.URL.Path, "err", err) s.render(w, r, http.StatusInternalServerError, "error", "Error", errorPage{ Code: 500, Message: fmt.Sprintf("Something went wrong: %v", err), }) }