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

+83
View File
@@ -0,0 +1,83 @@
package server
import (
"encoding/json"
"html/template"
"net/http"
"github.com/cdnutter/topdeck/internal/store"
)
// adminPage carries a JSON blob the page script boots from.
type adminPage struct {
Boot template.JS
List *store.List
}
func bootJSON(v any) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
}
type sizeLimits struct {
Max int `json:"max"`
Recommended int `json:"recommended"`
Warn int `json:"warn"`
}
var limits = sizeLimits{store.MaxListSize, store.RecommendedListSize, store.SoftListSizeWarning}
func (s *Server) handleAdminLists(w http.ResponseWriter, r *http.Request) {
lists, err := s.store.Lists(r.Context(), true)
if err != nil {
s.serverError(w, r, err)
return
}
if lists == nil {
lists = []store.List{}
}
covers, err := s.store.ListCovers(r.Context(), 3)
if err != nil {
s.serverError(w, r, err)
return
}
s.render(w, r, http.StatusOK, "admin_lists", "Your lists", adminPage{Boot: bootJSON(map[string]any{
"lists": lists,
"covers": covers,
"categories": store.Categories,
"limits": limits,
})})
}
func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.notFound(w, r)
return
}
l, err := s.store.ListByID(r.Context(), id)
if err != nil {
s.notFound(w, r)
return
}
cat, _ := store.CategoryByKey(l.Category)
provider := ""
if p, err := s.providers.For(r.Context(), l.Category); err == nil {
provider = p.Label()
}
s.render(w, r, http.StatusOK, "admin_list", l.Title, adminPage{List: &l, Boot: bootJSON(map[string]any{
"list": l,
"category": cat,
"categories": store.Categories,
"limits": limits,
"provider": provider,
"productions": store.TheaterProductions,
"pageSize": listPageSize,
})})
}
func (s *Server) handleAdminSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, r, http.StatusOK, "admin_settings", "Settings", adminPage{Boot: bootJSON(map[string]any{
"categories": store.Categories,
})})
}
+696
View File
@@ -0,0 +1,696 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/cdnutter/topdeck/internal/auth"
"github.com/cdnutter/topdeck/internal/imgcache"
"github.com/cdnutter/topdeck/internal/providers"
"github.com/cdnutter/topdeck/internal/store"
)
// ---- lists ----
func (s *Server) apiLists(w http.ResponseWriter, r *http.Request) {
lists, err := s.store.Lists(r.Context(), true)
if err != nil {
s.writeErr(w, err)
return
}
if lists == nil {
lists = []store.List{}
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) apiCreateList(w http.ResponseWriter, r *http.Request) {
var l store.List
if err := decodeJSON(r, &l); err != nil {
s.writeErr(w, err)
return
}
l, err := s.store.CreateList(r.Context(), l)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, l)
}
func (s *Server) apiUpdateList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var l store.List
if err := decodeJSON(r, &l); err != nil {
s.writeErr(w, err)
return
}
l.ID = id
l, err = s.store.UpdateList(r.Context(), l)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, l)
}
func (s *Server) apiDeleteList(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
images, err := s.store.ListImages(r.Context(), id)
if err != nil {
s.writeErr(w, err)
return
}
if err := s.store.DeleteList(r.Context(), id); err != nil {
s.writeErr(w, err)
return
}
for _, img := range images {
s.releaseImage(r.Context(), img)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) apiReorderLists(w http.ResponseWriter, r *http.Request) {
var body struct {
IDs []int64 `json:"ids"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
if err := s.store.ReorderLists(r.Context(), body.IDs); err != nil {
s.writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---- items ----
func (s *Server) apiItems(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 500 {
limit = 100
}
items, err := s.store.Items(r.Context(), id, max(0, offset), limit)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, items)
}
type itemInput struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Year string `json:"year"`
Note string `json:"note"`
ExternalURL string `json:"externalUrl"`
Extra map[string]string `json:"extra"`
ImageURL string `json:"imageUrl"`
ClearImage bool `json:"clearImage"`
// Only used when adding.
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
Rank int `json:"rank"`
Details bool `json:"details"`
Force bool `json:"force"`
}
type itemResponse struct {
Item store.Item `json:"item"`
Warning string `json:"warning,omitempty"`
}
func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
listID, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var in itemInput
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
list, err := s.store.ListByID(ctx, listID)
if err != nil {
s.writeErr(w, err)
return
}
if list.ItemCount >= list.MaxItems {
s.writeErr(w, store.ValidationError{Msg: fmt.Sprintf("This list is full (%d of %d). Increase its size or remove an item first.", list.ItemCount, list.MaxItems)})
return
}
if !in.Force {
if dup, err := s.store.FindItemByProvider(ctx, listID, in.Provider, in.ProviderID); err == nil {
writeJSON(w, http.StatusConflict, map[string]any{
"error": fmt.Sprintf("“%s” is already on this list at #%d.", dup.Title, dup.Rank),
"rank": dup.Rank,
})
return
}
}
res := providers.Result{
Provider: in.Provider, ProviderID: in.ProviderID, Title: in.Title, Subtitle: in.Subtitle,
Year: in.Year, ImageURL: in.ImageURL, ExternalURL: in.ExternalURL, Extra: in.Extra,
}
if in.Details && in.Provider != "" {
res = s.fillDetails(ctx, list.Category, res)
}
it := store.Item{
ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year,
Provider: res.Provider, ProviderID: res.ProviderID, ExternalURL: res.ExternalURL,
Note: in.Note, Extra: res.Extra,
}
var warning string
if res.ImageURL != "" {
name, err := s.fetchImage(ctx, res.ImageURL)
if err != nil {
warning = "Added without artwork: " + err.Error()
} else {
it.Image, it.ImageSource = name, res.ImageURL
}
}
saved, err := s.store.AddItem(ctx, it, in.Rank)
if err != nil {
s.releaseImage(ctx, it.Image)
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, itemResponse{Item: saved, Warning: warning})
}
// fillDetails asks the provider for richer metadata. Failures are logged
// and the search result is used as-is.
func (s *Server) fillDetails(ctx context.Context, category string, res providers.Result) providers.Result {
p, err := s.providers.For(ctx, category)
if err != nil || p.Name() != res.Provider {
return res
}
d, ok := p.(providers.Detailer)
if !ok {
return res
}
dctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
full, err := d.Details(dctx, res)
if err != nil {
s.log.Warn("fetching details", "provider", res.Provider, "id", res.ProviderID, "err", err)
return res
}
return full
}
func (s *Server) fetchImage(ctx context.Context, url string) (string, error) {
fctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return s.images.Fetch(fctx, url)
}
// releaseImage deletes a cached image once nothing references it.
func (s *Server) releaseImage(ctx context.Context, name string) {
if name == "" {
return
}
inUse, err := s.store.ImageInUse(ctx, name)
if err != nil || inUse {
return
}
if err := s.images.Remove(name); err != nil {
s.log.Warn("removing image", "name", name, "err", err)
}
}
func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var in itemInput
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
cur, err := s.store.ItemByID(ctx, id)
if err != nil {
s.writeErr(w, err)
return
}
it := cur
it.Title, it.Subtitle, it.Year, it.Note, it.ExternalURL = in.Title, in.Subtitle, in.Year, in.Note, in.ExternalURL
if in.Extra != nil {
it.Extra = in.Extra
}
if in.ClearImage {
it.Image, it.ImageSource = "", ""
}
if url := strings.TrimSpace(in.ImageURL); url != "" && url != cur.ImageSource {
name, err := s.fetchImage(ctx, url)
if err != nil {
s.writeErr(w, store.ValidationError{Msg: "Couldn't use that image: " + err.Error()})
return
}
it.Image, it.ImageSource = name, url
}
saved, err := s.store.UpdateItem(ctx, it)
if err != nil {
if it.Image != cur.Image {
s.releaseImage(ctx, it.Image)
}
s.writeErr(w, err)
return
}
if cur.Image != saved.Image {
s.releaseImage(ctx, cur.Image)
}
writeJSON(w, http.StatusOK, itemResponse{Item: saved})
}
func (s *Server) saveUpload(r *http.Request) (string, error) {
r.Body = http.MaxBytesReader(nil, r.Body, imgcache.MaxBytes+1<<20)
f, _, err := r.FormFile("file")
if err != nil {
return "", store.ValidationError{Msg: "Choose an image file (up to 15 MB)."}
}
defer f.Close()
name, err := s.images.Save(f)
if err != nil && !errors.Is(err, imgcache.ErrNotImage) {
return "", store.ValidationError{Msg: err.Error()}
}
return name, err
}
func (s *Server) apiUploadItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
cur, err := s.store.ItemByID(ctx, id)
if err != nil {
s.writeErr(w, err)
return
}
name, err := s.saveUpload(r)
if err != nil {
s.writeErr(w, err)
return
}
it := cur
it.Image, it.ImageSource = name, "upload"
saved, err := s.store.UpdateItem(ctx, it)
if err != nil {
s.releaseImage(ctx, name)
s.writeErr(w, err)
return
}
if cur.Image != name {
s.releaseImage(ctx, cur.Image)
}
writeJSON(w, http.StatusOK, itemResponse{Item: saved})
}
func (s *Server) apiMoveItem(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var body struct {
Rank int `json:"rank"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
if err := s.store.MoveItem(r.Context(), id, body.Rank); err != nil {
s.writeErr(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
it, err := s.store.DeleteItem(r.Context(), id)
if err != nil {
s.writeErr(w, err)
return
}
s.releaseImage(r.Context(), it.Image)
w.WriteHeader(http.StatusNoContent)
}
// ---- search ----
type searchResult struct {
providers.Result
ExistingRank int `json:"existingRank,omitempty"`
}
func (s *Server) apiSearch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := strings.TrimSpace(r.URL.Query().Get("q"))
category := r.URL.Query().Get("category")
if q == "" || utf8.RuneCountInString(q) > 200 {
writeError(w, http.StatusBadRequest, "Type something to search for.")
return
}
p, err := s.providers.For(ctx, category)
if err != nil {
s.writeErr(w, err)
return
}
sctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
results, err := p.Search(sctx, q)
if err != nil {
s.log.Warn("search failed", "provider", p.Name(), "err", err)
writeError(w, http.StatusBadGateway, fmt.Sprintf("%s search failed: %v", p.Label(), err))
return
}
listID, _ := strconv.ParseInt(r.URL.Query().Get("list"), 10, 64)
out := make([]searchResult, 0, len(results))
for _, res := range results {
sr := searchResult{Result: res}
if listID > 0 {
if it, err := s.store.FindItemByProvider(ctx, listID, res.Provider, res.ProviderID); err == nil {
sr.ExistingRank = it.Rank
}
}
out = append(out, sr)
}
writeJSON(w, http.StatusOK, map[string]any{"provider": p.Name(), "label": p.Label(), "results": out})
}
// ---- settings ----
type secretStatus struct {
Set bool `json:"set"`
Hint string `json:"hint"`
Locked bool `json:"locked"` // provided by an environment variable
}
var secretKeys = []string{store.SettingTMDBKey, store.SettingTVDBKey, store.SettingTVDBPin, store.SettingIGDBClientID, store.SettingIGDBClientSecret}
func (s *Server) envSecret(key string) string {
switch key {
case store.SettingTMDBKey:
return s.cfg.TMDBKey
case store.SettingTVDBKey:
return s.cfg.TVDBKey
case store.SettingTVDBPin:
return s.cfg.TVDBPin
case store.SettingIGDBClientID:
return s.cfg.IGDBClientID
case store.SettingIGDBClientSecret:
return s.cfg.IGDBClientSecret
}
return ""
}
func hint(v string) string {
if len(v) <= 4 {
return strings.Repeat("•", len(v))
}
return "••••" + v[len(v)-4:]
}
func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
set, err := s.store.Settings(r.Context())
if err != nil {
s.writeErr(w, err)
return
}
site := s.site(r.Context())
secrets := map[string]secretStatus{}
for _, k := range secretKeys {
v, locked := set[k], false
if env := s.envSecret(k); env != "" {
v, locked = env, true
}
secrets[k] = secretStatus{Set: v != "", Hint: hint(v), Locked: locked}
}
u, _ := s.currentUser(r)
writeJSON(w, http.StatusOK, map[string]any{
"siteTitle": set[store.SettingSiteTitle],
"ownerName": site.OwnerName,
"tagline": site.Tagline,
"avatar": site.AvatarURL,
"theme": site.Theme,
"customCss": set[store.SettingCustomCSS],
"previewCount": site.PreviewCount,
"showNotes": site.ShowNotes,
"musicProvider": orDefault(set[store.SettingMusicProvider], "deezer"),
"tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"),
"secrets": secrets,
"themes": Themes,
"username": u.Username,
})
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
func (s *Server) apiUpdateSettings(w http.ResponseWriter, r *http.Request) {
var in map[string]any
if err := decodeJSON(r, &in); err != nil {
s.writeErr(w, err)
return
}
out := map[string]string{}
str := func(field, key string, maxLen int) error {
v, ok := in[field]
if !ok {
return nil
}
sv, ok := v.(string)
if !ok {
return store.ValidationError{Msg: field + " must be text"}
}
sv = strings.TrimSpace(sv)
if len(sv) > maxLen {
return store.ValidationError{Msg: fmt.Sprintf("%s is too long (max %d characters)", field, maxLen)}
}
out[key] = sv
return nil
}
checks := []error{
str("siteTitle", store.SettingSiteTitle, 100),
str("ownerName", store.SettingOwnerName, 100),
str("tagline", store.SettingTagline, 300),
str("theme", store.SettingTheme, 50),
str("customCss", store.SettingCustomCSS, 200_000),
str("musicProvider", store.SettingMusicProvider, 20),
str("tvProvider", store.SettingTVProvider, 20),
}
for _, k := range secretKeys {
checks = append(checks, str(k, k, 500))
}
for _, err := range checks {
if err != nil {
s.writeErr(w, err)
return
}
}
if v, ok := out[store.SettingTheme]; ok && !validTheme(v) {
s.writeErr(w, store.ValidationError{Msg: "Unknown theme"})
return
}
if v, ok := out[store.SettingMusicProvider]; ok && v != "deezer" && v != "musicbrainz" {
s.writeErr(w, store.ValidationError{Msg: "Unknown music provider"})
return
}
if v, ok := out[store.SettingTVProvider]; ok && v != "tvdb" && v != "tmdb" {
s.writeErr(w, store.ValidationError{Msg: "Unknown TV provider"})
return
}
if v, ok := in["previewCount"]; ok {
n, ok := v.(float64)
if !ok || n < 0 || n > 50 || n != float64(int(n)) {
s.writeErr(w, store.ValidationError{Msg: "Home page preview must be between 0 and 50 items"})
return
}
out[store.SettingPreviewCount] = strconv.Itoa(int(n))
}
if v, ok := in["showNotes"]; ok {
b, ok := v.(bool)
if !ok {
s.writeErr(w, store.ValidationError{Msg: "showNotes must be true or false"})
return
}
out[store.SettingShowNotes] = strconv.FormatBool(b)
}
if err := s.store.SetSettings(r.Context(), out); err != nil {
s.writeErr(w, err)
return
}
s.apiSettings(w, r)
}
func (s *Server) apiUploadAvatar(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
name, err := s.saveUpload(r)
if err != nil {
s.writeErr(w, err)
return
}
set, err := s.store.Settings(ctx)
if err != nil {
s.writeErr(w, err)
return
}
old := set[store.SettingAvatar]
if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: name}); err != nil {
s.writeErr(w, err)
return
}
if old != name {
s.releaseImage(ctx, old)
}
writeJSON(w, http.StatusOK, map[string]string{"avatar": "/images/" + name})
}
func (s *Server) apiDeleteAvatar(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
set, err := s.store.Settings(ctx)
if err != nil {
s.writeErr(w, err)
return
}
if err := s.store.SetSettings(ctx, map[string]string{store.SettingAvatar: ""}); err != nil {
s.writeErr(w, err)
return
}
s.releaseImage(ctx, set[store.SettingAvatar])
w.WriteHeader(http.StatusNoContent)
}
var testQueries = map[string]string{
"movie": "Inception",
"tv": "Breaking Bad",
"album": "Abbey Road",
"song": "Bohemian Rhapsody",
"game": "Zelda",
"theater": "Hamilton",
}
func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) {
var body struct {
Category string `json:"category"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
q, ok := testQueries[body.Category]
if !ok {
s.writeErr(w, store.ValidationError{Msg: "Unknown category"})
return
}
p, err := s.providers.For(r.Context(), body.Category)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
results, err := p.Search(ctx, q)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "message": fmt.Sprintf("%s: %v", p.Label(), err)})
return
}
msg := fmt.Sprintf("%s is working. Searching “%s” returned %d results.", p.Label(), q, len(results))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "message": msg})
}
// ---- account ----
func (s *Server) apiUpdateAccount(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var body struct {
CurrentPassword string `json:"currentPassword"`
Username string `json:"username"`
NewPassword string `json:"newPassword"`
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
su, _ := s.currentUser(r)
u, err := s.store.UserByID(ctx, su.ID)
if err != nil {
s.writeErr(w, err)
return
}
if !auth.CheckPassword(u.PasswordHash, body.CurrentPassword) {
writeError(w, http.StatusBadRequest, "Your current password isn't right.")
return
}
username := strings.TrimSpace(body.Username)
if username == "" || len(username) > 64 {
writeError(w, http.StatusBadRequest, "Pick a username (up to 64 characters).")
return
}
hash := u.PasswordHash
if body.NewPassword != "" {
if len(body.NewPassword) < auth.MinPasswordLength {
writeError(w, http.StatusBadRequest, "Use a password of at least 8 characters.")
return
}
if hash, err = auth.HashPassword(body.NewPassword); err != nil {
s.writeErr(w, err)
return
}
}
if err := s.store.UpdateUser(ctx, u.ID, username, hash); err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
writeError(w, http.StatusBadRequest, "That username is taken.")
return
}
s.writeErr(w, err)
return
}
if body.NewPassword != "" {
// Sign out every other browser.
s.store.DeleteUserSessions(ctx, u.ID, su.TokenHash)
}
writeJSON(w, http.StatusOK, map[string]string{"username": username})
}
+186
View File
@@ -0,0 +1,186 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
"time"
"github.com/cdnutter/topdeck/internal/auth"
)
type loginPage struct {
Error string
Username string
Next string
}
// safeNext only allows local redirect targets.
func safeNext(next string) string {
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") {
return "/admin"
}
return next
}
func (s *Server) needsSetup(r *http.Request) bool {
n, err := s.store.UserCount(r.Context())
return err == nil && n == 0
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.needsSetup(r) {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
if _, ok := s.currentUser(r); ok {
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
return
}
s.render(w, r, http.StatusOK, "login", "Sign in", loginPage{Next: r.URL.Query().Get("next")})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
next := r.PostFormValue("next")
page := loginPage{Username: username, Next: next}
ip := s.clientIP(r)
if s.loginLimit.Blocked(ip) {
page.Error = "Too many failed attempts. Try again in a few minutes."
s.render(w, r, http.StatusTooManyRequests, "login", "Sign in", page)
return
}
u, err := s.store.UserByName(r.Context(), username)
if err != nil {
auth.BurnTime(password)
}
if err != nil || !auth.CheckPassword(u.PasswordHash, password) {
s.loginLimit.Fail(ip)
page.Error = "That username and password don't match."
s.render(w, r, http.StatusUnauthorized, "login", "Sign in", page)
return
}
s.loginLimit.Reset(ip)
if err := s.startSession(w, r, u.ID); err != nil {
s.serverError(w, r, err)
return
}
http.Redirect(w, r, safeNext(next), http.StatusSeeOther)
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID int64) error {
token, hash := auth.NewToken()
expires := time.Now().Add(sessionTTL)
if err := s.store.CreateSession(r.Context(), hash, userID, expires); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
Secure: s.isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
return nil
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
return
}
if c, err := r.Cookie(sessionCookie); err == nil {
s.store.DeleteSession(r.Context(), auth.HashToken(c.Value))
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
type setupPage struct {
Error string
Username string
}
func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
if !s.needsSetup(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.render(w, r, http.StatusOK, "setup", "Welcome", setupPage{})
}
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.sameOrigin(r) {
http.Error(w, "Cross-site request blocked", http.StatusForbidden)
return
}
if !s.needsSetup(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
ip := s.clientIP(r)
page := setupPage{Username: strings.TrimSpace(r.PostFormValue("username"))}
password := r.PostFormValue("password")
code := normalizeCode(r.PostFormValue("code"))
fail := func(status int, msg string) {
page.Error = msg
s.render(w, r, status, "setup", "Welcome", page)
}
if s.loginLimit.Blocked(ip) {
fail(http.StatusTooManyRequests, "Too many failed attempts. Try again in a few minutes.")
return
}
if s.setupCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(normalizeCode(s.setupCode))) != 1 {
s.loginLimit.Fail(ip)
fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs topdeck).")
return
}
if page.Username == "" || len(page.Username) > 64 {
fail(http.StatusBadRequest, "Pick a username (up to 64 characters).")
return
}
if len(password) < auth.MinPasswordLength {
fail(http.StatusBadRequest, "Use a password of at least 8 characters.")
return
}
if password != r.PostFormValue("confirm") {
fail(http.StatusBadRequest, "The passwords don't match.")
return
}
hash, err := auth.HashPassword(password)
if err != nil {
s.serverError(w, r, err)
return
}
if err := s.store.CreateUser(r.Context(), page.Username, hash); err != nil {
s.serverError(w, r, err)
return
}
s.log.Info("admin account created", "username", page.Username)
u, err := s.store.UserByName(r.Context(), page.Username)
if err != nil {
s.serverError(w, r, err)
return
}
if err := s.startSession(w, r, u.ID); err != nil {
s.serverError(w, r, err)
return
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
// normalizeCode makes setup codes forgiving of case, spaces and dashes.
func normalizeCode(c string) string {
return strings.ToUpper(strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(c)))
}
+198
View File
@@ -0,0 +1,198 @@
package server
import (
"errors"
"net/http"
"strconv"
"github.com/cdnutter/topdeck/internal/store"
)
// ItemView is an item shaped for public display.
type ItemView struct {
ID int64 `json:"id"`
Rank int `json:"rank"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Year string `json:"year"`
Image string `json:"image"`
ExternalURL string `json:"link"`
Source string `json:"source"`
Note string `json:"note"`
Badge string `json:"badge"` // e.g. theater production
Meta []MetaKV `json:"meta"`
}
type MetaKV struct {
Label string `json:"label"`
Value string `json:"value"`
}
var providerLabels = map[string]string{
"tmdb": "TMDB",
"tvdb": "TheTVDB",
"igdb": "IGDB",
"deezer": "Deezer",
"musicbrainz": "MusicBrainz",
"wikipedia": "Wikipedia",
}
// extraFields lists the known item extras in display order.
var extraFields = []struct{ Key, Label string }{
{"production", "Production"},
{"venue", "Venue"},
{"cast", "Notable cast"},
{"album", "Album"},
{"type", "Type"},
{"platforms", "Platforms"},
{"genres", "Genres"},
{"runtime", "Runtime"},
{"length", "Length"},
{"label", "Label"},
}
func itemView(it store.Item, showNotes bool) ItemView {
v := ItemView{
ID: it.ID,
Rank: it.Rank,
Title: it.Title,
Subtitle: it.Subtitle,
Year: it.Year,
ExternalURL: it.ExternalURL,
Source: providerLabels[it.Provider],
Badge: it.Extra["production"],
Meta: []MetaKV{},
}
if it.Image != "" {
v.Image = "/images/" + it.Image
}
if showNotes {
v.Note = it.Note
}
for _, f := range extraFields {
if val := it.Extra[f.Key]; val != "" {
v.Meta = append(v.Meta, MetaKV{f.Label, val})
}
}
return v
}
type listSection struct {
List store.List
Category store.Category
Items []ItemView
More int // items not shown in the preview
}
type homePage struct {
Sections []listSection
TotalItems int
TotalLists int
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_, loggedIn := s.currentUser(r)
site := s.site(ctx)
lists, err := s.store.Lists(ctx, false)
if err != nil {
s.serverError(w, r, err)
return
}
var page homePage
for _, l := range lists {
if l.ItemCount == 0 {
continue
}
items, err := s.store.Items(ctx, l.ID, 0, site.PreviewCount)
if err != nil {
s.serverError(w, r, err)
return
}
sec := listSection{List: l, More: l.ItemCount - len(items)}
sec.Category, _ = store.CategoryByKey(l.Category)
for _, it := range items {
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
}
page.Sections = append(page.Sections, sec)
page.TotalItems += l.ItemCount
page.TotalLists++
}
// With nothing to show yet, send a fresh owner straight to the editor.
if len(page.Sections) == 0 && loggedIn && len(lists) == 0 {
http.Redirect(w, r, "/admin", http.StatusSeeOther)
return
}
s.render(w, r, http.StatusOK, "home", "", page)
}
const listPageSize = 100
type listPage struct {
List store.List
Category store.Category
Items []ItemView
Hero *ItemView
Page int
Pages int
PageNums []int
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l, err := s.store.ListBySlug(ctx, r.PathValue("slug"))
if errors.Is(err, store.ErrNotFound) {
s.notFound(w, r)
return
}
if err != nil {
s.serverError(w, r, err)
return
}
if !l.Visible {
if _, ok := s.currentUser(r); !ok {
s.notFound(w, r)
return
}
}
site := s.site(ctx)
p := listPage{List: l, Page: 1}
p.Category, _ = store.CategoryByKey(l.Category)
p.Pages = max(1, (l.ItemCount+listPageSize-1)/listPageSize)
if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 {
p.Page = min(n, p.Pages)
}
items, err := s.store.Items(ctx, l.ID, (p.Page-1)*listPageSize, listPageSize)
if err != nil {
s.serverError(w, r, err)
return
}
for _, it := range items {
p.Items = append(p.Items, itemView(it, site.ShowNotes))
}
if len(p.Items) > 0 && p.Items[0].Rank == 1 && p.Items[0].Image != "" {
p.Hero = &p.Items[0]
}
p.PageNums = pageWindow(p.Page, p.Pages)
s.render(w, r, http.StatusOK, "list", l.Title, p)
}
// pageWindow returns page numbers to show around the current page, with 0
// standing in for an ellipsis.
func pageWindow(cur, total int) []int {
if total <= 1 {
return nil
}
var out []int
last := 0
for i := 1; i <= total; i++ {
if i == 1 || i == total || (i >= cur-2 && i <= cur+2) {
if last != 0 && i-last > 1 {
out = append(out, 0)
}
out = append(out, i)
last = i
}
}
return out
}
+193
View File
@@ -0,0 +1,193 @@
package server
import (
"bytes"
"context"
"fmt"
"html/template"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/topdeck/web"
)
// Site is the owner-configurable identity shown on every page.
type Site struct {
Title string
OwnerName string
Tagline string
AvatarURL string
Theme string
CustomCSS bool
CSSVersion string
PreviewCount int
ShowNotes bool
}
type pageData struct {
Site Site
PageTitle string
LoggedIn bool
Username string
Path string
AssetVer string
Data any
}
func (s *Server) site(ctx context.Context) Site {
set, err := s.store.Settings(ctx)
if err != nil {
s.log.Error("loading settings", "err", err)
set = map[string]string{}
}
site := Site{
Title: set[store.SettingSiteTitle],
OwnerName: set[store.SettingOwnerName],
Tagline: set[store.SettingTagline],
Theme: set[store.SettingTheme],
PreviewCount: 10,
ShowNotes: set[store.SettingShowNotes] != "false",
}
if site.Title == "" {
site.Title = "TopDeck"
}
if !validTheme(site.Theme) {
site.Theme = DefaultTheme
}
if n, err := strconv.Atoi(set[store.SettingPreviewCount]); err == nil && n >= 0 && n <= 50 {
site.PreviewCount = n
}
if a := set[store.SettingAvatar]; a != "" {
site.AvatarURL = "/images/" + a
}
if css := strings.TrimSpace(set[store.SettingCustomCSS]); css != "" {
site.CustomCSS = true
site.CSSVersion = shortHash(css)
}
return site
}
func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) {
set, err := s.store.Settings(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Write([]byte(set[store.SettingCustomCSS]))
}
var funcs = template.FuncMap{
"icon": func(name string) template.HTML {
return template.HTML(fmt.Sprintf(`<svg class="icon" aria-hidden="true"><use href="/static/img/icons.svg#%s"></use></svg>`, template.HTMLEscapeString(name)))
},
"category": func(key string) store.Category {
c, _ := store.CategoryByKey(key)
return c
},
"add": func(a, b int) int { return a + b },
"hasPrefix": strings.HasPrefix,
// dict builds a map for passing several values to a partial.
"dict": func(kv ...any) map[string]any {
m := map[string]any{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i].(string)] = kv[i+1]
}
return m
},
"num": formatNumber,
"plural": func(n int, one, many string) string {
if n == 1 {
return one
}
return many
},
"initial": func(s string) string {
for _, r := range strings.TrimSpace(s) {
return strings.ToUpper(string(r))
}
return "?"
},
"pct": func(a, b int) int {
if b <= 0 {
return 0
}
p := a * 100 / b
if a > 0 && p == 0 {
p = 1
}
return p
},
}
func formatNumber(n int) string {
s := strconv.Itoa(n)
if n < 0 {
return "-" + formatNumber(-n)
}
var b strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(c)
}
return b.String()
}
// parseTemplates builds one template set per page, each combining the base
// layout, shared partials and the page itself.
func (s *Server) parseTemplates() error {
shared, err := fs.Glob(web.FS, "templates/partials/*.html")
if err != nil {
return err
}
pages, err := fs.Glob(web.FS, "templates/pages/*.html")
if err != nil {
return err
}
s.pages = map[string]*template.Template{}
for _, p := range pages {
files := append([]string{"templates/layout.html"}, shared...)
files = append(files, p)
t, err := template.New("layout.html").Funcs(funcs).ParseFS(web.FS, files...)
if err != nil {
return fmt.Errorf("parsing %s: %w", p, err)
}
s.pages[strings.TrimSuffix(path.Base(p), ".html")] = t
}
return nil
}
func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page, title string, data any) {
t, ok := s.pages[page]
if !ok {
http.Error(w, "missing template "+page, http.StatusInternalServerError)
return
}
pd := pageData{
Site: s.site(r.Context()),
PageTitle: title,
Path: r.URL.Path,
AssetVer: s.assetVer,
Data: data,
}
if u, ok := s.currentUser(r); ok {
pd.LoggedIn, pd.Username = true, u.Username
}
var buf bytes.Buffer
if err := t.Execute(&buf, pd); err != nil {
s.log.Error("rendering", "page", page, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(status)
buf.WriteTo(w)
}
+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),
})
}
+35
View File
@@ -0,0 +1,35 @@
package server
// Theme is a built-in color preset. The actual colors live in
// web/static/css/themes.css under [data-theme="<key>"].
type Theme struct {
Key string `json:"key"`
Label string `json:"label"`
Dark bool `json:"dark"`
}
var Themes = []Theme{
{"midnight", "Midnight", true},
{"obsidian", "Obsidian", true},
{"nord", "Nord", true},
{"dracula", "Dracula", true},
{"mocha", "Mocha", true},
{"rose-pine", "Rosé Pine", true},
{"gruvbox", "Gruvbox", true},
{"forest", "Forest", true},
{"synthwave", "Synthwave", true},
{"daylight", "Daylight", false},
{"paper", "Paper", false},
{"latte", "Latte", false},
}
const DefaultTheme = "midnight"
func validTheme(key string) bool {
for _, t := range Themes {
if t.Key == key {
return true
}
}
return false
}