From e1fa5242e313ef9ef3b97b859da39d5c9c765745e5cc696ff6453ff5d5d09057 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Mon, 19 Oct 2026 18:15:31 -0400 Subject: [PATCH] v0.15.0 password recovery, phone layout, CLI pager, embed policy fixes --- .github/workflows/test.yml | 4 +- README.md | 9 +- cmd/chess-client-cli/main.go | 8 +- cmd/chess-server/cli/cli.go | 48 +++- cmd/chess-server/main.go | 34 +++ deploy/freebsd/rc.d/chessd | 33 +++ doc/api.md | 47 ++++ doc/client.md | 7 + doc/database.md | 12 +- doc/deployment-linux.md | 2 +- doc/deployment.md | 51 +++- doc/development.md | 9 +- doc/todo.md | 25 +- go.sum | 6 - internal/client/api/client.go | 2 +- internal/client/command/auth.go | 20 +- internal/client/command/debug.go | 8 +- internal/client/command/game.go | 46 +-- internal/client/command/pass_native.go | 6 +- internal/client/command/registry.go | 28 +- internal/client/command/replay.go | 13 +- internal/client/command/save_wasm.go | 8 +- internal/client/display/board.go | 2 +- internal/client/display/colors.go | 4 +- internal/client/display/format.go | 2 +- internal/client/display/output.go | 79 ++++++ internal/client/display/output_test.go | 58 ++++ internal/client/display/termsize_native.go | 23 ++ internal/client/display/termsize_wasm.go | 21 ++ internal/client/session/session.go | 7 +- internal/server/core/error.go | 1 + internal/server/http/auth.go | 112 ++++++++ internal/server/http/handler.go | 28 ++ internal/server/http/handler_test.go | 2 + internal/server/mail/mail.go | 259 +++++++++++++++++ internal/server/mail/mail_test.go | 91 ++++++ internal/server/service/reset.go | 208 ++++++++++++++ internal/server/service/reset_test.go | 147 ++++++++++ internal/server/service/service.go | 7 + internal/server/storage/reset.go | 120 ++++++++ internal/server/storage/schema.go | 19 +- .../server/webserver/chess-client-web/app.js | 264 ++++++++++++++++-- .../webserver/chess-client-web/index.html | 61 +++- .../webserver/chess-client-web/style.css | 163 ++++++++++- test/README.md | 22 +- test/e2e/replay.spec.js | 92 +++++- test/run-test-server.sh | 11 +- web/chess-client-wasm/terminal.js | 7 + 48 files changed, 2101 insertions(+), 135 deletions(-) create mode 100644 internal/client/display/output.go create mode 100644 internal/client/display/output_test.go create mode 100644 internal/client/display/termsize_native.go create mode 100644 internal/client/display/termsize_wasm.go create mode 100644 internal/server/mail/mail.go create mode 100644 internal/server/mail/mail_test.go create mode 100644 internal/server/service/reset.go create mode 100644 internal/server/service/reset_test.go create mode 100644 internal/server/storage/reset.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index efb1265..ae5e7ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,11 +80,13 @@ jobs: run: npm install && npx playwright install --with-deps chromium - name: Start the test server with the web client run: | - WEB_PORT=9090 LOG_LEVEL=info test/run-test-server.sh bin/chess-server > server.log 2>&1 & + WEB_PORT=9090 MAIL_DIR="$RUNNER_TEMP/chess-mail" LOG_LEVEL=info test/run-test-server.sh bin/chess-server > server.log 2>&1 & for i in $(seq 1 60); do curl -sf http://localhost:8080/health && break; sleep 1; done curl -sf http://localhost:8080/health - name: Run the web client tests working-directory: test/e2e + env: + CHESS_E2E_MAIL_DIR: ${{ runner.temp }}/chess-mail run: npm test - name: Server log if: failure() diff --git a/README.md b/README.md index f5861da..01bc243 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Go backend server providing a RESTful API for chess gameplay with user authentic - Authenticated stored-game listing with cursor pagination and filters - Configurable structured debug logs for persistence, cleanup, and engine work - User management with secure Argon2id password storage and scoped JWTs +- Password recovery by email (SMTP relay, or a directory of `.eml` files in + development) and administrative reset links - Self-service account deletion: claims and names are cleared from stored games - Static, CGO-free binaries (cross-compiles for FreeBSD) - Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit) @@ -134,9 +136,12 @@ their last activity. # List all users ./chess-server db user list -# Update password +# Update password (a forgotten one too); without -password it prompts ./chess-server db user set-password -username alice -password NewPass456 +# One-time reset link to hand to the person (what the recovery email carries) +./chess-server db user reset-link -username alice -url https://example.com/chess/web-client/ + # Update email ./chess-server db user set-email -username alice -email newemail@example.com @@ -175,7 +180,7 @@ The chess server includes an embedded web UI for playing games through a browser export FEN and PGN at any ply, and shareable links (`?replay=&ply=`) - Server and storage health in the top bar -- Accounts: register, sign in, delete +- Accounts: register, sign in, forgotten-password recovery by email, delete - Responsive layout, board beside or above the panel Access the UI at `http://localhost:9090` when server is running with `-serve` flag. diff --git a/cmd/chess-client-cli/main.go b/cmd/chess-client-cli/main.go index df344b4..1931874 100644 --- a/cmd/chess-client-cli/main.go +++ b/cmd/chess-client-cli/main.go @@ -35,8 +35,14 @@ func runClient() (restart bool) { Verbose: false, } - // Initialize simple input scanner + // Initialize simple input scanner; the pager reads from it between screens scanner := bufio.NewScanner(os.Stdin) + s.ReadLine = func() (string, bool) { + if !scanner.Scan() { + return "", false + } + return scanner.Text(), true + } display.Println(display.Cyan, "Chess Debug Client") display.Println(display.Cyan, "API: %s", s.APIBaseURL) diff --git a/cmd/chess-server/cli/cli.go b/cmd/chess-server/cli/cli.go index 7e10146..44e6b9f 100644 --- a/cmd/chess-server/cli/cli.go +++ b/cmd/chess-server/cli/cli.go @@ -5,6 +5,7 @@ import ( "errors" "flag" "fmt" + "net/url" "os" "strings" "syscall" @@ -12,6 +13,7 @@ import ( "time" "github.com/lixenwraith/chess/internal/server/replay" + "github.com/lixenwraith/chess/internal/server/service" "github.com/lixenwraith/chess/internal/server/storage" "github.com/google/uuid" @@ -40,7 +42,7 @@ func Run(args []string) error { return runVerify(args[1:]) case "user": if len(args) < 2 { - return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list") + return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, reset-link, list") } return runUser(args[1], args[2:]) default: @@ -303,6 +305,8 @@ func runUser(subcommand string, args []string) error { return runUserSetEmail(args) case "set-username": return runUserSetUsername(args) + case "reset-link": + return runUserResetLink(args) case "list": return runUserList(args) default: @@ -310,6 +314,48 @@ func runUser(subcommand string, args []string) error { } } +// runUserResetLink prints a one-time password-reset link for an account, the +// same link the recovery email carries, for an administrator to pass on when +// the server sends no mail or the account has no email. It works for an hour +// and replaces any pending link. +func runUserResetLink(args []string) error { + fs, dsn := newFlagSet("user reset-link") + username := fs.String("username", "", "Username") + userID := fs.String("id", "", "User ID") + publicURL := fs.String("url", "", "Web client address the link opens, e.g. https://example.com/chess/web-client/ (required)") + if err := fs.Parse(args); err != nil { + return err + } + base, err := url.Parse(*publicURL) + if *publicURL == "" || err != nil || base.Scheme == "" || base.Host == "" { + return fmt.Errorf("-url must be the web client's absolute address") + } + + store, err := openStore(*dsn, true) + if err != nil { + return err + } + defer store.Close() + + targetID, err := lookupUser(store, *username, *userID) + if err != nil { + return err + } + token, hash, err := service.NewResetToken() + if err != nil { + return err + } + now := time.Now().UTC() + if err := store.CreatePasswordReset(storage.PasswordResetRecord{ + UserID: targetID, TokenHash: hash, CreatedAt: now, ExpiresAt: now.Add(service.PasswordResetTTL), + }); err != nil { + return fmt.Errorf("failed to create reset: %w", err) + } + fmt.Printf("Reset link for %s, valid until %s (one use):\n%s\n", + abbreviateID(targetID), now.Add(service.PasswordResetTTL).Format(time.RFC3339), service.ResetLink(base, token)) + return nil +} + // readPassword prompts on the terminal without echo. func readPassword(prompt string) (string, error) { fmt.Print(prompt) diff --git a/cmd/chess-server/main.go b/cmd/chess-server/main.go index 9ae7c11..5864dd8 100644 --- a/cmd/chess-server/main.go +++ b/cmd/chess-server/main.go @@ -5,6 +5,7 @@ package main import ( "context" "crypto/rand" + "errors" "flag" "fmt" "log" @@ -16,6 +17,7 @@ import ( "github.com/lixenwraith/chess/cmd/chess-server/cli" "github.com/lixenwraith/chess/internal/server/http" + "github.com/lixenwraith/chess/internal/server/mail" "github.com/lixenwraith/chess/internal/server/processor" "github.com/lixenwraith/chess/internal/server/service" "github.com/lixenwraith/chess/internal/server/storage" @@ -58,6 +60,17 @@ func main() { jwtFile = flag.String("jwt-secret-file", "", "File holding a stable JWT signing key, mode 0600, at least 32 bytes (default $CHESS_JWT_SECRET_FILE)") dbCleanup = flag.String("db-cleanup", "off", "Hourly integrity sweep of stored games and accounts: off, report (log only), or delete") + // 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. + smtpHost = flag.String("smtp-host", "", "SMTP relay for password-reset email (recovery is off when empty)") + smtpPort = flag.Int("smtp-port", 25, "SMTP relay port") + smtpUser = flag.String("smtp-user", "", "SMTP user name (optional; the password comes from -smtp-password-file)") + smtpPassFile = flag.String("smtp-password-file", "", "File holding the SMTP password, mode 0600") + mailFrom = flag.String("mail-from", "", "Sender address of server email, e.g. \"Chess \"") + mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)") + publicURL = flag.String("public-url", "", "Web client address that emailed links open, e.g. https://example.com/chess/web-client/") + // Web UI server flags serve = flag.Bool("serve", false, "Enable web UI server") webHost = flag.String("web-host", "localhost", "Web UI server host") @@ -165,6 +178,27 @@ func main() { } svc.SetIntegrityMode(integrity) + // Password recovery needs mail and the client's public address. + sender, err := mail.Config{ + SMTPHost: *smtpHost, SMTPPort: *smtpPort, SMTPUser: *smtpUser, SMTPPasswordFile: *smtpPassFile, + Dir: *mailDir, From: *mailFrom, + }.Build() + switch { + case errors.Is(err, mail.ErrNoSender): + log.Printf("Password recovery disabled (no -smtp-host or -mail-dir)") + case err != nil: + log.Fatalf("Invalid mail configuration: %v", err) + case store == nil: + log.Fatalf("Password recovery (-smtp-host or -mail-dir) requires a database (-dsn)") + case *publicURL == "": + log.Fatalf("Password recovery requires -public-url, the web client address that reset links open") + default: + if err := svc.SetMailer(sender, *publicURL); err != nil { + log.Fatalf("Invalid -public-url: %v", err) + } + log.Printf("Password recovery enabled: %s; links open %s", sender.Describe(), *publicURL) + } + // Start cleanup job for expired users/sessions cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) cleanupDone := make(chan struct{}) diff --git a/deploy/freebsd/rc.d/chessd b/deploy/freebsd/rc.d/chessd index f7567bc..81880e9 100755 --- a/deploy/freebsd/rc.d/chessd +++ b/deploy/freebsd/rc.d/chessd @@ -18,11 +18,25 @@ # 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_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 +# chessd_smtp_user: Relay user name, when the relay authenticates +# this host. Default: none +# chessd_smtp_password_file: File holding that user's password, mode 0600. +# Default: ${chessd_home}/smtp.secret when it exists +# chessd_mail_from: Sender address of server email. Default: none # chessd_flags: Additional flags passed to the binary. Default: "" # # Add the following to /etc/rc.conf to enable: # chessd_enable="YES" # chessd_trusted_proxies="
" +# and, for password recovery by email, once the mail jail relays for this one: +# chessd_public_url="https:///chess/web-client/" +# chessd_smtp_host="" +# chessd_mail_from="Chess >" . /etc/rc.subr @@ -43,6 +57,12 @@ load_rc_config "$name" : ${chessd_dsn:="postgres:///chess?host=/tmp"} : ${chessd_jwt_key:="${chessd_home}/jwt.key"} : ${chessd_trusted_proxies:=""} +: ${chessd_public_url:=""} +: ${chessd_smtp_host:=""} +: ${chessd_smtp_port:="25"} +: ${chessd_smtp_user:=""} +: ${chessd_smtp_password_file:=""} +: ${chessd_mail_from:=""} : ${chessd_flags:=""} command="${chessd_bin}" @@ -85,6 +105,19 @@ 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_smtp_host}" ]; then + set -- "$@" -smtp-host "${chessd_smtp_host}" -smtp-port "${chessd_smtp_port}" \ + -mail-from "${chessd_mail_from}" -public-url "${chessd_public_url}" + if [ -n "${chessd_smtp_user}" ]; then + set -- "$@" -smtp-user "${chessd_smtp_user}" + secret="${chessd_smtp_password_file:-${chessd_home}/smtp.secret}" + if [ -f "${secret}" ]; then + set -- "$@" -smtp-password-file "${secret}" + fi + fi + fi echo "Starting ${desc}." # -r restarts the server 5 seconds after an unexpected exit (for example diff --git a/doc/api.md b/doc/api.md index 3ae161a..468b648 100644 --- a/doc/api.md +++ b/doc/api.md @@ -116,6 +116,48 @@ Errors: 400 `INVALID_REQUEST` without a password, 401 for a wrong password or token, 429 past 5 attempts a minute per IP, 503 `RESOURCE_LIMIT` when password hashing is saturated or `STORAGE_UNAVAILABLE` when storage is down. +### Request a Password Reset +`POST /auth/recover` + +Emails a one-time reset link to the account's address. The response is the +same whether or not the account exists or has an email, so nothing is +revealed. + +**Request:** +```json +{"identifier": "alice"} +``` + +- `identifier` (string, required): Username or email address + +**Response (202):** +```json +{"message": "If the account has an email address, a reset link has been sent to it."} +``` + +Errors: 429 past 3 requests a minute per IP, 503 `NOT_CONFIGURED` when the +server has no mail transport (`/health` reports `"mail": "disabled"`), 502 +when the relay refused the message. + +### Reset a Password +`POST /auth/reset` + +Sets a new password with the token from the link (`?reset=` on the web +client) and signs the user in; the token works once, for an hour, and every +other session of the account ends. + +**Request:** +```json +{"token": "…", "password": "NewPass123"} +``` + +**Response (200):** as login (`token`, `userId`, `username`, `email`, +`expiresAt`). + +Errors: 400 `INVALID_REQUEST` for a weak password or an invalid, used or +expired token (`"invalid or expired reset link"`), 429 past 10 a minute per +IP, 503 when storage or password hashing is unavailable. + ## Game Endpoints ### Health Check @@ -129,10 +171,14 @@ Returns server and storage status, and the build the server runs. "status": "healthy", "time": 1699123456, "storage": "ok", + "mail": "disabled", "version": "v0.14.0-3-g1a2b3c4" } ``` +`mail` is `"ok"` when the server can email password-reset links (the web +client then offers **Forgot password?**), otherwise `"disabled"`. + `version` is `git describe` of the source the binary was built from (`make` sets it), or `dev` for a plain `go build`. Compare it after a deployment: a client newer than the server gets 404 `NOT_FOUND` for endpoints the server @@ -556,6 +602,7 @@ Error codes: - Registration: 5 requests/minute/IP - Login: 10 requests/minute/IP - Account deletion: 5 requests/minute/IP +- Password recovery: 3 requests/minute/IP; reset: 10 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 0dc809e..e2dd6ee 100644 --- a/doc/client.md +++ b/doc/client.md @@ -294,6 +294,13 @@ Exit the client. chess > exit ``` +### Long output + +`help`, `games`, `pgn`, `state`, `show` and `replay` print one screen at a +time when their output is taller than the terminal: `-- More (N lines left; +Enter continues, q quits) --`. The terminal's size comes from the TTY, or in +the browser from xterm.js; output piped to another program is never paged. + ### Verbose Mode Append `-v` flag to any command for detailed output: diff --git a/doc/database.md b/doc/database.md index a748194..42bd83a 100644 --- a/doc/database.md +++ b/doc/database.md @@ -131,9 +131,15 @@ sudo chess-db user list su -m chess -c '/home/chess/bin/chess-server db user add -username -dsn "postgres:///chess?host=/tmp"' ``` -Other `user` subcommands: `delete`, `set-email`, `set-username`, and -`set-hash` (an existing PHC string). CLI-created accounts are identical to -site registrations, and the CLI ignores the registration cap. +Other `user` subcommands: `delete`, `set-email`, `set-username`, `set-hash` +(an existing PHC string), and `reset-link -url `, which prints a +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. + +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. Sign a user out everywhere (takes effect on their next request): diff --git a/doc/deployment-linux.md b/doc/deployment-linux.md index 294cf88..bf96e6b 100644 --- a/doc/deployment-linux.md +++ b/doc/deployment-linux.md @@ -120,7 +120,7 @@ file is kept next to it with a timestamp). | `CHESSD_DSN` | `"host= dbname=chess"` | Database over the socket as the OS account | | `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key | | `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses | -| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report` | +| `CHESSD_FLAGS` | empty | Other server flags, e.g. `-max-users 500 -db-cleanup report`, or password recovery by email: `-smtp-host -mail-from "Chess " -public-url https://example.com/chess/web-client/` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) | Server flags worth knowing: `-max-users` (registration cap, default 100), `-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`), diff --git a/doc/deployment.md b/doc/deployment.md index aefa896..26439b5 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -154,6 +154,10 @@ as `postgres`, list them with `\dt chess.*` and name them as `chess.games`. | `chessd_dsn` | `postgres:///chess?host=/tmp` | Database `chess` over the socket as the OS user | | `chessd_jwt_key` | `${chessd_home}/jwt.key` | JWT signing key; created (0600) on first start if missing | | `chessd_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted | +| `chessd_public_url` | empty | The web client's public address, which emailed reset links open (`https:///chess/web-client/`) | +| `chessd_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 >` | | `chessd_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` | The script runs `daemon(8)` as root, which writes the supervisor pidfile @@ -174,6 +178,11 @@ Server flags: | `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) | | `-finished-game-ttl` | | Memory retention of finished games (default `1h`) | | `-db-cleanup` | | Hourly integrity sweep: `off` (default), `report` (log findings), or `delete` ([database.md](database.md#integrity-sweep)) | +| `-smtp-host`, `-smtp-port` | | SMTP relay for password-reset email (default port 25); recovery is off without a transport | +| `-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 | Administrative CLI commands run as `chess` so peer authentication selects the right role; root may use `su -m` although the account has no shell: @@ -186,7 +195,47 @@ jail# su -m chess -c '/home/chess/bin/chess-server db user list -dsn "postgres:/ (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|list`. +set-password|set-hash|set-email|set-username|reset-link|list`. + +A forgotten password is reset from the jail without mail: + +```sh +jail# su -m chess -c '/home/chess/bin/chess-server db user set-password -username -dsn "postgres:///chess?host=/tmp"' +``` + +which prompts for the new password (or takes `-password`), or with a one-time +link to hand to the person, the same link the recovery email carries: + +```sh +jail# su -m chess -c '/home/chess/bin/chess-server db user reset-link -username -url https:///chess/web-client/ -dsn "postgres:///chess?host=/tmp"' +``` + +## Password Recovery by Email + +With an SMTP relay the server emails a reset link on request (`POST +/api/auth/recover`, the **Forgot password?** link in the web client); the link +opens the client, which sets the new password and signs the person in. The +link works for an hour and once; using it signs the account out everywhere +else. Accounts without an email address cannot use it (the CLI commands above +can). + +The relay is a separate jail reached over the internal network. Set in +`rc.conf`: + +```sh +sysrc chessd_public_url="https:///chess/web-client/" +sysrc chessd_smtp_host="" # chessd_smtp_port, default 25 +sysrc chessd_mail_from="Chess >" +# only when the relay authenticates this host: +sysrc chessd_smtp_user="" +(umask 077 && echo '' > /home/chess/smtp.secret) && chown chess:chess /home/chess/smtp.secret +service chessd restart +``` + +The server uses STARTTLS when the relay offers it and sends credentials only +over TLS. `GET /chess/health` reports `"mail": "ok"` once recovery is on. For +a test without a relay, `-mail-dir ` in `chessd_flags` writes each +message to a file instead. ## Host nginx and Web Clients diff --git a/doc/development.md b/doc/development.md index 7101bb5..5f08387 100644 --- a/doc/development.md +++ b/doc/development.md @@ -38,6 +38,9 @@ go build ./cmd/chess-client-cli - `-anonymous-game-ttl`: How long games without a registered player survive after their last activity, in memory and in the database (default: `24h`; `0` keeps them) - `-max-users`: Accounts at which public registration closes (default: `100`; `0` = no limit; the CLI is not limited) - `-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) ### Modes ```bash @@ -223,8 +226,8 @@ go test ./... go test -race ./internal/server/storage ./internal/server/service # Web client end-to-end (Playwright): see test/README.md -WEB_PORT=9090 test/run-test-server.sh # terminal 1 -make test-e2e # terminal 2 +WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh # terminal 1 +CHESS_E2E_MAIL_DIR=/tmp/chess-mail make test-e2e # terminal 2 ``` ## Configuration @@ -308,7 +311,7 @@ make test-e2e # terminal 2 - JWT tokens don't support refresh (must re-login after expiry) - User deletion doesn't cascade to games (games keep player and claim IDs) - Games played without a registered player are deleted 24 hours after their last activity -- No password recovery mechanism +- Password recovery needs an SMTP relay and an email on the account - No email verification for registration - Fixed worker pool size for engine calculations - No push-based game updates (30-second long-polling is used) diff --git a/doc/todo.md b/doc/todo.md index c90c212..9453535 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -174,13 +174,36 @@ Test gate: while replaying, `show` shows the replayed position, and the prompt reads `replay /`; `live` returns to the game as it was. +Follow-up polish (done): the phone layout is full-width rows under the board +(`svh` units, so the browser's bars never cover the controls; a replay +reserves more height than live play), the turn strip says whose move it is in +two words with the dot showing the side, the sign-in dialog keeps the focus in +the password field after a wrong password, the brand mark is the site's +bishop, and the terminal client pages long output (help, games, pgn, state, +show, replay) one screen at a time, natively and in the browser. + Later for the replay clients: - [ ] Web: an "Analysis" link on a replay (chess.com-style evaluation needs an engine in the browser or a server endpoint; none exists). -- [ ] Web: a light theme, following the host page's choice. - [ ] CLI: `auto` cannot be interrupted before the end of the line. +## Accounts + +- [x] Password recovery by email: `POST /api/auth/recover` sends a one-time + link (an hour, SHA-256 of the token stored in `password_resets`, schema + v4) to the account's address through an SMTP relay (`-smtp-host`, with + optional credentials over TLS) or, for development, a directory of `.eml` + files (`-mail-dir`); `POST /api/auth/reset` sets the password, ends the + account's other sessions and signs the person in; the web client offers + **Forgot password?** when `/health` reports `"mail": "ok"` and opens the + password form from `?reset=`. `db user reset-link` prints the same + link for an administrator; `db user set-password` resets without one. +- [ ] Deliver from the chess jail through the mail jail (devops): relay + address, sender, and `chessd_smtp_*` in `rc.conf` (deployment.md). +- [ ] 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) - [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site, diff --git a/go.sum b/go.sum index b6a183f..1e4e95d 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= -github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/gabriel-vasile/mimetype v1.4.16 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E= github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -43,8 +41,6 @@ github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5I github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/molecule-man/go-brrr v1.3.0 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjRB7GeU= github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= -github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= -github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ= github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -58,8 +54,6 @@ github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE= github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.75.0 h1:W3xrvNJHG60j2ZCjxpP8kkB+oPG8kh9/g1n4+3xUwNA= -github.com/valyala/fasthttp v1.75.0/go.mod h1:JiVkPRZU124PuPsreV/s9dZRgG2lRK7wNEikdQ0Rk4s= github.com/valyala/fasthttp v1.76.0 h1:QitrT4foDQuqvUnr/hzmQUDbYzV12Zz4vkLOlipU4Cw= github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= diff --git a/internal/client/api/client.go b/internal/client/api/client.go index eac7f2a..568b03b 100644 --- a/internal/client/api/client.go +++ b/internal/client/api/client.go @@ -107,7 +107,7 @@ func (c *Client) doRequest(method, path string, body any, result any) error { if resp.StatusCode >= 400 { statusColor = display.Red } - fmt.Printf("%s[%d %s]%s\n", statusColor, resp.StatusCode, http.StatusText(resp.StatusCode), display.Reset) + display.Printf("%s[%d %s]%s\n", statusColor, resp.StatusCode, http.StatusText(resp.StatusCode), display.Reset) // Display response body if verbose if c.Verbose && len(respBody) > 0 { diff --git a/internal/client/command/auth.go b/internal/client/command/auth.go index d674b50..0f083d4 100644 --- a/internal/client/command/auth.go +++ b/internal/client/command/auth.go @@ -81,8 +81,8 @@ func registerHandler(s *session.Session, args []string) error { c.SetToken(resp.Token) display.Println(display.Green, "Registered successfully") - fmt.Printf("User ID: %s\n", resp.UserID) - fmt.Printf("Username: %s\n", resp.Username) + display.Printf("User ID: %s\n", resp.UserID) + display.Printf("Username: %s\n", resp.Username) return nil } @@ -111,8 +111,8 @@ func loginHandler(s *session.Session, args []string) error { c.SetToken(resp.Token) display.Println(display.Green, "Logged in successfully") - fmt.Printf("User ID: %s\n", resp.UserID) - fmt.Printf("Username: %s\n", resp.Username) + display.Printf("User ID: %s\n", resp.UserID) + display.Printf("Username: %s\n", resp.Username) return nil } @@ -151,14 +151,14 @@ func whoamiHandler(s *session.Session, args []string) error { } display.Println(display.Cyan, "Current User:") - fmt.Printf(" User ID: %s\n", user.UserID) - fmt.Printf(" Username: %s\n", user.Username) + display.Printf(" User ID: %s\n", user.UserID) + display.Printf(" Username: %s\n", user.Username) if user.Email != "" { - fmt.Printf(" Email: %s\n", user.Email) + display.Printf(" Email: %s\n", user.Email) } - fmt.Printf(" Created: %s\n", user.CreatedAt.Format("2006-01-02 15:04:05")) + display.Printf(" Created: %s\n", user.CreatedAt.Format("2006-01-02 15:04:05")) if user.LastLogin != nil { - fmt.Printf(" Last Login: %s\n", user.LastLogin.Format("2006-01-02 15:04:05")) + display.Printf(" Last Login: %s\n", user.LastLogin.Format("2006-01-02 15:04:05")) } return nil @@ -172,7 +172,7 @@ func setUserHandler(s *session.Session, args []string) error { userID := args[0] s.SetCurrentUser(userID) display.Println(display.Cyan, "User ID set to: %s", userID) - fmt.Println("Note: This doesn't authenticate, just sets the ID for display") + display.Line("Note: This doesn't authenticate, just sets the ID for display") return nil } diff --git a/internal/client/command/debug.go b/internal/client/command/debug.go index 85c2e5a..c0a3d47 100644 --- a/internal/client/command/debug.go +++ b/internal/client/command/debug.go @@ -44,12 +44,12 @@ func healthHandler(s *session.Session, args []string) error { } display.Println(display.Cyan, "Server Health:") - fmt.Printf(" Status: %s\n", resp.Status) + display.Printf(" Status: %s\n", resp.Status) // Convert Unix timestamp to readable time t := time.Unix(resp.Time, 0) - fmt.Printf(" Time: %s\n", t.Format("2006-01-02 15:04:05")) + display.Printf(" Time: %s\n", t.Format("2006-01-02 15:04:05")) if resp.Storage != "" { - fmt.Printf(" Storage: %s\n", resp.Storage) + display.Printf(" Storage: %s\n", resp.Storage) } return nil @@ -57,7 +57,7 @@ func healthHandler(s *session.Session, args []string) error { func urlHandler(s *session.Session, args []string) error { if len(args) == 0 { - fmt.Printf("Current API URL: %s\n", s.GetAPIBaseURL()) + display.Printf("Current API URL: %s\n", s.GetAPIBaseURL()) return nil } diff --git a/internal/client/command/game.go b/internal/client/command/game.go index 6edac44..180a07e 100644 --- a/internal/client/command/game.go +++ b/internal/client/command/game.go @@ -59,6 +59,7 @@ func (r *Registry) registerGameCommands() { Description: "Show board and game state", Usage: "show", Handler: showBoardHandler, + Paged: true, }) r.Register(&Command{ @@ -67,6 +68,7 @@ func (r *Registry) registerGameCommands() { Description: "Show raw game JSON", Usage: "state", Handler: gameStateHandler, + Paged: true, }) r.Register(&Command{ @@ -99,6 +101,7 @@ func (r *Registry) registerGameCommands() { Description: "List your stored games, newest first (login required)", Usage: "games [more]", Handler: gamesHandler, + Paged: true, }) r.Register(&Command{ @@ -107,6 +110,7 @@ func (r *Registry) registerGameCommands() { Description: "Print or save a stored game as PGN", Usage: "pgn [gameId] [ply] [save ] (default: current or replayed game)", Handler: pgnHandler, + Paged: true, }) r.Register(&Command{ @@ -259,8 +263,8 @@ func joinGameHandler(s *session.Session, args []string) error { } } - fmt.Printf("%sJoined game: %s%s\n", display.Green, gameID, display.Reset) - fmt.Printf("Turn: %s | State: %s | Moves: %d\n", resp.Turn, resp.State, len(resp.Moves)) + display.Printf("%sJoined game: %s%s\n", display.Green, gameID, display.Reset) + display.Printf("Turn: %s | State: %s | Moves: %d\n", resp.Turn, resp.State, len(resp.Moves)) return nil } @@ -368,9 +372,9 @@ func computerMoveHandler(s *session.Session, args []string) error { if final.LastMove != nil && len(final.Moves) > baselineMoves { display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move) if final.LastMove.Depth > 0 { - fmt.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score) + display.Printf(" (depth %d, score %d)", final.LastMove.Depth, final.LastMove.Score) } - fmt.Println() + display.Line() } printOutcome(final) @@ -546,28 +550,28 @@ func showBoardHandler(s *session.Session, args []string) error { s.SetGameState(game) // Display board with colors - fmt.Println() + display.Line() display.RenderBoard(board.Board) // Display game info - fmt.Printf("\nFEN: %s\n", game.FEN) - fmt.Printf("Turn: %s | State: %s | Moves: %d\n", + display.Printf("\nFEN: %s\n", game.FEN) + display.Printf("Turn: %s | State: %s | Moves: %d\n", display.ColorForTurn(game.Turn), game.State, len(game.Moves)) // Display move history if len(game.Moves) > 0 { - fmt.Printf("\nHistory: ") + display.Printf("\nHistory: ") for i, move := range game.Moves { if i > 0 { - fmt.Print(" ") + display.Write(" ") } if i%2 == 0 { - fmt.Printf("%d.%s", (i/2)+1, move) + display.Printf("%d.%s", (i/2)+1, move) } else { - fmt.Printf(" %s", move) + display.Printf(" %s", move) } } - fmt.Println() + display.Line() } // Display last move info @@ -576,11 +580,11 @@ func showBoardHandler(s *session.Session, args []string) error { if game.LastMove.PlayerColor == "b" { color = "Black" } - fmt.Printf("Last move: %s by %s", game.LastMove.Move, color) + display.Printf("Last move: %s by %s", game.LastMove.Move, color) if game.LastMove.Depth > 0 { - fmt.Printf(" (depth %d, score %d)", game.LastMove.Depth, game.LastMove.Score) + display.Printf(" (depth %d, score %d)", game.LastMove.Depth, game.LastMove.Score) } - fmt.Println() + display.Line() } printOutcome(game) printConcession(game) @@ -630,7 +634,7 @@ func deleteGameHandler(s *session.Session, args []string) error { s.SetLastMoveCount(0) } - fmt.Printf("%sLive game unloaded (history retained): %s%s\n", display.Green, gameID, display.Reset) + display.Printf("%sLive game unloaded (history retained): %s%s\n", display.Green, gameID, display.Reset) return nil } @@ -661,7 +665,7 @@ func pollHandler(s *session.Session, args []string) error { case len(resp.Moves) > moveCount: display.Println(display.Green, "Game updated! New moves detected") if resp.LastMove != nil { - fmt.Printf("Last move: %s\n", resp.LastMove.Move) + display.Printf("Last move: %s\n", resp.LastMove.Move) } case len(resp.Moves) < moveCount: display.Println(display.Green, "Game updated: %d move(s) taken back", moveCount-len(resp.Moves)) @@ -701,9 +705,9 @@ func gamesHandler(s *session.Session, args []string) error { return nil } - fmt.Printf("\n%3s %-36s %-20s %-20s %-7s %5s %s\n", "#", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)") + display.Printf("\n%3s %-36s %-20s %-20s %-7s %5s %s\n", "#", "Game ID", "White", "Black", "Result", "Plies", "Started (UTC)") for i, g := range resp.Games { - fmt.Printf("%3d %-36s %-20s %-20s %-7s %5d %s\n", i+1, g.GameID, + display.Printf("%3d %-36s %-20s %-20s %-7s %5d %s\n", i+1, g.GameID, playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount, g.StartTimeUTC.UTC().Format("2006-01-02 15:04")) } @@ -761,7 +765,7 @@ func pgnHandler(s *session.Session, args []string) error { display.Println(display.Green, "Saved PGN of %s to %s", describePGNTarget(gameID, ply), path) return nil } - fmt.Println() - fmt.Print(text) + display.Line() + display.Write(text) return nil } diff --git a/internal/client/command/pass_native.go b/internal/client/command/pass_native.go index e085f1a..6a12a88 100644 --- a/internal/client/command/pass_native.go +++ b/internal/client/command/pass_native.go @@ -3,15 +3,15 @@ package command import ( - "fmt" + "github.com/lixenwraith/chess/internal/client/display" "golang.org/x/term" ) func readPassword(prompt string) (string, error) { - fmt.Print(prompt) + display.Write(prompt) bytePassword, err := term.ReadPassword(0) // 0 is stdin - fmt.Println() + display.Line() if err != nil { return "", err } diff --git a/internal/client/command/registry.go b/internal/client/command/registry.go index 13cb552..2dc0e45 100644 --- a/internal/client/command/registry.go +++ b/internal/client/command/registry.go @@ -1,6 +1,7 @@ package command import ( + "bytes" "fmt" "strings" @@ -16,6 +17,9 @@ type Command struct { Description string Usage string Handler func(*session.Session, []string) error + // Paged commands print at length and read nothing: their output is + // shown one screen at a time (display.Page). + Paged bool } type Registry struct { @@ -43,6 +47,7 @@ func NewRegistry(s *session.Session) *Registry { Description: "Show available commands", Usage: "help [command]", Handler: r.helpHandler, + Paged: true, }) // Exit command (handled in main loop, but registered for help display) @@ -89,6 +94,18 @@ func (r *Registry) Execute(input string) { cl.SetVerbose(r.session.IsVerbose()) } + if cmd.Paged && r.session.ReadLine != nil { + var buf bytes.Buffer + previous := display.Out + display.Out = &buf + err := cmd.Handler(r.session, args) + display.Out = previous + display.Page(buf.String(), r.session.ReadLine) + if err != nil { + display.Println(display.Red, "Error: %s", err.Error()) + } + return + } if err := cmd.Handler(r.session, args); err != nil { display.Println(display.Red, "Error: %s", err.Error()) } @@ -101,13 +118,13 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error { if !exists { return fmt.Errorf("unknown command: %s", args[0]) } - fmt.Println() + display.Line() display.Print(display.Cyan, cmd.Name) display.Println(display.Reset, " - %s", cmd.Description) if cmd.ShortName != "" { display.Println(display.Cyan, "Short form: %s", cmd.ShortName) } - fmt.Printf("Usage: %s\n", cmd.Usage) + display.Printf("Usage: %s\n", cmd.Usage) return nil } @@ -173,10 +190,10 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error { if info.shortName != "" { shortPart = fmt.Sprintf("[%s%s%s] ", display.Cyan, info.shortName, display.Reset) } - fmt.Printf(" %s%-10s %s\n", shortPart, cmd.Name, cmd.Description) + display.Printf(" %s%-10s %s\n", shortPart, cmd.Name, cmd.Description) } } - fmt.Println() + display.Line() } printCommandGroup("Game Commands", gameCommands) @@ -185,7 +202,8 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error { printCommandGroup("Utility Commands", utilCommands) display.Println(display.Reset, "Type 'help ' for detailed usage") - display.Println(display.Reset, "Add '-v' to any command for verbose output\n") + display.Println(display.Reset, "Add '-v' to any command for verbose output") + display.Println(display.Reset, "Long output pauses after each screen: Enter continues, q stops\n") return nil } diff --git a/internal/client/command/replay.go b/internal/client/command/replay.go index 7adcc6e..6795308 100644 --- a/internal/client/command/replay.go +++ b/internal/client/command/replay.go @@ -22,6 +22,7 @@ func (r *Registry) registerReplayCommands() { Description: "Step through a stored game", Usage: "replay (index from the last 'games' listing)", Handler: replayHandler, + Paged: true, }) r.Register(&Command{ Name: "next", @@ -122,7 +123,7 @@ func replayHandler(s *session.Session, args []string) error { white, black := s.Replay.Players() display.Println(display.Cyan, "\nReplaying %s", history.GameID) - fmt.Printf("%s vs %s · %s · %d plies · started %s\n", + display.Printf("%s vs %s · %s · %d plies · started %s\n", display.C(display.Blue, white), display.C(display.Red, black), s.Replay.Result(), s.Replay.Len(), history.StartTimeUTC.UTC().Format("2006-01-02 15:04")) if c := history.Concession; c != nil { @@ -135,8 +136,8 @@ func replayHandler(s *session.Session, args []string) error { } display.Println(display.Yellow, "%s at ply %d; play continued.", what, c.Ply) } - fmt.Println() - fmt.Println(s.Replay.Movetext(80)) + display.Line() + display.Line(s.Replay.Movetext(80)) display.Println(display.Reset, "\nnext, prev, first, last, goto , auto [ms], fen, pgn [save ], live") return showReplayPosition(s) } @@ -217,7 +218,7 @@ func fenHandler(s *session.Session, args []string) error { if s.Replay == nil { return errNotReplaying } - fmt.Println(s.Replay.FEN()) + display.Line(s.Replay.FEN()) return nil } @@ -241,13 +242,13 @@ func showReplayPosition(s *session.Session) error { if err != nil { return fmt.Errorf("stored position cannot be shown: %w", err) } - fmt.Println() + display.Line() display.RenderBoard(board) label := r.Label(r.Ply) if r.Ply == 0 { label = "initial position" } - fmt.Printf("\nPly %d/%d %s\n", r.Ply, r.Len(), display.C(display.Cyan, label)) + display.Printf("\nPly %d/%d %s\n", r.Ply, r.Len(), display.C(display.Cyan, label)) if r.AtEnd() && r.History.Result != "" { display.Println(display.Green, "Result: %s", r.Result()) } diff --git a/internal/client/command/save_wasm.go b/internal/client/command/save_wasm.go index 34a1963..b6b3f0a 100644 --- a/internal/client/command/save_wasm.go +++ b/internal/client/command/save_wasm.go @@ -2,11 +2,15 @@ package command -import "fmt" +import ( + "fmt" + + "github.com/lixenwraith/chess/internal/client/display" +) // savePGN has no file system in the browser: the PGN is printed instead, // to be copied from the terminal. func savePGN(path, text string) error { - fmt.Print(text) + display.Write(text) return fmt.Errorf("files cannot be saved from the browser; the PGN is printed above") } diff --git a/internal/client/display/board.go b/internal/client/display/board.go index f81bce7..67ebfa2 100644 --- a/internal/client/display/board.go +++ b/internal/client/display/board.go @@ -40,7 +40,7 @@ func RenderBoard(asciiBoard string) { Print(Reset, "%c", char) } } - fmt.Println() + fmt.Fprintln(Out) } } diff --git a/internal/client/display/colors.go b/internal/client/display/colors.go index 7ac7ef1..0430d65 100644 --- a/internal/client/display/colors.go +++ b/internal/client/display/colors.go @@ -24,12 +24,12 @@ func C(color, text string) string { // Print outputs colored text immediately func Print(color, format string, args ...any) { - fmt.Printf(C(color, format), args...) + fmt.Fprintf(Out, C(color, format), args...) } // Println outputs colored text with newline func Println(color, format string, args ...any) { - fmt.Println(C(color, fmt.Sprintf(format, args...))) + fmt.Fprintln(Out, C(color, fmt.Sprintf(format, args...))) } // Build creates a multi-colored string diff --git a/internal/client/display/format.go b/internal/client/display/format.go index 003684e..64123f7 100644 --- a/internal/client/display/format.go +++ b/internal/client/display/format.go @@ -12,5 +12,5 @@ func PrettyPrintJSON(v any) { Print(Red, "Error formatting JSON: %s\n", err.Error()) return } - fmt.Println(string(data)) + fmt.Fprintln(Out, string(data)) } diff --git a/internal/client/display/output.go b/internal/client/display/output.go new file mode 100644 index 0000000..1770f4d --- /dev/null +++ b/internal/client/display/output.go @@ -0,0 +1,79 @@ +package display + +import ( + "fmt" + "io" + "os" + "regexp" + "strings" + "unicode/utf8" +) + +// Out receives everything the client prints. A paged command runs with Out +// swapped for a buffer, which Page then shows one screen at a time. +var Out io.Writer = os.Stdout + +// Printf, Line and Write are fmt.Printf, fmt.Println and fmt.Print to Out. +func Printf(format string, args ...any) { fmt.Fprintf(Out, format, args...) } +func Line(args ...any) { fmt.Fprintln(Out, args...) } +func Write(args ...any) { fmt.Fprint(Out, args...) } + +var ansiPattern = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]") + +// TermSize returns the terminal's rows and columns, or zeros when the output +// is not a terminal (then nothing is paged). Platform files set it. +var TermSize = func() (rows, cols int) { return 0, 0 } + +// Page prints text to Out one screen at a time. After each full screen it +// asks to continue; readLine supplies the answer (an empty line continues, +// "q" stops). Without a terminal, or when the text fits, it prints at once. +func Page(text string, readLine func() (string, bool)) { + rows, cols := TermSize() + lines := strings.Split(strings.TrimRight(text, "\n"), "\n") + if rows < 4 || readLine == nil || visualRows(lines, cols) <= rows-1 { + if text != "" { + fmt.Fprint(Out, strings.TrimRight(text, "\n")+"\n") + } + return + } + pageRows := rows - 1 // one line for the prompt + used, i := 0, 0 + for i < len(lines) { + need := wrappedRows(lines[i], cols) + if used > 0 && used+need > pageRows { + remaining := len(lines) - i + fmt.Fprintf(Out, "%s-- More (%d lines left; Enter continues, q quits) --%s", Yellow, remaining, Reset) + answer, ok := readLine() + // Clear the prompt and the echoed answer line. + fmt.Fprint(Out, "\r\x1b[2K\x1b[1A\r\x1b[2K") + if !ok || strings.HasPrefix(strings.ToLower(strings.TrimSpace(answer)), "q") { + return + } + used = 0 + continue + } + fmt.Fprintln(Out, lines[i]) + used += need + i++ + } +} + +func visualRows(lines []string, cols int) int { + total := 0 + for _, line := range lines { + total += wrappedRows(line, cols) + } + return total +} + +// wrappedRows is how many terminal rows a line takes, ignoring colour codes. +func wrappedRows(line string, cols int) int { + if cols <= 0 { + return 1 + } + width := utf8.RuneCountInString(ansiPattern.ReplaceAllString(line, "")) + if width == 0 { + return 1 + } + return (width + cols - 1) / cols +} diff --git a/internal/client/display/output_test.go b/internal/client/display/output_test.go new file mode 100644 index 0000000..192aa42 --- /dev/null +++ b/internal/client/display/output_test.go @@ -0,0 +1,58 @@ +package display + +import ( + "bytes" + "strings" + "testing" +) + +func TestPagePrintsScreensAndStopsOnQ(t *testing.T) { + var buf bytes.Buffer + prevOut, prevSize := Out, TermSize + Out = &buf + TermSize = func() (int, int) { return 5, 80 } // 4 lines a page + defer func() { Out, TermSize = prevOut, prevSize }() + + var lines []string + for i := 1; i <= 10; i++ { + lines = append(lines, "line "+string(rune('0'+i%10))) + } + answers := []string{"", "q"} + reads := 0 + readLine := func() (string, bool) { + if reads >= len(answers) { + return "", false + } + reads++ + return answers[reads-1], true + } + Page(strings.Join(lines, "\n")+"\n", readLine) + out := buf.String() + if reads != 2 { + t.Errorf("prompted %d times, want 2", reads) + } + if !strings.Contains(out, "line 1\nline 2\nline 3\nline 4\n") || !strings.Contains(out, "More (6 lines left") { + t.Errorf("first page = %q", out) + } + if !strings.Contains(out, "line 5\nline 6\nline 7\nline 8\n") || strings.Contains(out, "line 9") { + t.Errorf("second page / stop = %q", out) + } + + // Fits on one screen: no prompt. A wrapped long line counts as two rows. + buf.Reset() + reads = 0 + Page("a\nb\n", readLine) + if buf.String() != "a\nb\n" || reads != 0 { + t.Errorf("short text = %q, prompts %d", buf.String(), reads) + } + if wrappedRows(Red+strings.Repeat("x", 100)+Reset, 80) != 2 || wrappedRows("", 80) != 1 { + t.Error("wrapped rows") + } + // No terminal: everything at once. + TermSize = func() (int, int) { return 0, 0 } + buf.Reset() + Page(strings.Join(lines, "\n"), readLine) + if strings.Count(buf.String(), "\n") != 10 || reads != 0 { + t.Errorf("unpaged = %q", buf.String()) + } +} diff --git a/internal/client/display/termsize_native.go b/internal/client/display/termsize_native.go new file mode 100644 index 0000000..a6ff013 --- /dev/null +++ b/internal/client/display/termsize_native.go @@ -0,0 +1,23 @@ +//go:build !js && !wasm + +package display + +import ( + "os" + + "golang.org/x/term" +) + +func init() { + TermSize = func() (rows, cols int) { + fd := int(os.Stdout.Fd()) + if !term.IsTerminal(fd) { + return 0, 0 + } + cols, rows, err := term.GetSize(fd) + if err != nil { + return 0, 0 + } + return rows, cols + } +} diff --git a/internal/client/display/termsize_wasm.go b/internal/client/display/termsize_wasm.go new file mode 100644 index 0000000..4552aa4 --- /dev/null +++ b/internal/client/display/termsize_wasm.go @@ -0,0 +1,21 @@ +//go:build js && wasm + +package display + +import "syscall/js" + +// The page's terminal wrapper (terminal.js) publishes xterm's size as +// globalThis.chessTerminal.rows and .cols. +func init() { + TermSize = func() (rows, cols int) { + t := js.Global().Get("chessTerminal") + if t.IsUndefined() || t.IsNull() { + return 24, 80 + } + rows, cols = t.Get("rows").Int(), t.Get("cols").Int() + if rows <= 0 || cols <= 0 { + return 24, 80 + } + return rows, cols + } +} diff --git a/internal/client/session/session.go b/internal/client/session/session.go index 2d74fcf..f9c9ebf 100644 --- a/internal/client/session/session.go +++ b/internal/client/session/session.go @@ -18,8 +18,11 @@ type Session struct { Client *api.Client // Replay is the stored game being stepped through, nil while live. The // live game, user, and poll state above are untouched by a replay. - Replay *replay.State - Verbose bool + Replay *replay.State + // ReadLine reads the next input line (false at end of input); the pager + // uses it to wait between screens. + ReadLine func() (string, bool) + Verbose bool // Game state for prompt CurrentGameState *api.GameResponse PlayerColor string // "w", "b", or "" diff --git a/internal/server/core/error.go b/internal/server/core/error.go index a1a288d..e99996f 100644 --- a/internal/server/core/error.go +++ b/internal/server/core/error.go @@ -16,4 +16,5 @@ const ( ErrConflict = "GAME_CONFLICT" ErrStorageUnavailable = "STORAGE_UNAVAILABLE" ErrRouteNotFound = "NOT_FOUND" + ErrNotConfigured = "NOT_CONFIGURED" ) diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index a110bb2..aa89111 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -35,6 +35,17 @@ type DeleteAccountRequest struct { Password string `json:"password" validate:"required"` } +// RecoverRequest asks for a password-reset email. +type RecoverRequest struct { + Identifier string `json:"identifier" validate:"required"` // username or email +} + +// ResetPasswordRequest sets a new password with the token from the email. +type ResetPasswordRequest struct { + Token string `json:"token" validate:"required"` + Password string `json:"password" validate:"required"` +} + // AuthResponse contains JWT token and user information type AuthResponse struct { Token string `json:"token"` @@ -322,3 +333,104 @@ func (h *HTTPHandler) DeleteAccountHandler(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } + +// RecoverHandler emails a password-reset link. The answer is the same whether +// or not the account exists or has an email, so nothing is revealed; only a +// missing transport (503) or a delivery failure (502) is reported. +func (h *HTTPHandler) RecoverHandler(c *fiber.Ctx) error { + var req RecoverRequest + if err := c.BodyParser(&req); err != nil || strings.TrimSpace(req.Identifier) == "" { + return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ + Error: "invalid request body", + Code: core.ErrInvalidRequest, + Details: "identifier (username or email) is required", + }) + } + if len(req.Identifier) > 254 { + return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ + Error: "invalid identifier", Code: core.ErrInvalidRequest, + }) + } + + if err := h.svc.RequestPasswordReset(req.Identifier); err != nil { + switch { + case errors.Is(err, service.ErrMailDisabled): + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "password recovery is not available on this server", Code: core.ErrNotConfigured, + }) + case errors.Is(err, service.ErrMailFailed): + return c.Status(fiber.StatusBadGateway).JSON(core.ErrorResponse{ + Error: "the reset email could not be sent; try again later", Code: core.ErrInternalError, + }) + case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable): + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "account storage unavailable", Code: core.ErrStorageUnavailable, + }) + default: + return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ + Error: "failed to start password recovery", Code: core.ErrInternalError, + }) + } + } + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ + "message": "If the account has an email address, a reset link has been sent to it.", + }) +} + +// ResetPasswordHandler sets a new password from a reset link and signs the +// user in, returning a session token like login. +func (h *HTTPHandler) ResetPasswordHandler(c *fiber.Ctx) error { + var req ResetPasswordRequest + if err := c.BodyParser(&req); err != nil || req.Token == "" || req.Password == "" { + return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ + Error: "invalid request body", + Code: core.ErrInvalidRequest, + Details: "token and password are required", + }) + } + if err := validatePassword(req.Password); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ + Error: "weak password", + Code: core.ErrInvalidRequest, + Details: err.Error(), + }) + } + + user, sessionID, err := h.svc.ResetPassword(req.Token, req.Password) + if err != nil { + switch { + case errors.Is(err, service.ErrResetInvalid): + return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{ + Error: "invalid or expired reset link", + Code: core.ErrInvalidRequest, + Details: "request a new link from the sign-in dialog", + }) + case errors.Is(err, service.ErrAuthBusy): + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit, + }) + case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable): + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "account storage unavailable", Code: core.ErrStorageUnavailable, + }) + default: + return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ + Error: "failed to reset password", Code: core.ErrInternalError, + }) + } + } + + token, err := h.svc.GenerateUserToken(user.UserID, sessionID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ + Error: "failed to generate token", Code: core.ErrInternalError, + }) + } + return c.JSON(AuthResponse{ + Token: token, + UserID: user.UserID, + Username: user.Username, + Email: user.Email, + ExpiresAt: time.Now().Add(service.SessionTTL), + }) +} diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 1e67d14..c57bb9f 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -140,6 +140,33 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) }, }), h.LoginHandler) + // Password recovery: 3 requests a minute per IP, each of which may send + // an email; the reset itself is limited like login. + auth.Post("/recover", limiter.New(limiter.Config{ + Max: 3, + 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: "3 recovery requests per minute allowed", + }) + }, + }), h.RecoverHandler) + auth.Post("/reset", limiter.New(limiter.Config{ + Max: 10, + 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: "10 password resets per minute allowed", + }) + }, + }), h.ResetPasswordHandler) + // Create token validator closure validateToken := svc.ValidateToken @@ -263,6 +290,7 @@ func (h *HTTPHandler) Health(c *fiber.Ctx) error { "status": status, "time": time.Now().Unix(), "storage": storageHealth, + "mail": h.svc.MailHealth(), "version": h.version, }) } diff --git a/internal/server/http/handler_test.go b/internal/server/http/handler_test.go index b6f5ef7..7268b8a 100644 --- a/internal/server/http/handler_test.go +++ b/internal/server/http/handler_test.go @@ -89,6 +89,8 @@ func TestAPIRoutes(t *testing.T) { "GET /api/auth/me", "POST /api/auth/logout", "DELETE /api/auth/me", + "POST /api/auth/recover", + "POST /api/auth/reset", "POST /api/games", "GET /api/games/:gameId", "GET /api/games/:gameId/history", diff --git a/internal/server/mail/mail.go b/internal/server/mail/mail.go new file mode 100644 index 0000000..c20400c --- /dev/null +++ b/internal/server/mail/mail.go @@ -0,0 +1,259 @@ +// Package mail sends the server's few transactional messages, today the +// password-reset link. A Sender is an SMTP relay (production), a directory +// of .eml files (development and tests), or nothing, in which case the +// features that need mail are off. +package mail + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "net" + "net/mail" + "net/smtp" + "os" + "path/filepath" + "strings" + "time" +) + +// Message is one plain-text email. +type Message struct { + To string + Subject string + Body string +} + +// Sender delivers messages. +type Sender interface { + Send(ctx context.Context, m Message) error + // Describe names the transport for logs, without secrets. + Describe() string +} + +// SMTP delivers through a relay. Username and Password are optional: a relay +// inside the same network usually accepts mail from this host without them. +// STARTTLS is used when the relay offers it; credentials are only ever sent +// over TLS (or to localhost), which net/smtp enforces. +type SMTP struct { + Host string + Port int + Username string + Password string + From string // "Chess " or a bare address + // Timeout bounds the whole exchange; 30 seconds when zero. + Timeout time.Duration +} + +// Describe names the relay. +func (s *SMTP) Describe() string { + auth := "no authentication" + if s.Username != "" { + auth = "as " + s.Username + } + return fmt.Sprintf("smtp %s:%d (%s), from %s", s.Host, s.Port, auth, s.From) +} + +// Send delivers one message through the relay. +func (s *SMTP) Send(ctx context.Context, m Message) error { + from, err := mail.ParseAddress(s.From) + if err != nil { + return fmt.Errorf("mail from %q: %w", s.From, err) + } + to, err := mail.ParseAddress(m.To) + if err != nil { + return fmt.Errorf("mail to %q: %w", m.To, err) + } + timeout := s.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + if deadline, ok := ctx.Deadline(); ok { + if until := time.Until(deadline); until < timeout { + timeout = until + } + } + addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port)) + conn, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("connect to %s: %w", addr, err) + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return err + } + + client, err := smtp.NewClient(conn, s.Host) + if err != nil { + return fmt.Errorf("smtp handshake with %s: %w", addr, err) + } + defer client.Close() + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: s.Host, MinVersion: tls.VersionTLS12}); err != nil { + return fmt.Errorf("starttls with %s: %w", addr, err) + } + } + if s.Username != "" { + if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil { + return fmt.Errorf("smtp authentication with %s: %w", addr, err) + } + } + if err := client.Mail(from.Address); err != nil { + return fmt.Errorf("smtp from: %w", err) + } + if err := client.Rcpt(to.Address); err != nil { + return fmt.Errorf("smtp to: %w", err) + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := w.Write(Format(s.From, m)); err != nil { + return fmt.Errorf("smtp write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp send: %w", err) + } + return client.Quit() +} + +// Dir writes each message to a file in a directory instead of sending it, +// for development and tests. The file holds exactly what SMTP would carry. +type Dir struct { + Path string + From string +} + +// Describe names the directory. +func (d *Dir) Describe() string { + return fmt.Sprintf("directory %s, from %s", d.Path, d.From) +} + +// Send writes the message as -.eml. +func (d *Dir) Send(_ context.Context, m Message) error { + if _, err := mail.ParseAddress(m.To); err != nil { + return fmt.Errorf("mail to %q: %w", m.To, err) + } + if err := os.MkdirAll(d.Path, 0o700); err != nil { + return err + } + var nonce [4]byte + rand.Read(nonce[:]) + name := fmt.Sprintf("%d-%s.eml", time.Now().UnixNano(), hex.EncodeToString(nonce[:])) + return os.WriteFile(filepath.Join(d.Path, name), Format(d.From, m), 0o600) +} + +// Format renders the message as RFC 5322 text with CRLF line ends: headers, +// a blank line, the UTF-8 body. Header values are kept to one line. +func Format(from string, m Message) []byte { + var nonce [8]byte + rand.Read(nonce[:]) + domain := "localhost" + if addr, err := mail.ParseAddress(from); err == nil { + if at := strings.LastIndex(addr.Address, "@"); at >= 0 { + domain = addr.Address[at+1:] + } + } + var b bytes.Buffer + header := func(name, value string) { + value = strings.NewReplacer("\r", " ", "\n", " ").Replace(value) + fmt.Fprintf(&b, "%s: %s\r\n", name, value) + } + header("From", from) + header("To", m.To) + header("Subject", m.Subject) + header("Date", time.Now().UTC().Format(time.RFC1123Z)) + header("Message-ID", fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(nonce[:]), domain)) + header("MIME-Version", "1.0") + header("Content-Type", "text/plain; charset=utf-8") + header("Content-Transfer-Encoding", "8bit") + header("Auto-Submitted", "auto-generated") + b.WriteString("\r\n") + body := strings.ReplaceAll(strings.ReplaceAll(m.Body, "\r\n", "\n"), "\n", "\r\n") + b.WriteString(body) + if !strings.HasSuffix(body, "\r\n") { + b.WriteString("\r\n") + } + return b.Bytes() +} + +// ErrNoSender is returned by Validate for an empty configuration. +var ErrNoSender = errors.New("no mail transport configured") + +// Config is the server's mail configuration, from its flags. SMTPHost or Dir +// selects the transport; both empty means no mail. +type Config struct { + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPasswordFile string + Dir string + From string +} + +// Build returns the configured Sender, nil (with ErrNoSender) when nothing is +// configured, or an error for an inconsistent configuration. +func (c Config) Build() (Sender, error) { + switch { + case c.SMTPHost != "" && c.Dir != "": + return nil, errors.New("-smtp-host and -mail-dir are exclusive") + case c.SMTPHost == "" && c.Dir == "": + return nil, ErrNoSender + } + if c.From == "" { + return nil, errors.New("-mail-from is required with a mail transport") + } + if _, err := mail.ParseAddress(c.From); err != nil { + return nil, fmt.Errorf("-mail-from %q: %w", c.From, err) + } + if c.Dir != "" { + if err := os.MkdirAll(c.Dir, 0o700); err != nil { + return nil, fmt.Errorf("-mail-dir: %w", err) + } + return &Dir{Path: c.Dir, From: c.From}, nil + } + port := c.SMTPPort + if port <= 0 { + port = 25 + } + s := &SMTP{Host: c.SMTPHost, Port: port, Username: c.SMTPUser, From: c.From} + if c.SMTPPasswordFile != "" { + secret, err := readSecretFile(c.SMTPPasswordFile) + if err != nil { + return nil, fmt.Errorf("-smtp-password-file: %w", err) + } + s.Password = secret + } + if s.Password != "" && s.Username == "" { + return nil, errors.New("-smtp-password-file needs -smtp-user") + } + return s, nil +} + +// readSecretFile reads a one-line secret from a file that only its owner may +// read, as the JWT key file is. +func readSecretFile(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("%s is not a regular file", path) + } + if info.Mode().Perm()&0o077 != 0 { + return "", fmt.Errorf("%s is readable by group or others (mode %04o); use mode 0600", path, info.Mode().Perm()) + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + secret := strings.TrimRight(string(data), "\r\n") + if secret == "" { + return "", fmt.Errorf("%s is empty", path) + } + return secret, nil +} diff --git a/internal/server/mail/mail_test.go b/internal/server/mail/mail_test.go new file mode 100644 index 0000000..c0c4c46 --- /dev/null +++ b/internal/server/mail/mail_test.go @@ -0,0 +1,91 @@ +package mail + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFormatIsRFC5322WithCRLF(t *testing.T) { + text := string(Format("Chess ", Message{ + To: "alice@example.com", Subject: "Reset your\npassword", Body: "line one\nline two", + })) + head, body, ok := strings.Cut(text, "\r\n\r\n") + if !ok { + t.Fatalf("no header/body separator in %q", text) + } + for _, want := range []string{ + "From: Chess \r\n", "To: alice@example.com\r\n", + "Subject: Reset your password\r\n", "Content-Type: text/plain; charset=utf-8\r\n", "@example.com>\r\n", + } { + if !strings.Contains(head+"\r\n", want) { + t.Errorf("header %q missing in %q", want, head) + } + } + if body != "line one\r\nline two\r\n" { + t.Errorf("body = %q", body) + } + if strings.Contains(text, "\n\n") || strings.Contains(strings.ReplaceAll(text, "\r\n", ""), "\n") { + t.Error("bare LF in message") + } +} + +func TestDirWritesOneFilePerMessage(t *testing.T) { + dir := filepath.Join(t.TempDir(), "mail") + d := &Dir{Path: dir, From: "chess@example.com"} + if err := d.Send(context.Background(), Message{To: "bob@example.com", Subject: "s", Body: "b"}); err != nil { + t.Fatal(err) + } + if err := d.Send(context.Background(), Message{To: "not an address", Subject: "s", Body: "b"}); err == nil { + t.Error("invalid recipient accepted") + } + entries, err := os.ReadDir(dir) + if err != nil || len(entries) != 1 || !strings.HasSuffix(entries[0].Name(), ".eml") { + t.Fatalf("entries = %v, %v", entries, err) + } + data, _ := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if !strings.Contains(string(data), "To: bob@example.com\r\n") { + t.Errorf("file = %q", data) + } +} + +func TestConfigBuild(t *testing.T) { + if _, err := (Config{}).Build(); !errors.Is(err, ErrNoSender) { + t.Errorf("empty config: %v", err) + } + for name, c := range map[string]Config{ + "both transports": {SMTPHost: "h", Dir: "d", From: "a@b.c"}, + "no from": {SMTPHost: "h"}, + "bad from": {SMTPHost: "h", From: "nope"}, + "password no user": {SMTPHost: "h", From: "a@b.c", SMTPPasswordFile: func() string { + p := filepath.Join(t.TempDir(), "pw") + os.WriteFile(p, []byte("secret\n"), 0o600) + return p + }()}, + } { + if _, err := c.Build(); err == nil { + t.Errorf("%s: accepted", name) + } + } + pw := filepath.Join(t.TempDir(), "pw") + os.WriteFile(pw, []byte("secret\n"), 0o644) + if _, err := (Config{SMTPHost: "h", From: "a@b.c", SMTPUser: "u", SMTPPasswordFile: pw}).Build(); err == nil { + t.Error("world-readable password file accepted") + } + os.Chmod(pw, 0o600) + sender, err := (Config{SMTPHost: "mail.internal", From: "Chess ", SMTPUser: "u", SMTPPasswordFile: pw}).Build() + if err != nil { + t.Fatal(err) + } + s, ok := sender.(*SMTP) + if !ok || s.Port != 25 || s.Password != "secret" || !strings.Contains(s.Describe(), "as u") || + strings.Contains(s.Describe(), "secret") { + t.Errorf("smtp = %+v, %q", s, sender.Describe()) + } + if d, err := (Config{Dir: "x", From: "a@b.c"}).Build(); err != nil || d.(*Dir).Path != "x" { + t.Errorf("dir = %v, %v", d, err) + } +} diff --git a/internal/server/service/reset.go b/internal/server/service/reset.go new file mode 100644 index 0000000..6423e7f --- /dev/null +++ b/internal/server/service/reset.go @@ -0,0 +1,208 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "net/url" + "strings" + "time" + + "github.com/lixenwraith/chess/internal/server/mail" + "github.com/lixenwraith/chess/internal/server/storage" + + "github.com/google/uuid" +) + +const ( + // PasswordResetTTL is how long a reset link works. + PasswordResetTTL = time.Hour + resetTokenBytes = 32 + mailSendTimeout = 20 * time.Second +) + +var ( + // ErrMailDisabled reports that no mail transport is configured, so + // password recovery is off. + ErrMailDisabled = errors.New("password recovery is not configured") + // ErrMailFailed reports a delivery failure to the relay. + ErrMailFailed = errors.New("could not send the email") + // ErrResetInvalid reports an unknown, used, or expired reset token. + ErrResetInvalid = errors.New("invalid or expired reset link") +) + +// mailer is the configured transport and the client URL reset links open. +type mailer struct { + sender mail.Sender + publicURL *url.URL +} + +// SetMailer configures password recovery: sender delivers the email and +// publicURL is the web client's address, which the link opens with +// ?reset=. A nil sender turns recovery off. +func (s *Service) SetMailer(sender mail.Sender, publicURL string) error { + if sender == nil { + s.mail = nil + return nil + } + u, err := url.Parse(publicURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("public URL %q must be absolute (https://host/chess/web-client/)", publicURL) + } + s.mail = &mailer{sender: sender, publicURL: u} + return nil +} + +// MailHealth reports "ok" when password recovery can send mail, otherwise +// "disabled". +func (s *Service) MailHealth() string { + if s.mail == nil || s.store == nil { + return "disabled" + } + return "ok" +} + +// NewResetToken returns a fresh reset token and the hash stored for it. +func NewResetToken() (token string, hash []byte, err error) { + raw := make([]byte, resetTokenBytes) + if _, err := rand.Read(raw); err != nil { + return "", nil, err + } + token = base64.RawURLEncoding.EncodeToString(raw) + return token, hashResetToken(token), nil +} + +func hashResetToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// ResetLink is the web client URL that opens the password form for token. +func ResetLink(publicURL *url.URL, token string) string { + u := *publicURL + q := u.Query() + q.Set("reset", token) + u.RawQuery = q.Encode() + u.Fragment = "" + return u.String() +} + +// RequestPasswordReset emails a reset link to the account named by +// identifier (username or email) when it has an email address. It reports +// success for unknown accounts and accounts without an email, so the request +// reveals nothing about who is registered; the caller sees the same answer. +func (s *Service) RequestPasswordReset(identifier string) error { + if s.store == nil { + return ErrStorageDisabled + } + if s.mail == nil { + return ErrMailDisabled + } + identifier = strings.ToLower(strings.TrimSpace(identifier)) + var record *storage.UserRecord + var err error + if strings.Contains(identifier, "@") { + record, err = s.store.GetUserByEmail(identifier) + } else { + record, err = s.store.GetUserByUsername(identifier) + } + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + slog.Info("password reset requested for an unknown account") + return nil + } + return fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err) + } + if record.Email == "" { + slog.Info("password reset requested for an account without email", "user_id", record.UserID) + return nil + } + + token, hash, err := NewResetToken() + if err != nil { + return fmt.Errorf("generate reset token: %w", err) + } + now := time.Now().UTC() + if err := s.store.CreatePasswordReset(storage.PasswordResetRecord{ + UserID: record.UserID, TokenHash: hash, CreatedAt: now, ExpiresAt: now.Add(PasswordResetTTL), + }); err != nil { + return fmt.Errorf("%w: store reset: %v", ErrStorageUnavailable, err) + } + + link := ResetLink(s.mail.publicURL, token) + ctx, cancel := context.WithTimeout(context.Background(), mailSendTimeout) + defer cancel() + err = s.mail.sender.Send(ctx, mail.Message{ + To: record.Email, + Subject: "Reset your chess password", + Body: fmt.Sprintf(`Hello %s, + +Someone asked to reset the password of your chess account at %s. +If it was you, open this link within the next hour: + + %s + +If you did not ask for this, ignore this message: your password stays as +it is and nobody else can use the link without this email. +`, record.Username, s.mail.publicURL.Host, link), + }) + if err != nil { + slog.Error("password reset email failed", "user_id", record.UserID, "error", err) + return ErrMailFailed + } + slog.Info("password reset email sent", "user_id", record.UserID) + return nil +} + +// ResetPassword sets a new password for the account whose pending reset +// token this is, ends its other sessions, and signs the user in. The token +// works once and only until its expiry. +func (s *Service) ResetPassword(token, password string) (*User, string, error) { + if s.store == nil { + return nil, "", ErrStorageDisabled + } + if raw, err := base64.RawURLEncoding.DecodeString(token); err != nil || len(raw) != resetTokenBytes { + return nil, "", ErrResetInvalid + } + now := time.Now().UTC() + userID, err := s.store.ConsumePasswordReset(hashResetToken(token), now) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, "", ErrResetInvalid + } + return nil, "", fmt.Errorf("%w: consume reset: %v", ErrStorageUnavailable, err) + } + + passwordHash, err := s.hashPassword(password) + if err != nil { + if errors.Is(err, ErrAuthBusy) { + return nil, "", err + } + return nil, "", fmt.Errorf("failed to hash password: %w", err) + } + if err := s.store.ResetUserPassword(userID, passwordHash); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, "", ErrResetInvalid + } + return nil, "", fmt.Errorf("%w: reset password: %v", ErrStorageUnavailable, err) + } + record, err := s.store.GetUserByID(userID) + if err != nil { + return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err) + } + sessionID := uuid.NewString() + if err := s.store.CreateSession(storage.SessionRecord{ + SessionID: sessionID, UserID: userID, CreatedAt: now, ExpiresAt: now.Add(SessionTTL), + }); err != nil { + return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err) + } + slog.Info("password reset completed", "user_id", userID) + return &User{ + UserID: record.UserID, Username: record.Username, Email: record.Email, CreatedAt: record.CreatedAt, + }, sessionID, nil +} diff --git a/internal/server/service/reset_test.go b/internal/server/service/reset_test.go new file mode 100644 index 0000000..b34721c --- /dev/null +++ b/internal/server/service/reset_test.go @@ -0,0 +1,147 @@ +package service + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/lixenwraith/chess/internal/server/mail" + "github.com/lixenwraith/chess/internal/server/storage" +) + +var linkPattern = regexp.MustCompile(`https://example\.test/chess/web-client/\?reset=([A-Za-z0-9_-]{43})`) + +func TestPasswordResetFlow(t *testing.T) { + svc := newPersistentTestService(t) + if svc.MailHealth() != "disabled" { + t.Fatal("mail reported before configuration") + } + if err := svc.RequestPasswordReset("alice"); !errors.Is(err, ErrMailDisabled) { + t.Fatalf("recovery without mail: %v", err) + } + dir := filepath.Join(t.TempDir(), "mail") + if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "/chess/"); err == nil { + t.Fatal("relative public URL accepted") + } + if err := svc.SetMailer(&mail.Dir{Path: dir, From: "chess@example.test"}, "https://example.test/chess/web-client/"); err != nil { + t.Fatal(err) + } + if svc.MailHealth() != "ok" { + t.Fatal("mail not reported ok") + } + + user, sessionID, err := svc.RegisterUser("alice", "Alice@Example.test", "Password1") + if err != nil { + t.Fatal(err) + } + if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil { + t.Fatal(err) + } + + // Unknown accounts and accounts without email get the same answer and no mail. + for _, id := range []string{"nobody", "nobody@example.test", "bob"} { + if err := svc.RequestPasswordReset(id); err != nil { + t.Errorf("recover %q: %v", id, err) + } + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatalf("mail sent for unknown accounts: %d files", len(entries)) + } + + // By email, case-insensitively; then by username, which replaces the link. + if err := svc.RequestPasswordReset("ALICE@example.test"); err != nil { + t.Fatal(err) + } + first := lastMailToken(t, dir) + if err := svc.RequestPasswordReset("alice"); err != nil { + t.Fatal(err) + } + token := lastMailToken(t, dir) + if token == first { + t.Fatal("second request reused the token") + } + if _, _, err := svc.ResetPassword(first, "Password2"); !errors.Is(err, ErrResetInvalid) { + t.Errorf("replaced token: %v", err) + } + for _, bad := range []string{"", "x", strings.Repeat("A", 43), token + "A"} { + if _, _, err := svc.ResetPassword(bad, "Password2"); !errors.Is(err, ErrResetInvalid) { + t.Errorf("token %q: %v", bad, err) + } + } + + reset, newSession, err := svc.ResetPassword(token, "Password2") + if err != nil { + t.Fatal(err) + } + if reset.UserID != user.UserID || reset.Username != "alice" || newSession == "" || newSession == sessionID { + t.Fatalf("reset returned %+v, session %q", reset, newSession) + } + if _, _, err := svc.ResetPassword(token, "Password3"); !errors.Is(err, ErrResetInvalid) { + t.Errorf("token reused: %v", err) + } + // The old session is gone, the new one and the new password work. + if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, sessionID)); err == nil { + t.Error("old session still valid") + } + if _, _, err := svc.ValidateToken(mustToken(t, svc, user.UserID, newSession)); err != nil { + t.Errorf("new session invalid: %v", err) + } + if _, _, err := svc.AuthenticateUser("alice", "Password1"); !errors.Is(err, ErrInvalidCredentials) { + t.Errorf("old password: %v", err) + } + if _, _, err := svc.AuthenticateUser("alice", "Password2"); err != nil { + t.Errorf("new password: %v", err) + } + + // An expired link is refused and the cleanup removes it. + expired, hash, err := NewResetToken() + if err != nil { + t.Fatal(err) + } + past := time.Now().UTC().Add(-time.Minute) + if err := svc.store.CreatePasswordReset(storage.PasswordResetRecord{ + UserID: user.UserID, TokenHash: hash, CreatedAt: past.Add(-time.Hour), ExpiresAt: past, + }); err != nil { + t.Fatal(err) + } + if _, _, err := svc.ResetPassword(expired, "Password4"); !errors.Is(err, ErrResetInvalid) { + t.Errorf("expired token: %v", err) + } + if deleted, err := svc.store.DeleteExpiredPasswordResets(); err != nil || deleted != 1 { + t.Errorf("expired resets deleted = %d, %v", deleted, err) + } +} + +func lastMailToken(t *testing.T, dir string) string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil || len(entries) == 0 { + t.Fatalf("no mail in %s: %v", dir, err) + } + data, err := os.ReadFile(filepath.Join(dir, entries[len(entries)-1].Name())) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "To: alice@example.test\r\n") || !strings.Contains(text, "Subject: Reset your chess password\r\n") { + t.Fatalf("mail = %q", text) + } + m := linkPattern.FindStringSubmatch(text) + if m == nil { + t.Fatalf("no reset link in %q", text) + } + return m[1] +} + +func mustToken(t *testing.T, svc *Service, userID, sessionID string) string { + t.Helper() + token, err := svc.GenerateUserToken(userID, sessionID) + if err != nil { + t.Fatal(err) + } + return token +} diff --git a/internal/server/service/service.go b/internal/server/service/service.go index 3c29d92..0248316 100644 --- a/internal/server/service/service.go +++ b/internal/server/service/service.go @@ -41,6 +41,8 @@ type Service struct { integrityMode IntegrityMode integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only + + mail *mailer // password recovery; nil when no transport is configured } // New creates a service with optional storage. jwtSecret signs session tokens @@ -193,6 +195,11 @@ func (s *Service) cleanupExpired() { } else if deleted > 0 { slog.Info("cleanup deleted expired sessions", "count", deleted) } + if deleted, err := s.store.DeleteExpiredPasswordResets(); err != nil { + slog.Error("cleanup failed to delete expired password resets", "error", err) + } else if deleted > 0 { + slog.Info("cleanup deleted expired password resets", "count", deleted) + } } s.cleanupGames(now) s.checkIntegrity(now) diff --git a/internal/server/storage/reset.go b/internal/server/storage/reset.go new file mode 100644 index 0000000..9abea8d --- /dev/null +++ b/internal/server/storage/reset.go @@ -0,0 +1,120 @@ +package storage + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "time" +) + +// PasswordResetRecord is a pending password reset: the SHA-256 of the token +// sent to the account's email, and when it stops working. +type PasswordResetRecord struct { + UserID string + TokenHash []byte + CreatedAt time.Time + ExpiresAt time.Time +} + +// CreatePasswordReset stores a reset for the user, replacing any pending one, +// so only the latest link works. It returns sql.ErrNoRows for an unknown +// user. +func (s *Store) CreatePasswordReset(record PasswordResetRecord) error { + if !validUUID(record.UserID) { + return sql.ErrNoRows + } + if len(record.TokenHash) != 32 { + return errors.New("token hash must be 32 bytes") + } + ctx, cancel := opContext() + defer cancel() + result, err := s.db.ExecContext(ctx, `INSERT INTO password_resets (user_id, token_hash, created_at, expires_at) + SELECT user_id, $2, $3, $4 FROM users WHERE user_id = $1 + ON CONFLICT (user_id) DO UPDATE SET + token_hash = excluded.token_hash, + created_at = excluded.created_at, + expires_at = excluded.expires_at`, + record.UserID, record.TokenHash, record.CreatedAt, record.ExpiresAt) + if err != nil { + return fmt.Errorf("create password reset: %w", err) + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows == 0 { + return sql.ErrNoRows + } + slog.Debug("storage password reset created", "user_id", record.UserID, "expires_at", record.ExpiresAt) + return nil +} + +// ConsumePasswordReset deletes the reset matching tokenHash and returns its +// user, or sql.ErrNoRows when none is pending or it has expired. A token +// works once. +func (s *Store) ConsumePasswordReset(tokenHash []byte, now time.Time) (string, error) { + if len(tokenHash) != 32 { + return "", sql.ErrNoRows + } + ctx, cancel := opContext() + defer cancel() + var userID string + err := s.db.QueryRowContext(ctx, + `DELETE FROM password_resets WHERE token_hash = $1 AND expires_at > $2 RETURNING user_id`, + tokenHash, now.UTC()).Scan(&userID) + if err != nil { + return "", err + } + return userID, nil +} + +// DeleteExpiredPasswordResets removes resets past their expiry. +func (s *Store) DeleteExpiredPasswordResets() (int64, error) { + ctx, cancel := opContext() + defer cancel() + result, err := s.db.ExecContext(ctx, `DELETE FROM password_resets WHERE expires_at < $1`, time.Now().UTC()) + if err != nil { + return 0, err + } + deleted, err := result.RowsAffected() + if err == nil && deleted > 0 { + slog.Debug("storage expired password resets deleted", "count", deleted) + } + return deleted, err +} + +// ResetUserPassword sets a new password hash and ends the user's sessions in +// one transaction: whoever held the old password is signed out everywhere. +// It returns sql.ErrNoRows for an unknown user. +func (s *Store) ResetUserPassword(userID, passwordHash string) error { + if !validUUID(userID) { + return sql.ErrNoRows + } + ctx, cancel := opContext() + defer cancel() + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + result, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = $2 WHERE user_id = $1`, userID, passwordHash) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + if rows, err := result.RowsAffected(); err != nil { + return err + } else if rows == 0 { + return sql.ErrNoRows + } + if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil { + return fmt.Errorf("end sessions: %w", err) + } + if _, err := tx.ExecContext(ctx, `DELETE FROM password_resets WHERE user_id = $1`, userID); err != nil { + return fmt.Errorf("clear password resets: %w", err) + } + if err := tx.Commit(); err != nil { + return err + } + slog.Debug("storage password reset", "user_id", userID) + return nil +} diff --git a/internal/server/storage/schema.go b/internal/server/storage/schema.go index a5303cd..1eb0b4a 100644 --- a/internal/server/storage/schema.go +++ b/internal/server/storage/schema.go @@ -81,7 +81,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 = 3 +const schemaVersion = 4 // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a // released migration; append a new one. @@ -212,8 +212,23 @@ ALTER TABLE games ADD CONSTRAINT games_concession_check CHECK ( OR (concession_ply >= 0 AND concession_time_utc IS NOT NULL AND ( (concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation') OR (concession_result = 'draw' AND concession_termination = 'agreement')))); +`, + + // v4: password recovery. One pending reset per account; the token the + // email carries is never stored, only its SHA-256, and it expires after + // an hour. Consuming a token deletes the row. + ` +CREATE TABLE password_resets ( + user_id uuid PRIMARY KEY REFERENCES users (user_id) ON DELETE CASCADE, + token_hash bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + CONSTRAINT password_resets_token_hash_key UNIQUE (token_hash), + CONSTRAINT password_resets_token_hash_check CHECK (octet_length(token_hash) = 32) +); +CREATE INDEX password_resets_expires_at_idx ON password_resets (expires_at); `, } // ownedTables lists every table created by migrations, children first. -var ownedTables = []string{"moves", "games", "sessions", "users", "schema_version"} +var ownedTables = []string{"moves", "games", "password_resets", "sessions", "users", "schema_version"} diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index 630025d..e4da7a2 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -93,6 +93,7 @@ const games = { const ui = { healthTimer: null, storage: 'unknown', + mail: 'unknown', // "ok" when the server emails password-reset links toastTimer: null, historyCache: new Map(), // gameId -> {etag, history} }; @@ -211,6 +212,22 @@ function describeResult(state, termination) { return 'Game over'; } +// shortResult is the outcome in a few words, for the narrow turn strip. +function shortResult(state, termination) { + const winner = state === 'white wins' ? 'White' : 'Black'; + switch (termination) { + case 'resignation': return `${winner} wins · resigned`; + case 'checkmate': return `${winner} wins · mate`; + case 'stalemate': return 'Draw · stalemate'; + case 'insufficient_material': return 'Draw · material'; + case 'threefold_repetition': return 'Draw · repetition'; + case 'fifty_move_rule': return 'Draw · 50 moves'; + case 'agreement': return 'Draw · agreed'; + } + if (state === 'white wins' || state === 'black wins') return `${winner} wins`; + return 'Draw'; +} + function terminationLabel(termination) { switch (termination) { case 'checkmate': return 'Checkmate'; @@ -358,6 +375,11 @@ function setStorageStatus(status) { updateLiveTools(); } +function setMailStatus(status) { + ui.mail = status; + $('forgot-row').classList.toggle('hidden', status !== 'ok'); +} + function startHealthCheck() { const checkHealth = async () => { try { @@ -366,6 +388,7 @@ function startHealthCheck() { const health = await response.json(); setServerStatus(health.status === 'healthy' ? 'healthy' : 'degraded'); setStorageStatus(health.storage || 'unknown'); + setMailStatus(health.mail || 'disabled'); } else { handleApiError('health check', null, response); setStorageStatus('unknown'); @@ -551,7 +574,8 @@ async function deleteAccount() { // re-entrancy from the Enter-key handler and stops edits mid-request. function setAuthModalBusy(busy) { auth.busy = busy; - for (const id of ['login-submit-btn', 'register-submit-btn', 'auth-cancel-btn', 'auth-cancel-btn-2']) { + for (const id of ['login-submit-btn', 'register-submit-btn', 'auth-cancel-btn', 'auth-cancel-btn-2', + 'forgot-btn', 'recover-submit-btn', 'recover-back-btn']) { $(id).disabled = busy; } document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy); @@ -560,7 +584,7 @@ function setAuthModalBusy(busy) { function showAuthModal() { $('auth-modal-overlay').classList.add('show'); - $('login-identifier').focus(); + switchAuthTab('login'); document.removeEventListener('keydown', handleAuthModalKeydown); document.addEventListener('keydown', handleAuthModalKeydown); } @@ -584,50 +608,72 @@ function handleAuthModalKeydown(e) { hideAuthModal(); } else if (e.key === 'Enter') { e.preventDefault(); - if (!$('login-form').classList.contains('hidden')) handleLogin(); else handleRegister(); + ({ login: handleLogin, register: handleRegister, recover: handleRecover })[activeAuthForm()](); } } +// The auth modal shows one of three forms: sign in, register, or password +// recovery (reached from the sign-in form, without a tab). function switchAuthTab(tab) { document.querySelectorAll('.auth-tab').forEach(t => { const active = t.dataset.tab === tab; t.classList.toggle('active', active); t.setAttribute('aria-selected', active ? 'true' : 'false'); }); + document.querySelector('.auth-tabs').classList.toggle('hidden', tab === 'recover'); $('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'); - $(tab === 'login' ? 'login-identifier' : 'register-username').focus(); + $({ login: 'login-identifier', register: 'register-username', recover: 'recover-identifier' }[tab]).focus(); } -async function submitAuth(path, body, failure) { +function activeAuthForm() { + if (!$('login-form').classList.contains('hidden')) return 'login'; + if (!$('register-form').classList.contains('hidden')) return 'register'; + return 'recover'; +} + +// Returns the focus to a field after a failed request: the request disabled +// the fields, which drops the focus to the page, and a retyped password +// would otherwise go nowhere. +function refocus(id, select = true) { + const el = $(id); + if (!el) return; + el.focus(); + if (select && typeof el.select === 'function') el.select(); +} + +async function submitAuth(path, body, failure, focusId) { setAuthModalBusy(true); clearModalMessage('auth-modal-message'); + const fail = message => { + setModalMessage('auth-modal-message', message, 'error'); + setAuthModalBusy(false); + refocus(focusId); + return null; + }; + let response; try { response = await apiJSON('POST', path, body); } catch (error) { - const errorInfo = handleApiError(path, error); - setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error'); - setAuthModalBusy(false); - return null; + return fail(handleApiError(path, error).statusMessage); } if (!response.ok) { const err = await parseErrorResponse(response); - setModalMessage('auth-modal-message', err.details || err.error || failure, 'error'); - setAuthModalBusy(false); - return null; + if (response.status === 401) return fail('Wrong username or password'); + if (response.status === 429) return fail('Too many attempts. Wait a minute and try again.'); + return fail(err.details || err.error || failure); } try { return await response.json(); } catch (error) { console.error(`${path}: response OK but JSON parse failed:`, error); - setModalMessage('auth-modal-message', 'Unexpected response from server', 'error'); - setAuthModalBusy(false); - return null; + return fail('Unexpected response from server'); } } @@ -637,15 +683,156 @@ async function handleLogin() { const password = $('login-password').value; if (!identifier || !password) { setModalMessage('auth-modal-message', 'Fill in both fields', 'error'); + refocus(identifier ? 'login-password' : 'login-identifier', false); return; } - const result = await submitAuth('/api/auth/login', { identifier, password }, 'Sign-in failed'); + const result = await submitAuth('/api/auth/login', { identifier, password }, 'Sign-in failed', 'login-password'); if (!result) return; setSignedIn(result.userId, result.username, result.token); setModalMessage('auth-modal-message', `Signed in as ${result.username}`, 'success'); setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); } +// The server's password rule, checked first for a quicker answer. +function passwordProblem(password) { + if (password.length < 8) return 'Password needs at least 8 characters'; + if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) return 'Password needs a letter and a number'; + return ''; +} + +// Asks for a reset link. The server answers the same whether or not the +// account exists, so the message is the same too. +async function handleRecover() { + if (auth.busy) return; + const identifier = $('recover-identifier').value.trim(); + if (!identifier) { + setModalMessage('auth-modal-message', 'Enter your username or email', 'error'); + refocus('recover-identifier', false); + return; + } + setAuthModalBusy(true); + clearModalMessage('auth-modal-message'); + let response; + try { + response = await apiJSON('POST', '/api/auth/recover', { identifier }); + } catch (error) { + setModalMessage('auth-modal-message', handleApiError('recover', error).statusMessage, 'error'); + setAuthModalBusy(false); + refocus('recover-identifier'); + return; + } + setAuthModalBusy(false); + if (response.status === 202) { + setModalMessage('auth-modal-message', 'If the account has an email address, a reset link is on its way. Check your inbox.', 'success'); + $('recover-identifier').value = ''; + $('recover-back-btn').focus(); + return; + } + const err = await parseErrorResponse(response); + let message = err.error || 'Could not send the link'; + if (response.status === 429) message = 'Too many requests. Wait a minute and try again.'; + else if (response.status === 503 && err.code === 'NOT_CONFIGURED') message = 'This server cannot send email; ask its administrator for a reset link.'; + setModalMessage('auth-modal-message', message, 'error'); + refocus('recover-identifier'); +} + +// --- reset: the link in the recovery email opens the client with ?reset= + +const reset = { token: null, busy: false }; + +function openResetModal(token) { + reset.token = token; + reset.busy = false; + $('reset-password').value = ''; + $('reset-password-2').value = ''; + clearModalMessage('reset-modal-message'); + $('reset-overlay').classList.add('show'); + document.removeEventListener('keydown', handleResetKeydown); + document.addEventListener('keydown', handleResetKeydown); + $('reset-password').focus(); +} + +function closeResetModal() { + if (reset.busy) return; + reset.token = null; + $('reset-overlay').classList.remove('show'); + $('reset-password').value = ''; + $('reset-password-2').value = ''; + document.removeEventListener('keydown', handleResetKeydown); +} + +function setResetBusy(busy) { + reset.busy = busy; + for (const id of ['reset-password', 'reset-password-2', 'reset-submit-btn', 'reset-cancel-btn']) { + $(id).disabled = busy; + } +} + +function handleResetKeydown(e) { + if (reset.busy) { + if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + closeResetModal(); + } else if (e.key === 'Enter') { + e.preventDefault(); + submitReset(); + } +} + +async function submitReset() { + if (reset.busy || !reset.token) return; + const password = $('reset-password').value; + const again = $('reset-password-2').value; + const weak = passwordProblem(password); + if (weak) { + setModalMessage('reset-modal-message', weak, 'error'); + refocus('reset-password'); + return; + } + if (password !== again) { + setModalMessage('reset-modal-message', 'The two passwords differ', 'error'); + refocus('reset-password-2'); + return; + } + setResetBusy(true); + clearModalMessage('reset-modal-message'); + let response; + try { + response = await apiJSON('POST', '/api/auth/reset', { token: reset.token, password }); + } catch (error) { + setResetBusy(false); + setModalMessage('reset-modal-message', handleApiError('reset', error).statusMessage, 'error'); + refocus('reset-password'); + return; + } + if (!response.ok) { + const err = await parseErrorResponse(response); + setResetBusy(false); + setModalMessage('reset-modal-message', response.status === 400 + ? (err.details && err.error === 'weak password' ? err.details : 'This link is invalid or has expired. Ask for a new one from Sign in.') + : (err.error || `Server error (${response.status})`), 'error'); + refocus('reset-password'); + return; + } + let result; + try { + result = await response.json(); + } catch { + setResetBusy(false); + setModalMessage('reset-modal-message', 'Unexpected response from server', 'error'); + return; + } + setSignedIn(result.userId, result.username, result.token); + setModalMessage('reset-modal-message', `Password changed — signed in as ${result.username}`, 'success'); + setTimeout(() => { + setResetBusy(false); + closeResetModal(); + }, MODAL_SUCCESS_DISPLAY_MS * 2); +} + async function handleRegister() { if (auth.busy) return; const username = $('register-username').value.trim(); @@ -655,17 +842,15 @@ async function handleRegister() { setModalMessage('auth-modal-message', 'Username and password required', 'error'); return; } - if (password.length < 8) { - setModalMessage('auth-modal-message', 'Password needs at least 8 characters', 'error'); - return; - } - if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) { - setModalMessage('auth-modal-message', 'Password needs a letter and a number', 'error'); + const weak = passwordProblem(password); + if (weak) { + setModalMessage('auth-modal-message', weak, 'error'); + refocus('register-password'); return; } const body = { username, password }; if (email) body.email = email; - const result = await submitAuth('/api/auth/register', body, 'Registration failed'); + const result = await submitAuth('/api/auth/register', body, 'Registration failed', 'register-password'); if (!result) return; setSignedIn(result.userId, result.username, result.token); setModalMessage('auth-modal-message', `Account created, welcome ${result.username}`, 'success'); @@ -1344,26 +1529,32 @@ function updateTurnStrip() { const dot = $('turn-dot'); const text = $('turn-text'); let status = 'idle', label = 'No game'; + let title = ''; if (!live.gameId) { status = 'idle'; label = 'No game'; } else if (live.state === 'pending' || live.isLocked) { status = 'thinking'; - label = 'Computer thinking…'; + label = 'Thinking…'; + title = 'The computer is thinking'; } else if (isGameOver(live.state)) { status = live.state === 'white wins' ? 'white-wins' : live.state === 'black wins' ? 'black-wins' : live.state; - label = describeResult(live.state, live.termination); + label = shortResult(live.state, live.termination); + title = describeResult(live.state, live.termination); } else if (live.state === 'stuck') { status = 'stuck'; - label = 'Engine error — undo or start a new game'; + label = 'Engine error'; + title = 'Engine error — undo or start a new game'; } else { + // The dot shows the side to move; the text says whose it is. status = live.turn === 'w' ? 'white' : 'black'; const side = live.turn === 'w' ? 'White' : 'Black'; - label = live.turn === playerColor() ? `Your move (${side})` : `Computer to move (${side})`; + label = live.turn === playerColor() ? 'Your move' : 'Computer'; + title = `${side} to move: ${live.turn === playerColor() ? 'you' : 'the computer'}`; } dot.dataset.status = status; text.textContent = label; - $('turn-strip').title = label; + $('turn-strip').title = title || label; } // Draw needs a game in progress with the board free; resigning only needs @@ -2144,6 +2335,11 @@ function bindEvents() { }); $('login-submit-btn').addEventListener('click', handleLogin); $('register-submit-btn').addEventListener('click', handleRegister); + $('forgot-btn').addEventListener('click', () => switchAuthTab('recover')); + $('recover-back-btn').addEventListener('click', () => switchAuthTab('login')); + $('recover-submit-btn').addEventListener('click', handleRecover); + $('reset-submit-btn').addEventListener('click', submitReset); + $('reset-cancel-btn').addEventListener('click', closeResetModal); $('auth-cancel-btn').addEventListener('click', hideAuthModal); $('auth-cancel-btn-2').addEventListener('click', hideAuthModal); $('logout-btn').addEventListener('click', handleLogoutClick); @@ -2198,6 +2394,18 @@ document.addEventListener('DOMContentLoaded', async () => { updateTurnStrip(); updateGameControls(); startHealthCheck(); + // A reset link: take the token off the address before anything else, so + // it is neither kept in history nor shared with a copied link. + const params = new URLSearchParams(location.search); + const resetToken = params.get('reset'); + if (resetToken !== null) { + params.delete('reset'); + const query = params.toString(); + try { + history.replaceState({ chess: true }, '', location.pathname + (query ? `?${query}` : '')); + } catch { /* history blocked */ } + } await restoreAuthSession(); await applyURL(); + if (resetToken) openResetModal(resetToken); }); diff --git a/internal/server/webserver/chess-client-web/index.html b/internal/server/webserver/chess-client-web/index.html index 3e205b0..c655f0c 100644 --- a/internal/server/webserver/chess-client-web/index.html +++ b/internal/server/webserver/chess-client-web/index.html @@ -10,7 +10,31 @@
-
Chess
+
+ + Chess +
@@ -44,7 +68,7 @@ No game
- + @@ -201,6 +225,19 @@
+ +
+ + + + +