Initial TopDeck: self-hosted ranked favorites
A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])) + "…"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user