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,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