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
+202 -107

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
.git .git
data data
.env .env
topdeck canon
+12 -12
View File
@@ -1,21 +1,21 @@
# Copy to .env and fill in what you need. Everything here is optional. # Copy to .env and fill in what you need. Everything here is optional.
# Create the owner account on first start. If unset, TopDeck prints a one-time # Create the owner account on first start. If unset, Canon prints a one-time
# setup code to the log and you create the account at http://<host>:4242/setup. # setup code to the log and you create the account at http://<host>:4242/setup.
TOPDECK_ADMIN_USERNAME= CANON_ADMIN_USERNAME=
TOPDECK_ADMIN_PASSWORD= CANON_ADMIN_PASSWORD=
# API keys. You can also enter these in Settings → Integrations; values set # API keys. You can also enter these in Settings → Integrations; values set
# here take precedence and are locked in the web UI. # here take precedence and are locked in the web UI.
TOPDECK_TMDB_API_KEY= CANON_TMDB_API_KEY=
TOPDECK_TVDB_API_KEY= CANON_TVDB_API_KEY=
TOPDECK_TVDB_PIN= CANON_TVDB_PIN=
TOPDECK_IGDB_CLIENT_ID= CANON_IGDB_CLIENT_ID=
TOPDECK_IGDB_CLIENT_SECRET= CANON_IGDB_CLIENT_SECRET=
# Behind a reverse proxy that terminates HTTPS: # Behind a reverse proxy that terminates HTTPS:
# TOPDECK_TRUST_PROXY=true # honor X-Forwarded-For / -Proto / -Host # CANON_TRUST_PROXY=true # honor X-Forwarded-For / -Proto / -Host
# TOPDECK_SECURE_COOKIES=true # always mark the session cookie Secure # CANON_SECURE_COOKIES=true # always mark the session cookie Secure
# TOPDECK_PORT=4242 # CANON_PORT=4242
# TOPDECK_LOG_LEVEL=debug # CANON_LOG_LEVEL=debug
+1 -1
View File
@@ -1,3 +1,3 @@
/data/ /data/
/topdeck /canon
.env .env
+9 -9
View File
@@ -6,18 +6,18 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG VERSION=dev ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/topdeck ./cmd/topdeck RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/canon ./cmd/canon
FROM alpine:3 FROM alpine:3
RUN apk add --no-cache ca-certificates tzdata \ RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -u 1000 topdeck \ && adduser -D -u 1000 canon \
&& mkdir -p /data && chown topdeck:topdeck /data && mkdir -p /data && chown canon:canon /data
COPY --from=build /out/topdeck /usr/local/bin/topdeck COPY --from=build /out/canon /usr/local/bin/canon
USER topdeck USER canon
ENV TOPDECK_DATA_DIR=/data \ ENV CANON_DATA_DIR=/data \
TOPDECK_PORT=4242 CANON_PORT=4242
VOLUME /data VOLUME /data
EXPOSE 4242 EXPOSE 4242
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1:${TOPDECK_PORT}/healthz >/dev/null || exit 1 CMD wget -qO- http://127.0.0.1:${CANON_PORT}/healthz >/dev/null || exit 1
ENTRYPOINT ["topdeck"] ENTRYPOINT ["canon"]
+39 -23
View File
@@ -1,7 +1,9 @@
# TopDeck # Canon
A self-hosted page for **your** ranked favorites: movies, TV shows, albums, songs, video games, theater, and anything else you care to rank in a custom list. One owner, as many lists as you like, each with its own size (Top 5, Top 100, … up to 65,535), with proper posters and cover art pulled from the best free metadata sources. A self-hosted page for **your** ranked favorites: movies, TV shows, albums, songs, video games, theater, and anything else you care to rank in a custom list. One owner, as many lists as you like, each with its own size (Top 5, Top 100, … up to 65,535), with proper posters and cover art pulled from the best free metadata sources.
Your page is titled **"*Your name*'s Canon"** by default. Set your name under Settings → Profile, or give the site any title you like there.
Visitors just see your lists. There's no sign-up, no feed and no login button. You manage everything from `/login`. Visitors just see your lists. There's no sign-up, no feed and no login button. You manage everything from `/login`.
- **Ranked lists per category**: Movies, TV Shows, Albums, Songs, Video Games and Theater. Each list sets its own size, title, description and layout (artwork grid or ranked list). - **Ranked lists per category**: Movies, TV Shows, Albums, Songs, Video Games and Theater. Each list sets its own size, title, description and layout (artwork grid or ranked list).
@@ -18,24 +20,24 @@ Visitors just see your lists. There's no sign-up, no feed and no login button. Y
## Quick start (Docker Compose) ## Quick start (Docker Compose)
```sh ```sh
git clone https://git.iamchrisama.com/cdnutter/topdeck && cd topdeck git clone https://git.iamchrisama.com/cdnutter/canon && cd canon
cp .env.example .env # optional: admin account + API keys cp .env.example .env # optional: admin account + API keys
docker compose up -d docker compose up -d
docker compose logs topdeck # shows the one-time setup code if you didn't set an admin in .env docker compose logs canon # shows the one-time setup code if you didn't set an admin in .env
``` ```
Open `http://localhost:4242/setup`, enter the setup code and create your account. Then go to **Settings → Integrations** to add API keys. Open `http://localhost:4242/setup`, enter the setup code and create your account. Then go to **Settings → Integrations** to add API keys.
To skip the setup page, set `TOPDECK_ADMIN_USERNAME` and `TOPDECK_ADMIN_PASSWORD` in `.env` before the first start. They're only used when no account exists yet. To skip the setup page, set `CANON_ADMIN_USERNAME` and `CANON_ADMIN_PASSWORD` in `.env` before the first start. They're only used when no account exists yet.
### Without Compose ### Without Compose
```sh ```sh
docker build -t topdeck . docker build -t canon .
docker run -d --name topdeck -p 4242:4242 -v topdeck-data:/data topdeck docker run -d --name canon -p 4242:4242 -v canon-data:/data canon
``` ```
Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`. Or from source (Go 1.26+): `go run ./cmd/canon` stores data in `./data`.
## Data sources and API keys ## Data sources and API keys
@@ -50,7 +52,7 @@ Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`.
**Why these for music and theater?** Neither needs a key. **Why these for music and theater?** Neither needs a key.
- **Music:** Deezer's public catalogue API has no key, generous limits (about 50 requests per 5 seconds) and 1000×1000 covers for both tracks and albums. MusicBrainz is the fully open alternative, but it's limited to one request per second and art coverage is patchier. Spotify, Apple Music and Last.fm all need keys or OAuth and have stricter terms. - **Music:** Deezer's public catalogue API has no key, generous limits (about 50 requests per 5 seconds) and 1000×1000 covers for both tracks and albums. MusicBrainz is the fully open alternative, but it's limited to one request per second and art coverage is patchier. Spotify, Apple Music and Last.fm all need keys or OAuth and have stricter terms.
- **Theater:** there's no open theater database with an API (IBDB and Playbill don't offer one). Wikipedia covers nearly every musical, play and opera, usually with poster or logo art. TopDeck filters search to pages that use the musical, play or opera infobox. Wikipedia describes the *work*, so you pick the production yourself and can swap in that production's poster. - **Theater:** there's no open theater database with an API (IBDB and Playbill don't offer one). Wikipedia covers nearly every musical, play and opera, usually with poster or logo art. Canon filters search to pages that use the musical, play or opera infobox. Wikipedia describes the *work*, so you pick the production yourself and can swap in that production's poster.
Keys entered in the UI are stored in the SQLite database on your server. Keys set as environment variables take precedence and show as locked in the UI. Keys entered in the UI are stored in the SQLite database on your server. Keys set as environment variables take precedence and show as locked in the UI.
@@ -58,23 +60,23 @@ Keys entered in the UI are stored in the SQLite database on your server. Keys se
| Variable | Default | | | Variable | Default | |
|---|---|---| |---|---|---|
| `TOPDECK_PORT` | `4242` | Listen port | | `CANON_PORT` | `4242` | Listen port |
| `TOPDECK_DATA_DIR` | `/data` (Docker), `./data` | Database + cached images | | `CANON_DATA_DIR` | `/data` (Docker), `./data` | Database + cached images |
| `TOPDECK_ADMIN_USERNAME` / `_PASSWORD` | | Create the owner account on first start | | `CANON_ADMIN_USERNAME` / `_PASSWORD` | | Create the owner account on first start |
| `TOPDECK_TMDB_API_KEY` | | | | `CANON_TMDB_API_KEY` | | |
| `TOPDECK_TVDB_API_KEY` / `TOPDECK_TVDB_PIN` | | The PIN is only for user-supported keys | | `CANON_TVDB_API_KEY` / `CANON_TVDB_PIN` | | The PIN is only for user-supported keys |
| `TOPDECK_IGDB_CLIENT_ID` / `_SECRET` | | Twitch app credentials | | `CANON_IGDB_CLIENT_ID` / `_SECRET` | | Twitch app credentials |
| `TOPDECK_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` headers (set behind a reverse proxy) | | `CANON_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` headers (set behind a reverse proxy) |
| `TOPDECK_SECURE_COOKIES` | `false` | Force `Secure` cookies (set if served over HTTPS) | | `CANON_SECURE_COOKIES` | `false` | Force `Secure` cookies (set if served over HTTPS) |
| `TOPDECK_LOG_LEVEL` | `info` | `debug` logs every request | | `CANON_LOG_LEVEL` | `info` | `debug` logs every request |
### Reverse proxy ### Reverse proxy
TopDeck serves plain HTTP. Put it behind Caddy, Traefik or nginx for HTTPS, and set `TOPDECK_TRUST_PROXY=true` so logins are rate-limited per real client and cookies are marked secure. Caddy example: Canon serves plain HTTP. Put it behind Caddy, Traefik or nginx for HTTPS, and set `CANON_TRUST_PROXY=true` so logins are rate-limited per real client and cookies are marked secure. Caddy example:
``` ```
favorites.example.com { favorites.example.com {
reverse_proxy topdeck:4242 reverse_proxy canon:4242
} }
``` ```
@@ -107,25 +109,39 @@ Every list can be downloaded as its titles only, in rank order, one per line. Th
The Export menu appears in the list editor and, when you're signed in, on the list's public page. The links follow the list's visibility: anyone can download a public list, and only you can download a hidden one. The Export menu appears in the list editor and, when you're signed in, on the list's public page. The links follow the list's visibility: anyone can download a public list, and only you can download a hidden one.
## Upgrading from TopDeck
Canon was called TopDeck, and upgrading keeps all your data:
- The database is renamed from `topdeck.db` to `canon.db` automatically on first start.
- Environment variables changed prefix from `TOPDECK_` to `CANON_`. Rename them in your `.env`.
- You'll be signed out once (the session cookie was renamed).
- Docker Compose now always uses the project name `canon`. If you ran the old compose file, your data is in the `topdeck_topdeck-data` volume. Copy it into the new one before starting:
```sh
docker volume create canon_canon-data
docker run --rm -v topdeck_topdeck-data:/from -v canon_canon-data:/to alpine cp -a /from/. /to/
```
## Backups ## Backups
Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running: Everything lives in the data volume: `canon.db` (SQLite) and `images/`. Copy the volume, or while running:
```sh ```sh
docker compose exec topdeck sh -c 'cd /data && tar c topdeck.db* images' > topdeck-backup.tar docker compose exec canon sh -c 'cd /data && tar c canon.db* images' > canon-backup.tar
``` ```
## Development ## Development
```sh ```sh
go run ./cmd/topdeck # http://localhost:4242 go run ./cmd/canon # http://localhost:4242
go test ./... go test ./...
``` ```
Templates and static assets in `web/` are embedded into the binary, so rebuild after editing them. The front end is plain ES modules with no build step. Templates and static assets in `web/` are embedded into the binary, so rebuild after editing them. The front end is plain ES modules with no build step.
``` ```
cmd/topdeck entry point, first-run bootstrap cmd/canon entry point, first-run bootstrap
internal/store SQLite schema, lists, items, ranking, settings, sessions internal/store SQLite schema, lists, items, ranking, settings, sessions
internal/providers TMDB, TheTVDB, IGDB, Deezer, MusicBrainz, Wikipedia internal/providers TMDB, TheTVDB, IGDB, Deezer, MusicBrainz, Wikipedia
internal/imgcache downloads and stores artwork internal/imgcache downloads and stores artwork
+10 -10
View File
@@ -1,4 +1,4 @@
// Command topdeck serves a personal, self-hosted collection of ranked // Command canon serves a personal, self-hosted collection of ranked
// favorites. // favorites.
package main package main
@@ -16,12 +16,12 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/cdnutter/topdeck/internal/auth" "github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/topdeck/internal/config" "github.com/cdnutter/canon/internal/config"
"github.com/cdnutter/topdeck/internal/imgcache" "github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/topdeck/internal/providers" "github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/topdeck/internal/server" "github.com/cdnutter/canon/internal/server"
"github.com/cdnutter/topdeck/internal/store" "github.com/cdnutter/canon/internal/store"
) )
// version is set at build time with -ldflags "-X main.version=…". // version is set at build time with -ldflags "-X main.version=…".
@@ -29,7 +29,7 @@ var version = "dev"
func main() { func main() {
level := slog.LevelInfo level := slog.LevelInfo
if strings.EqualFold(os.Getenv("TOPDECK_LOG_LEVEL"), "debug") { if strings.EqualFold(os.Getenv("CANON_LOG_LEVEL"), "debug") {
level = slog.LevelDebug level = slog.LevelDebug
} }
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
@@ -92,7 +92,7 @@ func run(log *slog.Logger) error {
} }
errc := make(chan error, 1) errc := make(chan error, 1)
go func() { go func() {
log.Info("TopDeck is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version) log.Info("Canon is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version)
errc <- httpSrv.ListenAndServe() errc <- httpSrv.ListenAndServe()
}() }()
select { select {
@@ -119,7 +119,7 @@ func bootstrapAdmin(ctx context.Context, cfg config.Config, st *store.Store, srv
} }
if cfg.AdminUsername != "" && cfg.AdminPassword != "" { if cfg.AdminUsername != "" && cfg.AdminPassword != "" {
if len(cfg.AdminPassword) < auth.MinPasswordLength { if len(cfg.AdminPassword) < auth.MinPasswordLength {
return fmt.Errorf("TOPDECK_ADMIN_PASSWORD must be at least %d characters", auth.MinPasswordLength) return fmt.Errorf("CANON_ADMIN_PASSWORD must be at least %d characters", auth.MinPasswordLength)
} }
hash, err := auth.HashPassword(cfg.AdminPassword) hash, err := auth.HashPassword(cfg.AdminPassword)
if err != nil { if err != nil {
+7 -5
View File
@@ -1,16 +1,18 @@
name: canon
services: services:
topdeck: canon:
build: . build: .
image: topdeck:latest image: canon:latest
container_name: topdeck container_name: canon
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4242:4242" - "4242:4242"
volumes: volumes:
- topdeck-data:/data - canon-data:/data
env_file: env_file:
- path: .env - path: .env
required: false required: false
volumes: volumes:
topdeck-data: canon-data:
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/cdnutter/topdeck module github.com/cdnutter/canon
go 1.26.0 go 1.26.0
+12 -12
View File
@@ -28,19 +28,19 @@ type Config struct {
} }
func Load() Config { func Load() Config {
port := env("TOPDECK_PORT", "4242") port := env("CANON_PORT", "4242")
return Config{ return Config{
ListenAddr: env("TOPDECK_LISTEN", ":"+port), ListenAddr: env("CANON_LISTEN", ":"+port),
DataDir: env("TOPDECK_DATA_DIR", "./data"), DataDir: env("CANON_DATA_DIR", "./data"),
AdminUsername: env("TOPDECK_ADMIN_USERNAME", ""), AdminUsername: env("CANON_ADMIN_USERNAME", ""),
AdminPassword: env("TOPDECK_ADMIN_PASSWORD", ""), AdminPassword: env("CANON_ADMIN_PASSWORD", ""),
SecureCookies: envBool("TOPDECK_SECURE_COOKIES", false), SecureCookies: envBool("CANON_SECURE_COOKIES", false),
TrustProxy: envBool("TOPDECK_TRUST_PROXY", false), TrustProxy: envBool("CANON_TRUST_PROXY", false),
TMDBKey: env("TOPDECK_TMDB_API_KEY", ""), TMDBKey: env("CANON_TMDB_API_KEY", ""),
TVDBKey: env("TOPDECK_TVDB_API_KEY", ""), TVDBKey: env("CANON_TVDB_API_KEY", ""),
TVDBPin: env("TOPDECK_TVDB_PIN", ""), TVDBPin: env("CANON_TVDB_PIN", ""),
IGDBClientID: env("TOPDECK_IGDB_CLIENT_ID", ""), IGDBClientID: env("CANON_IGDB_CLIENT_ID", ""),
IGDBClientSecret: env("TOPDECK_IGDB_CLIENT_SECRET", ""), IGDBClientSecret: env("CANON_IGDB_CLIENT_SECRET", ""),
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"time" "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. // Result is one search hit, already shaped like a list item.
type Result struct { type Result struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"html/template" "html/template"
"net/http" "net/http"
"github.com/cdnutter/topdeck/internal/store" "github.com/cdnutter/canon/internal/store"
) )
// adminPage carries a JSON blob the page script boots from. // adminPage carries a JSON blob the page script boots from.
+5 -4
View File
@@ -10,10 +10,10 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/cdnutter/topdeck/internal/auth" "github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/topdeck/internal/imgcache" "github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/topdeck/internal/providers" "github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/topdeck/internal/store" "github.com/cdnutter/canon/internal/store"
) )
// ---- lists ---- // ---- lists ----
@@ -467,6 +467,7 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) {
u, _ := s.currentUser(r) u, _ := s.currentUser(r)
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"siteTitle": set[store.SettingSiteTitle], "siteTitle": set[store.SettingSiteTitle],
"siteTitleShown": site.Title,
"ownerName": site.OwnerName, "ownerName": site.OwnerName,
"tagline": site.Tagline, "tagline": site.Tagline,
"avatar": site.AvatarURL, "avatar": site.AvatarURL,
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/cdnutter/topdeck/internal/auth" "github.com/cdnutter/canon/internal/auth"
) )
type loginPage struct { 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 { if s.setupCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(normalizeCode(s.setupCode))) != 1 {
s.loginLimit.Fail(ip) 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 return
} }
if page.Username == "" || len(page.Username) > 64 { if page.Username == "" || len(page.Username) > 64 {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/cdnutter/topdeck/internal/store" "github.com/cdnutter/canon/internal/store"
) )
// ItemView is an item shaped for public display. // ItemView is an item shaped for public display.
+12 -3
View File
@@ -11,8 +11,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/cdnutter/topdeck/internal/store" "github.com/cdnutter/canon/internal/store"
"github.com/cdnutter/topdeck/web" "github.com/cdnutter/canon/web"
) )
// Site is the owner-configurable identity shown on every page. // 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", ShowNotes: set[store.SettingShowNotes] != "false",
} }
if site.Title == "" { if site.Title == "" {
site.Title = "TopDeck" site.Title = DefaultTitle(site.OwnerName)
} }
if !validTheme(site.Theme) { if !validTheme(site.Theme) {
site.Theme = DefaultTheme site.Theme = DefaultTheme
@@ -71,6 +71,15 @@ func (s *Server) site(ctx context.Context) Site {
return 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) { func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) {
set, err := s.store.Settings(r.Context()) set, err := s.store.Settings(r.Context())
if err != nil { 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" "strings"
"time" "time"
"github.com/cdnutter/topdeck/internal/auth" "github.com/cdnutter/canon/internal/auth"
"github.com/cdnutter/topdeck/internal/config" "github.com/cdnutter/canon/internal/config"
"github.com/cdnutter/topdeck/internal/imgcache" "github.com/cdnutter/canon/internal/imgcache"
"github.com/cdnutter/topdeck/internal/providers" "github.com/cdnutter/canon/internal/providers"
"github.com/cdnutter/topdeck/internal/store" "github.com/cdnutter/canon/internal/store"
"github.com/cdnutter/topdeck/web" "github.com/cdnutter/canon/web"
) )
const ( const (
sessionCookie = "topdeck_session" sessionCookie = "canon_session"
sessionTTL = 30 * 24 * time.Hour sessionTTL = 30 * 24 * time.Hour
) )
@@ -240,7 +240,7 @@ func (s *Server) requireUser(h http.HandlerFunc) http.HandlerFunc {
// preflight, which blocks CSRF. // preflight, which blocks CSRF.
func (s *Server) requireAPI(h http.HandlerFunc) http.HandlerFunc { func (s *Server) requireAPI(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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") writeError(w, http.StatusForbidden, "Missing request header")
return return
} }
+24 -1
View File
@@ -6,6 +6,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
@@ -18,7 +19,10 @@ type Store struct {
} }
func Open(dataDir string) (*Store, error) { 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) 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) db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
@@ -35,6 +39,25 @@ func Open(dataDir string) (*Store, error) {
func (s *Store) Close() error { return s.db.Close() } 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 // migrations are applied in order; the index into this slice (plus one) is
// the schema version stored in PRAGMA user_version. // the schema version stored in PRAGMA user_version.
var migrations = []string{ var migrations = []string{
+30
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
@@ -177,3 +179,31 @@ func TestCustomList(t *testing.T) {
t.Errorf("movie aspect = %q", m.Aspect()) 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")
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
/* ========================================================================== /* ==========================================================================
TopDeck. Colors and fonts come from themes.css. Canon. Colors and fonts come from themes.css.
========================================================================== */ ========================================================================== */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
+1 -1
View File
@@ -1,5 +1,5 @@
/* /*
* TopDeck theme presets. * Canon theme presets.
* *
* Every color on the site comes from these variables, so a custom theme only * Every color on the site comes from these variables, so a custom theme only
* needs to override them (Settings → Appearance → Custom CSS), e.g. * needs to override them (Settings → Appearance → Custom CSS), e.g.
+7 -4
View File
@@ -70,7 +70,10 @@ function profileSection() {
}); });
const ownerName = h("input", { maxlength: 100, value: settings.ownerName, placeholder: "Your name" }); const ownerName = h("input", { maxlength: 100, value: settings.ownerName, placeholder: "Your name" });
const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: "TopDeck" }); // Mirrors DefaultTitle in render.go.
const defaultTitle = (name) => (name.trim() ? `${name.trim()}'s Canon` : "Canon");
const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: defaultTitle(settings.ownerName) });
ownerName.addEventListener("input", () => { siteTitle.placeholder = defaultTitle(ownerName.value); });
const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" }); const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" });
const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } }); const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } });
const notes = h("input", { type: "checkbox", checked: settings.showNotes }); const notes = h("input", { type: "checkbox", checked: settings.showNotes });
@@ -78,7 +81,7 @@ function profileSection() {
const form = h("form", { class: "stack" }, const form = h("form", { class: "stack" },
h("div", { class: "avatar-edit" }, avatarBox, h("div", { class: "row-btns", style: { display: "flex", gap: "6px", flexWrap: "wrap" } }, uploadBtn, removeBtn, file)), h("div", { class: "avatar-edit" }, avatarBox, h("div", { class: "row-btns", style: { display: "flex", gap: "6px", flexWrap: "wrap" } }, uploadBtn, removeBtn, file)),
h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab.")), h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab. Leave blank to use “Your name's Canon”.")),
field("Tagline", tagline), field("Tagline", tagline),
field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."), field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."),
h("label", { class: "check" }, notes, "Show my notes to visitors"), h("label", { class: "check" }, notes, "Show my notes to visitors"),
@@ -93,7 +96,7 @@ function profileSection() {
if (ok) { if (ok) {
renderAvatar(); renderAvatar();
const brand = document.querySelector(".brand-name"); const brand = document.querySelector(".brand-name");
if (brand) brand.textContent = settings.siteTitle || "TopDeck"; if (brand) brand.textContent = settings.siteTitleShown;
} }
}); });
renderAvatar(); renderAvatar();
@@ -181,7 +184,7 @@ function secretInput(key, label, hintHTML) {
disabled: st.locked, disabled: st.locked,
}); });
const hint = h("span", { class: "field-hint" }); const hint = h("span", { class: "field-hint" });
if (st.locked) hint.textContent = `Set by the ${"TOPDECK_" + key.toUpperCase()} environment variable.`; if (st.locked) hint.textContent = `Set by the ${"CANON_" + key.toUpperCase()} environment variable.`;
else if (hintHTML) hint.append(...hintHTML); else if (hintHTML) hint.append(...hintHTML);
const wrap = field(label, input); const wrap = field(label, input);
wrap.append(hint); wrap.append(hint);
+2 -2
View File
@@ -1,4 +1,4 @@
// Shared helpers for TopDeck's pages. No build step, no dependencies. // Shared helpers for Canon's pages. No build step, no dependencies.
/** Create an element: h("button", { class: "btn", onclick }, "Save") */ /** Create an element: h("button", { class: "btn", onclick }, "Save") */
export function h(tag, attrs = {}, ...children) { export function h(tag, attrs = {}, ...children) {
@@ -53,7 +53,7 @@ export class APIError extends Error {
/** Call the admin JSON API. Throws APIError with a human message. */ /** Call the admin JSON API. Throws APIError with a human message. */
export async function api(method, path, body) { export async function api(method, path, body) {
const opts = { method, headers: { "X-TopDeck": "1" }, credentials: "same-origin" }; const opts = { method, headers: { "X-Canon": "1" }, credentials: "same-origin" };
if (body instanceof FormData) { if (body instanceof FormData) {
opts.body = body; opts.body = body;
} else if (body !== undefined) { } else if (body !== undefined) {
+1 -1
View File
@@ -75,7 +75,7 @@ document.addEventListener("click", (e) => {
// Grid / list toggle on list pages, remembered per list in this browser. // Grid / list toggle on list pages, remembered per list in this browser.
const toggle = document.querySelector("[data-layout-toggle]"); const toggle = document.querySelector("[data-layout-toggle]");
if (toggle) { if (toggle) {
const key = "topdeck:layout:" + toggle.dataset.key; const key = "canon:layout:" + toggle.dataset.key;
const apply = (layout) => { const apply = (layout) => {
for (const { ol } of lists) { for (const { ol } of lists) {
ol.classList.toggle("items--grid", layout === "grid"); ol.classList.toggle("items--grid", layout === "grid");
+1 -1
View File
@@ -39,7 +39,7 @@
<div class="container footer-inner"> <div class="container footer-inner">
<span>{{if .Site.OwnerName}}Curated by {{.Site.OwnerName}}{{else}}{{.Site.Title}}{{end}}</span> <span>{{if .Site.OwnerName}}Curated by {{.Site.OwnerName}}{{else}}{{.Site.Title}}{{end}}</span>
<span class="footer-sep" aria-hidden="true">·</span> <span class="footer-sep" aria-hidden="true">·</span>
<span>Powered by <a href="https://git.iamchrisama.com/cdnutter/topdeck" rel="noopener">TopDeck</a></span> <span>Powered by <a href="https://git.iamchrisama.com/cdnutter/canon" rel="noopener">Canon</a></span>
</div> </div>
</footer> </footer>
{{block "scripts" .}}{{end}} {{block "scripts" .}}{{end}}
+2 -2
View File
@@ -2,14 +2,14 @@
{{define "content"}} {{define "content"}}
<section class="auth-card"> <section class="auth-card">
<div class="auth-mark">{{icon "deck"}}</div> <div class="auth-mark">{{icon "deck"}}</div>
<h1>Welcome to TopDeck</h1> <h1>Welcome to Canon</h1>
<p class="muted">Create the owner account. Only you can sign in; everyone else just sees your lists.</p> <p class="muted">Create the owner account. Only you can sign in; everyone else just sees your lists.</p>
{{with .Data.Error}}<p class="alert alert--error" role="alert">{{icon "alert"}}{{.}}</p>{{end}} {{with .Data.Error}}<p class="alert alert--error" role="alert">{{icon "alert"}}{{.}}</p>{{end}}
<form method="post" action="/setup" class="stack"> <form method="post" action="/setup" class="stack">
<label class="field"> <label class="field">
<span class="field-label">Setup code</span> <span class="field-label">Setup code</span>
<input name="code" required autocomplete="one-time-code" placeholder="XXXX-XXXX" spellcheck="false" autocapitalize="characters"> <input name="code" required autocomplete="one-time-code" placeholder="XXXX-XXXX" spellcheck="false" autocapitalize="characters">
<span class="field-hint">Printed in the server log when TopDeck starts. Run <code>docker compose logs topdeck</code> to see it.</span> <span class="field-hint">Printed in the server log when Canon starts. Run <code>docker compose logs canon</code> to see it.</span>
</label> </label>
<label class="field"> <label class="field">
<span class="field-label">Username</span> <span class="field-label">Username</span>