diff --git a/PLAN.md b/PLAN.md index 1c05946..4523227 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1387,6 +1387,37 @@ provided cert) at the contour caddy; prod VPN; rollback. `kind='message'`, the source via a SQL `CASE`), reusing the now-exported `account.LikePattern` glob helper. Owner decisions: messages only (no nudges), separate name/ext masks (matching the Users section), a top-level nav entry plus the card deep-links. + - **Round-6 follow-up — UX polish + client-IP fix (this PR):** + - **Client IP through the edge.** The compose caddy now sets `trusted_proxies static + private_ranges`, so the real client IP survives the host-caddy hop (it was logging the + docker-network caddy hop `172.18.0.x` for chat moderation, and bucketing the gateway's + per-IP rate limiter on it). Correct + spoof-safe in **both** contours (prod has no host + caddy → public clients untrusted → real peer used). `peerIP` unit-tested. + - **Ad banner** gated **off** behind a compile-time `SHOW_AD_BANNER=false` in `Screen.svelte` + — the `{#if}` branch, the `AdBanner` import and `banner.ts` are tree-shaken out of the prod + bundle (code kept for post-release polish). + - **Landing** Telegram entry is now just the **64px logo** (clickable, no button/caption). + - **TG-fullscreen header** reworked again: title + menu are one **centred pair** (hamburger + right of the title) pinned to the **bottom** of the TG nav band, lining up with Telegram's + own controls. + - **Edge-swipe back** (`Screen.svelte`): a left-edge rightward drag navigates to `back` + (touch/pen only, armed only from ≤24px so it never fights the board's gestures; skipped + inside Telegram, which has its own back). + - **Chat + word-check are now their own routed screens** (`/game/:id/chat`, + `/game/:id/check`, header back to the game, no tab-bar) so the soft keyboard simply resizes + the **visible viewport** — mirrored into a `--vvh` CSS var the `Screen` height uses, since + iOS doesn't shrink `dvh` for the keyboard — with the input pinned to the bottom: no modal + relayout, no page jump (this superseded a first bottom-sheet-`Modal` attempt). New chat + messages raise an **unread badge** on the in-game hamburger + the Chat menu row (per game, + cleared on open), mirroring the lobby badge; the chat screen is routable for a future + Telegram deep-link. The TG-fullscreen header was also finalised over a couple of review + passes: title + menu as a centred pair inside Telegram's nav band (between `--tg-safe-top` + and `--tg-content-top`), with a small padding bump so the native controls aren't flush. + - **Tests backfilled** for the merged round-6 work: e2e for the in-game "✓ in friends" item + and a board→board tile relocation; codec units for `last_activity_unix` + `OutgoingRequestList`. + - **Deferred to the next PR (agreed):** #4 enrich the out-of-app "your turn" / game-end push + with the opponent's name, last word and score; #5 let a player hide finished games from + their lobby (swipe + a desktop affordance). ## Deferred TODOs (cross-stage) diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 8386ee3..0c802d7 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } .subnav a.active { color: var(--ink); } .form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; } +.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; } .form.col { flex-direction: column; align-items: stretch; max-width: 540px; } .form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); } .form input, .form select, .form textarea { diff --git a/backend/internal/adminconsole/templates/pages/messages.gohtml b/backend/internal/adminconsole/templates/pages/messages.gohtml index 7c985f5..917079b 100644 --- a/backend/internal/adminconsole/templates/pages/messages.gohtml +++ b/backend/internal/adminconsole/templates/pages/messages.gohtml @@ -7,6 +7,7 @@ +Export CSV ↓ {{if or .GameID .UserID}}
Filtered{{if .GameID}} to game {{.GameID}}{{end}}{{if .UserID}} from sender{{end}} · clear
diff --git a/backend/internal/server/csvsafe_test.go b/backend/internal/server/csvsafe_test.go new file mode 100644 index 0000000..a3f207f --- /dev/null +++ b/backend/internal/server/csvsafe_test.go @@ -0,0 +1,25 @@ +package server + +import "testing" + +// TestCSVSafe checks the CSV/spreadsheet formula-injection guard used by the admin Messages +// export: a leading formula trigger is quoted, everything else is left intact. +func TestCSVSafe(t *testing.T) { + tests := []struct{ in, want string }{ + {"", ""}, + {"hello", "hello"}, + {"=1+1", "'=1+1"}, + {"+cmd", "'+cmd"}, + {"-2", "'-2"}, + {"@SUM(A1)", "'@SUM(A1)"}, + {"\tx", "'\tx"}, + {"\rx", "'\rx"}, + {"good luck", "good luck"}, + {"a=b", "a=b"}, // a formula char that is not leading must be left untouched + } + for _, tc := range tests { + if got := csvSafe(tc.in); got != tc.want { + t.Errorf("csvSafe(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index c1a0b3f..bfa3758 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "encoding/csv" "fmt" "net/http" "net/url" @@ -53,6 +54,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/complaints/:id", s.consoleComplaintDetail) gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint) gm.GET("/messages", s.consoleMessages) + gm.GET("/messages.csv", s.consoleMessagesCSV) gm.GET("/dictionary", s.consoleDictionary) gm.POST("/dictionary/reload", s.consoleReloadDictionary) gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) @@ -186,6 +188,54 @@ func (s *Server) consoleMessages(c *gin.Context) { s.renderConsole(c, "messages", "messages", "Messages", view) } +// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small). +const adminMessagesExportCap = 100000 + +// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a +// CSV download, for offline moderation review. +func (s *Server) consoleMessagesCSV(c *gin.Context) { + ctx := c.Request.Context() + gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game"))) + userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user"))) + filter := social.AdminMessageFilter{ + GameID: gameID, + SenderID: userID, + NameMask: c.Query("name"), + ExtMask: c.Query("ext"), + } + items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0) + if err != nil { + s.consoleError(c, err) + return + } + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", `attachment; filename="messages.csv"`) + w := csv.NewWriter(c.Writer) + _ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"}) + for _, m := range items { + // The sender name and message body are user-controlled; defuse spreadsheet formula + // injection so a moderator opening the export can't trigger a formula. + _ = w.Write([]string{ + fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(), + }) + } + w.Flush() +} + +// csvSafe defuses CSV/spreadsheet formula injection: a value a spreadsheet would treat as a +// formula (a leading =, +, -, @, tab or CR) is prefixed with a single quote so it renders as +// plain text on open. +func csvSafe(s string) string { + if s == "" { + return s + } + switch s[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + s + } + return s +} + // consoleUserDetail renders one account with its stats, identities and games. func (s *Server) consoleUserDetail(c *gin.Context) { ctx := c.Request.Context() diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 25a4e20..ba26fbe 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -8,6 +8,14 @@ # ACME and the contour is self-contained. { admin off + # Trust X-Forwarded-For from private-range upstreams so the real client IP survives + # (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy + # (a private IP) is trusted, so its forwarded client IP is preserved. Prod (no host caddy): + # clients connect from public IPs, which are NOT trusted, so Caddy uses the real peer — + # the same config is correct (and spoof-safe) in both contours (Stage 17). + servers { + trusted_proxies static private_ranges + } } {$CADDY_SITE_ADDRESS::80} { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fd8b691..96154dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,8 +40,9 @@ Three executables plus per-platform side-services: in-memory mock transport (`pnpm start`) runs the whole slice with no backend. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), - a one-line **announcement banner** under the nav (a client-side mock rotation today — - a server-driven channel later, §10), and a client **board-style** setting (bonus-label + a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off + in the build until polished after release**, Stage 17 — a server-driven channel later, §10), + and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in [`UI_DESIGN.md`](UI_DESIGN.md). - **`platform/telegram`** — the Telegram side-service (the "connector", module @@ -608,7 +609,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`): (`.gitea/workflows/ci.yaml` → `docker compose up -d --build` on the Gitea runner host, then a `GET /` probe through caddy). The host caddy terminates TLS and forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP - (`CADDY_SITE_ADDRESS=:80`). + (`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from + private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP — + used for chat-moderation logging and the gateway's per-IP rate limiting — survives the + host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the + real peer, so the single config is correct and spoof-safe in both contours (Stage 17). - **Prod** (Stage 18): a manual SSH deploy after `development → master`. There is no host caddy, so the contour ships its own caddy terminating TLS — set `CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 39b93c5..c064ce7 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -125,6 +125,8 @@ existing friendship). Per-game chat is for quick reactions: messages are short (up to 60 characters) and may not contain links, email addresses or phone numbers, even disguised. Nudge the player whose turn is awaited at most once per hour (the nudge is part of the game chat); the out-of-app push is delivered via the platform. +Chat and the word-check tool open as their **own screens** (with a back to the game), and a +new chat message raises an **unread badge** on the game's menu until the chat is opened. ### Profile & settings *(Stage 4 / 8)* Edit the display name (letters joined by a single space / "." / "_" separator, with an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1add5a3..164e4fe 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -128,6 +128,8 @@ Mini App** авторизует по подписанным `initData` плат содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий push доставляется через платформу. +Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в +партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата. ### Профиль и настройки *(Stage 4 / 8)* Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / diff --git a/gateway/internal/connectsrv/peerip_test.go b/gateway/internal/connectsrv/peerip_test.go new file mode 100644 index 0000000..f54b1cb --- /dev/null +++ b/gateway/internal/connectsrv/peerip_test.go @@ -0,0 +1,35 @@ +package connectsrv + +import ( + "net/http" + "testing" +) + +// TestPeerIP covers the client-IP extraction the chat-moderation IP and the per-IP rate +// limiter both rely on: the first X-Forwarded-For hop (the real client, once Caddy is +// configured to trust its upstream), falling back to the connection peer (Stage 17). +func TestPeerIP(t *testing.T) { + tests := []struct { + name string + addr string + xff string + want string + }{ + {"xff single", "10.0.0.1:5000", "203.0.113.7", "203.0.113.7"}, + {"xff client then proxies", "10.0.0.1:5000", "203.0.113.7, 172.18.0.3", "203.0.113.7"}, + {"xff trims spaces", "10.0.0.1:5000", " 203.0.113.9 , 10.0.0.2", "203.0.113.9"}, + {"no xff uses peer host", "203.0.113.5:42000", "", "203.0.113.5"}, + {"no xff no port", "203.0.113.6", "", "203.0.113.6"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := http.Header{} + if tc.xff != "" { + h.Set("X-Forwarded-For", tc.xff) + } + if got := peerIP(tc.addr, h); got != tc.want { + t.Errorf("peerIP(%q, xff=%q) = %q, want %q", tc.addr, tc.xff, got, tc.want) + } + }) + } +} diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 511d7c2..a903c60 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -139,6 +139,48 @@ test('dropping the game ends it and shows the result', async ({ page }) => { await expect(page.locator('.status .over')).toBeVisible(); }); +test('a placed tile drags from one board cell to another (Stage 17 relocation)', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + const from = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; + + const target = page.locator('[data-cell]:not(.filled):not(.pending)').nth(45); + const fb = await pending.first().boundingBox(); + const tb = await target.boundingBox(); + // Pointer-drag the placed tile to a new cell (mouse events synthesise pointer events). + await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2); + await page.mouse.down(); + await page.mouse.move(tb!.x + tb!.width / 2, tb!.y + tb!.height / 2, { steps: 10 }); + await page.mouse.up(); + + // Still exactly one pending tile (relocated, not duplicated), now at a different cell. + await expect(pending).toHaveCount(1); + const to = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`; + expect(to).not.toBe(from); +}); + +test('chat and word-check open as their own screens and back to the game (Stage 17)', async ({ page }) => { + await openGame(page); + + await page.locator('.burger').click(); + await page.getByRole('button', { name: /^Chat$/ }).click(); + await expect(page).toHaveURL(/\/game\/g1\/chat$/); + await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle + await expect(page.locator('.chat')).toBeVisible(); + await page.locator('.back').click(); // header back chevron returns to the game + await expect(page).toHaveURL(/\/game\/g1$/); + await expect(page.locator('.pane')).toHaveCount(1); + + await page.locator('.burger').click(); + await page.getByRole('button', { name: /Check word/ }).click(); + await expect(page).toHaveURL(/\/game\/g1\/check$/); + await expect(page.locator('.pane')).toHaveCount(1); + await expect(page.locator('.check input')).toBeVisible(); +}); + test('the board-label mode in Settings changes the on-board labels', async ({ page }) => { await openGame(page); // beginner (default) renders split "3× / word" labels. diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index a0ce08b..228eeda 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -122,6 +122,18 @@ test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) await expect(sent).toBeDisabled(); }); +test('game: an opponent who is already a friend shows a disabled "in friends"', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend + await page.locator('.burger').first().click(); + // The in-game friend item is derived from the server's friend list (Stage 17): a friend reads + // a disabled "✓ in friends", not the addable "Add to friends". + const inFriends = page.getByRole('button', { name: /in friends/i }); + await expect(inFriends).toBeVisible(); + await expect(inFriends).toBeDisabled(); + await expect(page.getByRole('button', { name: /Add to friends: Kaya/ })).toHaveCount(0); +}); + test('profile edit disables Save and flags an invalid display name', async ({ page }) => { await loginLobby(page); await page.locator('.burger').first().click(); diff --git a/ui/src/App.svelte b/ui/src/App.svelte index f58157f..a811c03 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { cubicOut } from 'svelte/easing'; import { app, bootstrap } from './lib/app.svelte'; - import { navigate, router } from './lib/router.svelte'; + import { navigate, router, type RouteName } from './lib/router.svelte'; import { t } from './lib/i18n/index.svelte'; import { insideTelegram, telegramBackButton } from './lib/telegram'; import Toast from './components/Toast.svelte'; @@ -15,6 +15,8 @@ import Friends from './screens/Friends.svelte'; import Stats from './screens/Stats.svelte'; import Game from './game/Game.svelte'; + import ChatScreen from './game/ChatScreen.svelte'; + import CheckScreen from './game/CheckScreen.svelte'; onMount(() => { void bootstrap(); @@ -25,15 +27,32 @@ // back chevron is hidden in Telegram (Header.svelte) so only the native one shows. $effect(() => { if (!insideTelegram()) return; - const name = router.route.name; - telegramBackButton(name !== 'lobby' && name !== 'login', () => navigate('/')); + const r = router.route; + // The chat / check sub-screens step back to their game; every other sub-screen to the lobby. + const sub = r.name === 'gameChat' || r.name === 'gameCheck'; + const target = sub ? `/game/${r.params.id}` : '/'; + telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target)); }); // Screen transitions: the lobby is the navigation root. Entering a screen from the // lobby slides it in from the right (forward); returning to the lobby slides the // screen out to the right and reveals the lobby (back). Transitions are local, so // they do not play on the initial mount, and collapse to nothing under reduce-motion. - const dir = $derived(router.route.name === 'lobby' ? 'back' : 'forward'); + // Slide direction by route depth: going deeper (lobby → game → chat/check) slides forward, + // returning to a shallower screen slides back. A plain "lobby is back" rule slid the chat/check + // back-to-the-game the wrong way. dir is read with the previous depth (the effect updates it + // only after the transition has captured its sign). + function routeDepth(name: RouteName): number { + if (name === 'gameChat' || name === 'gameCheck') return 2; + if (name === 'lobby' || name === 'login') return 0; + return 1; + } + const curDepth = $derived(routeDepth(router.route.name)); + let prevDepth = $state(routeDepth(router.route.name)); + const dir = $derived(curDepth < prevDepth ? 'back' : 'forward'); + $effect(() => { + prevDepth = curDepth; + }); const enterSign = $derived(dir === 'forward' ? 1 : -1); const leaveSign = $derived(dir === 'forward' ? -1 : 1); const routeKey = $derived(router.route.name + (router.route.params.id ?? '')); @@ -63,6 +82,10 @@{t('landing.tagline')}
{#if tgLink} - -+ {result.legal + ? t('game.wordLegal', { word: result.word }) + : t('game.wordIllegal', { word: result.word })} +
+ + {/if} +- {checkResult.legal - ? t('game.wordLegal', { word: checkResult.word }) - : t('game.wordIllegal', { word: checkResult.word })} -
- - {/if} -