From 664cf9654ebb23620d3f02e577c270f98d044e562d5e62b481baf6ab207ba66f Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Mon, 19 Oct 2026 21:02:39 -0400 Subject: [PATCH] v0.16.0 import PGN, delete games, two-layout web client --- cmd/chess-server/cli/cli.go | 95 +++++- doc/api.md | 58 +++- doc/client.md | 20 +- doc/database.md | 8 + doc/deployment.md | 5 +- doc/todo.md | 41 ++- internal/client/api/client.go | 15 + internal/client/api/types.go | 21 ++ internal/client/command/game.go | 71 +++- internal/client/command/import_native.go | 26 ++ internal/client/command/import_wasm.go | 11 + internal/client/command/registry.go | 1 + internal/server/chess/pgnread.go | 312 ++++++++++++++++++ internal/server/chess/pgnread_test.go | 149 +++++++++ internal/server/core/api.go | 32 +- internal/server/http/handler.go | 86 +++++ internal/server/http/handler_test.go | 2 + internal/server/http/validator.go | 2 + internal/server/replay/replay.go | 11 +- internal/server/service/history.go | 6 + internal/server/service/import.go | 250 ++++++++++++++ internal/server/service/import_test.go | 179 ++++++++++ internal/server/storage/game.go | 15 +- internal/server/storage/import.go | 127 +++++++ internal/server/storage/integrity.go | 11 +- internal/server/storage/schema.go | 33 +- internal/server/storage/storage_test.go | 2 +- internal/server/storage/user.go | 5 +- .../server/webserver/chess-client-web/app.js | 244 +++++++++++++- .../webserver/chess-client-web/index.html | 31 +- .../webserver/chess-client-web/style.css | 175 +++++----- test/README.md | 16 +- test/e2e/replay.spec.js | 70 ++++ test/run-test-server.sh | 27 +- 34 files changed, 2007 insertions(+), 150 deletions(-) create mode 100644 internal/client/command/import_native.go create mode 100644 internal/client/command/import_wasm.go create mode 100644 internal/server/chess/pgnread.go create mode 100644 internal/server/chess/pgnread_test.go create mode 100644 internal/server/service/import.go create mode 100644 internal/server/service/import_test.go create mode 100644 internal/server/storage/import.go diff --git a/cmd/chess-server/cli/cli.go b/cmd/chess-server/cli/cli.go index 44e6b9f..08382e9 100644 --- a/cmd/chess-server/cli/cli.go +++ b/cmd/chess-server/cli/cli.go @@ -1,6 +1,7 @@ package cli import ( + "context" "database/sql" "errors" "flag" @@ -26,7 +27,7 @@ const dsnEnv = "CHESS_DSN" // Run is the entry point for the CLI mini-app func Run(args []string) error { if len(args) == 0 { - return fmt.Errorf("subcommand required: init, delete, query, pgn, verify, user") + return fmt.Errorf("subcommand required: init, delete, query, pgn, verify, game, user") } switch args[0] { @@ -40,6 +41,11 @@ func Run(args []string) error { return runPGN(args[1:]) case "verify": return runVerify(args[1:]) + case "game": + if len(args) < 2 { + return fmt.Errorf("game subcommand required: delete, import") + } + return runGame(args[1], args[2:]) case "user": if len(args) < 2 { return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, reset-link, list") @@ -268,6 +274,93 @@ func runVerify(args []string) error { return nil } +func runGame(subcommand string, args []string) error { + switch subcommand { + case "delete": + return runGameDelete(args) + case "import": + return runGameImport(args) + default: + return fmt.Errorf("unknown game subcommand: %s", subcommand) + } +} + +// runGameDelete removes a stored game and its moves outright, whoever holds +// it. A live copy in a running server keeps playing in memory until it is +// unloaded; its later writes find no row and are dropped. +func runGameDelete(args []string) error { + fs, dsn := newFlagSet("game delete") + gameID := fs.String("gameId", "", "Game ID or unique prefix of at least 8 hex digits (required)") + confirm := fs.Bool("confirm", false, "Confirm deleting the game and its moves") + if err := fs.Parse(args); err != nil { + return err + } + if *gameID == "" { + return errors.New("-gameId is required") + } + if !*confirm { + return errors.New("refusing to delete a game without -confirm") + } + + store, err := openStore(*dsn, true) + if err != nil { + return err + } + defer store.Close() + + id, err := resolveGameID(store, *gameID) + if err != nil { + return err + } + if err := store.DeleteGames([]string{id}); err != nil { + return fmt.Errorf("delete game: %w", err) + } + if err := store.Flush(context.Background()); err != nil { + return fmt.Errorf("delete game: %w", err) + } + fmt.Printf("Deleted game %s\n", id) + return nil +} + +// runGameImport stores a PGN file as a game of the named account, as the +// API's import does: every move is checked by the rules core. +func runGameImport(args []string) error { + fs, dsn := newFlagSet("game import") + file := fs.String("file", "", "PGN file holding one game (required)") + username := fs.String("username", "", "Account that will own the game (required)") + if err := fs.Parse(args); err != nil { + return err + } + if *file == "" || *username == "" { + return errors.New("-file and -username are required") + } + text, err := os.ReadFile(*file) + if err != nil { + return err + } + + store, err := openStore(*dsn, true) + if err != nil { + return err + } + defer store.Close() + + ownerID, err := lookupUser(store, *username, "") + if err != nil { + return err + } + svc, err := service.New(store, []byte(strings.Repeat("x", 32))) // no tokens are minted here + if err != nil { + return err + } + imported, err := svc.ImportGame(ownerID, string(text)) + if err != nil { + return err + } + fmt.Printf("Imported game %s (%d plies) for %s\n", imported.GameID, imported.Plies, *username) + return nil +} + func resolveGameID(store *storage.Store, prefix string) (string, error) { id, err := store.ResolveGameID(prefix) if errors.Is(err, sql.ErrNoRows) { diff --git a/doc/api.md b/doc/api.md index 468b648..2dd6401 100644 --- a/doc/api.md +++ b/doc/api.md @@ -290,6 +290,7 @@ carries its SAN, derived on read from the stored position before it. "termination": "checkmate", "startTimeUtc": "2026-09-07T12:00:00Z", "endTimeUtc": "2026-09-07T12:15:00Z", + "origin": "played", "players": { "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"}, "black": {"id": "player-id", "color": 2, "type": 1} @@ -312,6 +313,8 @@ resignation or agreed draw, in stored-result form (`"result": "black_wins"`); see [Continuing after a resignation or agreed draw](#continuing-after-a-resignation-or-agreed-draw). +`origin` is `played` or `imported`; an imported game also carries the +`event`, `site` and `round` its PGN had. `result` is omitted while a game is ongoing. Persisted terminal values are `white_wins`, `black_wins`, `draw`, and `stalemate`. `pgnResult` is the PGN token for the same outcome (`1-0`, `0-1`, `1/2-1/2`, or `*` while ongoing), @@ -389,7 +392,8 @@ later first-move slot claim, newest first. Requires Each item contains game ID, initial FEN, final FEN (after the last stored move, or the initial FEN when there is none), result, its `pgnResult` token and `termination` (as in the history; both omitted while ongoing), timestamps, -players, and move count. `nextCursor` is present only when another +players, move count, `origin` (`played` or `imported`) and, for an imported +game, its `event`. `nextCursor` is present only when another page exists; `nextOffset` also, on requests without a cursor. A game belongs to a user who created it while authenticated or claimed a slot with a first move; the association survives later player reconfiguration. @@ -410,6 +414,7 @@ Returns 400 `INVALID_REQUEST` for a malformed cursor or filter. "startTimeUtc": "2026-09-07T12:00:00Z", "endTimeUtc": "2026-09-07T12:15:00Z", "moveCount": 7, + "origin": "played", "players": { "white": {"id": "user-id", "color": 1, "type": 1, "claimedBy": "user-id", "name": "alice"}, "black": {"id": "player-id", "color": 2, "type": 1} @@ -422,6 +427,56 @@ Returns 400 `INVALID_REQUEST` for a malformed cursor or filter. } ``` +### Import a Game from PGN +`POST /games/import` + +Stores one PGN game under the signed-in account, for replay and export. +Requires `Authorization: Bearer ` and persistent storage; 5 requests a +minute per IP. The PGN is read strictly: well-formed tag pairs, printable +text, balanced comments and variations (which are dropped, with annotations +and numeric glyphs), one game, at most 64 KiB and 1000 plies. Every move is +replayed by the rules core; an illegal move is refused with its ply. The +result comes from the PGN's result token and must agree with the final +position (a `1-0` after Black mates is refused); the stored termination is +checkmate or stalemate when the position says so, a draw rule when one +applies, otherwise resignation or agreement. `White`, `Black`, `Event`, +`Site`, `Round` and `Date`/`UTCDate`/`UTCTime` tags are kept; `SetUp`/`FEN` +set the starting position. + +**Request:** +```json +{"pgn": "[White \"Spassky, Boris\"]\n[Black \"Fischer, Robert\"]\n[Result \"1-0\"]\n\n1. d4 Nf6 2. c4 e6 3. Nf3 d5 1-0\n"} +``` + +**Response (201):** +```json +{"gameId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", "moveCount": 6, "result": "white_wins", "pgnResult": "1-0"} +``` + +Errors: 400 `INVALID_REQUEST` with `details` saying what is wrong with the +PGN, 401 without a token, 429 past the limit, 503 `STORAGE_UNAVAILABLE`. + +An imported game has no claimed side: it is listed for its importer with +`"origin": "imported"` and its `event`, is excluded from the `color` filter, +and is never purged as anonymous. Its history and PGN carry the tags. + +### Remove a Stored Game +`DELETE /users/me/games/{gameId}` + +Takes a stored game off the signed-in user's list. A game the user alone +holds (the only claimant, or its importer) is deleted with its moves, and +unloaded if it was still in memory; a game another user also claims is kept +for them, and only the caller's claim and name are cleared from it. + +**Response (200):** +```json +{"removed": "game"} +``` + +`removed` is `game` or `claim`. Errors: 400 for a malformed ID, 404 +`GAME_NOT_FOUND` when the user holds nothing in that game, 503 when storage +is unavailable. + ### Make Move `POST /games/{gameId}/moves` @@ -603,6 +658,7 @@ Error codes: - Login: 10 requests/minute/IP - Account deletion: 5 requests/minute/IP - Password recovery: 3 requests/minute/IP; reset: 10 requests/minute/IP +- PGN import: 5 requests/minute/IP Exceeding limit returns 429 status. Behind a reverse proxy the client IP is the proxy's `X-Real-IP` header, trusted only from addresses listed in diff --git a/doc/client.md b/doc/client.md index e2dd6ee..674c72a 100644 --- a/doc/client.md +++ b/doc/client.md @@ -182,11 +182,23 @@ repetition.` or `Black wins by resignation.`; a pending offer is shown as #### `games` / `g` List your stored games, newest first, 20 per page (requires login), numbered -for `replay <#>`. The result column is the PGN token (`1-0`, `0-1`, -`1/2-1/2`, `*`). +for `replay <#>` and `games delete <#>`. The result column is the PGN token +(`1-0`, `0-1`, `1/2-1/2`, `*`). Deleting a game you alone hold removes it +with its moves; a game another player also claims is only released from +your list. ``` -chess > games # Newest games -chess > games more # Next page +chess > games # Newest games +chess > games more # Next page +chess > games delete 3 # Remove the third game of the last listing (or an ID) +``` + +#### `import` +Store a PGN file (one game) under your account, for replay with `replay`. +The server checks every move; tags such as `White`, `Black`, `Event` and +`Date` are kept. Not available in the browser build, which has no files; +the web board imports a pasted PGN instead. +``` +chess > import games/fischer-spassky-1972-1.pgn ``` #### `pgn` diff --git a/doc/database.md b/doc/database.md index 42bd83a..b6130d6 100644 --- a/doc/database.md +++ b/doc/database.md @@ -137,6 +137,14 @@ one-time password-reset link (valid an hour) to hand to someone who cannot receive the recovery email. CLI-created accounts are identical to site registrations, and the CLI ignores the registration cap. +A game imported from PGN (`POST /api/games/import`, `db game import`) has +`imported_by` set to its owner, no claims, the PGN's `event`, `site` and +`round`, and both sides human with the tag names as snapshots. It is listed +for its owner, never purged as anonymous, and deleted with its moves when the +owner removes it (`DELETE /api/users/me/games/{id}`) or by `db game delete +-gameId -confirm`. A player removes a game they share with +another registered player by releasing their own claim instead. + Pending password resets live in `password_resets`: the SHA-256 of the token in the emailed link, one per account, deleted when used, expired, or by the hourly cleanup. The token itself is never stored. diff --git a/doc/deployment.md b/doc/deployment.md index 26439b5..1176349 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -194,8 +194,9 @@ jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:/ `db` subcommands: `init`, `delete -confirm` (drops the tables), `query` (games, with 8-digit ID prefixes), `pgn -gameId [-ply N]`, `verify [-gameId ]` (replays stored games against the rules and -exits non-zero on a mismatch; run it after an upgrade), and `user add|delete| -set-password|set-hash|set-email|set-username|reset-link|list`. +exits non-zero on a mismatch; run it after an upgrade), `game delete -gameId + -confirm`, `game import -file -username `, and `user +add|delete|set-password|set-hash|set-email|set-username|reset-link|list`. A forgotten password is reset from the jail without mail: diff --git a/doc/todo.md b/doc/todo.md index 9453535..cd247bc 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -141,9 +141,11 @@ Constraints from the host's security headers: - `connect-src 'self'`: all requests go to the same origin under `/chess/` (the board's default API base, or the host's `data-api`); no CDN or cross-origin API. -- `frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN`: the iframe host - page must stay on the same origin. Downloads from `blob:` URLs and - `navigator.clipboard` work in a same-origin frame. +- `frame-ancestors 'self'`: the iframe host page must stay on the same + origin. No `X-Frame-Options` beside it: browsers that read + `frame-ancestors` ignore the header and warn in the console on every page + load. Downloads from `blob:` URLs and `navigator.clipboard` work in a + same-origin frame. Test gate: @@ -152,7 +154,10 @@ Test gate: step backward and forward with buttons and keys, autoplay to the end, compare the exported FEN and PGN with the API, download, copy a link, browser back through replay and list; a deep link to a checkmate; the - device list for an anonymous visitor. + device list for an anonymous visitor; a PGN import replayed and deleted + from its row; a phone viewport that holds the board and the controls, + with a long replay scrolling inside its move list; a password reset + through the emailed link. - [x] Keyboard-only navigation and labels: every control is a button with a name, the move list is a list of buttons, the current ply has `aria-current` and is announced; `prefers-reduced-motion` disables the @@ -204,14 +209,25 @@ Later for the replay clients: - [ ] Email verification at registration and on `set-email`; today an address is taken as typed, so a typo sends nobody a link. -## Phase R5 — Curated Archive (later) +## Phase R5 — Imported and Curated Games -- [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site, - date, round, names, Elo, ECO, source URL, license, attribution), publication - state, featured order; no user ownership. -- [ ] `chess-server db archive import ` using the R1 SAN decoder: - validate every move, import each game in one transaction, idempotent by - content hash, dry-run and per-file summary. +Done, per user: a signed-in user imports one PGN game (`POST +/api/games/import`, **Import PGN** in My games, `import ` in the CLI, +`db game import` for an administrator) into their own list. Schema v5 adds +`imported_by`, `event`, `site`, `round`; the strict parser +(`chess.ParsePGN`, fuzzed) refuses anything but one well-formed game, every +move is replayed by the rules core, and the result must agree with the +final position. Imported games are never purged as anonymous and are +deleted by their owner (`DELETE /api/users/me/games/{id}`, the row's delete +button, `games delete`), as are played games the user alone holds; a game +shared with another player is released instead. + +Later, a curated archive: + +- [ ] No user ownership: publication state, featured order, archive metadata + (Elo, ECO, source URL, license, attribution). +- [ ] Bulk import of a PGN file with many games or a directory, idempotent + by content hash, dry-run and per-file summary. - [ ] Public, bounded, cacheable list endpoint; replay through the same history/PGN endpoints. - [ ] Verify redistribution rights for every bundled collection. @@ -223,7 +239,8 @@ Later for the replay clients: - [ ] Degraded-mode recovery without a restart (replay missing writes from memory or fail gameplay closed); today recovery requires a restart. - [ ] Live-game restoration after restart (unfinished games are replay-only). -- [ ] Retention and user-initiated deletion or anonymization of games. +- [x] User-initiated deletion of games (R5); retention of played games is + the anonymous purge only. - [ ] Thread request contexts from HTTP handlers into storage calls; today each call carries its own deadline. - [x] Draw and resign flows (D5) for live play. diff --git a/internal/client/api/client.go b/internal/client/api/client.go index 568b03b..f5a159f 100644 --- a/internal/client/api/client.go +++ b/internal/client/api/client.go @@ -254,6 +254,21 @@ func (c *Client) GetMyGames(limit int, cursor string) (*GameListResponse, error) return &resp, err } +// RemoveMyGame takes a stored game off the caller's list: deleted when the +// caller alone holds it, released when another player keeps it. +func (c *Client) RemoveMyGame(gameID string) (*RemoveGameResponse, error) { + var resp RemoveGameResponse + err := c.doRequest("DELETE", "/api/users/me/games/"+gameID, nil, &resp) + return &resp, err +} + +// ImportGame stores a PGN game for the caller. +func (c *Client) ImportGame(pgn string) (*ImportGameResponse, error) { + var resp ImportGameResponse + err := c.doRequest("POST", "/api/games/import", &ImportGameRequest{PGN: pgn}, &resp) + return &resp, err +} + func (c *Client) Register(username, password, email string) (*AuthResponse, error) { req := &RegisterRequest{ Username: username, diff --git a/internal/client/api/types.go b/internal/client/api/types.go index 6bdc4da..45f1b98 100644 --- a/internal/client/api/types.go +++ b/internal/client/api/types.go @@ -131,6 +131,10 @@ type GameHistoryResponse struct { StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` Concession *Concession `json:"concession,omitempty"` // Result in stored form ("black_wins") + Origin string `json:"origin,omitempty"` + Event string `json:"event,omitempty"` + Site string `json:"site,omitempty"` + Round string `json:"round,omitempty"` Players PlayersResponse `json:"players"` Moves []HistoryMove `json:"moves"` } @@ -154,9 +158,26 @@ type GameSummary struct { StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` MoveCount int `json:"moveCount"` + Origin string `json:"origin,omitempty"` + Event string `json:"event,omitempty"` Players PlayersResponse `json:"players"` } +type ImportGameRequest struct { + PGN string `json:"pgn"` +} + +type ImportGameResponse struct { + GameID string `json:"gameId"` + MoveCount int `json:"moveCount"` + Result string `json:"result,omitempty"` + PGNResult string `json:"pgnResult"` +} + +type RemoveGameResponse struct { + Removed string `json:"removed"` // "game" or "claim" +} + type GameListResponse struct { Games []GameSummary `json:"games"` Limit int `json:"limit"` diff --git a/internal/client/command/game.go b/internal/client/command/game.go index 180a07e..df267c6 100644 --- a/internal/client/command/game.go +++ b/internal/client/command/game.go @@ -98,8 +98,8 @@ func (r *Registry) registerGameCommands() { r.Register(&Command{ Name: "games", ShortName: "g", - Description: "List your stored games, newest first (login required)", - Usage: "games [more]", + Description: "List, or delete, your stored games (login required)", + Usage: "games [more] | games delete <#|gameId>", Handler: gamesHandler, Paged: true, }) @@ -113,6 +113,14 @@ func (r *Registry) registerGameCommands() { Paged: true, }) + r.Register(&Command{ + Name: "import", + ShortName: "", + Description: "Store a PGN file as one of your games (login required)", + Usage: "import ", + Handler: importHandler, + }) + r.Register(&Command{ Name: "poll", ShortName: "p", @@ -686,6 +694,9 @@ func gamesHandler(s *session.Session, args []string) error { if s.AuthToken == "" { return fmt.Errorf("login required") } + if len(args) > 0 && args[0] == "delete" { + return gamesDeleteHandler(s, args[1:]) + } cursor := "" if len(args) > 0 && args[0] == "more" { if s.GamesCursor == "" { @@ -718,6 +729,62 @@ func gamesHandler(s *session.Session, args []string) error { return nil } +// gamesDeleteHandler removes a game from the user's list by its number in +// the last listing or its ID. A game another player also claims is only +// released; one the user alone holds is deleted with its moves. +func gamesDeleteHandler(s *session.Session, args []string) error { + if len(args) < 1 { + return fmt.Errorf("usage: games delete <#|gameId>") + } + gameID := args[0] + if index, err := strconv.Atoi(gameID); err == nil { + if index < 1 || index > len(s.GamesPage) { + return fmt.Errorf("index %d is outside the last listing (1-%d)", index, len(s.GamesPage)) + } + gameID = s.GamesPage[index-1].GameID + } + resp, err := s.Client.RemoveMyGame(gameID) + if err != nil { + return err + } + if resp.Removed == "claim" { + display.Println(display.Yellow, "Released your side of %s; the other player keeps the game", gameID) + } else { + display.Println(display.Green, "Deleted game %s", gameID) + } + if s.Replay != nil && s.Replay.History.GameID == gameID { + s.Replay = nil + } + if s.CurrentGame == gameID && resp.Removed == "game" { + s.CurrentGame = "" + s.CurrentGameState = nil + s.LastMoveCount = 0 + } + return nil +} + +// importHandler stores a PGN file (one game) under the user's account and +// makes it the replay. +func importHandler(s *session.Session, args []string) error { + if s.AuthToken == "" { + return fmt.Errorf("login required") + } + if len(args) < 1 { + return fmt.Errorf("usage: import ") + } + text, err := readImportFile(args[0]) + if err != nil { + return err + } + resp, err := s.Client.ImportGame(text) + if err != nil { + return err + } + display.Println(display.Green, "Imported game %s (%d plies, %s)", resp.GameID, resp.MoveCount, resp.PGNResult) + display.Println(display.Cyan, "Replay it: replay %s", resp.GameID) + return nil +} + func playerLabel(p api.PlayerInfo) string { switch { case p.Type == 2: diff --git a/internal/client/command/import_native.go b/internal/client/command/import_native.go new file mode 100644 index 0000000..65c0777 --- /dev/null +++ b/internal/client/command/import_native.go @@ -0,0 +1,26 @@ +//go:build !js && !wasm + +package command + +import ( + "fmt" + "os" +) + +const maxImportFile = 64 << 10 + +// readImportFile reads a PGN file for import. +func readImportFile(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + if info.Size() > maxImportFile { + return "", fmt.Errorf("%s is larger than %d bytes", path, maxImportFile) + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/client/command/import_wasm.go b/internal/client/command/import_wasm.go new file mode 100644 index 0000000..7ada197 --- /dev/null +++ b/internal/client/command/import_wasm.go @@ -0,0 +1,11 @@ +//go:build js && wasm + +package command + +import "fmt" + +// readImportFile has no file system in the browser; the web board imports +// a pasted PGN instead. +func readImportFile(path string) (string, error) { + return "", fmt.Errorf("files cannot be read in the browser; paste the PGN into the web board's Import instead") +} diff --git a/internal/client/command/registry.go b/internal/client/command/registry.go index 2dc0e45..ed793d0 100644 --- a/internal/client/command/registry.go +++ b/internal/client/command/registry.go @@ -152,6 +152,7 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error { {"draw", "", ""}, {"games", "g", ""}, {"pgn", "", ""}, + {"import", "", ""}, } replayCommands := []cmdInfo{ diff --git a/internal/server/chess/pgnread.go b/internal/server/chess/pgnread.go new file mode 100644 index 0000000..2fe2cc2 --- /dev/null +++ b/internal/server/chess/pgnread.go @@ -0,0 +1,312 @@ +package chess + +import ( + "errors" + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +// Limits on imported PGN. One game of ordinary length is a few kilobytes; +// the longest games on record are under 300 moves. +const ( + MaxPGNSize = 64 << 10 + MaxPGNPlies = 1000 + maxPGNTags = 64 + maxPGNTagName = 32 + maxPGNTagValue = 255 + maxPGNTokenLength = 16 + maxVariationDepth = 16 +) + +// ErrInvalidPGN reports text that is not one well-formed PGN game. +var ErrInvalidPGN = errors.New("invalid PGN") + +// ParsedPGN is one game read from PGN import format: its tag pairs in order +// and the main line's SAN moves. Comments, annotations, numeric annotation +// glyphs and variations are read and dropped. +type ParsedPGN struct { + Tags []Tag + SAN []string + Result string // "1-0", "0-1", "1/2-1/2", or "*" +} + +// Tag returns the value of the named tag, or "" when absent. +func (g *ParsedPGN) Tag(name string) string { + for _, t := range g.Tags { + if t.Name == name { + return t.Value + } + } + return "" +} + +var ( + pgnTagName = regexp.MustCompile(`^[A-Za-z0-9_]{1,32}$`) + // A SAN token: castling, or piece, disambiguation, capture, destination, + // promotion, with check and annotation suffixes. + pgnSAN = regexp.MustCompile(`^(?:O-O(?:-O)?|0-0(?:-0)?|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](?:=?[QRBN])?)[+#]?[!?]{0,2}$`) +) + +func pgnError(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidPGN, fmt.Sprintf(format, args...)) +} + +// ParsePGN reads exactly one game. It is strict about form: well-formed tag +// pairs, printable text only, balanced comments and variations, and SAN +// tokens of the standard shape; anything else is refused rather than +// guessed at. Move legality is not checked here (see Replay and ParseSAN). +func ParsePGN(text string) (*ParsedPGN, error) { + if len(text) > MaxPGNSize { + return nil, pgnError("larger than %d bytes", MaxPGNSize) + } + text = strings.TrimPrefix(text, "\uFEFF") + if !utf8.ValidString(text) { + return nil, pgnError("not valid UTF-8") + } + for i, r := range text { + if r == '\n' || r == '\r' || r == '\t' { + continue + } + if unicode.IsControl(r) || !unicode.IsPrint(r) && !unicode.IsSpace(r) { + return nil, pgnError("control character at byte %d", i) + } + } + text = strings.ReplaceAll(text, "\r\n", "\n") + + game := &ParsedPGN{} + rest, err := parsePGNTags(text, game) + if err != nil { + return nil, err + } + if err := parsePGNMovetext(rest, game); err != nil { + return nil, err + } + if len(game.SAN) == 0 { + return nil, pgnError("no moves") + } + if tag := game.Tag("Result"); tag != "" { + switch tag { + case "1-0", "0-1", "1/2-1/2", "*": + default: + return nil, pgnError("Result tag %q is not a result", tag) + } + if game.Result == "" { + game.Result = tag + } else if game.Result != tag { + return nil, pgnError("Result tag %q disagrees with the movetext result %q", tag, game.Result) + } + } + if game.Result == "" { + game.Result = "*" + } + return game, nil +} + +// parsePGNTags reads the tag section and returns the movetext that follows. +func parsePGNTags(text string, game *ParsedPGN) (string, error) { + seen := make(map[string]bool) + for { + trimmed := strings.TrimLeft(text, " \t\n") + if !strings.HasPrefix(trimmed, "[") { + return text, nil + } + end := strings.IndexByte(trimmed, '\n') + if end < 0 { + end = len(trimmed) + } + line := trimmed[:end] + tag, err := parsePGNTag(line) + if err != nil { + return "", err + } + if seen[tag.Name] { + return "", pgnError("tag %s given twice", tag.Name) + } + if len(game.Tags) >= maxPGNTags { + return "", pgnError("more than %d tags", maxPGNTags) + } + seen[tag.Name] = true + game.Tags = append(game.Tags, tag) + text = trimmed[end:] + } +} + +// parsePGNTag reads one `[Name "Value"]` line. +func parsePGNTag(line string) (Tag, error) { + body := strings.TrimSpace(line) + if !strings.HasPrefix(body, "[") || !strings.HasSuffix(body, "]") { + return Tag{}, pgnError("malformed tag line %q", line) + } + body = strings.TrimSpace(body[1 : len(body)-1]) + space := strings.IndexAny(body, " \t") + if space < 0 { + return Tag{}, pgnError("malformed tag line %q", line) + } + name := body[:space] + if !pgnTagName.MatchString(name) { + return Tag{}, pgnError("invalid tag name %q", name) + } + quoted := strings.TrimSpace(body[space:]) + if len(quoted) < 2 || quoted[0] != '"' || quoted[len(quoted)-1] != '"' { + return Tag{}, pgnError("tag %s value is not quoted", name) + } + var value strings.Builder + escaped := false + inner := quoted[1 : len(quoted)-1] + for i := 0; i < len(inner); i++ { + c := inner[i] + switch { + case escaped: + if c != '\\' && c != '"' { + return Tag{}, pgnError("tag %s has an invalid escape", name) + } + value.WriteByte(c) + escaped = false + case c == '\\': + escaped = true + case c == '"': + return Tag{}, pgnError("tag %s value has an unescaped quote", name) + default: + value.WriteByte(c) + } + } + if escaped { + return Tag{}, pgnError("tag %s value ends in a backslash", name) + } + if utf8.RuneCountInString(value.String()) > maxPGNTagValue { + return Tag{}, pgnError("tag %s value longer than %d characters", name, maxPGNTagValue) + } + return Tag{Name: name, Value: value.String()}, nil +} + +// parsePGNMovetext tokenizes the movetext: moves of the main line are kept, +// comments, variations, annotations and move numbers are read past, and +// the game ends at a result token. +func parsePGNMovetext(text string, game *ParsedPGN) error { + depth := 0 + lineStart := true + i := 0 + for i < len(text) { + c := text[i] + switch { + case c == '\n': + lineStart = true + i++ + continue + case c == ' ' || c == '\t': + i++ + continue + case c == '%' && lineStart: + // Escape mechanism: the rest of the line is for other software. + end := strings.IndexByte(text[i:], '\n') + if end < 0 { + return nil + } + i += end + continue + } + lineStart = false + if game.Result != "" { + return pgnError("text after the result (one game per import)") + } + switch c { + case '{': + end := strings.IndexByte(text[i:], '}') + if end < 0 { + return pgnError("unterminated comment") + } + i += end + 1 + case '}': + return pgnError("unexpected }") + case ';': + end := strings.IndexByte(text[i:], '\n') + if end < 0 { + return nil + } + i += end + case '(': + depth++ + if depth > maxVariationDepth { + return pgnError("variations nested deeper than %d", maxVariationDepth) + } + i++ + case ')': + depth-- + if depth < 0 { + return pgnError("unexpected )") + } + i++ + case '$': + j := i + 1 + for j < len(text) && j-i <= 3 && text[j] >= '0' && text[j] <= '9' { + j++ + } + if j == i+1 { + return pgnError("malformed numeric annotation glyph") + } + i = j + case '*': + if depth == 0 { + game.Result = "*" + } + i++ + default: + // A token of visible, non-delimiter characters. + j := i + for j < len(text) && !strings.ContainsRune(" \t\n{}();$", rune(text[j])) { + j++ + } + token := text[i:j] + if len(token) > maxPGNTokenLength { + return pgnError("token %q too long", token[:maxPGNTokenLength]+"…") + } + i = j + if err := pgnToken(token, depth, game); err != nil { + return err + } + } + } + if depth != 0 { + return pgnError("unterminated variation") + } + return nil +} + +// pgnToken classifies one movetext token: a result, a move number, or a SAN +// move (kept when on the main line). +func pgnToken(token string, depth int, game *ParsedPGN) error { + switch token { + case "1-0", "0-1", "1/2-1/2": + if depth == 0 { + game.Result = token + } + return nil + } + if token[0] >= '0' && token[0] <= '9' && !strings.HasPrefix(token, "0-0") { + // A move number: digits and dots ("12." or "12..."), possibly glued + // to the move that follows ("12.Nf3"). + digits := strings.TrimLeft(token, "0123456789") + number := token[:len(token)-len(digits)] + if len(number) > 4 || !strings.HasPrefix(digits, ".") { + return pgnError("unexpected token %q", token) + } + if rest := strings.TrimLeft(digits, "."); rest != "" { + return pgnToken(rest, depth, game) + } + return nil + } + if !pgnSAN.MatchString(token) { + return pgnError("unexpected token %q", token) + } + if depth > 0 { + return nil + } + if len(game.SAN) >= MaxPGNPlies { + return pgnError("more than %d plies", MaxPGNPlies) + } + game.SAN = append(game.SAN, strings.TrimRight(token, "!?")) + return nil +} diff --git a/internal/server/chess/pgnread_test.go b/internal/server/chess/pgnread_test.go new file mode 100644 index 0000000..ba6cdb7 --- /dev/null +++ b/internal/server/chess/pgnread_test.go @@ -0,0 +1,149 @@ +package chess + +import ( + "errors" + "strings" + "testing" +) + +const scholarsMate = `[Event "Casual game"] +[Site "?"] +[Date "2026.01.02"] +[Round "-"] +[White "alice"] +[Black "bob"] +[Result "1-0"] +[Note "with \"quotes\" and a \\ backslash"] + +1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6?? {a blunder} ( 3... g6 $1 4. Qf3 ) 4. Qxf7# 1-0 +` + +func TestParsePGN(t *testing.T) { + game, err := ParsePGN(scholarsMate) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(game.SAN, " "); got != "e4 e5 Bc4 Nc6 Qh5 Nf6 Qxf7#" { + t.Errorf("SAN = %q", got) + } + if game.Result != "1-0" || game.Tag("White") != "alice" || game.Tag("Round") != "-" { + t.Errorf("result %q, tags %v", game.Result, game.Tags) + } + if game.Tag("Note") != `with "quotes" and a \ backslash` { + t.Errorf("escaped value = %q", game.Tag("Note")) + } + if len(game.Tags) != 8 { + t.Errorf("%d tags", len(game.Tags)) + } + + // Legal in the rules core, and the final position is mate. + pos, _ := ParseFEN(StartFEN) + for i, san := range game.SAN { + m, err := pos.ParseSAN(san) + if err != nil { + t.Fatalf("ply %d %q: %v", i+1, san, err) + } + pos = pos.Play(m) + } + if !pos.InCheck() || pos.HasLegalMoves() { + t.Error("final position is not mate") + } +} + +func TestParsePGNAcceptsLooseButValidForms(t *testing.T) { + for name, text := range map[string]string{ + "no tags, no result": "1.e4 e5 2.Nf3", + "semicolon comment": "1. e4 ; best by test\ne5 *", + "black first": "[FEN \"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1\"]\n[SetUp \"1\"]\n\n1... e5 2. Nf3", + "castling zeros": "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 Nf6 5. 0-0-0", + "escape line": "%ignored line\n1. e4 e5 1/2-1/2", + "nag and annotations": "1. e4!! $14 e5?! 2. Nf3+ ", + "windows line endings": "[Event \"x\"]\r\n\r\n1. e4 e5 0-1\r\n", + "byte order mark": "\uFEFF1. d4 *", + } { + if _, err := ParsePGN(text); err != nil { + t.Errorf("%s: %v", name, err) + } + } +} + +func TestParsePGNRefusesWeirdness(t *testing.T) { + for name, text := range map[string]string{ + "empty": "", + "no moves": "[Event \"x\"]\n\n*", + "two games": "1. e4 e5 1-0\n\n1. d4 d5 *", + "text after result": "1. e4 e5 * and then", + "unterminated comment": "1. e4 {oops e5", + "unterminated variation": "1. e4 (1. d4 e5", + "stray paren": "1. e4 ) e5", + "stray brace": "1. e4 } e5", + "bad token": "1. e4 e5 2. Nf3 hello", + "bad move number": "1x. e4", + "long token": "1. " + strings.Repeat("e", 40), + "html": "1. e4 ", + "bad tag name": "[Ev ent \"x\"]\n\n1. e4 *", + "unquoted tag": "[Event x]\n\n1. e4 *", + "unescaped quote": "[Event \"a\"b\"]\n\n1. e4 *", + "bad escape": "[Event \"a\\n\"]\n\n1. e4 *", + "duplicate tag": "[Event \"x\"]\n[Event \"y\"]\n\n1. e4 *", + "result mismatch": "[Result \"1-0\"]\n\n1. e4 e5 0-1", + "bad result tag": "[Result \"won\"]\n\n1. e4 e5", + "control character": "1. e4\x07 e5", + "invalid utf8": "1. e4 \xff e5", + "too many plies": strings.Repeat("Nf3 Nf6 Ng1 Ng8 ", MaxPGNPlies/4+1), + "deep variations": strings.Repeat("(", maxVariationDepth+1) + "1. e4" + strings.Repeat(")", maxVariationDepth+1), + "long tag value": "[Event \"" + strings.Repeat("x", maxPGNTagValue+1) + "\"]\n\n1. e4 *", + "nag without digits": "1. e4 $ e5", + "trailing garbage in tag": "[Event \"x\"] junk\n\n1. e4 *", + } { + _, err := ParsePGN(text) + if !errors.Is(err, ErrInvalidPGN) { + t.Errorf("%s: error = %v, want ErrInvalidPGN", name, err) + } + } + if _, err := ParsePGN(strings.Repeat("1. e4 e5 ", MaxPGNSize)); !errors.Is(err, ErrInvalidPGN) { + t.Errorf("oversized: %v", err) + } +} + +// Exported games read back unchanged. +func TestParsePGNRoundTripsExport(t *testing.T) { + line, err := Replay(StartFEN, []string{"e2e4", "e7e5", "g1f3", "b8c6", "f1b5", "a7a6", "b5a4", "g8f6", "e1g1"}) + if err != nil { + t.Fatal(err) + } + text := PGN{ + Tags: []Tag{{Name: "Event", Value: "Round trip \"test\""}, {Name: "White", Value: "a"}, {Name: "Black", Value: "b"}}, + StartFEN: StartFEN, SAN: line.SAN, Comment: "Still going.", Result: "*", + }.String() + game, err := ParsePGN(text) + if err != nil { + t.Fatalf("%v\n%s", err, text) + } + if strings.Join(game.SAN, " ") != strings.Join(line.SAN, " ") || game.Tag("Event") != `Round trip "test"` || game.Result != "*" { + t.Errorf("round trip: %v %q", game.SAN, game.Tag("Event")) + } +} + +func FuzzParsePGN(f *testing.F) { + f.Add(scholarsMate) + f.Add("1. e4 e5 (1... c5) {x} $1 2. Nf3 *") + f.Add("[A \"b\"]\n\n") + f.Fuzz(func(t *testing.T, text string) { + game, err := ParsePGN(text) + if err != nil { + if !errors.Is(err, ErrInvalidPGN) { + t.Fatalf("error %v is not ErrInvalidPGN", err) + } + return + } + if len(game.SAN) == 0 || len(game.SAN) > MaxPGNPlies { + t.Fatalf("accepted %d plies", len(game.SAN)) + } + for _, san := range game.SAN { + if !pgnSAN.MatchString(san) { + t.Fatalf("accepted token %q", san) + } + } + }) +} diff --git a/internal/server/core/api.go b/internal/server/core/api.go index f9940b4..c54740d 100644 --- a/internal/server/core/api.go +++ b/internal/server/core/api.go @@ -82,9 +82,30 @@ type BoardResponse struct { Board string `json:"board"` // ASCII representation } +// ImportGameRequest is a PGN game to store for the signed-in user. +type ImportGameRequest struct { + PGN string `json:"pgn" validate:"required,max=65536"` +} + +// ImportGameResponse names the stored game. +type ImportGameResponse struct { + GameID string `json:"gameId"` + MoveCount int `json:"moveCount"` + Result string `json:"result,omitempty"` + PGNResult string `json:"pgnResult"` +} + +// RemoveGameResponse says what DELETE /users/me/games/{id} did: "game" (the +// game is gone) or "claim" (another player keeps it; the caller's side was +// released). +type RemoveGameResponse struct { + Removed string `json:"removed"` +} + // GameHistoryResponse is the durable replay representation of a game. Moves // are ordered and include the resulting FEN so clients do not need an engine -// to replay a stored game. +// to replay a stored game. Origin is "played" or "imported"; Event, Site and +// Round are set for imported games that carried them. // // PGNResult is the PGN result token ("1-0", "0-1", "1/2-1/2", or "*") and // Termination names how a result was reached (see core.Termination; omitted @@ -99,6 +120,10 @@ type GameHistoryResponse struct { StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` Concession *Concession `json:"concession,omitempty"` + Origin string `json:"origin"` + Event string `json:"event,omitempty"` + Site string `json:"site,omitempty"` + Round string `json:"round,omitempty"` Players PlayersResponse `json:"players"` Moves []HistoryMove `json:"moves"` } @@ -117,7 +142,8 @@ type HistoryMove struct { // GameSummary is intentionally sufficient for a client-side game picker; the // full move list remains on the per-game history endpoint. FinalFEN is the // position after the last stored move (the initial FEN when there is none); -// Termination says how a finished game ended, as in the history. +// Termination says how a finished game ended, as in the history; Origin is +// "played" or "imported", and Event is the imported game's event tag. type GameSummary struct { GameID string `json:"gameId"` InitialFEN string `json:"initialFen"` @@ -128,6 +154,8 @@ type GameSummary struct { StartTimeUTC time.Time `json:"startTimeUtc"` EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"` MoveCount int `json:"moveCount"` + Origin string `json:"origin"` + Event string `json:"event,omitempty"` Players PlayersResponse `json:"players"` } diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index c57bb9f..a0d2539 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/lixenwraith/chess/internal/server/chess" "github.com/lixenwraith/chess/internal/server/core" "github.com/lixenwraith/chess/internal/server/processor" "github.com/lixenwraith/chess/internal/server/service" @@ -229,6 +230,20 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) api.Post("/games/:gameId/draw", OptionalAuth(validateToken), h.Draw) api.Get("/games/:gameId/board", h.GetBoard) api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames) + api.Delete("/users/me/games/:gameId", AuthRequired(validateToken), h.RemoveGame) + // Importing replays a whole game through the rules core: 5 a minute per IP. + api.Post("/games/import", AuthRequired(validateToken), limiter.New(limiter.Config{ + Max: 5, + Expiration: 1 * time.Minute, + KeyGenerator: clientIP, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{ + Error: "rate limit exceeded", + Code: core.ErrRateLimitExceeded, + Details: "5 imports per minute allowed", + }) + }, + }), h.ImportGame) return app } @@ -816,6 +831,77 @@ func (h *HTTPHandler) GetCurrentUserGames(c *fiber.Ctx) error { return c.JSON(games) } +// ImportGame stores a PGN game for the signed-in user and names it. +func (h *HTTPHandler) ImportGame(c *fiber.Ctx) error { + userID, _ := c.Locals("userID").(string) + if userID == "" { + return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ + Error: "unauthorized", Code: core.ErrUnauthorized, + }) + } + body := c.Locals("validatedBody") + if validated, _ := c.Locals("validated").(bool); !validated || body == nil { + return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ + Error: "validation bypass detected", Code: core.ErrInternalError, + }) + } + req := *(body.(*core.ImportGameRequest)) + + imported, err := h.svc.ImportGame(userID, req.PGN) + if err != nil { + switch { + case errors.Is(err, service.ErrInvalidPGN): + return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ + Error: "invalid PGN", Code: core.ErrInvalidRequest, + Details: strings.TrimPrefix(err.Error(), "invalid PGN: "), + }) + case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable): + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "stored games unavailable", Code: core.ErrStorageUnavailable, + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ + Error: "failed to import game", Code: core.ErrInternalError, + }) + } + return c.Status(fiber.StatusCreated).JSON(core.ImportGameResponse{ + GameID: imported.GameID, MoveCount: imported.Plies, Result: imported.Result, + PGNResult: chess.ResultToken(imported.Result), + }) +} + +// RemoveGame takes a stored game off the signed-in user's list: deleted when +// the user alone holds it, released when another player keeps it. +func (h *HTTPHandler) RemoveGame(c *fiber.Ctx) error { + userID, _ := c.Locals("userID").(string) + if userID == "" { + return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{ + Error: "unauthorized", Code: core.ErrUnauthorized, + }) + } + gameID := c.Params("gameId") + if !isValidUUID(gameID) { + return invalidGameID(c) + } + outcome, err := h.svc.RemoveGame(gameID, userID) + if err != nil { + switch { + case errors.Is(err, service.ErrGameNotFound): + return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{ + Error: "no such game of yours", Code: core.ErrGameNotFound, + }) + case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable): + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "stored games unavailable", Code: core.ErrStorageUnavailable, + }) + } + return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ + Error: "failed to remove game", Code: core.ErrInternalError, + }) + } + return c.JSON(core.RemoveGameResponse{Removed: string(outcome)}) +} + func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) { raw := c.Query(name) if raw == "" { diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index 7268b8a..f28d036 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -99,6 +99,8 @@ func TestAPIRoutes(t *testing.T) { "POST /api/games/:gameId/resign", "POST /api/games/:gameId/draw", "GET /api/users/me/games", + "DELETE /api/users/me/games/:gameId", + "POST /api/games/import", } { if !registered[want] { t.Errorf("route %s is not registered", want) diff --git a/internal/server/http/validator.go b/internal/server/http/validator.go index 748b58f..e24f4fe 100644 --- a/internal/server/http/validator.go +++ b/internal/server/http/validator.go @@ -28,6 +28,8 @@ func validationMiddleware(c *fiber.Ctx) error { var requestType any switch { + case strings.HasSuffix(path, "/games/import") && method == fiber.MethodPost: + requestType = &core.ImportGameRequest{} case strings.HasSuffix(path, "/games") && method == fiber.MethodPost: requestType = &core.CreateGameRequest{} case strings.HasSuffix(path, "/players") && method == fiber.MethodPut: diff --git a/internal/server/replay/replay.go b/internal/server/replay/replay.go index e1f36fd..6c4f799 100644 --- a/internal/server/replay/replay.go +++ b/internal/server/replay/replay.go @@ -75,9 +75,18 @@ func BuildPGN(record *storage.GameRecord, moves []storage.MoveRecord, ply int) ( } } start := record.StartTimeUTC.UTC() + event, site, round := "Casual game", "", "" + if record.ImportedBy != "" { + event, site, round = record.Event, record.Site, record.Round + if event == "" { + event = "Imported game" + } + } tags := []chess.Tag{ - {Name: "Event", Value: "Casual game"}, + {Name: "Event", Value: event}, + {Name: "Site", Value: site}, {Name: "Date", Value: start.Format("2006.01.02")}, + {Name: "Round", Value: round}, {Name: "White", Value: playerName(record.WhiteType, record.WhiteLevel, record.WhiteName)}, {Name: "Black", Value: playerName(record.BlackType, record.BlackLevel, record.BlackName)}, {Name: "GameId", Value: record.GameID}, diff --git a/internal/server/service/history.go b/internal/server/service/history.go index d67dc4e..b5ff05b 100644 --- a/internal/server/service/history.go +++ b/internal/server/service/history.go @@ -43,6 +43,10 @@ func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, erro Termination: record.Termination, StartTimeUTC: record.StartTimeUTC, EndTimeUTC: record.EndTimeUTC, + Origin: record.Origin(), + Event: record.Event, + Site: record.Site, + Round: record.Round, Players: playersResponse(*record), Moves: make([]core.HistoryMove, 0, len(moves)), } @@ -177,6 +181,8 @@ func (s *Service) GetUserGames(userID string, opts UserGamesOptions) (*core.Game EndTimeUTC: record.EndTimeUTC, MoveCount: record.MoveCount, FinalFEN: record.FinalFEN, + Origin: record.Origin(), + Event: record.Event, Players: playersResponse(record.GameRecord), }) } diff --git a/internal/server/service/import.go b/internal/server/service/import.go new file mode 100644 index 0000000..95cb396 --- /dev/null +++ b/internal/server/service/import.go @@ -0,0 +1,250 @@ +package service + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/lixenwraith/chess/internal/server/chess" + "github.com/lixenwraith/chess/internal/server/core" + "github.com/lixenwraith/chess/internal/server/storage" + + "github.com/google/uuid" +) + +// ErrInvalidPGN wraps chess.ErrInvalidPGN and move-legality failures of an +// import; the message says what was wrong. +var ErrInvalidPGN = chess.ErrInvalidPGN + +// Imported is what ImportGame reports. +type Imported struct { + GameID string + Plies int + Result string // stored result, "" while unfinished +} + +// ImportGame stores a game read from PGN for userID: tags give the names, +// event, site, round and date; every move is replayed by the rules core and +// refused if illegal; the result comes from the PGN's result token, its +// termination from the final position (mate, stalemate, a dead position, +// repetition or the fifty-move rule) or else resignation or agreement. +func (s *Service) ImportGame(userID, text string) (*Imported, error) { + if s.store == nil { + return nil, ErrStorageDisabled + } + if uuid.Validate(userID) != nil { + return nil, errors.New("an import needs a signed-in user") + } + game, err := chess.ParsePGN(text) + if err != nil { + return nil, err + } + + startFEN := chess.StartFEN + if fen := game.Tag("FEN"); fen != "" || game.Tag("SetUp") == "1" { + if fen == "" { + return nil, fmt.Errorf("%w: SetUp without a FEN tag", ErrInvalidPGN) + } + normalized, err := chess.NormalizeFEN(fen) + if err != nil { + return nil, fmt.Errorf("%w: FEN tag: %v", ErrInvalidPGN, err) + } + startFEN = normalized + } + + // Replay the moves, keeping the position after each ply. + pos, err := chess.ParseFEN(startFEN) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidPGN, err) + } + gameID := uuid.NewString() + start := importStartTime(game) + moves := make([]storage.MoveRecord, 0, len(game.SAN)) + positions := []*chess.Position{pos} + for i, san := range game.SAN { + m, err := pos.ParseSAN(san) + if err != nil { + return nil, fmt.Errorf("%w: ply %d (%s): %v", ErrInvalidPGN, i+1, san, err) + } + color := "w" + if pos.Turn() == chess.Black { + color = "b" + } + pos = pos.Play(m) + positions = append(positions, pos) + moves = append(moves, storage.MoveRecord{ + GameID: gameID, MoveNumber: i + 1, MoveUCI: m.UCI(), FENAfterMove: pos.FEN(), + PlayerColor: color, MoveTimeUTC: start, + }) + } + + result, termination, err := importOutcome(game.Result, positions) + if err != nil { + return nil, err + } + record := storage.GameRecord{ + GameID: gameID, + InitialFEN: startFEN, + WhitePlayerID: uuid.NewString(), + WhiteType: int(core.PlayerHuman), + WhiteName: importName(game.Tag("White")), + BlackPlayerID: uuid.NewString(), + BlackType: int(core.PlayerHuman), + BlackName: importName(game.Tag("Black")), + Result: result, + Termination: termination, + StartTimeUTC: start, + ImportedBy: userID, + Event: importTag(game.Tag("Event"), 200), + Site: importTag(game.Tag("Site"), 200), + Round: importTag(game.Tag("Round"), 40), + } + if result != "" { + end := start + record.EndTimeUTC = &end + } + if err := s.store.ImportGame(record, moves); err != nil { + return nil, fmt.Errorf("%w: import game: %v", ErrStorageUnavailable, err) + } + slog.Info("game imported", "game_id", gameID, "user_id", userID, "plies", len(moves), "result", result) + return &Imported{GameID: gameID, Plies: len(moves), Result: result}, nil +} + +// importOutcome maps the PGN result to the stored result and a termination +// the final position supports. +func importOutcome(token string, positions []*chess.Position) (result, termination string, err error) { + final := positions[len(positions)-1] + mate := final.InCheck() && !final.HasLegalMoves() + stalemate := !final.InCheck() && !final.HasLegalMoves() + switch token { + case "1-0", "0-1": + result = "white_wins" + if token == "0-1" { + result = "black_wins" + } + // A decisive result against the side to move at mate; otherwise the + // loser resigned (or lost on time, which the schema has no word for). + if mate && ((token == "1-0") == (final.Turn() == chess.Black)) { + return result, string(core.TermCheckmate), nil + } + if mate || stalemate { + return "", "", fmt.Errorf("%w: result %s does not match the final position", ErrInvalidPGN, token) + } + return result, string(core.TermResignation), nil + case "1/2-1/2": + switch { + case mate: + return "", "", fmt.Errorf("%w: result 1/2-1/2 but the final position is checkmate", ErrInvalidPGN) + case stalemate: + return "stalemate", string(core.TermStalemate), nil + case final.InsufficientMaterial(): + return "draw", string(core.TermInsufficientMaterial), nil + case chess.Repetitions(positions) >= 3: + return "draw", string(core.TermThreefoldRepetition), nil + case final.Halfmove() >= 100: + return "draw", string(core.TermFiftyMoveRule), nil + } + return "draw", string(core.TermAgreement), nil + } + if mate || stalemate { + return "", "", fmt.Errorf("%w: the game is over but the result is *", ErrInvalidPGN) + } + return "", "", nil +} + +// importName is a player name from a tag: "?" and empty mean unknown. +func importName(value string) string { + value = strings.TrimSpace(value) + if value == "?" || value == "" { + return "" + } + if utf8.RuneCountInString(value) > 64 { + value = string([]rune(value)[:64]) + } + return value +} + +func importTag(value string, limit int) string { + value = strings.TrimSpace(value) + if value == "?" || value == "-" && limit == 40 || value == "" { + return "" + } + if utf8.RuneCountInString(value) > limit { + value = string([]rune(value)[:limit]) + } + return value +} + +// importStartTime reads UTCDate/UTCTime, else Date, else now. Unknown parts +// ("????.??.??") fall back to the current time. +func importStartTime(game *chess.ParsedPGN) time.Time { + now := time.Now().UTC() + date := game.Tag("UTCDate") + if date == "" { + date = game.Tag("Date") + } + parts := strings.Split(date, ".") + if len(parts) != 3 { + return now + } + year, err1 := strconv.Atoi(parts[0]) + month, err2 := strconv.Atoi(parts[1]) + day, err3 := strconv.Atoi(parts[2]) + if err1 != nil || year < 1400 || year > now.Year()+1 { + return now + } + if err2 != nil || month < 1 || month > 12 { + month = 1 + } + if err3 != nil || day < 1 || day > 31 { + day = 1 + } + hour, minute, second := 0, 0, 0 + if clock := game.Tag("UTCTime"); clock != "" { + if t, err := time.Parse("15:04:05", clock); err == nil { + hour, minute, second = t.Clock() + } + } + t := time.Date(year, time.Month(month), day, hour, minute, second, 0, time.UTC) + if t.After(now.Add(24 * time.Hour)) { + return now + } + return t +} + +// RemoveGame removes a stored game from the user's list: the game is deleted +// when the user alone holds it, or the user's claim is released when another +// user claims a side (see storage.RemoveGameForUser). A deleted game is also +// unloaded from memory; a released one keeps playing for the other side. +func (s *Service) RemoveGame(gameID, userID string) (storage.RemovalOutcome, error) { + if s.store == nil { + return "", ErrStorageDisabled + } + outcome, err := s.store.RemoveGameForUser(gameID, userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("%w: %s", ErrGameNotFound, gameID) + } + return "", fmt.Errorf("%w: remove game: %v", ErrStorageUnavailable, err) + } + s.mu.Lock() + if g, ok := s.games[gameID]; ok { + switch outcome { + case storage.RemovedGame: + delete(s.games, gameID) + case storage.RemovedClaim: + g.ReleaseUser(userID) + } + } + s.mu.Unlock() + if outcome == storage.RemovedGame { + s.waiter.RemoveGame(gameID) + } + slog.Info("game removed for user", "game_id", gameID, "user_id", userID, "outcome", outcome) + return outcome, nil +} diff --git a/internal/server/service/import_test.go b/internal/server/service/import_test.go new file mode 100644 index 0000000..b9d7a3d --- /dev/null +++ b/internal/server/service/import_test.go @@ -0,0 +1,179 @@ +package service + +import ( + "errors" + "strings" + "testing" + + "github.com/lixenwraith/chess/internal/server/core" + "github.com/lixenwraith/chess/internal/server/storage" +) + +const importPGN = `[Event "World Championship"] +[Site "Reykjavik"] +[Date "1972.07.11"] +[Round "1"] +[White "Spassky, Boris"] +[Black "Fischer, Robert"] +[Result "1-0"] + +1. d4 Nf6 2. c4 e6 3. Nf3 d5 1-0 +` + +func TestImportGameStoresReplaysAndLists(t *testing.T) { + svc := newPersistentTestService(t) + user, _, err := svc.RegisterUser("alice", "", "Password1") + if err != nil { + t.Fatal(err) + } + + imported, err := svc.ImportGame(user.UserID, importPGN) + if err != nil { + t.Fatal(err) + } + if imported.Plies != 6 || imported.Result != "white_wins" { + t.Fatalf("imported = %+v", imported) + } + + history, err := svc.GetGameHistory(imported.GameID) + if err != nil { + t.Fatal(err) + } + if history.Origin != "imported" || history.Event != "World Championship" || history.Site != "Reykjavik" || + history.Round != "1" || history.Players.White.Name != "Spassky, Boris" || history.Players.Black.Name != "Fischer, Robert" || + history.Termination != "resignation" || history.PGNResult != "1-0" || + history.StartTimeUTC.Format("2006-01-02") != "1972-07-11" { + t.Fatalf("history = %+v", history) + } + var san []string + for _, m := range history.Moves { + san = append(san, m.SAN) + } + if strings.Join(san, " ") != "d4 Nf6 c4 e6 Nf3 d5" { + t.Errorf("SAN = %v", san) + } + pgn, err := svc.GetGamePGN(imported.GameID, -1) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`[Event "World Championship"]`, `[Site "Reykjavik"]`, `[Round "1"]`, `[White "Spassky, Boris"]`, "1. d4 Nf6 2. c4 e6 3. Nf3 d5 { White wins by resignation. } 1-0"} { + if !strings.Contains(pgn.Text, want) { + t.Errorf("PGN lacks %q:\n%s", want, pgn.Text) + } + } + + page, err := svc.GetUserGames(user.UserID, UserGamesOptions{Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(page.Games) != 1 || page.Games[0].GameID != imported.GameID || page.Games[0].Origin != "imported" || + page.Games[0].Event != "World Championship" || page.Games[0].MoveCount != 6 { + t.Fatalf("listing = %+v", page.Games) + } + // Imported games have no claimed colour. + page, _ = svc.GetUserGames(user.UserID, UserGamesOptions{Limit: 10, Color: "white"}) + if len(page.Games) != 0 { + t.Errorf("imported game listed under a colour filter") + } + + // Outcomes from the final position. + for name, tc := range map[string]struct{ pgn, result, termination string }{ + "mate": {"1. f3 e5 2. g4 Qh4# 0-1", "black_wins", "checkmate"}, + "stalemate": {"[FEN \"7k/8/6K1/5Q2/8/8/8/8 w - - 0 1\"]\n[SetUp \"1\"]\n\n1. Qf7 1/2-1/2", "stalemate", "stalemate"}, + "agreed": {"1. e4 e5 1/2-1/2", "draw", "agreement"}, + "ongoing": {"1. e4 e5 *", "", ""}, + "material": {"[FEN \"4k3/8/8/8/8/8/8/3RK3 w - - 0 1\"]\n[SetUp \"1\"]\n\n1. Rd8+ Kxd8 1/2-1/2", "draw", "insufficient_material"}, + } { + got, err := svc.ImportGame(user.UserID, tc.pgn) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + h, _ := svc.GetGameHistory(got.GameID) + if h.Result != tc.result || h.Termination != tc.termination { + t.Errorf("%s: result %q %q, want %q %q", name, h.Result, h.Termination, tc.result, tc.termination) + } + } + // Illegal moves and impossible results are refused. + for name, bad := range map[string]string{ + "illegal": "1. e4 e5 2. Ke2 Ke7 3. Kd3 Qh4", + "mate but draw": "1. f3 e5 2. g4 Qh4# 1/2-1/2", + "mate but white": "1. f3 e5 2. g4 Qh4# 1-0", + "over but star": "1. f3 e5 2. g4 Qh4# *", + "bad fen": "[FEN \"nonsense\"]\n[SetUp \"1\"]\n\n1. e4 *", + "not pgn": "hello world", + } { + if _, err := svc.ImportGame(user.UserID, bad); !errors.Is(err, ErrInvalidPGN) { + t.Errorf("%s: error = %v, want ErrInvalidPGN", name, err) + } + } +} + +func TestRemoveGameDeletesOwnAndReleasesShared(t *testing.T) { + svc := newPersistentTestService(t) + alice, _, err := svc.RegisterUser("alice", "", "Password1") + if err != nil { + t.Fatal(err) + } + bob, _, err := svc.RegisterUser("bob", "", "Password1") + if err != nil { + t.Fatal(err) + } + human := core.PlayerConfig{Type: core.PlayerHuman} + + // A game alice alone claims: deleted outright and unloaded. + own := playLine(t, svc, alice.UserID, human, []string{"e2e4"}, core.StateOngoing) + outcome, err := svc.RemoveGame(own, alice.UserID) + if err != nil || outcome != storage.RemovedGame { + t.Fatalf("own game: %v %v", outcome, err) + } + if _, err := svc.GetGameView(own); err == nil { + t.Error("deleted game still in memory") + } + if _, err := svc.GetGameHistory(own); !errors.Is(err, ErrGameNotFound) { + t.Errorf("deleted game history: %v", err) + } + + // An imported game: deleted by its importer, unknown to anyone else. + imported, err := svc.ImportGame(alice.UserID, "1. e4 e5 *") + if err != nil { + t.Fatal(err) + } + if _, err := svc.RemoveGame(imported.GameID, bob.UserID); !errors.Is(err, ErrGameNotFound) { + t.Errorf("bob removing alice's import: %v", err) + } + if outcome, err := svc.RemoveGame(imported.GameID, alice.UserID); err != nil || outcome != storage.RemovedGame { + t.Errorf("alice removing her import: %v %v", outcome, err) + } + + // A game both claim: alice's removal releases her side; bob keeps it. + shared := playLine(t, svc, alice.UserID, human, []string{"e2e4"}, core.StateOngoing) + if err := svc.ApplyMoveWithState(shared, MoveCommit{ + ExpectedFEN: "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", ExpectedState: core.StateOngoing, + ExpectedTurn: core.ColorBlack, ActorUserID: bob.UserID, MoveUCI: "e7e5", + NewFEN: "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2", State: core.StateOngoing, + }); err != nil { + t.Fatal(err) + } + outcome, err = svc.RemoveGame(shared, alice.UserID) + if err != nil || outcome != storage.RemovedClaim { + t.Fatalf("shared game: %v %v", outcome, err) + } + view, err := svc.GetGameView(shared) + if err != nil || view.WhitePlayer.ClaimedBy != "" || view.BlackPlayer.ClaimedBy != bob.UserID { + t.Errorf("shared game after release: %v %+v", err, view.WhitePlayer) + } + history, _ := svc.GetGameHistory(shared) + if history.Players.White.ClaimedBy != "" || history.Players.White.Name != "" || history.Players.Black.Name != "bob" { + t.Errorf("stored claims after release: %+v %+v", history.Players.White, history.Players.Black) + } + if page, _ := svc.GetUserGames(alice.UserID, UserGamesOptions{Limit: 10}); len(page.Games) != 0 { + t.Errorf("alice still lists %d games", len(page.Games)) + } + if page, _ := svc.GetUserGames(bob.UserID, UserGamesOptions{Limit: 10}); len(page.Games) != 1 { + t.Errorf("bob lists %d games", len(page.Games)) + } + if _, err := svc.RemoveGame(shared, alice.UserID); !errors.Is(err, ErrGameNotFound) { + t.Errorf("second removal: %v", err) + } +} diff --git a/internal/server/storage/game.go b/internal/server/storage/game.go index 2a51922..063527f 100644 --- a/internal/server/storage/game.go +++ b/internal/server/storage/game.go @@ -17,7 +17,8 @@ const gameSelectColumns = ` g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name, g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name, g.result, g.termination, g.start_time_utc, g.end_time_utc, - g.concession_result, g.concession_termination, g.concession_ply, g.concession_time_utc` + g.concession_result, g.concession_termination, g.concession_ply, g.concession_time_utc, + g.imported_by, g.event, g.site, g.round` // claimantName snapshots a claimant's username inside the writing // transaction; NULL when the slot is unclaimed or the account is gone. @@ -265,7 +266,7 @@ func (s *Store) DeleteAnonymousGames(cutoff time.Time, live []string) error { } return s.enqueue("delete_anonymous_games", "", func(ctx context.Context, tx *sql.Tx) error { result, err := tx.ExecContext(ctx, `DELETE FROM games g - WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL + WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL AND g.imported_by IS NULL AND g.start_time_utc < $1 AND (g.end_time_utc IS NULL OR g.end_time_utc < $1) AND NOT EXISTS ( @@ -302,7 +303,7 @@ func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) { } args = append(args, playerID) query += fmt.Sprintf(` AND $%d IN (g.white_player_id, g.black_player_id, - g.white_claimed_by, g.black_claimed_by)`, len(args)) + g.white_claimed_by, g.black_claimed_by, g.imported_by)`, len(args)) } query += " ORDER BY g.start_time_utc DESC, g.game_id DESC" @@ -423,7 +424,7 @@ func (s *Store) QueryGamesForUser(userID string, q UserGamesQuery) ([]GameSummar var where string switch q.Color { case "": - where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1)" + where = "(g.white_claimed_by = $1 OR g.black_claimed_by = $1 OR g.imported_by = $1)" case "w": where = "g.white_claimed_by = $1" case "b": @@ -541,6 +542,7 @@ type rowScanner interface { func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { var whiteClaimed, whiteName, blackClaimed, blackName, result, termination sql.NullString var concessionResult, concessionTermination sql.NullString + var importedBy, event, site, round sql.NullString var concessionPly sql.NullInt64 var endTime, concessionTime sql.NullTime dest := []any{ @@ -551,6 +553,7 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { &blackClaimed, &blackName, &result, &termination, &record.StartTimeUTC, &endTime, &concessionResult, &concessionTermination, &concessionPly, &concessionTime, + &importedBy, &event, &site, &round, } if err := scanner.Scan(append(dest, extra...)...); err != nil { return err @@ -567,6 +570,10 @@ func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error { record.ConcessionTermination = concessionTermination.String record.ConcessionPly = int(concessionPly.Int64) record.ConcessionTimeUTC = utcPointer(concessionTime) + record.ImportedBy = importedBy.String + record.Event = event.String + record.Site = site.String + record.Round = round.String return nil } diff --git a/internal/server/storage/import.go b/internal/server/storage/import.go new file mode 100644 index 0000000..b27cc6a --- /dev/null +++ b/internal/server/storage/import.go @@ -0,0 +1,127 @@ +package storage + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" +) + +// ImportGame stores a game read from PGN with its whole move list in one +// transaction, behind every gameplay write already queued, and returns once +// it has committed. The record's ImportedBy names the owner; names, event, +// site and round come from the PGN's tags. +func (s *Store) ImportGame(record GameRecord, moves []MoveRecord) error { + if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" { + return errors.New("game ID, initial FEN, and player IDs are required") + } + if !validUUID(record.ImportedBy) { + return errors.New("an imported game needs its importer") + } + if err := validateOutcome(record.Result, record.Termination, record.EndTimeUTC); err != nil { + return err + } + for i, move := range moves { + if move.GameID != record.GameID || move.MoveNumber != i+1 || move.MoveUCI == "" || move.FENAfterMove == "" || + (move.PlayerColor != "w" && move.PlayerColor != "b") { + return fmt.Errorf("move %d is malformed", i+1) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout) + defer cancel() + return s.enqueueWait(ctx, "import_game", func(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `INSERT INTO games ( + game_id, initial_fen, + white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name, + black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name, + start_time_utc, result, termination, end_time_utc, + imported_by, event, site, round + ) VALUES ($1, $2, $3, $4, 0, 0, NULL, $5, $6, $7, 0, 0, NULL, $8, $9, $10, $11, $12, $13, $14, $15, $16)`, + record.GameID, record.InitialFEN, + record.WhitePlayerID, record.WhiteType, nullableString(record.WhiteName), + record.BlackPlayerID, record.BlackType, nullableString(record.BlackName), + record.StartTimeUTC, nullableString(record.Result), nullableString(record.Termination), record.EndTimeUTC, + record.ImportedBy, nullableString(record.Event), nullableString(record.Site), nullableString(record.Round), + ); err != nil { + return err + } + for _, move := range moves { + if _, err := tx.ExecContext(ctx, `INSERT INTO moves ( + game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc + ) VALUES ($1, $2, $3, $4, $5, $6)`, + move.GameID, move.MoveNumber, move.MoveUCI, move.FENAfterMove, move.PlayerColor, move.MoveTimeUTC, + ); err != nil { + return err + } + } + slog.Debug("storage game imported", "game_id", record.GameID, "plies", len(moves), "user_id", record.ImportedBy) + return nil + }) +} + +// RemovalOutcome says what RemoveGameForUser did. +type RemovalOutcome string + +const ( + // RemovedGame: the game and its moves are gone. + RemovedGame RemovalOutcome = "game" + // RemovedClaim: another user still claims a side, so only the caller's + // claim and name were cleared; the game stays with the other player. + RemovedClaim RemovalOutcome = "claim" +) + +// RemoveGameForUser deletes a stored game the user alone holds (as sole +// claimant or importer), or, when another user claims a side, releases the +// user's own claim so the game leaves their list but not the other's. It +// returns sql.ErrNoRows when the user holds nothing in the game. Ordered +// behind queued writes, it returns once committed. +func (s *Store) RemoveGameForUser(gameID, userID string) (RemovalOutcome, error) { + if !validUUID(gameID) || !validUUID(userID) { + return "", sql.ErrNoRows + } + var outcome RemovalOutcome + ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout) + defer cancel() + // A missing row or a game the user holds nothing in is an answer, not a + // failed write: the transaction then changes nothing and outcome stays + // empty, so the writer is never degraded by it. + err := s.enqueueWait(ctx, "remove_game_for_user", func(ctx context.Context, tx *sql.Tx) error { + var white, black, importer sql.NullString + err := tx.QueryRowContext(ctx, + `SELECT white_claimed_by, black_claimed_by, imported_by FROM games WHERE game_id = $1 FOR UPDATE`, + gameID).Scan(&white, &black, &importer) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + if white.String != userID && black.String != userID && importer.String != userID { + return nil + } + others := (white.Valid && white.String != userID) || (black.Valid && black.String != userID) + if others { + _, err := tx.ExecContext(ctx, `UPDATE games SET + white_claimed_by = CASE WHEN white_claimed_by = $2 THEN NULL ELSE white_claimed_by END, + white_name = CASE WHEN white_claimed_by = $2 THEN NULL ELSE white_name END, + black_claimed_by = CASE WHEN black_claimed_by = $2 THEN NULL ELSE black_claimed_by END, + black_name = CASE WHEN black_claimed_by = $2 THEN NULL ELSE black_name END + WHERE game_id = $1`, gameID, userID) + outcome = RemovedClaim + return err + } + _, err = tx.ExecContext(ctx, `DELETE FROM games WHERE game_id = $1`, gameID) + outcome = RemovedGame + return err + }) + if err != nil { + return "", err + } + if outcome == "" { + return "", sql.ErrNoRows + } + slog.Debug("storage game removed for user", "game_id", gameID, "user_id", userID, "outcome", outcome) + return outcome, nil +} diff --git a/internal/server/storage/integrity.go b/internal/server/storage/integrity.go index 869fb3b..570afc1 100644 --- a/internal/server/storage/integrity.go +++ b/internal/server/storage/integrity.go @@ -62,9 +62,10 @@ func (s *Store) GameIDsAfter(after string, limit int) ([]string, error) { return ids, collectIDs(rows, func(id string) { ids = append(ids, id) }) } -// OrphanedGames returns games that registered users claimed but whose -// claimants were all deleted, idle since cutoff by the anonymous-game rule -// (start, last move, and end all before it), excluding live games. +// OrphanedGames returns games that registered users claimed or imported but +// whose claimants and importer were all deleted, idle since cutoff by the +// anonymous-game rule (start, last move, and end all before it), excluding +// live games. func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error) { if live == nil { live = []string{} @@ -72,10 +73,10 @@ func (s *Store) OrphanedGames(cutoff time.Time, live []string) ([]string, error) ctx, cancel := opContext() defer cancel() rows, err := s.db.QueryContext(ctx, `SELECT g.game_id FROM games g - WHERE (g.white_claimed_by IS NOT NULL OR g.black_claimed_by IS NOT NULL) + WHERE (g.white_claimed_by IS NOT NULL OR g.black_claimed_by IS NOT NULL OR g.imported_by IS NOT NULL) AND NOT EXISTS ( SELECT 1 FROM users u - WHERE u.user_id IN (g.white_claimed_by, g.black_claimed_by) + WHERE u.user_id IN (g.white_claimed_by, g.black_claimed_by, g.imported_by) ) AND g.start_time_utc < $1 AND (g.end_time_utc IS NULL OR g.end_time_utc < $1) diff --git a/internal/server/storage/schema.go b/internal/server/storage/schema.go index 1eb0b4a..a246108 100644 --- a/internal/server/storage/schema.go +++ b/internal/server/storage/schema.go @@ -47,6 +47,20 @@ type GameRecord struct { ConcessionTermination string ConcessionPly int ConcessionTimeUTC *time.Time + // ImportedBy is the account that imported the game from PGN; empty for + // a game played here. Event, Site and Round come from its tags. + ImportedBy string + Event string + Site string + Round string +} + +// Origin names how a game came to be stored. +func (r GameRecord) Origin() string { + if r.ImportedBy != "" { + return "imported" + } + return "played" } // GameSummaryRecord is a game row plus its replay extent. MoveCount and @@ -81,7 +95,7 @@ type MovePersistence struct { // schemaVersion is the newest schema this binary understands. Migrations are // applied in order inside one transaction; PostgreSQL DDL is transactional, so // a failed upgrade leaves the previous version intact. -const schemaVersion = 4 +const schemaVersion = 5 // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a // released migration; append a new one. @@ -227,6 +241,23 @@ CREATE TABLE password_resets ( CONSTRAINT password_resets_token_hash_check CHECK (octet_length(token_hash) = 32) ); CREATE INDEX password_resets_expires_at_idx ON password_resets (expires_at); +`, + + // v5: imported games. A game read from PGN belongs to the account that + // imported it (imported_by), has no claims, and keeps the PGN's event, + // site and round for its export. Imported games are not anonymous, so + // the purge index excludes them. + ` +ALTER TABLE games + ADD COLUMN imported_by uuid, + ADD COLUMN event text CHECK (char_length(event) BETWEEN 1 AND 200), + ADD COLUMN site text CHECK (char_length(site) BETWEEN 1 AND 200), + ADD COLUMN round text CHECK (char_length(round) BETWEEN 1 AND 40); +DROP INDEX games_anonymous_start_idx; +CREATE INDEX games_anonymous_start_idx ON games (start_time_utc) + WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL AND imported_by IS NULL; +CREATE INDEX games_imported_by_idx ON games (imported_by, start_time_utc DESC, game_id DESC) + WHERE imported_by IS NOT NULL; `, } diff --git a/internal/server/storage/storage_test.go b/internal/server/storage/storage_test.go index d6b7f81..df5a4b8 100644 --- a/internal/server/storage/storage_test.go +++ b/internal/server/storage/storage_test.go @@ -341,7 +341,7 @@ func TestQueryPlansUsePurposeBuiltIndexes(t *testing.T) { { name: "anonymous purge candidates", query: `SELECT game_id FROM games g - WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL + WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL AND g.imported_by IS NULL AND g.start_time_utc < $1`, args: []any{time.Now()}, want: []string{"games_anonymous_start_idx"}, diff --git a/internal/server/storage/user.go b/internal/server/storage/user.go index 87c114c..46582da 100644 --- a/internal/server/storage/user.go +++ b/internal/server/storage/user.go @@ -150,8 +150,9 @@ func (s *Store) DeleteAccount(userID string) error { white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END, white_name = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_name END, black_claimed_by = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_claimed_by END, - black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END - WHERE white_claimed_by = $1 OR black_claimed_by = $1`, userID); err != nil { + black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END, + imported_by = CASE WHEN imported_by = $1 THEN NULL ELSE imported_by END + WHERE white_claimed_by = $1 OR black_claimed_by = $1 OR imported_by = $1`, userID); err != nil { return err } result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID) diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index e4da7a2..81485bc 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -124,7 +124,7 @@ function setModalMessage(elementId, message, type = 'error') { const el = $(elementId); if (!el) return; el.textContent = message; - el.classList.remove('error', 'success'); + el.classList.remove('error', 'success', 'hint'); el.classList.add('show', type); } @@ -132,7 +132,7 @@ function clearModalMessage(elementId) { const el = $(elementId); if (!el) return; el.textContent = ''; - el.classList.remove('show', 'error', 'success'); + el.classList.remove('show', 'error', 'success', 'hint'); } function anyModalOpen() { @@ -377,7 +377,15 @@ function setStorageStatus(status) { function setMailStatus(status) { ui.mail = status; - $('forgot-row').classList.toggle('hidden', status !== 'ok'); +} + +// A neutral hint fills a modal's message slot until a result replaces it. +function setModalHint(elementId, text) { + const el = $(elementId); + if (!el) return; + el.textContent = text; + el.classList.remove('error', 'success'); + el.classList.add('show', 'hint'); } function startHealthCheck() { @@ -467,7 +475,7 @@ function showAccountModal() { $('account-username').textContent = auth.username || ''; $('delete-account').open = false; $('delete-password').value = ''; - clearModalMessage('account-modal-message'); + setModalHint('account-modal-message', 'Signing out keeps your games; deleting the account removes your name from them.'); $('account-overlay').classList.add('show'); document.removeEventListener('keydown', handleAccountKeydown); document.addEventListener('keydown', handleAccountKeydown); @@ -624,7 +632,11 @@ function switchAuthTab(tab) { $('login-form').classList.toggle('hidden', tab !== 'login'); $('register-form').classList.toggle('hidden', tab !== 'register'); $('recover-form').classList.toggle('hidden', tab !== 'recover'); - clearModalMessage('auth-modal-message'); + setModalHint('auth-modal-message', { + login: 'Your games are kept under your account.', + register: 'A username and a password; the email is optional and lets you reset a forgotten password.', + recover: ui.mail === 'ok' ? 'The link in the email works for an hour.' : 'This server may not send email yet; the administrator can give you a reset link.', + }[tab]); $({ login: 'login-identifier', register: 'register-username', recover: 'recover-identifier' }[tab]).focus(); } @@ -1070,6 +1082,7 @@ function setNewGameModalBusy(busy) { function showNewGameModal() { if (replay.active) return; + setModalHint('new-game-modal-message', 'Level 0 is a beginner, 20 is full strength; the search time is how long it thinks per move.'); $('modal-overlay').classList.add('show'); document.addEventListener('keydown', handleModalKeydown); $('start-game-btn').focus(); @@ -1744,6 +1757,7 @@ function renderGamesSections() { $('games-filters').classList.toggle('hidden', !signedIn); $('account-games-heading').textContent = 'Your games'; $('games-list').classList.toggle('hidden', !signedIn); + $('games-import-btn').classList.toggle('hidden', !signedIn); $('games-more').classList.toggle('hidden', !signedIn || !games.cursor); if (!signedIn) $('games-status').textContent = ''; renderLocalGames(); @@ -1855,6 +1869,7 @@ function renderLocalGames() { // affordance. The whole row is a button. function renderGameRow(summary) { const li = document.createElement('li'); + li.className = 'game-item'; const row = document.createElement('button'); row.type = 'button'; row.className = 'game-row'; @@ -1886,7 +1901,16 @@ function renderGameRow(summary) { : `${terminationLabel(summary.termination) || describeResult(summary.result, summary.termination)} · ${moves} move${moves === 1 ? '' : 's'}`)); const when = document.createElement('div'); when.className = 'game-when'; - when.textContent = formatDate(summary.startTimeUtc) + (summary.gameId === live.gameId ? ' · this game' : ''); + const notes = [formatDate(summary.startTimeUtc)]; + if (summary.origin === 'imported') { + const origin = document.createElement('span'); + origin.className = 'game-origin'; + origin.textContent = summary.event ? `imported · ${summary.event}` : 'imported'; + when.append(document.createTextNode(notes[0] + ' · '), origin); + } else { + if (summary.gameId === live.gameId) notes.push('this game'); + when.textContent = notes.join(' · '); + } info.append(players, outcome, when); const go = document.createElement('span'); @@ -1898,9 +1922,203 @@ function renderGameRow(summary) { row.setAttribute('aria-label', `Replay ${players.textContent}, ${outcome.textContent}, ${when.textContent}`); row.addEventListener('click', () => enterReplay(summary.gameId, summary.moveCount || 0)); li.appendChild(row); + + // Delete, with an in-row confirmation. + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'btn game-delete'; + del.title = summary.local ? 'Forget this game on this device' : 'Delete this game'; + del.setAttribute('aria-label', `${del.title}: ${players.textContent}`); + del.innerHTML = ''; + del.addEventListener('click', () => confirmDeleteRow(li, del, summary)); + li.appendChild(del); return li; } +// Replaces the row's delete button with Yes/No for one decision. +function confirmDeleteRow(li, del, summary) { + const confirm = document.createElement('div'); + confirm.className = 'game-confirm'; + confirm.setAttribute('role', 'group'); + confirm.setAttribute('aria-label', 'Confirm deletion'); + const yes = document.createElement('button'); + yes.type = 'button'; + yes.className = 'btn btn-danger'; + yes.textContent = summary.local ? 'Forget' : 'Delete'; + const no = document.createElement('button'); + no.type = 'button'; + no.className = 'btn btn-secondary'; + no.textContent = 'Keep'; + const restore = () => { confirm.replaceWith(del); del.focus(); }; + no.addEventListener('click', restore); + yes.addEventListener('click', async () => { + yes.disabled = true; + no.disabled = true; + const ok = await deleteGame(summary); + if (!ok) restore(); + }); + confirm.append(yes, no); + del.replaceWith(confirm); + yes.focus(); +} + +// Deletes a game from the account list, or forgets a device entry. Returns +// whether the row is gone. +async function deleteGame(summary) { + if (summary.local) { + saveRecentGames(loadRecentGames().filter(g => g.gameId !== summary.gameId)); + renderLocalGames(); + toast('Game forgotten on this device', 'success'); + return true; + } + let response; + try { + response = await apiJSON('DELETE', `/api/users/me/games/${summary.gameId}`); + } catch (error) { + handleApiError('delete game', error); + return false; + } + if (response.status === 401) { + clearAuthState(); + return false; + } + if (!response.ok) { + handleApiError('delete game', null, response); + return false; + } + let outcome = 'game'; + try { + outcome = (await response.json()).removed || 'game'; + } catch { /* the row is gone either way */ } + games.items = games.items.filter(g => g.gameId !== summary.gameId); + renderGamesList(); + toast(outcome === 'claim' ? 'Removed from your games; the other player keeps it' : 'Game deleted', 'success', 2500); + if (replay.gameId === summary.gameId && outcome === 'game') exitReplay({ push: false }); + if (live.gameId === summary.gameId && outcome === 'game') dropLiveGame(); + ui.historyCache.delete(summary.gameId); + return true; +} + +// The live game was deleted from storage: the board returns to no game. +function dropLiveGame() { + stopPolling(); + live.gameId = null; + live.fen = null; + live.state = null; + live.moves = []; + live.san = []; + live.lastMove = null; + live.isLocked = false; + live.selectedSquare = null; + if (!replay.active) renderPosition(START_FEN); + renderLiveMoves(); + updateTurnStrip(); + updateGameControls(); +} + +// --- import: a PGN pasted or picked from a file, stored under the account + +const importState = { busy: false }; + +function openImportModal() { + if (!auth.token) { + showAuthModal(); + return; + } + importState.busy = false; + $('import-pgn').value = ''; + $('import-file').value = ''; + setModalHint('import-modal-message', 'One game: tags in brackets, then the moves. Comments and variations are dropped; every move is checked.'); + $('import-overlay').classList.add('show'); + document.removeEventListener('keydown', handleImportKeydown); + document.addEventListener('keydown', handleImportKeydown); + $('import-pgn').focus(); +} + +function closeImportModal() { + if (importState.busy) return; + $('import-overlay').classList.remove('show'); + document.removeEventListener('keydown', handleImportKeydown); +} + +function handleImportKeydown(e) { + if (e.key === 'Escape') { + e.preventDefault(); + closeImportModal(); + } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + submitImport(); + } +} + +function setImportBusy(busy) { + importState.busy = busy; + for (const id of ['import-pgn', 'import-file', 'import-submit-btn', 'import-cancel-btn']) $(id).disabled = busy; +} + +// A picked file fills the text box; the size limit is the server's. +function readImportFile() { + const file = $('import-file').files?.[0]; + if (!file) return; + if (file.size > 65536) { + setModalMessage('import-modal-message', 'That file is larger than 64 KiB; one game is a few kilobytes', 'error'); + return; + } + const reader = new FileReader(); + reader.onload = () => { + $('import-pgn').value = String(reader.result || ''); + setModalHint('import-modal-message', `${file.name} loaded; press Import.`); + }; + reader.onerror = () => setModalMessage('import-modal-message', 'Could not read the file', 'error'); + reader.readAsText(file); +} + +async function submitImport() { + if (importState.busy) return; + const pgn = $('import-pgn').value.trim(); + if (!pgn) { + setModalMessage('import-modal-message', 'Paste a PGN or pick a file', 'error'); + $('import-pgn').focus(); + return; + } + setImportBusy(true); + clearModalMessage('import-modal-message'); + let response; + try { + response = await apiJSON('POST', '/api/games/import', { pgn }); + } catch (error) { + setImportBusy(false); + setModalMessage('import-modal-message', handleApiError('import', error).statusMessage, 'error'); + return; + } + setImportBusy(false); + if (response.status === 401) { + clearAuthState(); + setModalMessage('import-modal-message', 'Sign in again to import', 'error'); + return; + } + if (!response.ok) { + const err = await parseErrorResponse(response); + setModalMessage('import-modal-message', response.status === 400 + ? `Not a valid game: ${err.details || err.error}` + : response.status === 429 ? 'Too many imports; wait a minute' : (err.error || `Server error (${response.status})`), 'error'); + $('import-pgn').focus(); + return; + } + let result; + try { + result = await response.json(); + } catch { + setModalMessage('import-modal-message', 'Unexpected response from server', 'error'); + return; + } + closeImportModal(); + toast(`Imported, ${Math.ceil((result.moveCount || 0) / 2)} moves`, 'success', 2500); + games.cursor = ''; + await loadGames(true, true); + enterReplay(result.gameId, 0); +} + // A side's name in the list, "You" for the signed-in user's claim. function sideLabel(player) { if (player && player.type !== 2 && auth.userId && player.claimedBy === auth.userId) return 'You'; @@ -2039,6 +2257,7 @@ function renderReplayHead() { $('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`; const plies = h.moves.length; const meta = [formatDate(h.startTimeUtc), `${plies} ${plies === 1 ? 'ply' : 'plies'}`]; + if (h.origin === 'imported') meta.unshift(h.event || 'imported'); if (h.concession && h.concession.termination) { meta.push(h.concession.termination === 'agreement' ? `draw agreed at ply ${h.concession.ply}` @@ -2352,6 +2571,19 @@ function bindEvents() { $('promotion-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) closePromotion(null); }); + // A click on a backdrop closes the dialog, unless a request is in flight. + const backdrop = (id, close) => $(id).addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); }); + backdrop('auth-modal-overlay', () => { if (!auth.busy) hideAuthModal(); }); + backdrop('account-overlay', hideAccountModal); + backdrop('modal-overlay', () => { if (!live.newGameBusy) hideNewGameModal(); }); + backdrop('resign-overlay', hideResignModal); + backdrop('reset-overlay', closeResetModal); + backdrop('import-overlay', closeImportModal); + + $('games-import-btn').addEventListener('click', openImportModal); + $('import-submit-btn').addEventListener('click', submitImport); + $('import-cancel-btn').addEventListener('click', closeImportModal); + $('import-file').addEventListener('change', readImportFile); $('games-btn').addEventListener('click', () => { if (games.open) closeGames(); else openGames(); }); $('games-close').addEventListener('click', () => closeGames()); diff --git a/internal/server/webserver/chess-client-web/index.html b/internal/server/webserver/chess-client-web/index.html index c655f0c..8b8344d 100644 --- a/internal/server/webserver/chess-client-web/index.html +++ b/internal/server/webserver/chess-client-web/index.html @@ -168,6 +168,10 @@ + + +