Rename project from TopDeck to Canon

- Module github.com/cdnutter/canon, binary/command cmd/canon, Docker
  image and compose project "canon"
- Environment variables now use the CANON_ prefix
- Default site title is "<Your name>'s Canon" (or "Canon" with no name);
  a Site title set in Settings still overrides it
- Existing topdeck.db (and WAL files) is renamed to canon.db on start
- README: upgrade notes, including moving the old compose volume

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 17:22:17 -07:00
1 parent 58a29b7339
commit 3923444b3b
26 files changed
+215 -120

No files matched your search

+12 -12
View File
@@ -28,19 +28,19 @@ type Config struct {
}
func Load() Config {
port := env("TOPDECK_PORT", "4242")
port := env("CANON_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", ""),
ListenAddr: env("CANON_LISTEN", ":"+port),
DataDir: env("CANON_DATA_DIR", "./data"),
AdminUsername: env("CANON_ADMIN_USERNAME", ""),
AdminPassword: env("CANON_ADMIN_PASSWORD", ""),
SecureCookies: envBool("CANON_SECURE_COOKIES", false),
TrustProxy: envBool("CANON_TRUST_PROXY", false),
TMDBKey: env("CANON_TMDB_API_KEY", ""),
TVDBKey: env("CANON_TVDB_API_KEY", ""),
TVDBPin: env("CANON_TVDB_PIN", ""),
IGDBClientID: env("CANON_IGDB_CLIENT_ID", ""),
IGDBClientSecret: env("CANON_IGDB_CLIENT_SECRET", ""),
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"time"
)
const UserAgent = "TopDeck/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/topdeck)"
const UserAgent = "Canon/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/canon)"
// Result is one search hit, already shaped like a list item.
type Result struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"html/template"
"net/http"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/canon/internal/store"
)
// adminPage carries a JSON blob the page script boots from.
+18 -17
View File
@@ -10,10 +10,10 @@ import (
"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"
"github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/store"
)
// ---- lists ----
@@ -466,19 +466,20 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
}
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,
"siteTitle": set[store.SettingSiteTitle],
"siteTitleShown": site.Title,
"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,
})
}
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/cdnutter/topdeck/internal/auth"
"github.com/cdnutter/canon/internal/auth"
)
type loginPage struct {
@@ -143,7 +143,7 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
}
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).")
fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs canon).")
return
}
if page.Username == "" || len(page.Username) > 64 {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http"
"strconv"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/canon/internal/store"
)
// ItemView is an item shaped for public display.
+12 -3
View File
@@ -11,8 +11,8 @@ import (
"strconv"
"strings"
"github.com/cdnutter/topdeck/internal/store"
"github.com/cdnutter/topdeck/web"
"github.com/cdnutter/canon/internal/store"
"github.com/cdnutter/canon/web"
)
// Site is the owner-configurable identity shown on every page.
@@ -53,7 +53,7 @@ func (s *Server) site(ctx context.Context) Site {
ShowNotes: set[store.SettingShowNotes] != "false",
}
if site.Title == "" {
site.Title = "TopDeck"
site.Title = DefaultTitle(site.OwnerName)
}
if !validTheme(site.Theme) {
site.Theme = DefaultTheme
@@ -71,6 +71,15 @@ func (s *Server) site(ctx context.Context) Site {
return site
}
// DefaultTitle is the site title when the owner hasn't set one:
// "Chris's Canon", or just "Canon" before they've entered a name.
func DefaultTitle(ownerName string) string {
if ownerName = strings.TrimSpace(ownerName); ownerName == "" {
return "Canon"
}
return ownerName + "'s Canon"
}
func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) {
set, err := s.store.Settings(r.Context())
if err != nil {
+11
View File
@@ -0,0 +1,11 @@
package server
import "testing"
func TestDefaultTitle(t *testing.T) {
for in, want := range map[string]string{"": "Canon", " ": "Canon", "Chris": "Chris's Canon", " Ana ": "Ana's Canon"} {
if got := DefaultTitle(in); got != want {
t.Errorf("DefaultTitle(%q) = %q, want %q", in, got, want)
}
}
}
+8 -8
View File
@@ -18,16 +18,16 @@ import (
"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"
"github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/canon/internal/config"
"github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/canon/internal/store"
"github.com/cdnutter/canon/web"
)
const (
sessionCookie = "topdeck_session"
sessionCookie = "canon_session"
sessionTTL = 30 * 24 * time.Hour
)
@@ -240,7 +240,7 @@ func (s *Server) requireUser(h http.HandlerFunc) http.HandlerFunc {
// 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) {
if r.Header.Get("X-Canon") != "1" || !s.sameOrigin(r) {
writeError(w, http.StatusForbidden, "Missing request header")
return
}
+24 -1
View File
@@ -6,6 +6,7 @@ import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
@@ -18,7 +19,10 @@ type Store struct {
}
func Open(dataDir string) (*Store, error) {
path := filepath.Join(dataDir, "topdeck.db")
path := filepath.Join(dataDir, "canon.db")
if err := adoptLegacyDB(dataDir, path); err != nil {
return nil, err
}
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 {
@@ -35,6 +39,25 @@ func Open(dataDir string) (*Store, error) {
func (s *Store) Close() error { return s.db.Close() }
// adoptLegacyDB renames a database from before the project was called
// Canon (topdeck.db, plus its WAL files) so upgrades keep their data.
func adoptLegacyDB(dataDir, path string) error {
legacy := filepath.Join(dataDir, "topdeck.db")
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
return nil
}
if _, err := os.Stat(legacy); err != nil {
return nil
}
for _, suffix := range []string{"-wal", "-shm", ""} {
err := os.Rename(legacy+suffix, path+suffix)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("renaming %s to %s: %w", legacy+suffix, path+suffix, err)
}
}
return nil
}
// migrations are applied in order; the index into this slice (plus one) is
// the schema version stored in PRAGMA user_version.
var migrations = []string{
+30
View File
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -177,3 +179,31 @@ func TestCustomList(t *testing.T) {
t.Errorf("movie aspect = %q", m.Aspect())
}
}
func TestAdoptLegacyDB(t *testing.T) {
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
if _, err := s.CreateList(context.Background(), List{Title: "Keep me", Category: "movie", MaxItems: 5}); err != nil {
t.Fatal(err)
}
s.Close()
// Pretend this data dir predates the rename.
for _, suffix := range []string{"", "-wal", "-shm"} {
os.Rename(filepath.Join(dir, "canon.db"+suffix), filepath.Join(dir, "topdeck.db"+suffix))
}
s, err = Open(dir)
if err != nil {
t.Fatal(err)
}
defer s.Close()
lists, err := s.Lists(context.Background(), true)
if err != nil || len(lists) != 1 || lists[0].Title != "Keep me" {
t.Fatalf("legacy data not adopted: %v %v", lists, err)
}
if _, err := os.Stat(filepath.Join(dir, "topdeck.db")); !os.IsNotExist(err) {
t.Errorf("topdeck.db should have been renamed")
}
}