v0.15.0 password recovery, phone layout, CLI pager, embed policy fixes
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-19 18:15:31 -04:00
1 parent a999177f0c
commit e1fa5242e3
48 files changed
+2101 -135

No files matched your search

+3 -1
View File
@@ -80,11 +80,13 @@ jobs:
run: npm install && npx playwright install --with-deps chromium run: npm install && npx playwright install --with-deps chromium
- name: Start the test server with the web client - name: Start the test server with the web client
run: | 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 for i in $(seq 1 60); do curl -sf http://localhost:8080/health && break; sleep 1; done
curl -sf http://localhost:8080/health curl -sf http://localhost:8080/health
- name: Run the web client tests - name: Run the web client tests
working-directory: test/e2e working-directory: test/e2e
env:
CHESS_E2E_MAIL_DIR: ${{ runner.temp }}/chess-mail
run: npm test run: npm test
- name: Server log - name: Server log
if: failure() if: failure()
+7 -2
View File
@@ -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 - Authenticated stored-game listing with cursor pagination and filters
- Configurable structured debug logs for persistence, cleanup, and engine work - Configurable structured debug logs for persistence, cleanup, and engine work
- User management with secure Argon2id password storage and scoped JWTs - 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 - Self-service account deletion: claims and names are cleared from stored games
- Static, CGO-free binaries (cross-compiles for FreeBSD) - Static, CGO-free binaries (cross-compiles for FreeBSD)
- Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit) - Scripted deployment: FreeBSD jail (rc.d) or Linux (sandboxed systemd unit)
@@ -134,9 +136,12 @@ their last activity.
# List all users # List all users
./chess-server db user list ./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 ./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 # Update email
./chess-server db user set-email -username alice -email newemail@example.com ./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 export FEN and PGN at any ply, and shareable links
(`?replay=<gameId>&ply=<n>`) (`?replay=<gameId>&ply=<n>`)
- Server and storage health in the top bar - 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 - Responsive layout, board beside or above the panel
Access the UI at `http://localhost:9090` when server is running with `-serve` flag. Access the UI at `http://localhost:9090` when server is running with `-serve` flag.
+7 -1
View File
@@ -35,8 +35,14 @@ func runClient() (restart bool) {
Verbose: false, Verbose: false,
} }
// Initialize simple input scanner // Initialize simple input scanner; the pager reads from it between screens
scanner := bufio.NewScanner(os.Stdin) 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, "Chess Debug Client")
display.Println(display.Cyan, "API: %s", s.APIBaseURL) display.Println(display.Cyan, "API: %s", s.APIBaseURL)
+47 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"net/url"
"os" "os"
"strings" "strings"
"syscall" "syscall"
@@ -12,6 +13,7 @@ import (
"time" "time"
"github.com/lixenwraith/chess/internal/server/replay" "github.com/lixenwraith/chess/internal/server/replay"
"github.com/lixenwraith/chess/internal/server/service"
"github.com/lixenwraith/chess/internal/server/storage" "github.com/lixenwraith/chess/internal/server/storage"
"github.com/google/uuid" "github.com/google/uuid"
@@ -40,7 +42,7 @@ func Run(args []string) error {
return runVerify(args[1:]) return runVerify(args[1:])
case "user": case "user":
if len(args) < 2 { 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:]) return runUser(args[1], args[2:])
default: default:
@@ -303,6 +305,8 @@ func runUser(subcommand string, args []string) error {
return runUserSetEmail(args) return runUserSetEmail(args)
case "set-username": case "set-username":
return runUserSetUsername(args) return runUserSetUsername(args)
case "reset-link":
return runUserResetLink(args)
case "list": case "list":
return runUserList(args) return runUserList(args)
default: 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. // readPassword prompts on the terminal without echo.
func readPassword(prompt string) (string, error) { func readPassword(prompt string) (string, error) {
fmt.Print(prompt) fmt.Print(prompt)
+34
View File
@@ -5,6 +5,7 @@ package main
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"errors"
"flag" "flag"
"fmt" "fmt"
"log" "log"
@@ -16,6 +17,7 @@ import (
"github.com/lixenwraith/chess/cmd/chess-server/cli" "github.com/lixenwraith/chess/cmd/chess-server/cli"
"github.com/lixenwraith/chess/internal/server/http" "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/processor"
"github.com/lixenwraith/chess/internal/server/service" "github.com/lixenwraith/chess/internal/server/service"
"github.com/lixenwraith/chess/internal/server/storage" "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)") 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") 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 <chess@example.com>\"")
mailDir = flag.String("mail-dir", "", "Write email to .eml files in this directory instead of sending (development)")
publicURL = flag.String("public-url", "", "Web client address that emailed links open, e.g. https://example.com/chess/web-client/")
// Web UI server flags // Web UI server flags
serve = flag.Bool("serve", false, "Enable web UI server") serve = flag.Bool("serve", false, "Enable web UI server")
webHost = flag.String("web-host", "localhost", "Web UI server host") webHost = flag.String("web-host", "localhost", "Web UI server host")
@@ -165,6 +178,27 @@ func main() {
} }
svc.SetIntegrityMode(integrity) 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 // Start cleanup job for expired users/sessions
cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
cleanupDone := make(chan struct{}) cleanupDone := make(chan struct{})
+33
View File
@@ -18,11 +18,25 @@
# Default: ${chessd_home}/jwt.key # Default: ${chessd_home}/jwt.key
# chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is # chessd_trusted_proxies: Reverse-proxy address(es) whose X-Real-IP header is
# trusted as the client address. Default: none # 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: "" # chessd_flags: Additional flags passed to the binary. Default: ""
# #
# Add the following to /etc/rc.conf to enable: # Add the following to /etc/rc.conf to enable:
# chessd_enable="YES" # chessd_enable="YES"
# chessd_trusted_proxies="<address nginx connects from>" # chessd_trusted_proxies="<address nginx connects from>"
# and, for password recovery by email, once the mail jail relays for this one:
# chessd_public_url="https://<site>/chess/web-client/"
# chessd_smtp_host="<mail jail address>"
# chessd_mail_from="Chess <chess@<site>>"
. /etc/rc.subr . /etc/rc.subr
@@ -43,6 +57,12 @@ load_rc_config "$name"
: ${chessd_dsn:="postgres:///chess?host=/tmp"} : ${chessd_dsn:="postgres:///chess?host=/tmp"}
: ${chessd_jwt_key:="${chessd_home}/jwt.key"} : ${chessd_jwt_key:="${chessd_home}/jwt.key"}
: ${chessd_trusted_proxies:=""} : ${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:=""} : ${chessd_flags:=""}
command="${chessd_bin}" command="${chessd_bin}"
@@ -85,6 +105,19 @@ chessd_start()
if [ -n "${chessd_trusted_proxies}" ]; then if [ -n "${chessd_trusted_proxies}" ]; then
set -- "$@" -trusted-proxies "${chessd_trusted_proxies}" set -- "$@" -trusted-proxies "${chessd_trusted_proxies}"
fi 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}." echo "Starting ${desc}."
# -r restarts the server 5 seconds after an unexpected exit (for example # -r restarts the server 5 seconds after an unexpected exit (for example
+47
View File
@@ -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 token, 429 past 5 attempts a minute per IP, 503 `RESOURCE_LIMIT` when password
hashing is saturated or `STORAGE_UNAVAILABLE` when storage is down. 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=<token>` 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 ## Game Endpoints
### Health Check ### Health Check
@@ -129,10 +171,14 @@ Returns server and storage status, and the build the server runs.
"status": "healthy", "status": "healthy",
"time": 1699123456, "time": 1699123456,
"storage": "ok", "storage": "ok",
"mail": "disabled",
"version": "v0.14.0-3-g1a2b3c4" "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` `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 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 client newer than the server gets 404 `NOT_FOUND` for endpoints the server
@@ -556,6 +602,7 @@ Error codes:
- Registration: 5 requests/minute/IP - Registration: 5 requests/minute/IP
- Login: 10 requests/minute/IP - Login: 10 requests/minute/IP
- Account deletion: 5 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 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 proxy's `X-Real-IP` header, trusted only from addresses listed in
+7
View File
@@ -294,6 +294,13 @@ Exit the client.
chess > exit 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 ### Verbose Mode
Append `-v` flag to any command for detailed output: Append `-v` flag to any command for detailed output:
+9 -3
View File
@@ -131,9 +131,15 @@ sudo chess-db user list
su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"' su -m chess -c '/home/chess/bin/chess-server db user add -username <name> -dsn "postgres:///chess?host=/tmp"'
``` ```
Other `user` subcommands: `delete`, `set-email`, `set-username`, and Other `user` subcommands: `delete`, `set-email`, `set-username`, `set-hash`
`set-hash` (an existing PHC string). CLI-created accounts are identical to (an existing PHC string), and `reset-link -url <web client>`, which prints a
site registrations, and the CLI ignores the registration cap. 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): Sign a user out everywhere (takes effect on their next request):
+1 -1
View File
@@ -120,7 +120,7 @@ file is kept next to it with a timestamp).
| `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account | | `CHESSD_DSN` | `"host=<socket dir> dbname=chess"` | Database over the socket as the OS account |
| `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key | | `CHESSD_JWT_KEY` | `/var/lib/chessd/jwt.key` | Stable signing key |
| `CHESSD_TRUSTED_PROXIES` | `TRUSTED_PROXIES` | Comma-separated proxy addresses | | `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 <relay> -mail-from "Chess <chess@example.com>" -public-url https://example.com/chess/web-client/` (see [deployment.md](deployment.md#password-recovery-by-email); the sandboxed unit can read a password file only under `/var/lib/chessd`) |
Server flags worth knowing: `-max-users` (registration cap, default 100), Server flags worth knowing: `-max-users` (registration cap, default 100),
`-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`), `-anonymous-game-ttl` (default `24h`), `-finished-game-ttl` (default `1h`),
+50 -1
View File
@@ -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_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_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_trusted_proxies` | empty | Proxy address(es) whose `X-Real-IP` is trusted |
| `chessd_public_url` | empty | The web client's public address, which emailed reset links open (`https://<site>/chess/web-client/`) |
| `chessd_smtp_host`, `chessd_smtp_port` | empty, `25` | SMTP relay for password-reset email; unset leaves recovery off |
| `chessd_smtp_user`, `chessd_smtp_password_file` | empty, `${chessd_home}/smtp.secret` if present | Relay credentials, when the relay authenticates this host; the file is mode 0600 |
| `chessd_mail_from` | empty | Sender address, e.g. `Chess <chess@<site>>` |
| `chessd_flags` | empty | Extra flags, e.g. `-max-users 500 -db-cleanup report` | | `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 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`) | | `-anonymous-game-ttl` | | Retention of games without a registered player (default `24h`) |
| `-finished-game-ttl` | | Memory retention of finished games (default `1h`) | | `-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)) | | `-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 Administrative CLI commands run as `chess` so peer authentication selects the
right role; root may use `su -m` although the account has no shell: 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 <id|prefix> [-ply N]`, (games, with 8-digit ID prefixes), `pgn -gameId <id|prefix> [-ply N]`,
`verify [-gameId <id|prefix>]` (replays stored games against the rules and `verify [-gameId <id|prefix>]` (replays stored games against the rules and
exits non-zero on a mismatch; run it after an upgrade), and `user add|delete| 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 <name> -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 <name> -url https://<site>/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://<site>/chess/web-client/"
sysrc chessd_smtp_host="<mail jail address>" # chessd_smtp_port, default 25
sysrc chessd_mail_from="Chess <chess@<site>>"
# only when the relay authenticates this host:
sysrc chessd_smtp_user="<user>"
(umask 077 && echo '<password>' > /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 <dir>` in `chessd_flags` writes each
message to a file instead.
## Host nginx and Web Clients ## Host nginx and Web Clients
+6 -3
View File
@@ -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) - `-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) - `-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 - `-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 ### Modes
```bash ```bash
@@ -223,8 +226,8 @@ go test ./...
go test -race ./internal/server/storage ./internal/server/service go test -race ./internal/server/storage ./internal/server/service
# Web client end-to-end (Playwright): see test/README.md # Web client end-to-end (Playwright): see test/README.md
WEB_PORT=9090 test/run-test-server.sh # terminal 1 WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh # terminal 1
make test-e2e # terminal 2 CHESS_E2E_MAIL_DIR=/tmp/chess-mail make test-e2e # terminal 2
``` ```
## Configuration ## Configuration
@@ -308,7 +311,7 @@ make test-e2e # terminal 2
- JWT tokens don't support refresh (must re-login after expiry) - JWT tokens don't support refresh (must re-login after expiry)
- User deletion doesn't cascade to games (games keep player and claim IDs) - 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 - 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 - No email verification for registration
- Fixed worker pool size for engine calculations - Fixed worker pool size for engine calculations
- No push-based game updates (30-second long-polling is used) - No push-based game updates (30-second long-polling is used)
+24 -1
View File
@@ -174,13 +174,36 @@ Test gate:
while replaying, `show` shows the replayed position, and the prompt reads while replaying, `show` shows the replayed position, and the prompt reads
`replay <ply>/<plies>`; `live` returns to the game as it was. `replay <ply>/<plies>`; `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: Later for the replay clients:
- [ ] Web: an "Analysis" link on a replay (chess.com-style evaluation needs - [ ] Web: an "Analysis" link on a replay (chess.com-style evaluation needs
an engine in the browser or a server endpoint; none exists). 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. - [ ] 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=<token>`. `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) ## Phase R5 — Curated Archive (later)
- [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site, - [ ] Schema: game origin (`player`, `curated`), archive metadata (event, site,
-6
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:5izNFuvA37st1dDRAcA7TtmngBmRgiIPDmTtiEvjx1E=
github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4= github.com/gabriel-vasile/mimetype v1.4.16/go.mod h1:3oFFwxX8kTklZwzIl2aQEDZT0lKY8xH92ST88bAwba4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 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/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 h1:KEGPkUk9PYFw5+Sv/evCOSU5OmMDXCGFqQ9YjRB7GeU=
github.com/molecule-man/go-brrr v1.3.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= 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 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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 h1:QitrT4foDQuqvUnr/hzmQUDbYzV12Zz4vkLOlipU4Cw=
github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o= github.com/valyala/fasthttp v1.76.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
+1 -1
View File
@@ -107,7 +107,7 @@ func (c *Client) doRequest(method, path string, body any, result any) error {
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
statusColor = display.Red 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 // Display response body if verbose
if c.Verbose && len(respBody) > 0 { if c.Verbose && len(respBody) > 0 {
+10 -10
View File
@@ -81,8 +81,8 @@ func registerHandler(s *session.Session, args []string) error {
c.SetToken(resp.Token) c.SetToken(resp.Token)
display.Println(display.Green, "Registered successfully") display.Println(display.Green, "Registered successfully")
fmt.Printf("User ID: %s\n", resp.UserID) display.Printf("User ID: %s\n", resp.UserID)
fmt.Printf("Username: %s\n", resp.Username) display.Printf("Username: %s\n", resp.Username)
return nil return nil
} }
@@ -111,8 +111,8 @@ func loginHandler(s *session.Session, args []string) error {
c.SetToken(resp.Token) c.SetToken(resp.Token)
display.Println(display.Green, "Logged in successfully") display.Println(display.Green, "Logged in successfully")
fmt.Printf("User ID: %s\n", resp.UserID) display.Printf("User ID: %s\n", resp.UserID)
fmt.Printf("Username: %s\n", resp.Username) display.Printf("Username: %s\n", resp.Username)
return nil return nil
} }
@@ -151,14 +151,14 @@ func whoamiHandler(s *session.Session, args []string) error {
} }
display.Println(display.Cyan, "Current User:") display.Println(display.Cyan, "Current User:")
fmt.Printf(" User ID: %s\n", user.UserID) display.Printf(" User ID: %s\n", user.UserID)
fmt.Printf(" Username: %s\n", user.Username) display.Printf(" Username: %s\n", user.Username)
if user.Email != "" { 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 { 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 return nil
@@ -172,7 +172,7 @@ func setUserHandler(s *session.Session, args []string) error {
userID := args[0] userID := args[0]
s.SetCurrentUser(userID) s.SetCurrentUser(userID)
display.Println(display.Cyan, "User ID set to: %s", 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 return nil
} }
+4 -4
View File
@@ -44,12 +44,12 @@ func healthHandler(s *session.Session, args []string) error {
} }
display.Println(display.Cyan, "Server Health:") 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 // Convert Unix timestamp to readable time
t := time.Unix(resp.Time, 0) 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 != "" { if resp.Storage != "" {
fmt.Printf(" Storage: %s\n", resp.Storage) display.Printf(" Storage: %s\n", resp.Storage)
} }
return nil return nil
@@ -57,7 +57,7 @@ func healthHandler(s *session.Session, args []string) error {
func urlHandler(s *session.Session, args []string) error { func urlHandler(s *session.Session, args []string) error {
if len(args) == 0 { if len(args) == 0 {
fmt.Printf("Current API URL: %s\n", s.GetAPIBaseURL()) display.Printf("Current API URL: %s\n", s.GetAPIBaseURL())
return nil return nil
} }
+25 -21
View File
@@ -59,6 +59,7 @@ func (r *Registry) registerGameCommands() {
Description: "Show board and game state", Description: "Show board and game state",
Usage: "show", Usage: "show",
Handler: showBoardHandler, Handler: showBoardHandler,
Paged: true,
}) })
r.Register(&Command{ r.Register(&Command{
@@ -67,6 +68,7 @@ func (r *Registry) registerGameCommands() {
Description: "Show raw game JSON", Description: "Show raw game JSON",
Usage: "state", Usage: "state",
Handler: gameStateHandler, Handler: gameStateHandler,
Paged: true,
}) })
r.Register(&Command{ r.Register(&Command{
@@ -99,6 +101,7 @@ func (r *Registry) registerGameCommands() {
Description: "List your stored games, newest first (login required)", Description: "List your stored games, newest first (login required)",
Usage: "games [more]", Usage: "games [more]",
Handler: gamesHandler, Handler: gamesHandler,
Paged: true,
}) })
r.Register(&Command{ r.Register(&Command{
@@ -107,6 +110,7 @@ func (r *Registry) registerGameCommands() {
Description: "Print or save a stored game as PGN", Description: "Print or save a stored game as PGN",
Usage: "pgn [gameId] [ply] [save <path>] (default: current or replayed game)", Usage: "pgn [gameId] [ply] [save <path>] (default: current or replayed game)",
Handler: pgnHandler, Handler: pgnHandler,
Paged: true,
}) })
r.Register(&Command{ 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) display.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("Turn: %s | State: %s | Moves: %d\n", resp.Turn, resp.State, len(resp.Moves))
return nil return nil
} }
@@ -368,9 +372,9 @@ func computerMoveHandler(s *session.Session, args []string) error {
if final.LastMove != nil && len(final.Moves) > baselineMoves { if final.LastMove != nil && len(final.Moves) > baselineMoves {
display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move) display.Print(display.Magenta, "Computer played: %s", final.LastMove.Move)
if final.LastMove.Depth > 0 { 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) printOutcome(final)
@@ -546,28 +550,28 @@ func showBoardHandler(s *session.Session, args []string) error {
s.SetGameState(game) s.SetGameState(game)
// Display board with colors // Display board with colors
fmt.Println() display.Line()
display.RenderBoard(board.Board) display.RenderBoard(board.Board)
// Display game info // Display game info
fmt.Printf("\nFEN: %s\n", game.FEN) display.Printf("\nFEN: %s\n", game.FEN)
fmt.Printf("Turn: %s | State: %s | Moves: %d\n", display.Printf("Turn: %s | State: %s | Moves: %d\n",
display.ColorForTurn(game.Turn), game.State, len(game.Moves)) display.ColorForTurn(game.Turn), game.State, len(game.Moves))
// Display move history // Display move history
if len(game.Moves) > 0 { if len(game.Moves) > 0 {
fmt.Printf("\nHistory: ") display.Printf("\nHistory: ")
for i, move := range game.Moves { for i, move := range game.Moves {
if i > 0 { if i > 0 {
fmt.Print(" ") display.Write(" ")
} }
if i%2 == 0 { if i%2 == 0 {
fmt.Printf("%d.%s", (i/2)+1, move) display.Printf("%d.%s", (i/2)+1, move)
} else { } else {
fmt.Printf(" %s", move) display.Printf(" %s", move)
} }
} }
fmt.Println() display.Line()
} }
// Display last move info // Display last move info
@@ -576,11 +580,11 @@ func showBoardHandler(s *session.Session, args []string) error {
if game.LastMove.PlayerColor == "b" { if game.LastMove.PlayerColor == "b" {
color = "Black" 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 { 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) printOutcome(game)
printConcession(game) printConcession(game)
@@ -630,7 +634,7 @@ func deleteGameHandler(s *session.Session, args []string) error {
s.SetLastMoveCount(0) 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 return nil
} }
@@ -661,7 +665,7 @@ func pollHandler(s *session.Session, args []string) error {
case len(resp.Moves) > moveCount: case len(resp.Moves) > moveCount:
display.Println(display.Green, "Game updated! New moves detected") display.Println(display.Green, "Game updated! New moves detected")
if resp.LastMove != nil { 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: case len(resp.Moves) < moveCount:
display.Println(display.Green, "Game updated: %d move(s) taken back", moveCount-len(resp.Moves)) 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 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 { 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, playerLabel(g.Players.White), playerLabel(g.Players.Black), g.PGNResult, g.MoveCount,
g.StartTimeUTC.UTC().Format("2006-01-02 15:04")) 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) display.Println(display.Green, "Saved PGN of %s to %s", describePGNTarget(gameID, ply), path)
return nil return nil
} }
fmt.Println() display.Line()
fmt.Print(text) display.Write(text)
return nil return nil
} }
+3 -3
View File
@@ -3,15 +3,15 @@
package command package command
import ( import (
"fmt" "github.com/lixenwraith/chess/internal/client/display"
"golang.org/x/term" "golang.org/x/term"
) )
func readPassword(prompt string) (string, error) { func readPassword(prompt string) (string, error) {
fmt.Print(prompt) display.Write(prompt)
bytePassword, err := term.ReadPassword(0) // 0 is stdin bytePassword, err := term.ReadPassword(0) // 0 is stdin
fmt.Println() display.Line()
if err != nil { if err != nil {
return "", err return "", err
} }
+23 -5
View File
@@ -1,6 +1,7 @@
package command package command
import ( import (
"bytes"
"fmt" "fmt"
"strings" "strings"
@@ -16,6 +17,9 @@ type Command struct {
Description string Description string
Usage string Usage string
Handler func(*session.Session, []string) error 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 { type Registry struct {
@@ -43,6 +47,7 @@ func NewRegistry(s *session.Session) *Registry {
Description: "Show available commands", Description: "Show available commands",
Usage: "help [command]", Usage: "help [command]",
Handler: r.helpHandler, Handler: r.helpHandler,
Paged: true,
}) })
// Exit command (handled in main loop, but registered for help display) // 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()) 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 { if err := cmd.Handler(r.session, args); err != nil {
display.Println(display.Red, "Error: %s", err.Error()) display.Println(display.Red, "Error: %s", err.Error())
} }
@@ -101,13 +118,13 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
if !exists { if !exists {
return fmt.Errorf("unknown command: %s", args[0]) return fmt.Errorf("unknown command: %s", args[0])
} }
fmt.Println() display.Line()
display.Print(display.Cyan, cmd.Name) display.Print(display.Cyan, cmd.Name)
display.Println(display.Reset, " - %s", cmd.Description) display.Println(display.Reset, " - %s", cmd.Description)
if cmd.ShortName != "" { if cmd.ShortName != "" {
display.Println(display.Cyan, "Short form: %s", 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 return nil
} }
@@ -173,10 +190,10 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
if info.shortName != "" { if info.shortName != "" {
shortPart = fmt.Sprintf("[%s%s%s] ", display.Cyan, info.shortName, display.Reset) 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) printCommandGroup("Game Commands", gameCommands)
@@ -185,7 +202,8 @@ func (r *Registry) helpHandler(s *session.Session, args []string) error {
printCommandGroup("Utility Commands", utilCommands) printCommandGroup("Utility Commands", utilCommands)
display.Println(display.Reset, "Type 'help <command>' for detailed usage") display.Println(display.Reset, "Type 'help <command>' 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 return nil
} }
+7 -6
View File
@@ -22,6 +22,7 @@ func (r *Registry) registerReplayCommands() {
Description: "Step through a stored game", Description: "Step through a stored game",
Usage: "replay <gameId|index> (index from the last 'games' listing)", Usage: "replay <gameId|index> (index from the last 'games' listing)",
Handler: replayHandler, Handler: replayHandler,
Paged: true,
}) })
r.Register(&Command{ r.Register(&Command{
Name: "next", Name: "next",
@@ -122,7 +123,7 @@ func replayHandler(s *session.Session, args []string) error {
white, black := s.Replay.Players() white, black := s.Replay.Players()
display.Println(display.Cyan, "\nReplaying %s", history.GameID) 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(), 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")) history.StartTimeUTC.UTC().Format("2006-01-02 15:04"))
if c := history.Concession; c != nil { 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) display.Println(display.Yellow, "%s at ply %d; play continued.", what, c.Ply)
} }
fmt.Println() display.Line()
fmt.Println(s.Replay.Movetext(80)) display.Line(s.Replay.Movetext(80))
display.Println(display.Reset, "\nnext, prev, first, last, goto <ply>, auto [ms], fen, pgn [save <path>], live") display.Println(display.Reset, "\nnext, prev, first, last, goto <ply>, auto [ms], fen, pgn [save <path>], live")
return showReplayPosition(s) return showReplayPosition(s)
} }
@@ -217,7 +218,7 @@ func fenHandler(s *session.Session, args []string) error {
if s.Replay == nil { if s.Replay == nil {
return errNotReplaying return errNotReplaying
} }
fmt.Println(s.Replay.FEN()) display.Line(s.Replay.FEN())
return nil return nil
} }
@@ -241,13 +242,13 @@ func showReplayPosition(s *session.Session) error {
if err != nil { if err != nil {
return fmt.Errorf("stored position cannot be shown: %w", err) return fmt.Errorf("stored position cannot be shown: %w", err)
} }
fmt.Println() display.Line()
display.RenderBoard(board) display.RenderBoard(board)
label := r.Label(r.Ply) label := r.Label(r.Ply)
if r.Ply == 0 { if r.Ply == 0 {
label = "initial position" 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 != "" { if r.AtEnd() && r.History.Result != "" {
display.Println(display.Green, "Result: %s", r.Result()) display.Println(display.Green, "Result: %s", r.Result())
} }
+6 -2
View File
@@ -2,11 +2,15 @@
package command 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, // savePGN has no file system in the browser: the PGN is printed instead,
// to be copied from the terminal. // to be copied from the terminal.
func savePGN(path, text string) error { 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") return fmt.Errorf("files cannot be saved from the browser; the PGN is printed above")
} }
+1 -1
View File
@@ -40,7 +40,7 @@ func RenderBoard(asciiBoard string) {
Print(Reset, "%c", char) Print(Reset, "%c", char)
} }
} }
fmt.Println() fmt.Fprintln(Out)
} }
} }
+2 -2
View File
@@ -24,12 +24,12 @@ func C(color, text string) string {
// Print outputs colored text immediately // Print outputs colored text immediately
func Print(color, format string, args ...any) { 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 // Println outputs colored text with newline
func Println(color, format string, args ...any) { 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 // Build creates a multi-colored string
+1 -1
View File
@@ -12,5 +12,5 @@ func PrettyPrintJSON(v any) {
Print(Red, "Error formatting JSON: %s\n", err.Error()) Print(Red, "Error formatting JSON: %s\n", err.Error())
return return
} }
fmt.Println(string(data)) fmt.Fprintln(Out, string(data))
} }
+79
View File
@@ -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
}
+58
View File
@@ -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())
}
}
@@ -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
}
}
+21
View File
@@ -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
}
}
+5 -2
View File
@@ -18,8 +18,11 @@ type Session struct {
Client *api.Client Client *api.Client
// Replay is the stored game being stepped through, nil while live. The // Replay is the stored game being stepped through, nil while live. The
// live game, user, and poll state above are untouched by a replay. // live game, user, and poll state above are untouched by a replay.
Replay *replay.State Replay *replay.State
Verbose bool // 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 // Game state for prompt
CurrentGameState *api.GameResponse CurrentGameState *api.GameResponse
PlayerColor string // "w", "b", or "" PlayerColor string // "w", "b", or ""
+1
View File
@@ -16,4 +16,5 @@ const (
ErrConflict = "GAME_CONFLICT" ErrConflict = "GAME_CONFLICT"
ErrStorageUnavailable = "STORAGE_UNAVAILABLE" ErrStorageUnavailable = "STORAGE_UNAVAILABLE"
ErrRouteNotFound = "NOT_FOUND" ErrRouteNotFound = "NOT_FOUND"
ErrNotConfigured = "NOT_CONFIGURED"
) )
+112
View File
@@ -35,6 +35,17 @@ type DeleteAccountRequest struct {
Password string `json:"password" validate:"required"` 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 // AuthResponse contains JWT token and user information
type AuthResponse struct { type AuthResponse struct {
Token string `json:"token"` Token string `json:"token"`
@@ -322,3 +333,104 @@ func (h *HTTPHandler) DeleteAccountHandler(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) 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),
})
}
+28
View File
@@ -140,6 +140,33 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options)
}, },
}), h.LoginHandler) }), 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 // Create token validator closure
validateToken := svc.ValidateToken validateToken := svc.ValidateToken
@@ -263,6 +290,7 @@ func (h *HTTPHandler) Health(c *fiber.Ctx) error {
"status": status, "status": status,
"time": time.Now().Unix(), "time": time.Now().Unix(),
"storage": storageHealth, "storage": storageHealth,
"mail": h.svc.MailHealth(),
"version": h.version, "version": h.version,
}) })
} }
+2
View File
@@ -89,6 +89,8 @@ func TestAPIRoutes(t *testing.T) {
"GET /api/auth/me", "GET /api/auth/me",
"POST /api/auth/logout", "POST /api/auth/logout",
"DELETE /api/auth/me", "DELETE /api/auth/me",
"POST /api/auth/recover",
"POST /api/auth/reset",
"POST /api/games", "POST /api/games",
"GET /api/games/:gameId", "GET /api/games/:gameId",
"GET /api/games/:gameId/history", "GET /api/games/:gameId/history",
+259
View File
@@ -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 <chess@example.com>" 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 <unix-nanos>-<random>.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
}
+91
View File
@@ -0,0 +1,91 @@
package mail
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFormatIsRFC5322WithCRLF(t *testing.T) {
text := string(Format("Chess <chess@example.com>", 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 <chess@example.com>\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 <a@b.c>", 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)
}
}
+208
View File
@@ -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=<token>. 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
}
+147
View File
@@ -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
}
+7
View File
@@ -41,6 +41,8 @@ type Service struct {
integrityMode IntegrityMode integrityMode IntegrityMode
integrityCursor string // last game ID the integrity sweep verified; cleanup goroutine only 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 // New creates a service with optional storage. jwtSecret signs session tokens
@@ -193,6 +195,11 @@ func (s *Service) cleanupExpired() {
} else if deleted > 0 { } else if deleted > 0 {
slog.Info("cleanup deleted expired sessions", "count", deleted) 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.cleanupGames(now)
s.checkIntegrity(now) s.checkIntegrity(now)
+120
View File
@@ -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
}
+17 -2
View File
@@ -81,7 +81,7 @@ type MovePersistence struct {
// schemaVersion is the newest schema this binary understands. Migrations are // schemaVersion is the newest schema this binary understands. Migrations are
// applied in order inside one transaction; PostgreSQL DDL is transactional, so // applied in order inside one transaction; PostgreSQL DDL is transactional, so
// a failed upgrade leaves the previous version intact. // 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 // migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
// released migration; append a new one. // 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 ( OR (concession_ply >= 0 AND concession_time_utc IS NOT NULL AND (
(concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation') (concession_result IN ('white_wins', 'black_wins') AND concession_termination = 'resignation')
OR (concession_result = 'draw' AND concession_termination = 'agreement')))); 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. // 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"}
+236 -28
View File
@@ -93,6 +93,7 @@ const games = {
const ui = { const ui = {
healthTimer: null, healthTimer: null,
storage: 'unknown', storage: 'unknown',
mail: 'unknown', // "ok" when the server emails password-reset links
toastTimer: null, toastTimer: null,
historyCache: new Map(), // gameId -> {etag, history} historyCache: new Map(), // gameId -> {etag, history}
}; };
@@ -211,6 +212,22 @@ function describeResult(state, termination) {
return 'Game over'; 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) { function terminationLabel(termination) {
switch (termination) { switch (termination) {
case 'checkmate': return 'Checkmate'; case 'checkmate': return 'Checkmate';
@@ -358,6 +375,11 @@ function setStorageStatus(status) {
updateLiveTools(); updateLiveTools();
} }
function setMailStatus(status) {
ui.mail = status;
$('forgot-row').classList.toggle('hidden', status !== 'ok');
}
function startHealthCheck() { function startHealthCheck() {
const checkHealth = async () => { const checkHealth = async () => {
try { try {
@@ -366,6 +388,7 @@ function startHealthCheck() {
const health = await response.json(); const health = await response.json();
setServerStatus(health.status === 'healthy' ? 'healthy' : 'degraded'); setServerStatus(health.status === 'healthy' ? 'healthy' : 'degraded');
setStorageStatus(health.storage || 'unknown'); setStorageStatus(health.storage || 'unknown');
setMailStatus(health.mail || 'disabled');
} else { } else {
handleApiError('health check', null, response); handleApiError('health check', null, response);
setStorageStatus('unknown'); setStorageStatus('unknown');
@@ -551,7 +574,8 @@ async function deleteAccount() {
// re-entrancy from the Enter-key handler and stops edits mid-request. // re-entrancy from the Enter-key handler and stops edits mid-request.
function setAuthModalBusy(busy) { function setAuthModalBusy(busy) {
auth.busy = 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; $(id).disabled = busy;
} }
document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy); document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy);
@@ -560,7 +584,7 @@ function setAuthModalBusy(busy) {
function showAuthModal() { function showAuthModal() {
$('auth-modal-overlay').classList.add('show'); $('auth-modal-overlay').classList.add('show');
$('login-identifier').focus(); switchAuthTab('login');
document.removeEventListener('keydown', handleAuthModalKeydown); document.removeEventListener('keydown', handleAuthModalKeydown);
document.addEventListener('keydown', handleAuthModalKeydown); document.addEventListener('keydown', handleAuthModalKeydown);
} }
@@ -584,50 +608,72 @@ function handleAuthModalKeydown(e) {
hideAuthModal(); hideAuthModal();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); 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) { function switchAuthTab(tab) {
document.querySelectorAll('.auth-tab').forEach(t => { document.querySelectorAll('.auth-tab').forEach(t => {
const active = t.dataset.tab === tab; const active = t.dataset.tab === tab;
t.classList.toggle('active', active); t.classList.toggle('active', active);
t.setAttribute('aria-selected', active ? 'true' : 'false'); t.setAttribute('aria-selected', active ? 'true' : 'false');
}); });
document.querySelector('.auth-tabs').classList.toggle('hidden', tab === 'recover');
$('login-form').classList.toggle('hidden', tab !== 'login'); $('login-form').classList.toggle('hidden', tab !== 'login');
$('register-form').classList.toggle('hidden', tab !== 'register'); $('register-form').classList.toggle('hidden', tab !== 'register');
$('recover-form').classList.toggle('hidden', tab !== 'recover');
clearModalMessage('auth-modal-message'); 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); setAuthModalBusy(true);
clearModalMessage('auth-modal-message'); clearModalMessage('auth-modal-message');
const fail = message => {
setModalMessage('auth-modal-message', message, 'error');
setAuthModalBusy(false);
refocus(focusId);
return null;
};
let response; let response;
try { try {
response = await apiJSON('POST', path, body); response = await apiJSON('POST', path, body);
} catch (error) { } catch (error) {
const errorInfo = handleApiError(path, error); return fail(handleApiError(path, error).statusMessage);
setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error');
setAuthModalBusy(false);
return null;
} }
if (!response.ok) { if (!response.ok) {
const err = await parseErrorResponse(response); const err = await parseErrorResponse(response);
setModalMessage('auth-modal-message', err.details || err.error || failure, 'error'); if (response.status === 401) return fail('Wrong username or password');
setAuthModalBusy(false); if (response.status === 429) return fail('Too many attempts. Wait a minute and try again.');
return null; return fail(err.details || err.error || failure);
} }
try { try {
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error(`${path}: response OK but JSON parse failed:`, error); console.error(`${path}: response OK but JSON parse failed:`, error);
setModalMessage('auth-modal-message', 'Unexpected response from server', 'error'); return fail('Unexpected response from server');
setAuthModalBusy(false);
return null;
} }
} }
@@ -637,15 +683,156 @@ async function handleLogin() {
const password = $('login-password').value; const password = $('login-password').value;
if (!identifier || !password) { if (!identifier || !password) {
setModalMessage('auth-modal-message', 'Fill in both fields', 'error'); setModalMessage('auth-modal-message', 'Fill in both fields', 'error');
refocus(identifier ? 'login-password' : 'login-identifier', false);
return; 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; if (!result) return;
setSignedIn(result.userId, result.username, result.token); setSignedIn(result.userId, result.username, result.token);
setModalMessage('auth-modal-message', `Signed in as ${result.username}`, 'success'); setModalMessage('auth-modal-message', `Signed in as ${result.username}`, 'success');
setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); 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=<token>
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() { async function handleRegister() {
if (auth.busy) return; if (auth.busy) return;
const username = $('register-username').value.trim(); const username = $('register-username').value.trim();
@@ -655,17 +842,15 @@ async function handleRegister() {
setModalMessage('auth-modal-message', 'Username and password required', 'error'); setModalMessage('auth-modal-message', 'Username and password required', 'error');
return; return;
} }
if (password.length < 8) { const weak = passwordProblem(password);
setModalMessage('auth-modal-message', 'Password needs at least 8 characters', 'error'); if (weak) {
return; setModalMessage('auth-modal-message', weak, 'error');
} refocus('register-password');
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
setModalMessage('auth-modal-message', 'Password needs a letter and a number', 'error');
return; return;
} }
const body = { username, password }; const body = { username, password };
if (email) body.email = email; 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; if (!result) return;
setSignedIn(result.userId, result.username, result.token); setSignedIn(result.userId, result.username, result.token);
setModalMessage('auth-modal-message', `Account created, welcome ${result.username}`, 'success'); setModalMessage('auth-modal-message', `Account created, welcome ${result.username}`, 'success');
@@ -1344,26 +1529,32 @@ function updateTurnStrip() {
const dot = $('turn-dot'); const dot = $('turn-dot');
const text = $('turn-text'); const text = $('turn-text');
let status = 'idle', label = 'No game'; let status = 'idle', label = 'No game';
let title = '';
if (!live.gameId) { if (!live.gameId) {
status = 'idle'; status = 'idle';
label = 'No game'; label = 'No game';
} else if (live.state === 'pending' || live.isLocked) { } else if (live.state === 'pending' || live.isLocked) {
status = 'thinking'; status = 'thinking';
label = 'Computer thinking…'; label = 'Thinking…';
title = 'The computer is thinking';
} else if (isGameOver(live.state)) { } else if (isGameOver(live.state)) {
status = live.state === 'white wins' ? 'white-wins' : live.state === 'black wins' ? 'black-wins' : 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') { } else if (live.state === 'stuck') {
status = '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 { } else {
// The dot shows the side to move; the text says whose it is.
status = live.turn === 'w' ? 'white' : 'black'; status = live.turn === 'w' ? 'white' : 'black';
const side = 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; dot.dataset.status = status;
text.textContent = label; 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 // 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); $('login-submit-btn').addEventListener('click', handleLogin);
$('register-submit-btn').addEventListener('click', handleRegister); $('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').addEventListener('click', hideAuthModal);
$('auth-cancel-btn-2').addEventListener('click', hideAuthModal); $('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
$('logout-btn').addEventListener('click', handleLogoutClick); $('logout-btn').addEventListener('click', handleLogoutClick);
@@ -2198,6 +2394,18 @@ document.addEventListener('DOMContentLoaded', async () => {
updateTurnStrip(); updateTurnStrip();
updateGameControls(); updateGameControls();
startHealthCheck(); 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 restoreAuthSession();
await applyURL(); await applyURL();
if (resetToken) openResetModal(resetToken);
}); });
@@ -10,7 +10,31 @@
<body data-mode="live"> <body data-mode="live">
<div class="app"> <div class="app">
<header class="topbar"> <header class="topbar">
<div class="brand" aria-label="Chess"><span class="brand-mark" aria-hidden="true">♞</span><span class="brand-name">Chess</span></div> <div class="brand" aria-label="Chess">
<svg class="brand-mark" viewBox="41 34 430 430" aria-hidden="true" focusable="false">
<defs>
<linearGradient id="brand-piece" x1="20%" y1="0%" x2="80%" y2="100%">
<stop offset="0%" stop-color="#C8CDD6"/>
<stop offset="45%" stop-color="#8B929E"/>
<stop offset="100%" stop-color="#4F545D"/>
</linearGradient>
</defs>
<g fill="url(#brand-piece)" stroke="#14161A" stroke-width="4" stroke-linejoin="round">
<path d="M136 450Q256 465 376 450L346 410Q256 420 166 410Z"/>
<path d="M166 410Q256 420 346 410L316 370Q256 380 196 370Z"/>
<path d="M196 370Q256 380 316 370C300 290 286 230 282 205H230C226 230 212 290 196 370Z"/>
</g>
<path d="M330 90Q265 96 230 120Q265 144 335 150Z" fill="#111318"/>
<g stroke="#F0F2F5" stroke-width="6" stroke-linecap="round">
<path d="M263.17 85V155M299.33 85V155"/>
</g>
<g fill="url(#brand-piece)" stroke="#14161A" stroke-width="4" stroke-linejoin="round">
<path d="M226 200C150 180 150 70 256 40C290 50 315 65 330 90Q265 96 230 120Q265 144 335 150C335 170 315 190 286 200Z"/>
<rect x="216" y="195" width="80" height="16" rx="8"/>
</g>
</svg>
<span class="brand-name">Chess</span>
</div>
<div class="health" aria-label="Server status"> <div class="health" aria-label="Server status">
<span class="dot" id="server-dot" data-status="unknown" title="Server: unknown" role="img" aria-label="Server: unknown"></span> <span class="dot" id="server-dot" data-status="unknown" title="Server: unknown" role="img" aria-label="Server: unknown"></span>
<span class="dot" id="storage-dot" data-status="unknown" title="Storage: unknown" role="img" aria-label="Storage: unknown"></span> <span class="dot" id="storage-dot" data-status="unknown" title="Storage: unknown" role="img" aria-label="Storage: unknown"></span>
@@ -44,7 +68,7 @@
<span class="turn-text" id="turn-text">No game</span> <span class="turn-text" id="turn-text">No game</span>
</div> </div>
<div class="controls" role="group" aria-label="Game controls"> <div class="controls" role="group" aria-label="Game controls">
<button id="new-game-btn" class="btn btn-primary" type="button">New game</button> <button id="new-game-btn" class="btn btn-primary" type="button" title="New game">New</button>
<button id="undo-btn" class="btn btn-secondary" type="button" disabled>Undo</button> <button id="undo-btn" class="btn btn-secondary" type="button" disabled>Undo</button>
<button id="draw-btn" class="btn btn-secondary" type="button" title="Offer a draw" disabled>Draw</button> <button id="draw-btn" class="btn btn-secondary" type="button" title="Offer a draw" disabled>Draw</button>
<button id="resign-btn" class="btn btn-secondary" type="button" title="Resign this game" disabled>Resign</button> <button id="resign-btn" class="btn btn-secondary" type="button" title="Resign this game" disabled>Resign</button>
@@ -201,6 +225,19 @@
<button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button> <button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button>
<button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button> <button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div> </div>
<p class="auth-aside hidden" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p>
</div>
<div id="recover-form" class="auth-form hidden">
<p class="auth-help">Enter your username or email. If the account has an email address, a link to choose a new password is sent to it; the link works for an hour.</p>
<div class="form-group">
<label for="recover-identifier">Username or email</label>
<input type="text" id="recover-identifier" autocomplete="username">
</div>
<div class="modal-buttons">
<button id="recover-submit-btn" class="btn btn-primary" type="button">Send reset link</button>
<button id="recover-back-btn" class="btn btn-secondary" type="button">Back</button>
</div>
</div> </div>
<div id="register-form" class="auth-form hidden"> <div id="register-form" class="auth-form hidden">
@@ -224,6 +261,26 @@
</div> </div>
</div> </div>
<!-- Reset Modal: opened by the link in the recovery email (?reset=<token>) -->
<div id="reset-overlay" class="modal-overlay">
<div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="reset-title">
<h2 id="reset-title">Choose a new password</h2>
<div id="reset-modal-message" class="modal-message" role="status" aria-live="polite"></div>
<div class="form-group">
<label for="reset-password">New password (8+ characters, a letter and a number)</label>
<input type="password" id="reset-password" autocomplete="new-password">
</div>
<div class="form-group">
<label for="reset-password-2">Repeat the password</label>
<input type="password" id="reset-password-2" autocomplete="new-password">
</div>
<div class="modal-buttons">
<button id="reset-submit-btn" class="btn btn-primary" type="button">Set password</button>
<button id="reset-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
</div>
</div>
</div>
<!-- Account Modal: shown instead of the login modal while signed in --> <!-- Account Modal: shown instead of the login modal while signed in -->
<div id="account-overlay" class="modal-overlay"> <div id="account-overlay" class="modal-overlay">
<div class="modal account-modal" role="dialog" aria-modal="true" aria-labelledby="account-title"> <div class="modal account-modal" role="dialog" aria-modal="true" aria-labelledby="account-title">
@@ -125,9 +125,9 @@ button { cursor: pointer; }
} }
.brand-mark { .brand-mark {
font-size: 1.35rem; width: 1.5rem;
line-height: 1; height: 1.5rem;
color: var(--periwinkle); flex: none;
} }
.health { .health {
@@ -722,6 +722,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.auth-form input[type="text"], .auth-form input[type="text"],
.auth-form input[type="email"], .auth-form input[type="email"],
.auth-form input[type="password"], .auth-form input[type="password"],
#reset-overlay input[type="password"],
.account-form input[type="password"] { .account-form input[type="password"] {
width: 100%; width: 100%;
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
@@ -740,7 +741,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
word-wrap: break-word; word-wrap: break-word;
} }
.fen-input:focus, .auth-form input:focus, .account-form input:focus { .fen-input:focus, .auth-form input:focus, .account-form input:focus, #reset-overlay input:focus {
outline: none; outline: none;
border-color: var(--royal); border-color: var(--royal);
} }
@@ -754,6 +755,33 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.auth-modal, .account-modal { max-width: 360px; } .auth-modal, .account-modal { max-width: 360px; }
.auth-aside {
margin: 0.9rem 0 0;
text-align: center;
font-size: 0.85rem;
}
.auth-help {
margin: 0 0 0.9rem;
font-size: 0.85rem;
line-height: 1.45;
color: var(--muted);
}
.link-btn {
padding: 0;
background: none;
border: none;
color: var(--periwinkle);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.link-btn:hover { color: var(--fg); }
.link-btn:focus-visible { outline: 2px solid var(--periwinkle); outline-offset: 2px; border-radius: 2px; }
.auth-form input[type="password"]#reset-password, .auth-form input[type="password"]#reset-password-2 { width: 100%; }
.auth-tabs { .auth-tabs {
display: flex; display: flex;
margin-bottom: 0.9rem; margin-bottom: 0.9rem;
@@ -1007,7 +1035,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
/* --- Stacked layout (board over panel) -------------------------------- */ /* --- Stacked layout (board over panel) -------------------------------- */
@media (max-width: 978px) { @media (min-width: 641px) and (max-width: 978px) {
:root { :root {
--panel-width: clamp(360px, 45vw, 440px); --panel-width: clamp(360px, 45vw, 440px);
--panel-height: 230px; --panel-height: 230px;
@@ -1036,12 +1064,18 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.pane { .pane {
display: grid; display: grid;
grid-template-columns: clamp(150px, 18.4vw, 180px) 1fr; grid-template-columns: clamp(150px, 18.4vw, 180px) minmax(0, 1fr);
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
gap: 0.5rem 0.75rem; gap: 0.5rem 0.75rem;
padding: 0.6rem; padding: 0.6rem;
} }
.pane > * { min-width: 0; }
.toolbar .btn-tool { min-width: 0; overflow: hidden; }
/* Four export buttons in the narrow right column: icons only. */
.pane-replay .toolbar .btn-tool span { display: none; }
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
body[data-mode="replay"] .pane-live, body[data-mode="live"] .pane-replay { display: none; } body[data-mode="replay"] .pane-live, body[data-mode="live"] .pane-replay { display: none; }
.pane-live .turn-strip { grid-column: 1; grid-row: 1; } .pane-live .turn-strip { grid-column: 1; grid-row: 1; }
@@ -1086,19 +1120,111 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.btn-tool { font-size: 0.74rem; padding: 0.3rem 0.2rem; } .btn-tool { font-size: 0.74rem; padding: 0.3rem 0.2rem; }
} }
@media (max-width: 530px) { /* --- Phone layout: full-width rows, edge to edge ------------------------ */
@media (max-width: 640px) {
:root { :root {
--panel-width: min(92vw, 440px); /* Reserve under the board: top bar 40 + gap 6 + turn strip 32 +
--board-edge: max(240px, min(min(92vw, 440px), calc(100vh - 332px))); controls 36 + move list 96 + toolbar 36 + gaps and padding 26 =
--board-pad: 12px; 276. Small viewport units, so the browser's bars never cover the
panel. The replay pane is taller (head, navigation, export, exit),
so a replay reserves more and the board gives way on a short
screen rather than the controls dropping below the fold. */
--board-edge: max(240px, min(100vw, calc(100svh - 284px)));
--board-pad: 14px;
--panel-width: 100%;
--panel-height: auto;
--topbar-height: 2.5rem;
}
body { overflow-y: auto; overflow-x: hidden; }
body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 372px))); }
/* The app is exactly the viewport, so the panel and its move list shrink
to fit; only when their minimums do not fit does the page scroll. */
.app { padding: 0; gap: 6px; height: 100svh; min-height: 100svh; overflow: visible; }
.topbar {
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
padding: 0 0.6rem;
} }
.brand-name, #games-btn span, #auth-label { display: none; } .brand-name, #games-btn span, #auth-label { display: none; }
/* Icon-only export row in the replay pane: four buttons in 190px. */ #games-btn, #auth-btn { padding: 0.35rem 0.55rem; }
.pane-replay .toolbar .btn-tool span { display: none; }
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; } .workspace {
#games-btn, #auth-btn { padding: 0.35rem 0.5rem; } flex: 1 1 auto;
.pane { grid-template-columns: 140px 1fr; } min-height: 0;
flex-direction: column;
justify-content: flex-start;
gap: 6px;
overflow: visible;
}
.board-area { flex: none; }
.board-area { padding: var(--board-pad) 6px 6px 16px; }
.board-wrapper {
width: calc(var(--board-edge) - 22px);
height: calc(var(--board-edge) - 22px);
}
.coordinates.ranks { left: -14px; }
.coordinates.files { top: -15px; }
.panel {
display: flex;
flex-direction: column;
width: 100%;
flex: 1 1 auto;
min-height: 0;
height: auto;
border-radius: 0;
border-left: none;
border-right: none;
border-bottom: none;
}
.pane, .pane-live, .pane-replay {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 0.6rem 0.6rem;
height: auto;
}
.pane > * { min-width: 0; }
.turn-strip { min-height: 2rem; padding: 0.25rem 0.6rem; }
.pane-live .controls {
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto;
gap: 0.35rem;
}
.pane-live .controls .btn { padding: 0.5rem 0.2rem; font-size: 0.85rem; min-height: 2.25rem; height: auto; }
.moves { flex: 1 1 auto; min-height: 96px; }
.move-grid { grid-template-columns: 2.2rem 1fr 1fr; font-size: 0.9rem; }
.toolbar .btn-tool { padding: 0.45rem 0.3rem; font-size: 0.82rem; min-width: 0; }
/* Replay: head, list, navigation, export and exit as rows. */
.pane-replay .replay-head { padding: 0.35rem 0.6rem; }
.pane-replay .replay-meta { display: none; }
.pane-replay .replay-nav {
display: grid;
grid-template-columns: repeat(5, 1fr) 1.4fr 1fr;
gap: 0.3rem;
}
.pane-replay .replay-nav .speed { margin: 0; }
.pane-replay .replay-nav .speed select { width: 100%; }
.pane-replay .btn-nav { width: 100%; height: 2.2rem; }
.pane-replay #replay-exit { padding: 0.5rem; font-size: 0.85rem; }
.games-modal { padding: 0.75rem; } .games-modal { padding: 0.75rem; }
.game-row { gap: 0.5rem; padding: 0.4rem; } .game-row { gap: 0.5rem; padding: 0.4rem; }
.mini-board { width: 56px; height: 56px; } .mini-board { width: 56px; height: 56px; }
@@ -1106,6 +1232,13 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.modal-overlay { padding: 0.5rem; } .modal-overlay { padding: 0.5rem; }
} }
/* Very narrow phones: icon-only export buttons. */
@media (max-width: 380px) {
.pane-replay .toolbar .btn-tool span { display: none; }
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
.pane-live .controls .btn { font-size: 0.8rem; }
}
/* --- Motion ---------------------------------------------------------- */ /* --- Motion ---------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
+14 -8
View File
@@ -38,11 +38,12 @@ The server runs with `-dev`, debug logging, HTTP request logging, and relaxed ra
It drops the test tables on exit, so it's preferred for clean testing. It drops the test tables on exit, so it's preferred for clean testing.
Can be used for all the tests. Can be used for all the tests.
Logging can be adjusted without editing the script, and `WEB_PORT` also Logging can be adjusted without editing the script; `WEB_PORT` also serves
serves the embedded web client (needed by the end-to-end suite): the embedded web client and `MAIL_DIR` turns on password recovery with the
email written to that directory (both used by the end-to-end suite):
```bash ```bash
LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server LOG_LEVEL=info LOG_HTTP=false test/run-test-server.sh bin/chess-server
WEB_PORT=9090 test/run-test-server.sh bin/chess-server WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh bin/chess-server
``` ```
### Pre-configured Users ### Pre-configured Users
@@ -226,20 +227,22 @@ Playwright drives the embedded web client in Chromium against a running
server with PostgreSQL and Stockfish: it plays a short game, lists it under server with PostgreSQL and Stockfish: it plays a short game, lists it under
My games, replays it with the buttons and the keyboard, autoplays to the end, My games, replays it with the buttons and the keyboard, autoplays to the end,
compares the FEN and PGN it exports (copy and download) with the API, follows compares the FEN and PGN it exports (copy and download) with the API, follows
a deep link to a checkmate, steps back through the browser history, and a deep link to a checkmate, steps back through the browser history, replays
replays an anonymous game from the device list. an anonymous game from the device list, checks that a phone-sized viewport
holds the board and the controls without scrolling, and resets a forgotten
password through the link in the emailed message (needs `MAIL_DIR`).
Requirements: Node.js 20+ and a Chromium that Playwright can run. Requirements: Node.js 20+ and a Chromium that Playwright can run.
```bash ```bash
# Terminal 1: the test server with the web client # Terminal 1: the test server with the web client and mail to a directory
WEB_PORT=9090 test/run-test-server.sh bin/chess-server WEB_PORT=9090 MAIL_DIR=/tmp/chess-mail test/run-test-server.sh bin/chess-server
# Terminal 2, once # Terminal 2, once
cd test/e2e && npm install && npx playwright install chromium cd test/e2e && npm install && npx playwright install chromium
# Terminal 2: run # Terminal 2: run
npm test # or, from the repository root: make test-e2e CHESS_E2E_MAIL_DIR=/tmp/chess-mail npm test # or, from the repository root: make test-e2e
``` ```
Environment: Environment:
@@ -249,6 +252,9 @@ Environment:
to register users, create games and fetch what the client should export to register users, create games and fetch what the client should export
- `CHESS_E2E_CHROMIUM`: path to a Chromium binary when - `CHESS_E2E_CHROMIUM`: path to a Chromium binary when
`npx playwright install` is not possible (an already installed browser) `npx playwright install` is not possible (an already installed browser)
- `CHESS_E2E_MAIL_DIR`: the server's `-mail-dir` (`MAIL_DIR` of the test
server); the password-reset test reads the link from the newest file and
is skipped when unset
Each test registers its own user or plays anonymously, so the suite can be Each test registers its own user or plays anonymously, so the suite can be
repeated against the same server. The engine plays at level 0 with a 100 ms repeated against the same server. The engine plays at level 0 with a 100 ms
+91 -1
View File
@@ -6,6 +6,11 @@
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const API = process.env.CHESS_E2E_API || 'http://localhost:8080'; const API = process.env.CHESS_E2E_API || 'http://localhost:8080';
// Where the server writes password-reset email (-mail-dir); the reset test
// is skipped without it.
const MAIL_DIR = process.env.CHESS_E2E_MAIL_DIR || '';
const fs = require('fs');
const path = require('path');
const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'; const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
// The engine level and search time are set through the sliders' inputs. // The engine level and search time are set through the sliders' inputs.
@@ -174,6 +179,90 @@ test.describe('web replay', () => {
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/$/);
}); });
test('a phone fits the board and the controls without scrolling', async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 412, height: 780 }, isMobile: true, hasTouch: true });
const page = await context.newPage();
await page.goto('/');
const fits = () => page.evaluate(() => ({
w: document.documentElement.scrollWidth <= innerWidth,
h: document.documentElement.scrollHeight <= innerHeight,
}));
expect(await fits()).toEqual({ w: true, h: true });
// The controls sit in one row of short labels.
await expect(page.locator('#new-game-btn')).toHaveText('New');
const [newBox, resignBox] = await Promise.all([
page.locator('#new-game-btn').boundingBox(), page.locator('#resign-btn').boundingBox()]);
expect(Math.abs(newBox.y - resignBox.y)).toBeLessThan(2);
expect(newBox.height).toBeLessThan(50);
await expect(page.locator('.pane-live .toolbar')).toBeInViewport();
// A replay keeps its controls on screen too.
const created = await (await context.request.post(`${API}/api/games`, { data: { white: { type: 1 }, black: { type: 1 } } })).json();
await context.request.post(`${API}/api/games/${created.gameId}/moves`, { data: { move: 'e2e4' } });
await page.goto(`/?replay=${created.gameId}&ply=1`);
await expect(page.locator('body')).toHaveAttribute('data-mode', 'replay');
expect(await fits()).toEqual({ w: true, h: true });
await expect(page.locator('#replay-exit')).toBeInViewport();
await expect(page.locator('#replay-link')).toBeInViewport();
await context.close();
});
test('a forgotten password is reset through the emailed link', async ({ page, request }) => {
test.skip(!MAIL_DIR, 'CHESS_E2E_MAIL_DIR is not set (start the server with MAIL_DIR)');
const username = `reset${Date.now().toString(36)}${Math.floor(Math.random() * 1000)}`;
const email = `${username}@example.test`;
const created = await request.post(`${API}/api/auth/register`, {
data: { username, email, password: 'OldPass123' },
});
expect(created.status(), await created.text()).toBe(201);
await page.goto('/');
await expect(page.locator('#storage-dot')).toHaveAttribute('data-status', 'ok');
await page.click('#auth-btn');
// A wrong password keeps the focus in the password field.
await page.fill('#login-identifier', username);
await page.fill('#login-password', 'wrong');
await page.click('#login-submit-btn');
await expect(page.locator('#auth-modal-message')).toContainText('Wrong username or password');
await expect(page.locator('#login-password')).toBeFocused();
await expect(page.locator('#forgot-btn')).toBeVisible();
await page.click('#forgot-btn');
await page.fill('#recover-identifier', email);
const mails = () => (fs.existsSync(MAIL_DIR) ? fs.readdirSync(MAIL_DIR) : []);
const before = mails().length;
await page.click('#recover-submit-btn');
await expect(page.locator('#auth-modal-message')).toContainText('on its way');
await expect.poll(() => mails().length, { timeout: 10_000 }).toBeGreaterThan(before);
const files = fs.readdirSync(MAIL_DIR).map(f => path.join(MAIL_DIR, f))
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
const mail = fs.readFileSync(files[0], 'utf8');
expect(mail).toContain(`To: ${email}`);
const link = /(https?:\/\/\S+\?reset=[A-Za-z0-9_-]+)/.exec(mail)[1];
const origin = new URL(page.url()).origin;
await page.goto(link.replace(/^https?:\/\/[^/]+/, origin));
await expect(page.locator('#reset-overlay')).toHaveClass(/show/);
// The token is removed from the address at once.
expect(page.url()).not.toContain('reset=');
await page.fill('#reset-password', 'NewPass123');
await page.fill('#reset-password-2', 'NewPass124');
await page.click('#reset-submit-btn');
await expect(page.locator('#reset-modal-message')).toContainText('differ');
await page.fill('#reset-password-2', 'NewPass123');
await page.keyboard.press('Enter');
await expect(page.locator('#auth-label')).toHaveText(username);
await expect(page.locator('#reset-overlay')).not.toHaveClass(/show/, { timeout: 5_000 });
// The old password is gone, the new one and the link are not reusable.
const old = await request.post(`${API}/api/auth/login`, { data: { identifier: username, password: 'OldPass123' } });
expect(old.status()).toBe(401);
const fresh = await request.post(`${API}/api/auth/login`, { data: { identifier: username, password: 'NewPass123' } });
expect(fresh.status()).toBe(200);
await page.goto(link.replace(/^https?:\/\/[^/]+/, origin));
await page.fill('#reset-password', 'NewPass125');
await page.fill('#reset-password-2', 'NewPass125');
await page.click('#reset-submit-btn');
await expect(page.locator('#reset-modal-message')).toContainText('invalid or has expired');
});
test('an anonymous visitor replays from the device list', async ({ page }) => { test('an anonymous visitor replays from the device list', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('#auth-label')).toHaveText('Sign in'); await expect(page.locator('#auth-label')).toHaveText('Sign in');
@@ -193,6 +282,7 @@ test.describe('web replay', () => {
await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1'); await expect(page.locator('.coordinates.ranks span').first()).toHaveText('1');
expect(await page.evaluate(() => replay.history.initialFen)).toBe(START_FEN); expect(await page.evaluate(() => replay.history.initialFen)).toBe(START_FEN);
await page.click('#replay-exit'); await page.click('#replay-exit');
await expect(page.locator('#turn-text')).toContainText('Your move (Black)'); await expect(page.locator('#turn-text')).toHaveText('Your move');
await expect(page.locator('#turn-dot')).toHaveAttribute('data-status', 'black');
}); });
}); });
+10 -1
View File
@@ -10,8 +10,11 @@ CHESS_SERVER_EXEC=${1:-"bin/chess-server"}
export CHESS_DSN="$CHESS_TEST_DSN" export CHESS_DSN="$CHESS_TEST_DSN"
PID_FILE="/tmp/chess-server_test.pid" PID_FILE="/tmp/chess-server_test.pid"
API_PORT=${API_PORT:-8080} API_PORT=${API_PORT:-8080}
# Set WEB_PORT to also serve the embedded web client (for test/e2e). # Set WEB_PORT to also serve the embedded web client (for test/e2e), and
# MAIL_DIR to enable password recovery with the email written to that
# directory instead of sent.
WEB_PORT=${WEB_PORT:-} WEB_PORT=${WEB_PORT:-}
MAIL_DIR=${MAIL_DIR:-}
LOG_LEVEL=${LOG_LEVEL:-debug} LOG_LEVEL=${LOG_LEVEL:-debug}
LOG_HTTP=${LOG_HTTP:-true} LOG_HTTP=${LOG_HTTP:-true}
@@ -98,6 +101,7 @@ echo "Configuration:"
echo " Executable: $CHESS_SERVER_EXEC" echo " Executable: $CHESS_SERVER_EXEC"
echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)" echo " Database: \$CHESS_TEST_DSN (tables dropped on exit)"
echo " Port: $API_PORT${WEB_PORT:+ (web client on $WEB_PORT)}" echo " Port: $API_PORT${WEB_PORT:+ (web client on $WEB_PORT)}"
echo " Mail: ${MAIL_DIR:-off (set MAIL_DIR for password recovery)}"
echo " Mode: Development (relaxed rate limits; PostgreSQL storage)" echo " Mode: Development (relaxed rate limits; PostgreSQL storage)"
echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)" echo " Log level: $LOG_LEVEL (HTTP requests: $LOG_HTTP)"
echo " Purpose: Backend for chess-server tests" echo " Purpose: Backend for chess-server tests"
@@ -115,6 +119,11 @@ echo ""
# Start chess-server in foreground with dev mode and storage # Start chess-server in foreground with dev mode and storage
WEB_FLAGS=() WEB_FLAGS=()
[ -n "$WEB_PORT" ] && WEB_FLAGS=(-serve -web-port "$WEB_PORT") [ -n "$WEB_PORT" ] && WEB_FLAGS=(-serve -web-port "$WEB_PORT")
if [ -n "$MAIL_DIR" ]; then
mkdir -p "$MAIL_DIR"
WEB_FLAGS+=(-mail-dir "$MAIL_DIR" -mail-from "Chess <chess@localhost>" \
-public-url "http://localhost:${WEB_PORT:-9090}/")
fi
"$CHESS_SERVER_EXEC" \ "$CHESS_SERVER_EXEC" \
-dev \ -dev \
-log-level "$LOG_LEVEL" \ -log-level "$LOG_LEVEL" \
+7
View File
@@ -56,6 +56,13 @@
const fitAddon = new FitAddon.FitAddon(); const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
// The client pages long output by the terminal's size, read from here
// (display/termsize_wasm.go).
globalThis.chessTerminal = {
get rows() { return term.rows; },
get cols() { return term.cols; }
};
term.open(screenEl); term.open(screenEl);
// WebGL must load after open(); the DOM renderer is the fallback. // WebGL must load after open(); the DOM renderer is the fallback.