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:
1 parent
58a29b7339
commit
3923444b3b
26 files changed
+215
-120
No files matched your search
+12
-12
@@ -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", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user