diff --git a/PLAN.md b/PLAN.md index 1c05946..3cb1e03 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1387,6 +1387,31 @@ 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 soft-keyboard** is a **bottom-sheet** `Modal` lifted above the keyboard by a + `transform` driven by `visualViewport` (compositor-only — the board behind and the sheet + no longer relayout as the keyboard animates). iOS-specific; needs on-device fine-tuning. + The native `Keyboard.setResizeMode('none')` path waits for Capacitor (not yet wired). + - **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/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/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..7abf0a2 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -139,6 +139,29 @@ 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('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/Landing.svelte b/ui/src/Landing.svelte index 16587da..64fd2bd 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -74,9 +74,8 @@

{about.title}

{t('landing.tagline')}

{#if tgLink} - - - {t('landing.playTelegram')} + + {/if} @@ -192,21 +191,20 @@ color: var(--text-muted); font-size: 1.05rem; } - .play { + /* The Telegram entry is just the bigger logo (no button chrome, no caption); the link + keeps an aria-label for assistive tech (Stage 17). */ + .tg { align-self: center; display: inline-flex; - align-items: center; - gap: 9px; - padding: 12px 24px; - border-radius: var(--radius-sm); - font-weight: 700; - text-decoration: none; - background: var(--accent); - color: var(--accent-text); - margin-top: 6px; + margin-top: 8px; + border-radius: 50%; } - .play img { + .tg img { display: block; + transition: transform 0.12s ease; + } + .tg:hover img { + transform: scale(1.06); } .info { background: var(--surface-2); diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index e2b5a2c..34b6529 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -89,11 +89,25 @@ transform: rotate(45deg); margin-left: 3px; } - /* Telegram fullscreen: its native nav overlays the top of the viewport (height - --tg-content-top, set from the content-safe-area inset). Drop the header content below the - nav and lift the menu up into the nav band, centred — Telegram's own controls sit in the - corners, leaving the centre clear (Stage 17). */ + /* Telegram fullscreen: TG's native nav overlays a band of height --tg-content-top at the top + of the viewport. Pull our title + menu up into the BOTTOM of that band and centre them as a + pair (hamburger right of the title) so they line up with Telegram's own nav controls rather + than floating above them (Stage 17). */ :global(html.tg-fullscreen) .bar { - padding-top: var(--tg-content-top); + min-height: var(--tg-content-top); + box-sizing: border-box; + align-items: flex-end; + justify-content: center; + padding-top: 0; + padding-bottom: 6px; + } + :global(html.tg-fullscreen) .spacer { + display: none; + } + :global(html.tg-fullscreen) h1 { + flex: 0 1 auto; + } + :global(html.tg-fullscreen) .end { + min-width: 0; } diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte index 7fa7379..5317616 100644 --- a/ui/src/components/Modal.svelte +++ b/ui/src/components/Modal.svelte @@ -5,8 +5,15 @@ title = '', onclose, overlayKeyboard = false, + bottomSheet = false, children, - }: { title?: string; onclose?: () => void; overlayKeyboard?: boolean; children?: Snippet } = $props(); + }: { + title?: string; + onclose?: () => void; + overlayKeyboard?: boolean; + bottomSheet?: boolean; + children?: Snippet; + } = $props(); // Track the visual viewport so the backdrop covers only the area above an open // mobile keyboard: dvh alone shrinks the sheet but the fixed, layout-viewport @@ -14,14 +21,21 @@ // visualViewport keeps the sheet (and the start of a chat) fully on screen. // overlayKeyboard opts out: the sheet is small and top-anchored, so the keyboard // simply overlays the empty lower area — no resize, no relayout jank (e.g. check word). + // bottomSheet anchors a tall sheet (the chat) to the bottom and lifts it above the + // keyboard with a transform (kb), driven by the visual viewport — a compositor-only + // move, so neither the page behind nor the sheet relayouts as the keyboard animates + // (Stage 17). The backdrop is not resized in this mode (no per-event reflow). let vh = $state(0); let top = $state(0); + let kb = $state(0); $effect(() => { const vv = typeof window !== 'undefined' ? window.visualViewport : null; if (!vv || overlayKeyboard) return; const update = () => { vh = vv.height; top = vv.offsetTop; + // Soft-keyboard height: the layout viewport minus the visible viewport. + kb = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); }; update(); vv.addEventListener('resize', update); @@ -38,11 +52,19 @@
onclose?.()} > -