Initial TopDeck: self-hosted ranked favorites

A single Go binary with SQLite that serves a public page of the owner's
ranked lists (movies, TV, albums, songs, video games, theater) and a
login-gated editor.

- Lists of 1–65,535 entries (100 recommended), several per category
- Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia;
  manual entry as a fallback
- Theater entries carry a production (Broadway, tour, …), venue and cast
- Artwork cached locally; URL override and uploads
- Drag, arrow and type-a-rank reordering; paginated for long lists
- 12 theme presets plus custom CSS
- First-run setup code or env-provided admin; bcrypt, rate-limited login,
  CSRF-guarded JSON API
- Dockerfile and docker-compose.yml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 16:54:19 -07:00
commit e9a75a968a
53 files changed
+7078

No files matched your search

+401
View File
@@ -0,0 +1,401 @@
// 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/topdeck/internal/auth"
"github.com/cdnutter/topdeck/internal/config"
"github.com/cdnutter/topdeck/internal/imgcache"
"github.com/cdnutter/topdeck/internal/providers"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/topdeck/web"
)
const (
sessionCookie = "topdeck_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 /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: 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-TopDeck") != "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),
})
}