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

+91
View File
@@ -0,0 +1,91 @@
// Package auth has password hashing, session tokens and a login rate limiter.
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const MinPasswordLength = 8
func HashPassword(pw string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(pw), 12)
return string(b), err
}
func CheckPassword(hash, pw string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
// dummyHash lets failed lookups of unknown users take as long as real ones.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), 12)
func BurnTime(pw string) { _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(pw)) }
// NewToken returns a random session token and the hash stored server side.
func NewToken() (token, hash string) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
token = base64.RawURLEncoding.EncodeToString(b)
return token, HashToken(token)
}
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// Limiter allows a fixed number of failed attempts per key per window.
type Limiter struct {
mu sync.Mutex
max int
window time.Duration
hits map[string][]time.Time
}
func NewLimiter(max int, window time.Duration) *Limiter {
return &Limiter{max: max, window: window, hits: map[string][]time.Time{}}
}
func (l *Limiter) prune(key string, now time.Time) []time.Time {
kept := l.hits[key][:0]
for _, t := range l.hits[key] {
if now.Sub(t) < l.window {
kept = append(kept, t)
}
}
if len(kept) == 0 {
delete(l.hits, key)
return nil
}
l.hits[key] = kept
return kept
}
// Blocked reports whether key has used up its attempts.
func (l *Limiter) Blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.prune(key, time.Now())) >= l.max
}
func (l *Limiter) Fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.hits[key] = append(l.prune(key, now), now)
}
func (l *Limiter) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
+64
View File
@@ -0,0 +1,64 @@
// Package config loads runtime configuration from environment variables.
package config
import (
"os"
"strconv"
"strings"
)
// Config holds process-level settings. Anything the owner can change from the
// web UI lives in the database instead (see store.Settings); the API-key
// fields here only act as overrides when set.
type Config struct {
ListenAddr string
DataDir string
AdminUsername string
AdminPassword string
SecureCookies bool
TrustProxy bool
// Optional overrides for provider credentials. When set, the matching
// setting is locked in the web UI.
TMDBKey string
TVDBKey string
TVDBPin string
IGDBClientID string
IGDBClientSecret string
}
func Load() Config {
port := env("TOPDECK_PORT", "4242")
return Config{
ListenAddr: env("TOPDECK_LISTEN", ":"+port),
DataDir: env("TOPDECK_DATA_DIR", "./data"),
AdminUsername: env("TOPDECK_ADMIN_USERNAME", ""),
AdminPassword: env("TOPDECK_ADMIN_PASSWORD", ""),
SecureCookies: envBool("TOPDECK_SECURE_COOKIES", false),
TrustProxy: envBool("TOPDECK_TRUST_PROXY", false),
TMDBKey: env("TOPDECK_TMDB_API_KEY", ""),
TVDBKey: env("TOPDECK_TVDB_API_KEY", ""),
TVDBPin: env("TOPDECK_TVDB_PIN", ""),
IGDBClientID: env("TOPDECK_IGDB_CLIENT_ID", ""),
IGDBClientSecret: env("TOPDECK_IGDB_CLIENT_SECRET", ""),
}
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return def
}
func envBool(key string, def bool) bool {
v, ok := os.LookupEnv(key)
if !ok {
return def
}
b, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return def
}
return b
}
+142
View File
@@ -0,0 +1,142 @@
// Package imgcache downloads and stores artwork locally so pages never
// hotlink third-party image servers.
package imgcache
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const MaxBytes = 15 << 20
var ErrNotImage = errors.New("that file isn't a supported image (JPEG, PNG, WebP, GIF or AVIF)")
type Cache struct {
dir string
client *http.Client
ua string
}
func New(dataDir, userAgent string) (*Cache, error) {
dir := filepath.Join(dataDir, "images")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
return &Cache{dir: dir, ua: userAgent, client: &http.Client{Timeout: 30 * time.Second}}, nil
}
func (c *Cache) Dir() string { return c.dir }
var nameRe = regexp.MustCompile(`^[0-9a-f]{40}\.(jpg|png|webp|gif|avif)$`)
// ValidName reports whether name looks like a file this cache created.
func ValidName(name string) bool { return nameRe.MatchString(name) }
// Fetch downloads an image URL and returns the cached file name.
func (c *Cache) Fetch(ctx context.Context, url string) (string, error) {
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
return "", fmt.Errorf("image URL must start with http:// or https://")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", c.ua)
req.Header.Set("Accept", "image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8")
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("downloading image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("no artwork found at that address")
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("downloading image: server returned %d", resp.StatusCode)
}
return c.Save(resp.Body)
}
// Save stores image data from r and returns the cached file name. The name
// is derived from the content, so identical images are stored once.
func (c *Cache) Save(r io.Reader) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, MaxBytes+1))
if err != nil {
return "", err
}
if len(data) > MaxBytes {
return "", fmt.Errorf("image is larger than %d MB", MaxBytes>>20)
}
ext := sniff(data)
if ext == "" {
return "", ErrNotImage
}
sum := sha256.Sum256(data)
name := hex.EncodeToString(sum[:20]) + "." + ext
path := filepath.Join(c.dir, name)
if _, err := os.Stat(path); err == nil {
return name, nil
}
tmp, err := os.CreateTemp(c.dir, ".upload-*")
if err != nil {
return "", err
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return "", err
}
if err := tmp.Close(); err != nil {
os.Remove(tmp.Name())
return "", err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
os.Remove(tmp.Name())
return "", err
}
if err := os.Rename(tmp.Name(), path); err != nil {
os.Remove(tmp.Name())
return "", err
}
return name, nil
}
// Remove deletes a cached image. Missing files are not an error.
func (c *Cache) Remove(name string) error {
if !ValidName(name) {
return nil
}
err := os.Remove(filepath.Join(c.dir, name))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
func sniff(b []byte) string {
switch http.DetectContentType(b) {
case "image/jpeg":
return "jpg"
case "image/png":
return "png"
case "image/webp":
return "webp"
case "image/gif":
return "gif"
}
if len(b) > 12 && bytes.Equal(b[4:8], []byte("ftyp")) && (bytes.Equal(b[8:12], []byte("avif")) || bytes.Equal(b[8:12], []byte("avis"))) {
return "avif"
}
return ""
}
+141
View File
@@ -0,0 +1,141 @@
package providers
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// igdb searches the Internet Game Database, authenticating through Twitch.
type igdb struct {
r *Registry
id string
secret string
}
type igdbTokens struct {
mu sync.Mutex
credKey string
token string
expires time.Time
}
func (g *igdb) Name() string { return "igdb" }
func (g *igdb) Label() string { return "IGDB" }
func (g *igdb) token(ctx context.Context, fresh bool) (string, error) {
c := g.r.igdb
c.mu.Lock()
defer c.mu.Unlock()
credKey := g.id + "\x00" + g.secret
if !fresh && c.credKey == credKey && c.token != "" && time.Now().Before(c.expires) {
return c.token, nil
}
q := url.Values{"client_id": {g.id}, "client_secret": {g.secret}, "grant_type": {"client_credentials"}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://id.twitch.tv/oauth2/token?"+q.Encode(), nil)
if err != nil {
return "", err
}
var resp struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := g.r.do(req, &resp); err != nil {
if isStatus(err, 400) || isStatus(err, 403) {
return "", fmt.Errorf("Twitch rejected the IGDB client ID/secret; check them under Settings → Integrations")
}
return "", err
}
c.credKey, c.token = credKey, resp.AccessToken
c.expires = time.Now().Add(time.Duration(resp.ExpiresIn)*time.Second - time.Hour)
return c.token, nil
}
func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) {
q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query)
body := `search "` + q + `";
fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.company.name,platforms.abbreviation,platforms.name;
limit 25;`
var games []struct {
ID int64 `json:"id"`
Name string `json:"name"`
FirstReleaseDate int64 `json:"first_release_date"`
Summary string `json:"summary"`
URL string `json:"url"`
Cover struct {
ImageID string `json:"image_id"`
} `json:"cover"`
InvolvedCompanies []struct {
Developer bool `json:"developer"`
Company struct {
Name string `json:"name"`
} `json:"company"`
} `json:"involved_companies"`
Platforms []struct {
Abbreviation string `json:"abbreviation"`
Name string `json:"name"`
} `json:"platforms"`
}
for attempt := 0; ; attempt++ {
tok, err := g.token(ctx, attempt > 0)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.igdb.com/v4/games", strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Client-ID", g.id)
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "text/plain")
err = g.r.do(req, &games)
if isStatus(err, 401) && attempt == 0 {
continue
}
if err != nil {
return nil, err
}
break
}
out := make([]Result, 0, len(games))
for _, gm := range games {
var devs, plats []string
for _, ic := range gm.InvolvedCompanies {
if ic.Developer && ic.Company.Name != "" {
devs = append(devs, ic.Company.Name)
}
}
for _, p := range gm.Platforms {
name := p.Abbreviation
if name == "" {
name = p.Name
}
plats = append(plats, name)
}
r := Result{
Provider: "igdb",
ProviderID: strconv.FormatInt(gm.ID, 10),
Title: gm.Name,
Subtitle: strings.Join(devs, ", "),
ExternalURL: gm.URL,
Overview: truncate(gm.Summary, 600),
Extra: map[string]string{},
}
if gm.FirstReleaseDate > 0 {
r.Year = strconv.Itoa(time.Unix(gm.FirstReleaseDate, 0).UTC().Year())
}
if gm.Cover.ImageID != "" {
r.ImageURL = "https://images.igdb.com/igdb/image/upload/t_cover_big_2x/" + gm.Cover.ImageID + ".jpg"
}
if len(plats) > 0 {
r.Extra["platforms"] = strings.Join(plats, ", ")
}
out = append(out, r)
}
return out, nil
}
+283
View File
@@ -0,0 +1,283 @@
package providers
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
)
// deezer uses Deezer's public catalogue API: no key, generous limits and
// 1000px cover art.
type deezer struct {
r *Registry
kind string // "album" or "song"
}
func (d *deezer) Name() string { return "deezer" }
func (d *deezer) Label() string { return "Deezer" }
type deezerErr struct {
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Deezer reports errors with a 200 status and an "error" object.
func (e *deezerErr) check() error {
if e.Error != nil {
return fmt.Errorf("Deezer: %s", e.Error.Message)
}
return nil
}
func (d *deezer) get(ctx context.Context, path string, out any, e *deezerErr) error {
if err := d.r.getJSON(ctx, "https://api.deezer.com"+path, nil, out); err != nil {
return err
}
return e.check()
}
type deezerArtist struct {
Name string `json:"name"`
}
type deezerAlbum struct {
deezerErr
ID int64 `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
RecordType string `json:"record_type"`
NbTracks int `json:"nb_tracks"`
ReleaseDate string `json:"release_date"`
Label string `json:"label"`
Artist deezerArtist `json:"artist"`
Genres struct {
Data []struct {
Name string `json:"name"`
} `json:"data"`
} `json:"genres"`
}
type deezerTrack struct {
deezerErr
ID int64 `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
Duration int `json:"duration"`
ReleaseDate string `json:"release_date"`
Artist deezerArtist `json:"artist"`
Album deezerAlbum `json:"album"`
}
func cover(a deezerAlbum) string {
if a.CoverXL != "" {
return a.CoverXL
}
return a.CoverBig
}
func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
q := url.Values{"q": {query}, "limit": {"25"}}.Encode()
var out []Result
if d.kind == "album" {
var resp struct {
deezerErr
Data []deezerAlbum `json:"data"`
}
if err := d.get(ctx, "/search/album?"+q, &resp, &resp.deezerErr); err != nil {
return nil, err
}
for _, a := range resp.Data {
r := Result{
Provider: "deezer",
ProviderID: "album:" + strconv.FormatInt(a.ID, 10),
Title: a.Title,
Subtitle: a.Artist.Name,
ImageURL: cover(a),
ExternalURL: a.Link,
Extra: map[string]string{},
}
if a.RecordType != "" && a.RecordType != "album" {
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
}
if a.NbTracks > 0 {
r.Overview = fmt.Sprintf("%d tracks", a.NbTracks)
}
out = append(out, r)
}
return out, nil
}
var resp struct {
deezerErr
Data []deezerTrack `json:"data"`
}
if err := d.get(ctx, "/search/track?"+q, &resp, &resp.deezerErr); err != nil {
return nil, err
}
for _, t := range resp.Data {
out = append(out, Result{
Provider: "deezer",
ProviderID: "track:" + strconv.FormatInt(t.ID, 10),
Title: t.Title,
Subtitle: t.Artist.Name,
ImageURL: cover(t.Album),
ExternalURL: t.Link,
Overview: "From " + t.Album.Title,
Extra: map[string]string{"album": t.Album.Title},
})
}
return out, nil
}
func (d *deezer) Details(ctx context.Context, res Result) (Result, error) {
kind, id, _ := strings.Cut(res.ProviderID, ":")
if res.Extra == nil {
res.Extra = map[string]string{}
}
switch kind {
case "album":
var a deezerAlbum
if err := d.get(ctx, "/album/"+id, &a, &a.deezerErr); err != nil {
return res, err
}
res.Year = yearOf(a.ReleaseDate)
if a.Label != "" {
res.Extra["label"] = a.Label
}
var g []string
for _, x := range a.Genres.Data {
g = append(g, x.Name)
}
if len(g) > 0 {
res.Extra["genres"] = strings.Join(g, ", ")
}
case "track":
var t deezerTrack
if err := d.get(ctx, "/track/"+id, &t, &t.deezerErr); err != nil {
return res, err
}
res.Year = yearOf(t.ReleaseDate)
if res.Year == "" {
res.Year = yearOf(t.Album.ReleaseDate)
}
if t.Duration > 0 {
res.Extra["length"] = fmt.Sprintf("%d:%02d", t.Duration/60, t.Duration%60)
}
}
return res, nil
}
// musicbrainz uses the MusicBrainz database with art from the Cover Art
// Archive. Fully open data, but limited to one request per second.
type musicbrainz struct {
r *Registry
kind string
}
func (m *musicbrainz) Name() string { return "musicbrainz" }
func (m *musicbrainz) Label() string { return "MusicBrainz" }
type mbCredit struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
func mbArtists(credits []mbCredit) string {
var b strings.Builder
for _, c := range credits {
b.WriteString(c.Name)
b.WriteString(c.JoinPhrase)
}
return strings.TrimSpace(b.String())
}
func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error) {
if err := m.r.mb.wait(ctx); err != nil {
return nil, err
}
var out []Result
if m.kind == "album" {
var resp struct {
ReleaseGroups []struct {
ID string `json:"id"`
Title string `json:"title"`
PrimaryType string `json:"primary-type"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"`
} `json:"release-groups"`
}
u := "https://musicbrainz.org/ws/2/release-group?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
return nil, err
}
for _, rg := range resp.ReleaseGroups {
r := Result{
Provider: "musicbrainz",
ProviderID: "release-group:" + rg.ID,
Title: rg.Title,
Subtitle: mbArtists(rg.ArtistCredit),
Year: yearOf(rg.FirstReleaseDate),
ImageURL: "https://coverartarchive.org/release-group/" + rg.ID + "/front-500",
ExternalURL: "https://musicbrainz.org/release-group/" + rg.ID,
Extra: map[string]string{},
}
if rg.PrimaryType != "" && rg.PrimaryType != "Album" {
r.Extra["type"] = rg.PrimaryType
}
out = append(out, r)
}
return out, nil
}
var resp struct {
Recordings []struct {
ID string `json:"id"`
Title string `json:"title"`
Length int `json:"length"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbCredit `json:"artist-credit"`
Releases []struct {
Title string `json:"title"`
ReleaseGroup struct {
ID string `json:"id"`
PrimaryType string `json:"primary-type"`
} `json:"release-group"`
} `json:"releases"`
} `json:"recordings"`
}
u := "https://musicbrainz.org/ws/2/recording?" + url.Values{"query": {query}, "fmt": {"json"}, "limit": {"25"}}.Encode()
if err := m.r.getJSON(ctx, u, nil, &resp); err != nil {
return nil, err
}
for _, rec := range resp.Recordings {
r := Result{
Provider: "musicbrainz",
ProviderID: "recording:" + rec.ID,
Title: rec.Title,
Subtitle: mbArtists(rec.ArtistCredit),
Year: yearOf(rec.FirstReleaseDate),
ExternalURL: "https://musicbrainz.org/recording/" + rec.ID,
Extra: map[string]string{},
}
// Prefer an album release for the artwork.
for i, rel := range rec.Releases {
if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" {
r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500"
r.Extra["album"] = rel.Title
r.Overview = "From " + rel.Title
if rel.ReleaseGroup.PrimaryType == "Album" {
break
}
}
}
if rec.Length > 0 {
s := rec.Length / 1000
r.Extra["length"] = fmt.Sprintf("%d:%02d", s/60, s%60)
}
out = append(out, r)
}
return out, nil
}
+208
View File
@@ -0,0 +1,208 @@
// Package providers searches external metadata services for things to rank.
package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const UserAgent = "TopDeck/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/topdeck)"
// Result is one search hit, already shaped like a list item.
type Result struct {
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Year string `json:"year"`
ImageURL string `json:"imageUrl"`
ExternalURL string `json:"externalUrl"`
Overview string `json:"overview"`
Extra map[string]string `json:"extra,omitempty"`
}
type Searcher interface {
// Name is the stable id stored with items, e.g. "tmdb".
Name() string
// Label is the human-readable service name.
Label() string
Search(ctx context.Context, query string) ([]Result, error)
}
// Detailer is implemented by searchers that can fill in more metadata (a
// director, a release year) once the owner picks a result.
type Detailer interface {
Details(ctx context.Context, r Result) (Result, error)
}
// NotConfiguredError means the owner hasn't supplied credentials yet.
type NotConfiguredError struct{ Service, Hint string }
func (e NotConfiguredError) Error() string {
return fmt.Sprintf("%s isn't set up yet. %s", e.Service, e.Hint)
}
// Credentials are resolved fresh for every search so settings changes apply
// immediately.
type Credentials struct {
TMDBKey string
TVDBKey string
TVDBPin string
IGDBClientID string
IGDBClientSecret string
MusicProvider string // "deezer" or "musicbrainz"
TVProvider string // "tvdb" or "tmdb"
}
type Registry struct {
creds func(context.Context) Credentials
client *http.Client
tvdb *tvdbTokens
igdb *igdbTokens
mb *throttle
}
func NewRegistry(creds func(context.Context) Credentials) *Registry {
return &Registry{
creds: creds,
client: &http.Client{Timeout: 20 * time.Second},
tvdb: &tvdbTokens{},
igdb: &igdbTokens{},
mb: &throttle{gap: 1100 * time.Millisecond},
}
}
// For returns the searcher for a category.
func (r *Registry) For(ctx context.Context, category string) (Searcher, error) {
c := r.creds(ctx)
switch category {
case "movie":
if c.TMDBKey == "" {
return nil, NotConfiguredError{"TMDB", "Add a TMDB API key under Settings → Integrations."}
}
return &tmdb{r: r, key: c.TMDBKey, kind: "movie"}, nil
case "tv":
if c.TVProvider == "tmdb" {
if c.TMDBKey == "" {
return nil, NotConfiguredError{"TMDB", "Add a TMDB API key under Settings → Integrations, or switch TV shows back to TheTVDB."}
}
return &tmdb{r: r, key: c.TMDBKey, kind: "tv"}, nil
}
if c.TVDBKey == "" {
return nil, NotConfiguredError{"TheTVDB", "Add a TheTVDB API key under Settings → Integrations."}
}
return &tvdb{r: r, key: c.TVDBKey, pin: c.TVDBPin}, nil
case "album", "song":
if c.MusicProvider == "musicbrainz" {
return &musicbrainz{r: r, kind: category}, nil
}
return &deezer{r: r, kind: category}, nil
case "game":
if c.IGDBClientID == "" || c.IGDBClientSecret == "" {
return nil, NotConfiguredError{"IGDB", "Add your Twitch client ID and secret under Settings → Integrations."}
}
return &igdb{r: r, id: c.IGDBClientID, secret: c.IGDBClientSecret}, nil
case "theater":
return &wikipedia{r: r}, nil
}
return nil, fmt.Errorf("no provider for category %q", category)
}
// ---- HTTP helpers ----
type httpError struct {
Status int
Body string
}
func (e *httpError) Error() string {
return fmt.Sprintf("upstream returned %d: %s", e.Status, e.Body)
}
func (r *Registry) do(req *http.Request, out any) error {
req.Header.Set("User-Agent", UserAgent)
if req.Header.Get("Accept") == "" {
req.Header.Set("Accept", "application/json")
}
resp, err := r.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
snippet := strings.TrimSpace(string(body))
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
return &httpError{Status: resp.StatusCode, Body: snippet}
}
if out == nil {
return nil
}
return json.Unmarshal(body, out)
}
func (r *Registry) getJSON(ctx context.Context, url string, header map[string]string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
for k, v := range header {
req.Header.Set(k, v)
}
return r.do(req, out)
}
func isStatus(err error, code int) bool {
var he *httpError
return errors.As(err, &he) && he.Status == code
}
// throttle spaces out calls for services with strict rate limits.
type throttle struct {
mu sync.Mutex
gap time.Duration
last time.Time
}
func (t *throttle) wait(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if d := t.gap - time.Since(t.last); d > 0 {
select {
case <-time.After(d):
case <-ctx.Done():
return ctx.Err()
}
}
t.last = time.Now()
return nil
}
var yearRe = regexp.MustCompile(`\b(1[5-9]\d\d|20\d\d)\b`)
// yearOf pulls the first plausible year out of a date or free text.
func yearOf(s string) string {
return yearRe.FindString(s)
}
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if len([]rune(s)) <= n {
return s
}
return strings.TrimSpace(string([]rune(s)[:n])) + "…"
}
+143
View File
@@ -0,0 +1,143 @@
package providers
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
)
// tmdb searches The Movie Database. It accepts either a v3 API key or a v4
// "API Read Access Token".
type tmdb struct {
r *Registry
key string
kind string // "movie" or "tv"
}
func (t *tmdb) Name() string { return "tmdb" }
func (t *tmdb) Label() string { return "TMDB" }
func (t *tmdb) get(ctx context.Context, path string, q url.Values, out any) error {
if q == nil {
q = url.Values{}
}
header := map[string]string{}
if strings.HasPrefix(t.key, "eyJ") {
header["Authorization"] = "Bearer " + t.key
} else {
q.Set("api_key", t.key)
}
err := t.r.getJSON(ctx, "https://api.themoviedb.org/3"+path+"?"+q.Encode(), header, out)
if isStatus(err, 401) {
return fmt.Errorf("TMDB rejected the API key; check it under Settings → Integrations")
}
return err
}
func tmdbImage(path string) string {
if path == "" {
return ""
}
return "https://image.tmdb.org/t/p/w780" + path
}
type tmdbHit struct {
ID int64 `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
PosterPath string `json:"poster_path"`
Overview string `json:"overview"`
}
func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
var resp struct {
Results []tmdbHit `json:"results"`
}
q := url.Values{"query": {query}, "include_adult": {"false"}}
if err := t.get(ctx, "/search/"+t.kind, q, &resp); err != nil {
return nil, err
}
out := make([]Result, 0, len(resp.Results))
for _, h := range resp.Results {
title, date := h.Title, h.ReleaseDate
if t.kind == "tv" {
title, date = h.Name, h.FirstAirDate
}
id := strconv.FormatInt(h.ID, 10)
out = append(out, Result{
Provider: "tmdb",
ProviderID: t.kind + ":" + id,
Title: title,
Year: yearOf(date),
ImageURL: tmdbImage(h.PosterPath),
ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id,
Overview: truncate(h.Overview, 600),
})
}
return out, nil
}
func (t *tmdb) Details(ctx context.Context, res Result) (Result, error) {
_, id, ok := strings.Cut(res.ProviderID, ":")
if !ok {
return res, nil
}
var d struct {
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
Runtime int `json:"runtime"`
Networks []struct {
Name string `json:"name"`
} `json:"networks"`
CreatedBy []struct {
Name string `json:"name"`
} `json:"created_by"`
Credits struct {
Crew []struct {
Name string `json:"name"`
Job string `json:"job"`
} `json:"crew"`
} `json:"credits"`
}
q := url.Values{}
if t.kind == "movie" {
q.Set("append_to_response", "credits")
}
if err := t.get(ctx, "/"+t.kind+"/"+id, q, &d); err != nil {
return res, err
}
var names []string
if t.kind == "movie" {
for _, c := range d.Credits.Crew {
if c.Job == "Director" {
names = append(names, c.Name)
}
}
} else {
for _, n := range d.Networks {
names = append(names, n.Name)
}
}
if len(names) > 0 {
res.Subtitle = strings.Join(names, ", ")
}
var genres []string
for _, g := range d.Genres {
genres = append(genres, g.Name)
}
if res.Extra == nil {
res.Extra = map[string]string{}
}
if len(genres) > 0 {
res.Extra["genres"] = strings.Join(genres, ", ")
}
if d.Runtime > 0 {
res.Extra["runtime"] = fmt.Sprintf("%dh %02dm", d.Runtime/60, d.Runtime%60)
}
return res, nil
}
+130
View File
@@ -0,0 +1,130 @@
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sync"
"time"
)
// tvdb searches TheTVDB (API v4).
type tvdb struct {
r *Registry
key string
pin string
}
type tvdbTokens struct {
mu sync.Mutex
credKey string
token string
expires time.Time
}
func (t *tvdb) Name() string { return "tvdb" }
func (t *tvdb) Label() string { return "TheTVDB" }
func (t *tvdb) token(ctx context.Context, fresh bool) (string, error) {
c := t.r.tvdb
c.mu.Lock()
defer c.mu.Unlock()
credKey := t.key + "\x00" + t.pin
if !fresh && c.credKey == credKey && c.token != "" && time.Now().Before(c.expires) {
return c.token, nil
}
body := map[string]string{"apikey": t.key}
if t.pin != "" {
body["pin"] = t.pin
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api4.thetvdb.com/v4/login", bytes.NewReader(b))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
var resp struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if err := t.r.do(req, &resp); err != nil {
if isStatus(err, 401) {
return "", fmt.Errorf("TheTVDB rejected the API key/PIN; check them under Settings → Integrations")
}
return "", err
}
// Tokens are valid for a month; refresh daily to be safe.
c.credKey, c.token, c.expires = credKey, resp.Data.Token, time.Now().Add(24*time.Hour)
return c.token, nil
}
func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
var resp struct {
Data []struct {
TVDBID string `json:"tvdb_id"`
Name string `json:"name"`
Year string `json:"year"`
FirstAired string `json:"first_air_time"`
ImageURL string `json:"image_url"`
Thumbnail string `json:"thumbnail"`
Overview string `json:"overview"`
Network string `json:"network"`
Slug string `json:"slug"`
Translations map[string]string `json:"translations"`
Overviews map[string]string `json:"overviews"`
} `json:"data"`
}
u := "https://api4.thetvdb.com/v4/search?" + url.Values{"query": {query}, "type": {"series"}, "limit": {"20"}}.Encode()
for attempt := 0; ; attempt++ {
tok, err := t.token(ctx, attempt > 0)
if err != nil {
return nil, err
}
err = t.r.getJSON(ctx, u, map[string]string{"Authorization": "Bearer " + tok}, &resp)
if isStatus(err, 401) && attempt == 0 {
continue
}
if err != nil {
return nil, err
}
break
}
out := make([]Result, 0, len(resp.Data))
for _, s := range resp.Data {
name := s.Name
if eng := s.Translations["eng"]; eng != "" {
name = eng
}
overview := s.Overview
if eng := s.Overviews["eng"]; eng != "" {
overview = eng
}
year := s.Year
if year == "" {
year = yearOf(s.FirstAired)
}
img := s.ImageURL
if img == "" {
img = s.Thumbnail
}
ext := "https://thetvdb.com/?tab=series&id=" + s.TVDBID
if s.Slug != "" {
ext = "https://thetvdb.com/series/" + s.Slug
}
out = append(out, Result{
Provider: "tvdb",
ProviderID: s.TVDBID,
Title: name,
Subtitle: s.Network,
Year: year,
ImageURL: img,
ExternalURL: ext,
Overview: truncate(overview, 600),
})
}
return out, nil
}
+100
View File
@@ -0,0 +1,100 @@
package providers
import (
"context"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
)
// wikipedia finds stage works (musicals, plays, operas) on English
// Wikipedia. There's no free, open theater database with an API, and
// Wikipedia has the best coverage plus poster/logo art for most shows. The
// specific production (Broadway, a tour, …) is chosen by the owner.
type wikipedia struct {
r *Registry
}
func (w *wikipedia) Name() string { return "wikipedia" }
func (w *wikipedia) Label() string { return "Wikipedia" }
const theaterTemplates = `hastemplate:"Infobox musical|Infobox play|Infobox opera"`
func (w *wikipedia) Search(ctx context.Context, query string) ([]Result, error) {
res, err := w.search(ctx, query+" "+theaterTemplates)
if err == nil && len(res) == 0 {
res, err = w.search(ctx, query)
}
return res, err
}
var leadingYear = regexp.MustCompile(`^(1[5-9]\d\d|20\d\d)\s+`)
func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error) {
q := url.Values{
"action": {"query"},
"format": {"json"},
"formatversion": {"2"},
"generator": {"search"},
"gsrsearch": {query},
"gsrlimit": {"15"},
"prop": {"pageimages|description|info|extracts"},
"piprop": {"thumbnail"},
"pithumbsize": {"800"},
"pilicense": {"any"},
"inprop": {"url"},
"exintro": {"1"},
"explaintext": {"1"},
"exsentences": {"3"},
"exlimit": {"max"},
}
var resp struct {
Query struct {
Pages []struct {
PageID int64 `json:"pageid"`
Title string `json:"title"`
Index int `json:"index"`
Description string `json:"description"`
Extract string `json:"extract"`
FullURL string `json:"fullurl"`
Thumbnail struct {
Source string `json:"source"`
} `json:"thumbnail"`
} `json:"pages"`
} `json:"query"`
}
if err := w.r.getJSON(ctx, "https://en.wikipedia.org/w/api.php?"+q.Encode(), nil, &resp); err != nil {
return nil, err
}
pages := resp.Query.Pages
sort.Slice(pages, func(i, j int) bool { return pages[i].Index < pages[j].Index })
out := make([]Result, 0, len(pages))
for _, p := range pages {
title := p.Title
// "Hamilton (musical)" → "Hamilton"
if i := strings.LastIndex(title, " ("); i > 0 && strings.HasSuffix(title, ")") {
title = title[:i]
}
desc := leadingYear.ReplaceAllString(p.Description, "")
if desc != "" {
desc = strings.ToUpper(desc[:1]) + desc[1:]
}
img := p.Thumbnail.Source
if i := strings.Index(img, "?"); i > 0 {
img = img[:i]
}
out = append(out, Result{
Provider: "wikipedia",
ProviderID: strconv.FormatInt(p.PageID, 10),
Title: title,
Subtitle: desc,
Year: yearOf(p.Description),
ImageURL: img,
ExternalURL: p.FullURL,
Overview: truncate(p.Extract, 600),
})
}
return out, nil
}
+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
}
+51
View File
@@ -0,0 +1,51 @@
package store
// Category describes one kind of list. The Key is what's stored in the
// database; everything else is presentation.
type Category struct {
Key string `json:"key"`
Label string `json:"label"` // plural, e.g. "Movies"
Singular string `json:"singular"` // e.g. "Movie"
Icon string `json:"icon"` // name of an inline SVG icon
Aspect string `json:"aspect"` // "poster" (2:3) or "square" (1:1)
// SubtitleHint labels the free-text subtitle field in the editor.
SubtitleHint string `json:"subtitleHint"`
}
var Categories = []Category{
{Key: "movie", Label: "Movies", Singular: "Movie", Icon: "film", Aspect: "poster", SubtitleHint: "Director"},
{Key: "tv", Label: "TV Shows", Singular: "TV Show", Icon: "tv", Aspect: "poster", SubtitleHint: "Network"},
{Key: "album", Label: "Albums", Singular: "Album", Icon: "disc", Aspect: "square", SubtitleHint: "Artist"},
{Key: "song", Label: "Songs", Singular: "Song", Icon: "music", Aspect: "square", SubtitleHint: "Artist"},
{Key: "game", Label: "Video Games", Singular: "Video Game", Icon: "gamepad", Aspect: "poster", SubtitleHint: "Developer"},
{Key: "theater", Label: "Theater", Singular: "Show", Icon: "masks", Aspect: "poster", SubtitleHint: "Creators"},
}
func CategoryByKey(key string) (Category, bool) {
for _, c := range Categories {
if c.Key == key {
return c, true
}
}
return Category{}, false
}
// TheaterProductions are the suggested production labels for theater items.
// The owner can also type anything else.
var TheaterProductions = []string{
"Broadway",
"Broadway Revival",
"Off-Broadway",
"West End",
"West End Revival",
"Off-West End",
"North American Tour",
"US National Tour",
"UK & Ireland Tour",
"Australian Production",
"International Tour",
"Regional",
"Concert / Staged Reading",
"Filmed / Pro-shot",
"Original Cast Recording",
}
+268
View File
@@ -0,0 +1,268 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
)
type Item struct {
ID int64 `json:"id"`
ListID int64 `json:"listId"`
Rank int `json:"rank"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Year string `json:"year"`
Image string `json:"image"` // cached file name under /images/
ImageSource string `json:"imageSource"` // where the image came from
Provider string `json:"provider"`
ProviderID string `json:"providerId"`
ExternalURL string `json:"externalUrl"`
Note string `json:"note"`
Extra map[string]string `json:"extra"`
}
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra`
func scanItem(row interface{ Scan(...any) error }) (Item, error) {
var it Item
var extra string
err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource,
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Note, &extra)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
}
if err != nil {
return it, err
}
it.Extra = map[string]string{}
_ = json.Unmarshal([]byte(extra), &it.Extra)
return it, nil
}
// Items returns a page of a list's items in rank order. limit <= 0 means all.
func (s *Store) Items(ctx context.Context, listID int64, offset, limit int) ([]Item, error) {
if limit <= 0 {
limit = -1
}
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? ORDER BY rank LIMIT ? OFFSET ?`, listID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Item{}
for rows.Next() {
it, err := scanItem(rows)
if err != nil {
return nil, err
}
out = append(out, it)
}
return out, rows.Err()
}
func (s *Store) ItemByID(ctx context.Context, id int64) (Item, error) {
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE id = ?`, id))
}
// FindItemByProvider returns an item in the list that came from the same
// provider entry, if any.
func (s *Store) FindItemByProvider(ctx context.Context, listID int64, provider, providerID string) (Item, error) {
if provider == "" || providerID == "" {
return Item{}, ErrNotFound
}
return scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM items WHERE list_id = ? AND provider = ? AND provider_id = ? LIMIT 1`,
listID, provider, providerID))
}
func (it *Item) normalize() error {
it.Title = strings.TrimSpace(it.Title)
it.Subtitle = strings.TrimSpace(it.Subtitle)
it.Year = strings.TrimSpace(it.Year)
it.Note = strings.TrimSpace(it.Note)
it.ExternalURL = strings.TrimSpace(it.ExternalURL)
if it.Title == "" {
return invalid("Title is required")
}
if len(it.Title) > 500 || len(it.Subtitle) > 500 || len(it.Year) > 20 || len(it.Note) > 10000 || len(it.ExternalURL) > 2000 {
return invalid("One of the fields is too long")
}
if it.ExternalURL != "" && !strings.HasPrefix(it.ExternalURL, "https://") && !strings.HasPrefix(it.ExternalURL, "http://") {
return invalid("Links must start with http:// or https://")
}
clean := map[string]string{}
for k, v := range it.Extra {
if v = strings.TrimSpace(v); v != "" && len(k) <= 50 && len(v) <= 500 {
clean[k] = v
}
}
it.Extra = clean
return nil
}
func extraJSON(m map[string]string) string {
b, _ := json.Marshal(m)
return string(b)
}
// AddItem inserts it into the list at rank (1-based); rank <= 0 or beyond the
// end appends. Items at or below the rank shift down by one.
func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) {
if err := it.normalize(); err != nil {
return it, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return it, err
}
defer tx.Rollback()
var maxItems, count int
err = tx.QueryRowContext(ctx, `SELECT max_items, (SELECT COUNT(*) FROM items WHERE list_id = lists.id) FROM lists WHERE id = ?`, it.ListID).Scan(&maxItems, &count)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
}
if err != nil {
return it, err
}
if count >= maxItems {
return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, maxItems)
}
if rank <= 0 || rank > count+1 {
rank = count + 1
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil {
return it, err
}
res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.Provider, it.ProviderID, it.ExternalURL, it.Note, extraJSON(it.Extra))
if err != nil {
return it, err
}
if err := touchList(ctx, tx, it.ListID); err != nil {
return it, err
}
if err := tx.Commit(); err != nil {
return it, err
}
id, _ := res.LastInsertId()
return s.ItemByID(ctx, id)
}
// UpdateItem saves everything except rank and list.
func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
if err := it.normalize(); err != nil {
return it, err
}
res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, external_url=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, it.Note, extraJSON(it.Extra), it.ID)
if err != nil {
return it, err
}
if n, _ := res.RowsAffected(); n == 0 {
return it, ErrNotFound
}
cur, err := s.ItemByID(ctx, it.ID)
if err == nil {
_, err = s.db.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, cur.ListID)
}
return cur, err
}
// MoveItem moves an item to a new 1-based rank, shifting the items between.
func (s *Store) MoveItem(ctx context.Context, id int64, newRank int) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var listID int64
var oldRank, count int
err = tx.QueryRowContext(ctx, `SELECT list_id, rank, (SELECT COUNT(*) FROM items x WHERE x.list_id = items.list_id) FROM items WHERE id = ?`, id).Scan(&listID, &oldRank, &count)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if newRank < 1 {
newRank = 1
}
if newRank > count {
newRank = count
}
if newRank == oldRank {
return nil
}
if newRank < oldRank {
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ? AND rank < ?`, listID, newRank, oldRank)
} else {
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ? AND rank <= ?`, listID, oldRank, newRank)
}
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`, newRank, id); err != nil {
return err
}
if err := touchList(ctx, tx, listID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) DeleteItem(ctx context.Context, id int64) (Item, error) {
it, err := s.ItemByID(ctx, id)
if err != nil {
return it, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return it, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil {
return it, err
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ?`, it.ListID, it.Rank); err != nil {
return it, err
}
if err := touchList(ctx, tx, it.ListID); err != nil {
return it, err
}
return it, tx.Commit()
}
// ImageInUse reports whether any item or setting still references a cached
// image file.
func (s *Store) ImageInUse(ctx context.Context, name string) (bool, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT (SELECT COUNT(*) FROM items WHERE image = ?) + (SELECT COUNT(*) FROM settings WHERE value = ?)`, name, name).Scan(&n)
return n > 0, err
}
// ListImages returns the image file names used by a list's items.
func (s *Store) ListImages(ctx context.Context, listID int64) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT image FROM items WHERE list_id = ? AND image != ''`, listID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func touchList(ctx context.Context, tx *sql.Tx, listID int64) error {
_, err := tx.ExecContext(ctx, `UPDATE lists SET updated_at = unixepoch() WHERE id = ?`, listID)
return err
}
+231
View File
@@ -0,0 +1,231 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
)
const (
MaxListSize = 65535
RecommendedListSize = 100
SoftListSizeWarning = 1000
)
type List struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Category string `json:"category"`
Description string `json:"description"`
MaxItems int `json:"maxItems"`
Position int `json:"position"`
Visible bool `json:"visible"`
Layout string `json:"layout"` // "grid" or "rows"
ItemCount int `json:"itemCount"`
UpdatedAt int64 `json:"updatedAt"`
}
const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.updated_at,
(SELECT COUNT(*) FROM items i WHERE i.list_id = l.id)`
func scanList(row interface{ Scan(...any) error }) (List, error) {
var l List
err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.UpdatedAt, &l.ItemCount)
if errors.Is(err, sql.ErrNoRows) {
return l, ErrNotFound
}
return l, err
}
func (s *Store) Lists(ctx context.Context, includeHidden bool) ([]List, error) {
q := `SELECT ` + listColumns + ` FROM lists l`
if !includeHidden {
q += ` WHERE l.visible = 1`
}
q += ` ORDER BY l.position, l.id`
rows, err := s.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []List
for rows.Next() {
l, err := scanList(rows)
if err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
func (s *Store) ListByID(ctx context.Context, id int64) (List, error) {
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.id = ?`, id))
}
func (s *Store) ListBySlug(ctx context.Context, slug string) (List, error) {
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.slug = ?`, slug))
}
// ValidationError is returned for bad user input; its message is safe to
// show to the user.
type ValidationError struct{ Msg string }
func (e ValidationError) Error() string { return e.Msg }
func invalid(format string, args ...any) error {
return ValidationError{Msg: fmt.Sprintf(format, args...)}
}
func (l *List) normalize() error {
l.Title = strings.TrimSpace(l.Title)
l.Description = strings.TrimSpace(l.Description)
if l.Title == "" {
return invalid("Title is required")
}
if len(l.Title) > 200 {
return invalid("Title is too long")
}
if _, ok := CategoryByKey(l.Category); !ok {
return invalid("Unknown category %q", l.Category)
}
if l.MaxItems < 1 || l.MaxItems > MaxListSize {
return invalid("List size must be between 1 and %d", MaxListSize)
}
if l.Layout != "rows" {
l.Layout = "grid"
}
return nil
}
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
func slugify(s string) string {
s = slugStrip.ReplaceAllString(strings.ToLower(s), "-")
s = strings.Trim(s, "-")
if len(s) > 60 {
s = strings.Trim(s[:60], "-")
}
if s == "" {
s = "list"
}
return s
}
func (s *Store) uniqueSlug(ctx context.Context, base string, exceptID int64) (string, error) {
slug := base
for i := 2; ; i++ {
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lists WHERE slug = ? AND id != ?`, slug, exceptID).Scan(&n); err != nil {
return "", err
}
if n == 0 {
return slug, nil
}
slug = fmt.Sprintf("%s-%d", base, i)
}
}
func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
if err := l.normalize(); err != nil {
return l, err
}
// Default to the category name so the address stays sensible if the
// list's size (and so its "Top N" title) changes later.
base := slugify(l.Slug)
if strings.TrimSpace(l.Slug) == "" {
c, _ := CategoryByKey(l.Category)
base = slugify(c.Label)
}
slug, err := s.uniqueSlug(ctx, base, 0)
if err != nil {
return l, err
}
res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout)
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?)`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout)
if err != nil {
return l, err
}
id, _ := res.LastInsertId()
return s.ListByID(ctx, id)
}
func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
if err := l.normalize(); err != nil {
return l, err
}
cur, err := s.ListByID(ctx, l.ID)
if err != nil {
return l, err
}
if l.MaxItems < cur.ItemCount {
return l, invalid("This list already has %d items; remove some before shrinking it to %d", cur.ItemCount, l.MaxItems)
}
if l.Category != cur.Category && cur.ItemCount > 0 {
return l, invalid("Can't change the category of a list that has items")
}
slug := cur.Slug
if strings.TrimSpace(l.Slug) != "" && slugify(l.Slug) != cur.Slug {
if slug, err = s.uniqueSlug(ctx, slugify(l.Slug), l.ID); err != nil {
return l, err
}
}
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, updated_at=unixepoch() WHERE id=?`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.ID)
if err != nil {
return l, err
}
return s.ListByID(ctx, l.ID)
}
func (s *Store) DeleteList(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM lists WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ReorderLists sets list positions to match the order of ids.
func (s *Store) ReorderLists(ctx context.Context, ids []int64) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for i, id := range ids {
if _, err := tx.ExecContext(ctx, `UPDATE lists SET position = ? WHERE id = ?`, i+1, id); err != nil {
return err
}
}
return tx.Commit()
}
// ListCovers returns up to n image file names per list, from the top ranks.
func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT list_id, image FROM (
SELECT list_id, image, ROW_NUMBER() OVER (PARTITION BY list_id ORDER BY rank) AS rn
FROM items WHERE image != '') WHERE rn <= ? ORDER BY list_id, rn`, n)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64][]string{}
for rows.Next() {
var id int64
var img string
if err := rows.Scan(&id, &img); err != nil {
return nil, err
}
out[id] = append(out[id], "/images/"+img)
}
return out, rows.Err()
}
+133
View File
@@ -0,0 +1,133 @@
package store
import (
"context"
"database/sql"
"errors"
"time"
)
// Setting keys.
const (
SettingSiteTitle = "site_title"
SettingOwnerName = "owner_name"
SettingTagline = "tagline"
SettingAvatar = "avatar" // cached image file name
SettingTheme = "theme"
SettingCustomCSS = "custom_css"
SettingPreviewCount = "preview_count" // items shown per list on the home page
SettingShowNotes = "show_notes"
SettingMusicProvider = "music_provider"
SettingTVProvider = "tv_provider"
SettingTMDBKey = "tmdb_api_key"
SettingTVDBKey = "tvdb_api_key"
SettingTVDBPin = "tvdb_pin"
SettingIGDBClientID = "igdb_client_id"
SettingIGDBClientSecret = "igdb_client_secret"
)
func (s *Store) Settings(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
func (s *Store) SetSettings(ctx context.Context, kv map[string]string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for k, v := range kv {
if _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, v); err != nil {
return err
}
}
return tx.Commit()
}
// ---- users & sessions ----
type User struct {
ID int64
Username string
PasswordHash string
}
func (s *Store) UserCount(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
func (s *Store) CreateUser(ctx context.Context, username, hash string) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash) VALUES (?, ?)`, username, hash)
return err
}
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
var u User
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
func (s *Store) UserByID(ctx context.Context, id int64) (User, error) {
var u User
err := s.db.QueryRowContext(ctx, `SELECT id, username, password_hash FROM users WHERE id = ?`, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
func (s *Store) UpdateUser(ctx context.Context, id int64, username, hash string) error {
_, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ? WHERE id = ?`, username, hash, id)
return err
}
func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expires time.Time) error {
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)`, tokenHash, userID, expires.Unix())
return err
}
// SessionUser returns the user for a live session.
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (User, error) {
var u User
err := s.db.QueryRowContext(ctx, `SELECT u.id, u.username, u.password_hash FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > unixepoch()`, tokenHash).Scan(&u.ID, &u.Username, &u.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
return u, err
}
func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash)
return err
}
// DeleteUserSessions logs a user out everywhere except the given session.
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keepTokenHash string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, userID, keepTokenHash)
return err
}
func (s *Store) PruneSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= unixepoch()`)
return err
}
+114
View File
@@ -0,0 +1,114 @@
// Package store is the SQLite persistence layer.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
_ "modernc.org/sqlite"
)
var ErrNotFound = errors.New("not found")
type Store struct {
db *sql.DB
}
func Open(dataDir string) (*Store, error) {
path := filepath.Join(dataDir, "topdeck.db")
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_txlock=immediate", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(4)
s := &Store{db: db}
if err := s.migrate(context.Background()); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
// migrations are applied in order; the index into this slice (plus one) is
// the schema version stored in PRAGMA user_version.
var migrations = []string{
`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE lists (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
max_items INTEGER NOT NULL DEFAULT 10 CHECK (max_items BETWEEN 1 AND 65535),
position INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 1,
layout TEXT NOT NULL DEFAULT 'grid',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE items (
id INTEGER PRIMARY KEY,
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
rank INTEGER NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL DEFAULT '',
year TEXT NOT NULL DEFAULT '',
image TEXT NOT NULL DEFAULT '',
image_source TEXT NOT NULL DEFAULT '',
provider TEXT NOT NULL DEFAULT '',
provider_id TEXT NOT NULL DEFAULT '',
external_url TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
extra TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX items_list_rank ON items(list_id, rank);
`,
}
func (s *Store) migrate(ctx context.Context) error {
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return err
}
for i := version; i < len(migrations); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
+153
View File
@@ -0,0 +1,153 @@
package store
import (
"context"
"errors"
"fmt"
"strings"
"testing"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func titles(t *testing.T, s *Store, listID int64) string {
t.Helper()
items, err := s.Items(context.Background(), listID, 0, 0)
if err != nil {
t.Fatal(err)
}
var out []string
for i, it := range items {
if it.Rank != i+1 {
t.Fatalf("rank gap: item %q has rank %d at position %d", it.Title, it.Rank, i+1)
}
out = append(out, it.Title)
}
return strings.Join(out, ",")
}
func TestRanking(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
l, err := s.CreateList(ctx, List{Title: "Top 5 Movies", Category: "movie", MaxItems: 5, Visible: true})
if err != nil {
t.Fatal(err)
}
if l.Slug != "movies" {
t.Errorf("slug = %q, want movies", l.Slug)
}
ids := map[string]int64{}
for _, title := range []string{"A", "B", "C", "D"} {
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, 0)
if err != nil {
t.Fatal(err)
}
ids[title] = it.ID
}
if got := titles(t, s, l.ID); got != "A,B,C,D" {
t.Fatalf("append: %s", got)
}
e, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "E"}, 2)
if err != nil {
t.Fatal(err)
}
ids["E"] = e.ID
if got := titles(t, s, l.ID); got != "A,E,B,C,D" {
t.Fatalf("insert at 2: %s", got)
}
var ve ValidationError
if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "F"}, 0); !errors.As(err, &ve) {
t.Fatalf("expected full-list error, got %v", err)
}
steps := []struct {
id string
rank int
want string
}{
{"A", 5, "E,B,C,D,A"},
{"D", 1, "D,E,B,C,A"},
{"B", 99, "D,E,C,A,B"}, // clamped to the end
{"C", 0, "C,D,E,A,B"}, // clamped to the top
}
for _, st := range steps {
if err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil {
t.Fatal(err)
}
if got := titles(t, s, l.ID); got != st.want {
t.Fatalf("move %s to %d: got %s, want %s", st.id, st.rank, got, st.want)
}
}
if _, err := s.DeleteItem(ctx, ids["E"]); err != nil {
t.Fatal(err)
}
if got := titles(t, s, l.ID); got != "C,D,A,B" {
t.Fatalf("delete: %s", got)
}
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "movie", MaxItems: 3}); !errors.As(err, &ve) {
t.Fatalf("expected shrink error, got %v", err)
}
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "x", Category: "tv", MaxItems: 5}); !errors.As(err, &ve) {
t.Fatalf("expected category change error, got %v", err)
}
}
func TestListLimits(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
for _, n := range []int{0, MaxListSize + 1} {
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: n}); err == nil {
t.Errorf("size %d accepted", n)
}
}
if _, err := s.CreateList(ctx, List{Title: "x", Category: "game", MaxItems: MaxListSize}); err != nil {
t.Errorf("max size rejected: %v", err)
}
if _, err := s.CreateList(ctx, List{Title: "x", Category: "podcast", MaxItems: 5}); err == nil {
t.Error("unknown category accepted")
}
// Second list in a category gets a unique slug.
l, err := s.CreateList(ctx, List{Title: "y", Category: "game", MaxItems: 5})
if err != nil {
t.Fatal(err)
}
if l.Slug != "video-games-2" {
t.Errorf("slug = %q", l.Slug)
}
}
func BenchmarkMoveInLargeList(b *testing.B) {
ctx := context.Background()
s, err := Open(b.TempDir())
if err != nil {
b.Fatal(err)
}
defer s.Close()
l, _ := s.CreateList(ctx, List{Title: "big", Category: "song", MaxItems: 5000})
var first int64
for i := 0; i < 5000; i++ {
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: fmt.Sprint(i)}, 0)
if err != nil {
b.Fatal(err)
}
if i == 0 {
first = it.ID
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.MoveItem(ctx, first, 5000-(i%2)*4999)
}
}