Import: each row's place is its line number, so blank lines leave a
place empty; a fully numbered text file ("38. Title") or a Rank column
gives places directly, and repeated numbers take the next free place.
Entries are added at those places (existing ones there move down), the
review shows each row's #, and the room check covers the highest place.
Export writes a blank line for every empty place, so line N is #N and a
file round-trips.
Static files now carry an ETag and unversioned ones are revalidated on
every load. Scripts import each other without a ?v= tag, so browsers kept
an hour-old import.js (with the "null" bug) after an update.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
14 KiB
Go
449 lines
14 KiB
Go
// 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/backup"
|
|
"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
|
|
backups *backup.Manager
|
|
providers *providers.Registry
|
|
log *slog.Logger
|
|
|
|
pages map[string]*template.Template
|
|
static fs.FS
|
|
assetVer string
|
|
loginLimit *auth.Limiter
|
|
setupCode string
|
|
|
|
previews previewCache
|
|
previewClient *http.Client
|
|
}
|
|
|
|
func New(cfg config.Config, st *store.Store, images *imgcache.Cache, backups *backup.Manager, log *slog.Logger, version string) (*Server, error) {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
store: st,
|
|
images: images,
|
|
backups: backups,
|
|
log: log,
|
|
assetVer: version,
|
|
loginLimit: auth.NewLimiter(10, 15*time.Minute),
|
|
// No overall timeout: clips stream for as long as the listener plays.
|
|
previewClient: &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: 15 * time.Second}},
|
|
}
|
|
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.gated(s.handleHome))
|
|
mux.HandleFunc("GET /l/{slug}", s.gated(s.handleList))
|
|
mux.HandleFunc("GET /l/{slug}/export", s.gatedAsset(s.handleExport))
|
|
mux.HandleFunc("GET /preview/{id}", s.gatedAsset(s.handleItemPreview))
|
|
mux.HandleFunc("GET /images/{name}", s.gatedAsset(s.handleImage))
|
|
mux.HandleFunc("POST /unlock", s.handleUnlock)
|
|
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))
|
|
mux.HandleFunc("GET /admin/preview", s.requireUser(s.handleAdminPreview))
|
|
mux.HandleFunc("GET /admin/backups/{name}", s.requireUser(s.handleBackupDownload))
|
|
|
|
// 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("POST /api/lists/{id}/places", s.apiPlaces)
|
|
api("POST /api/items/{id}/refresh", s.apiRefreshItem)
|
|
api("DELETE /api/items/{id}", s.apiDeleteItem)
|
|
api("GET /api/search", s.apiSearch)
|
|
api("POST /api/import/parse", s.apiImportParse)
|
|
api("POST /api/lists/{id}/import/match", s.apiImportMatch)
|
|
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)
|
|
api("PUT /api/site-password", s.apiSitePassword)
|
|
api("GET /api/backups", s.apiBackups)
|
|
api("POST /api/backups", s.apiCreateBackup)
|
|
api("PUT /api/backups/schedule", s.apiBackupSchedule)
|
|
api("DELETE /api/backups/{name}", s.apiDeleteBackup)
|
|
|
|
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)
|
|
etags := staticETags(s.static)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/") {
|
|
s.notFound(w, r)
|
|
return
|
|
}
|
|
if tag, ok := etags[strings.TrimPrefix(r.URL.Path, "/")]; ok {
|
|
w.Header().Set("ETag", tag) // FileServer answers If-None-Match with 304
|
|
}
|
|
if r.URL.Query().Get("v") != "" {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
} else {
|
|
// Scripts load each other without a ?v= (import "./places.js"),
|
|
// so unversioned files are re-checked on every load; the ETag
|
|
// makes that a quick 304 until a new build changes them.
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
}
|
|
fsrv.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// staticETags fingerprints every embedded static file once at startup.
|
|
func staticETags(fsys fs.FS) map[string]string {
|
|
tags := map[string]string{}
|
|
fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
b, err := fs.ReadFile(fsys, p)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
sum := sha256.Sum256(b)
|
|
tags[p] = `"` + hex.EncodeToString(sum[:8]) + `"`
|
|
return nil
|
|
})
|
|
return tags
|
|
}
|
|
|
|
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),
|
|
})
|
|
}
|