Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
424 lines
13 KiB
Go
424 lines
13 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)
|
|
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),
|
|
})
|
|
}
|