diff --git a/cmd/chess-server/cli/cli.go b/cmd/chess-server/cli/cli.go index 08382e9..04885ec 100644 --- a/cmd/chess-server/cli/cli.go +++ b/cmd/chess-server/cli/cli.go @@ -215,7 +215,7 @@ func runPGN(args []string) error { if err != nil { return fmt.Errorf("load game %s: %w", id, err) } - pgn, err := replay.BuildPGN(record, moves, *ply) + pgn, err := replay.BuildPGN(record, moves, *ply, "") if err != nil { return err } diff --git a/cmd/chess-server/main.go b/cmd/chess-server/main.go index 5864dd8..c4fa27e 100644 --- a/cmd/chess-server/main.go +++ b/cmd/chess-server/main.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "log/slog" + "net/url" "os" "os/signal" "syscall" @@ -62,14 +63,15 @@ func main() { // Password recovery by email: a transport (SMTP relay, or a directory // of .eml files for development) and the web client's address, which - // the emailed link opens. Without a transport, recovery is off. + // the emailed link opens. Without a transport, recovery is off. The + // address's host also names this server in exported PGN (Site). smtpHost = flag.String("smtp-host", "", "SMTP relay for password-reset email (recovery is off when empty)") smtpPort = flag.Int("smtp-port", 25, "SMTP relay port") smtpUser = flag.String("smtp-user", "", "SMTP user name (optional; the password comes from -smtp-password-file)") smtpPassFile = flag.String("smtp-password-file", "", "File holding the SMTP password, mode 0600") mailFrom = flag.String("mail-from", "", "Sender address of server email, e.g. \"Chess \"") mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)") - publicURL = flag.String("public-url", "", "Web client address that emailed links open, e.g. https://example.com/chess/web-client/") + publicURL = flag.String("public-url", "", "Web client address: emailed links open it, and its host is the Site of exported games, e.g. https://example.com/chess/web-client/") // Web UI server flags serve = flag.Bool("serve", false, "Enable web UI server") @@ -178,6 +180,14 @@ func main() { } svc.SetIntegrityMode(integrity) + if *publicURL != "" { + u, err := url.Parse(*publicURL) + if err != nil || u.Scheme == "" || u.Host == "" { + log.Fatalf("Invalid -public-url %q: must be absolute (https://host/chess/web-client/)", *publicURL) + } + svc.SetSite(u.Hostname()) + } + // Password recovery needs mail and the client's public address. sender, err := mail.Config{ SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile, diff --git a/deploy/freebsd/rc.d/chessd b/deploy/freebsd/rc.d/chessd index 81880e9..92fc021 100755 --- a/deploy/freebsd/rc.d/chessd +++ b/deploy/freebsd/rc.d/chessd @@ -18,8 +18,9 @@ # Default: ${chessd_home}/jwt.key # chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is # trusted as the client address. Default: none -# chessd_public_url: The web client's public address, which emailed links -# open. Default: none +# chessd_public_url: The web client's public address: emailed links open +# it, and its host is the Site of exported PGN. +# Default: none # chessd_smtp_host: SMTP relay for password-reset email (the mail jail). # Unset leaves password recovery off. Default: none # chessd_smtp_port: Relay port. Default: 25 @@ -33,8 +34,8 @@ # Add the following to /etc/rc.conf to enable: # chessd_enable="YES" # chessd_trusted_proxies="
" -# and, for password recovery by email, once the mail jail relays for this one: # chessd_public_url="https:///chess/web-client/" +# and, for password recovery by email, once the mail jail relays for this one: # chessd_smtp_host="" # chessd_mail_from="Chess >" @@ -105,11 +106,14 @@ chessd_start() if [ -n "${chessd_trusted_proxies}" ]; then set -- "$@" -trusted-proxies "${chessd_trusted_proxies}" fi - # Password recovery by email: relay, sender, and the client address the - # link opens. The server refuses a relay without the other two. + if [ -n "${chessd_public_url}" ]; then + set -- "$@" -public-url "${chessd_public_url}" + fi + # Password recovery by email: relay and sender; the link opens the public + # URL. The server refuses a relay without both and the URL. if [ -n "${chessd_smtp_host}" ]; then set -- "$@" -smtp-host "${chessd_smtp_host}" -smtp-port "${chessd_smtp_port}" \ - -mail-from "${chessd_mail_from}" -public-url "${chessd_public_url}" + -mail-from "${chessd_mail_from}" if [ -n "${chessd_smtp_user}" ]; then set -- "$@" -smtp-user "${chessd_smtp_user}" secret="${chessd_smtp_password_file:-${chessd_home}/smtp.secret}" diff --git a/doc/api.md b/doc/api.md index 2dd6401..9a20613 100644 --- a/doc/api.md +++ b/doc/api.md @@ -343,11 +343,12 @@ Same visibility, `ETag`, and error responses as the history. `ply` (optional, 0 up to the number of stored plies) exports only the first N plies; the result is then `*`, the `Termination` tag is left out, and the filename ends `-ply.pgn`. A `fetch` of the URL returns the text, so clients can copy it -as well as download it. +as well as download it. `Site` is the host of the server's `-public-url` (`?` +without one); an imported game keeps its own. ``` [Event "Casual game"] -[Site "?"] +[Site "example.com"] [Date "2026.09.27"] [Round "-"] [White "alice"] diff --git a/doc/deployment-linux.md b/doc/deployment-linux.md index bf96e6b..dc7d25b 100644 --- a/doc/deployment-linux.md +++ b/doc/deployment-linux.md @@ -120,7 +120,7 @@ file is kept next to it with a timestamp). | `CHESSD_DSN` | `"host= dbname=chess"` | Database over the socket as the OS account | | `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key | | `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses | -| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report`, or password recovery by email: `-smtp-host -mail-from "Chess " -public-url https://example.com/chess/web-client/` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) | +| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report -public-url https://example.com/chess/web-client/` (the URL's host is the `Site` of exported PGN), or password recovery by email, which also needs `-public-url`: `-smtp-host -mail-from "Chess "` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) | Server flags worth knowing: `-max-users` (registration cap, default 100), `-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`), diff --git a/doc/deployment.md b/doc/deployment.md index 1176349..6332567 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -154,7 +154,7 @@ as `postgres`, list them with `\dt chess.*` and name them as `chess.games`. | `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user | | `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing | | `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted | -| `chessd_public_url` | empty | The web client's public address, which emailed reset links open (`https:///chess/web-client/`) | +| `chessd_public_url` | empty | The web client's public address (`https:///chess/web-client/`): emailed reset links open it, and its host is the `Site` of exported PGN | | `chessd_smtp_host`, `chessd_smtp_port` | empty, `25` | SMTP relay for password-reset email; unset leaves recovery off | | `chessd_smtp_user`, `chessd_smtp_password_file` | empty, `${chessd_home}/smtp.secret` if present | Relay credentials, when the relay authenticates this host; the file is mode 0600 | | `chessd_mail_from` | empty | Sender address, e.g. `Chess >` | @@ -182,7 +182,7 @@ Server flags: | `-smtp-user`, `-smtp-password-file` | | Relay credentials, optional; the file is mode 0600 and only sent over TLS | | `-mail-from` | | Sender address of server email | | `-mail-dir` | | Development: write email to `.eml` files here instead of a relay | -| `-public-url` | | The web client's address that emailed links open; required with a transport | +| `-public-url` | | The web client's address: emailed links open it, and its host is the `Site` of exported PGN; required with a transport | Administrative CLI commands run as `chess` so peer authentication selects the right role; root may use `su -m` although the account has no shell: diff --git a/doc/development.md b/doc/development.md index 5f08387..371cb2b 100644 --- a/doc/development.md +++ b/doc/development.md @@ -40,7 +40,7 @@ go build ./cmd/chess-client-cli - `-web-api-url`: Browser-visible API origin for the embedded web client, set on its script element as `data-api`; useful when its public origin differs from the listen address - `-smtp-host`, `-smtp-port`, `-smtp-user`, `-smtp-password-file`, `-mail-from`: SMTP relay for password-reset email; without a transport, recovery is off - `-mail-dir`: Development: write email to `.eml` files in this directory instead of sending -- `-public-url`: The web client's address that emailed links open (required with a transport) +- `-public-url`: The web client's address: emailed links open it, and its host is the `Site` of exported PGN (required with a transport) ### Modes ```bash diff --git a/internal/server/replay/replay.go b/internal/server/replay/replay.go index 6c4f799..7bbf27c 100644 --- a/internal/server/replay/replay.go +++ b/internal/server/replay/replay.go @@ -52,8 +52,9 @@ type PGN struct { // BuildPGN exports a stored game. ply < 0 exports every move; otherwise the // first ply moves, with result "*" and no termination when that stops short -// of the end of the line. -func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) (*PGN, error) { +// of the end of the line. site is the Site tag of a game played on this +// server (PGN's "?" when empty); an imported game keeps its own. +func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int, site string) (*PGN, error) { if ply > len(moves) { return nil, fmt.Errorf("%w: game has %d plies", ErrPlyOutOfRange, len(moves)) } @@ -75,7 +76,7 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) ( } } start := record.StartTimeUTC.UTC() - event, site, round := "Casual game", "", "" + event, round := "Casual game", "" if record.ImportedBy != "" { event, site, round = record.Event, record.Site, record.Round if event == "" { diff --git a/internal/server/replay/replay_test.go b/internal/server/replay/replay_test.go index 5278af4..6ffe6a1 100644 --- a/internal/server/replay/replay_test.go +++ b/internal/server/replay/replay_test.go @@ -129,7 +129,7 @@ func TestBuildPGN(t *testing.T) { rec := record(chess.StartFEN, "white_wins", "resignation") rec.BlackType, rec.BlackLevel, rec.WhiteType, rec.WhiteName = 2, 4, 1, "alice" - pgn, err := BuildPGN(rec, moves, -1) + pgn, err := BuildPGN(rec, moves, -1, "") if err != nil { t.Fatal(err) } @@ -143,25 +143,45 @@ func TestBuildPGN(t *testing.T) { t.Errorf("filename %q", pgn.Filename) } - partial, err := BuildPGN(rec, moves, 1) + partial, err := BuildPGN(rec, moves, 1, "") if err != nil { t.Fatal(err) } if !strings.HasSuffix(partial.Text, "\n\n1. e4 *\n") || partial.Filename != "chess-20260928-68007fd1-ply1.pgn" { t.Errorf("partial %q:\n%s", partial.Filename, partial.Text) } - if _, err := BuildPGN(rec, moves, 3); err == nil { + if _, err := BuildPGN(rec, moves, 3, ""); err == nil { t.Error("ply beyond the line accepted") } } +// A game played here names the server's site; an imported game keeps its own. +func TestBuildPGNSite(t *testing.T) { + moves := stored(t, chess.StartFEN, "e2e4") + rec := record(chess.StartFEN, "", "") + for _, tc := range []struct{ importedBy, recordSite, site, want string }{ + {"", "", "lixen.com", "lixen.com"}, + {"", "", "", "?"}, + {"alice", "Linares ESP", "lixen.com", "Linares ESP"}, + } { + rec.ImportedBy, rec.Site = tc.importedBy, tc.recordSite + pgn, err := BuildPGN(rec, moves, -1, tc.site) + if err != nil { + t.Fatal(err) + } + if want := "[Site \"" + tc.want + "\"]\n"; !strings.Contains(pgn.Text, want) { + t.Errorf("site %q, imported by %q: PGN lacks %q:\n%s", tc.site, tc.importedBy, want, pgn.Text) + } + } +} + // A resignation taken back by an undo is noted before the live result. func TestBuildPGNNotesContinuedConcession(t *testing.T) { moves := stored(t, chess.StartFEN, "e2e4", "e7e5", "d2d4") rec := record(chess.StartFEN, "", "") rec.ConcessionResult, rec.ConcessionTermination, rec.ConcessionPly = "black_wins", "resignation", 2 - pgn, err := BuildPGN(rec, moves, -1) + pgn, err := BuildPGN(rec, moves, -1, "") if err != nil { t.Fatal(err) } @@ -171,7 +191,7 @@ func TestBuildPGNNotesContinuedConcession(t *testing.T) { // The concession that is still the result needs no note. rec.Result, rec.Termination, rec.ConcessionPly = "black_wins", "resignation", 3 - if pgn, _ = BuildPGN(rec, moves, -1); strings.Contains(pgn.Text, "continued") { + if pgn, _ = BuildPGN(rec, moves, -1, ""); strings.Contains(pgn.Text, "continued") { t.Errorf("standing resignation noted as continued:\n%s", pgn.Text) } } diff --git a/internal/server/service/history.go b/internal/server/service/history.go index b5ff05b..b8d7a72 100644 --- a/internal/server/service/history.go +++ b/internal/server/service/history.go @@ -79,7 +79,7 @@ func (s *Service) GetGamePGN(gameID string, ply int) (*replay.PGN, error) { if err != nil { return nil, err } - pgn, err := replay.BuildPGN(record, moves, ply) + pgn, err := replay.BuildPGN(record, moves, ply, s.site) if errors.Is(err, replay.ErrNotation) { slog.Warn("stored game cannot be exported as PGN", "game_id", gameID, "error", err) } diff --git a/internal/server/service/service.go b/internal/server/service/service.go index 0248316..367bd89 100644 --- a/internal/server/service/service.go +++ b/internal/server/service/service.go @@ -43,6 +43,7 @@ type Service struct { integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only mail *mailer // password recovery; nil when no transport is configured + site string // Site tag of exported games played here; empty is PGN's "?" } // New creates a service with optional storage. jwtSecret signs session tokens @@ -93,6 +94,12 @@ func (s *Service) SetAnonymousGameTTL(ttl time.Duration) { s.mu.Unlock() } +// SetSite names this server in the Site tag of the games it exports, from +// the web client's public address; call it before serving. +func (s *Service) SetSite(site string) { + s.site = site +} + // SetMaxUsers caps the number of accounts public registration may create; // zero removes the cap. Accounts created through the CLI are not limited. func (s *Service) SetMaxUsers(limit int) { diff --git a/internal/server/webserver/chess-client-web/style.css b/internal/server/webserver/chess-client-web/style.css index 86d8295..48a06cf 100644 --- a/internal/server/webserver/chess-client-web/style.css +++ b/internal/server/webserver/chess-client-web/style.css @@ -1,12 +1,8 @@ /* Chess web client. Dark theme matching lixen.com. The board colours and piece glyph treatment are fixed (see pieceMap in app.js); everything else is - open. Styles live here: the host's CSP has style-src 'unsafe-inline', but - state is expressed through classes and data attributes, never inline style. - - Two layouts share the markup: board beside the panel from 979px up, and - board over the panel below (the host's embed frame switches its height at - the same width). Both must fit the host's frame: 30rem tall side by side, - 46rem (capped at 80svh) stacked. */ + open. State is classes and data attributes, never inline style. Two layouts + share the markup: board beside the panel from 700px up, board over the + panel below; the host's embed frame switches its height at that width. */ *, *::before, *::after { box-sizing: border-box; } @@ -45,16 +41,14 @@ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; - /* Side-by-side sizes: the board edge includes its coordinate margin. The - panel takes a quarter of the width between 260px and 420px; the board - takes everything else the window leaves beside it and under the top - bar, so the margins give way first, the board second, and the panel - is never pushed out of view. 3.5rem and 2rem are the app's padding and - gaps (8px each side, a gap of up to 2rem beside the board) plus 8px of - slack; the host's embed sizes its frame to this formula. */ + /* Side by side, the board (its edge holds the coordinates) takes the + height under the top bar, or the width the panel's minimum leaves, and + the panel takes the rest of the row. 3.5rem and 2rem are the app's + padding and gaps (8px each side, a gap of up to 2rem) plus 8px of slack; + the host's embed sizes its frame to this formula. */ --topbar-height: 2.5rem; - --panel-width: clamp(260px, 24vw, 420px); - --board-edge: clamp(280px, min(100vw - var(--panel-width) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px); + --panel-min: 260px; + --board-edge: clamp(280px, min(100vw - var(--panel-min) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px); --board-pad: clamp(14px, 1.04vw, 20px); } @@ -399,9 +393,12 @@ body[data-mode="replay"] .square, .square.inert { cursor: default; } /* --- Panel ----------------------------------------------------------- */ +/* Up to 48rem: a full window on a large screen centres the pair rather than + stretching the controls across it. */ .panel { - flex: none; - width: var(--panel-width); + flex: 1 1 0; + min-width: var(--panel-min); + max-width: 48rem; height: var(--board-edge); background: var(--surface); border: 1px solid var(--line); @@ -1104,9 +1101,6 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin than the controls dropping below the fold. */ --board-edge: max(240px, min(100vw, calc(100svh - 15.75rem))); --board-pad: 14px; - --panel-width: 100%; - --panel-height: auto; - --topbar-height: 2.5rem; } body { overflow-y: auto; overflow-x: hidden; } @@ -1154,6 +1148,8 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin flex-direction: column; width: 100%; flex: 1 1 auto; + min-width: 0; + max-width: none; min-height: 0; height: auto; border-radius: 0;