v0.16.2 PGN site, web client panel update
This commit is contained in:
1 parent
0461020c46
commit
a012d07cd8
12 files changed
+84
-45
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <chess@example.com>\"")
|
||||
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,
|
||||
|
||||
@@ -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="<address nginx connects from>"
|
||||
# and, for password recovery by email, once the mail jail relays for this one:
|
||||
# chessd_public_url="https://<site>/chess/web-client/"
|
||||
# and, for password recovery by email, once the mail jail relays for this one:
|
||||
# chessd_smtp_host="<mail jail address>"
|
||||
# chessd_mail_from="Chess <chess@<site>>"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+3
-2
@@ -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<N>.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"]
|
||||
|
||||
@@ -120,7 +120,7 @@ file is kept next to it with a timestamp).
|
||||
| `CHESSD_DSN` | `"host=<socket dir> 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 <relay> -mail-from "Chess <chess@example.com>" -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 <relay> -mail-from "Chess <chess@example.com>"` (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`),
|
||||
|
||||
+2
-2
@@ -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://<site>/chess/web-client/`) |
|
||||
| `chessd_public_url` | empty | The web client's public address (`https://<site>/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 <chess@<site>>` |
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user