Files
canon/internal/store/settings.go
T
cdnutterandClaude Opus 5.5 e9a75a968a 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>
2026-09-24 16:54:19 -07:00

134 lines
4.1 KiB
Go

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
}