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
+203
-108
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
.git
|
.git
|
||||||
data
|
data
|
||||||
.env
|
.env
|
||||||
topdeck
|
canon
|
||||||
+12
-12
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
/data/
|
/data/
|
||||||
/topdeck
|
/canon
|
||||||
.env
|
.env
|
||||||
+9
-9
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
|
||||||
## Backups
|
## Upgrading from TopDeck
|
||||||
|
|
||||||
Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running:
|
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
|
```sh
|
||||||
docker compose exec topdeck sh -c 'cd /data && tar c topdeck.db* images' > topdeck-backup.tar
|
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
|
||||||
|
|
||||||
|
Everything lives in the data volume: `canon.db` (SQLite) and `images/`. Copy the volume, or while running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
@@ -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,4 +1,4 @@
|
|||||||
module github.com/cdnutter/topdeck
|
module github.com/cdnutter/canon
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -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", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
"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
@@ -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{
|
||||||
|
|||||||
@@ -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,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,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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
Reference in new issue
Block a user