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:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -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),
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user