Compare commits
29 Commits
v1.6.0
...
3a18e683ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a18e683ca | |||
| 00441e3657 | |||
| 3b7c7c077e | |||
| e900d592f8 | |||
| d76f1f4026 | |||
| 0ea9764a0d | |||
| 6a5ce12fab | |||
| da17c18895 | |||
| 303348ed39 | |||
| a06dfe00fc | |||
| a88678c5c6 | |||
| 500a01cf97 | |||
| d9ede77e2f | |||
| 65c194264c | |||
| 13c22734ee | |||
| 7dabcd1317 | |||
| 93d086a8a3 | |||
| b03e012011 | |||
| 2495446a47 | |||
| 35705f7d1e | |||
| 4f0cc81dfb | |||
| 2ba7cc3086 | |||
| 29b6c7e4d8 | |||
| 8de9fb1ecd | |||
| 8a5a5d6c4d | |||
| ea931c6680 | |||
| d0f60ee41d | |||
| b84bd1297e | |||
| 0fb6004a8b |
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
name: deploy-check
|
||||||
|
description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pre-deploy runtime-constraint check
|
||||||
|
|
||||||
|
Triggered before shipping anything that touches the deploy contour (Dockerfiles,
|
||||||
|
`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service,
|
||||||
|
edge config). The worst frictions in this repo were never logic bugs — they were
|
||||||
|
**environment mismatches that crashed a live env and forced a redesign**. Run this
|
||||||
|
list against the diff first; turn crash-and-redesign into a single pass.
|
||||||
|
|
||||||
|
This checklist is a prompt, **not** the source of truth. The canonical detail
|
||||||
|
lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the
|
||||||
|
agent memory files referenced below — read them when an item is in play, and add a
|
||||||
|
new class here when a new incident teaches one.
|
||||||
|
|
||||||
|
## How to run it
|
||||||
|
|
||||||
|
1. `git diff <base>...HEAD --stat` to see what the change actually touches.
|
||||||
|
2. For every risk class below that the diff touches, perform the **Check** and
|
||||||
|
report PASS / FAIL with the exact file:line to fix. Skip classes the diff does
|
||||||
|
not touch — say which you skipped and why.
|
||||||
|
3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically
|
||||||
|
passed with a dead backend (it only checked static landing+gateway). Verify the
|
||||||
|
real feature live (`/readyz`, the actual flow) after deploy, not just the green.
|
||||||
|
|
||||||
|
## Risk classes
|
||||||
|
|
||||||
|
### 1. Container user — distroless nonroot UID 65532
|
||||||
|
- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at
|
||||||
|
boot with "permission denied" — service images run UID 65532.
|
||||||
|
- **Check:** any new/changed mounted secret, key, or config file must be readable by
|
||||||
|
UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new
|
||||||
|
volume mounts. (memory: `distroless-nonroot-mounted-secrets`)
|
||||||
|
|
||||||
|
### 2. Caddy header pipeline ordering
|
||||||
|
- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went
|
||||||
|
empty); it passed CI and only showed up live.
|
||||||
|
- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up`
|
||||||
|
ordering for every affected route, and test the tripwire/route on the live
|
||||||
|
contour, not just CI.
|
||||||
|
|
||||||
|
### 3. Edge Alt-Svc / HTTP3
|
||||||
|
- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed
|
||||||
|
(docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead
|
||||||
|
QUIC before falling back to h2 — Mini App "hangs on load".
|
||||||
|
- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if
|
||||||
|
UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`,
|
||||||
|
`docs/EDGE_HTTP3.md`)
|
||||||
|
|
||||||
|
### 4. Prod caddy config recreate
|
||||||
|
- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change
|
||||||
|
(pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but
|
||||||
|
stayed inert until a manual `docker restart`.
|
||||||
|
- **Check:** a config-only edge change must `--force-recreate` caddy in
|
||||||
|
`prod-deploy.sh` `roll()`; never trust deploy-green for edge config.
|
||||||
|
(memory: `prod-deploy-caddy-config-recreate`)
|
||||||
|
|
||||||
|
### 5. DICT_VERSION / dictionary boot
|
||||||
|
- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the
|
||||||
|
live env when bumped on a seeded volume; it had to be redesigned to "marker-wins".
|
||||||
|
- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must
|
||||||
|
keep marker-wins semantics and survive a seeded volume **and** an image rollback.
|
||||||
|
`DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict
|
||||||
|
goes live via the admin console upload, not a redeploy. (memory:
|
||||||
|
`dict-version-deploy-verify`, `contour-schema-change-wipe`)
|
||||||
|
|
||||||
|
### 6. Migrations — expand-contract + rollback safety
|
||||||
|
- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB
|
||||||
|
ahead of rolled-back code).
|
||||||
|
- **Check:** migrations must be **expand-contract** (backward-compatible). A schema
|
||||||
|
change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the
|
||||||
|
**test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` +
|
||||||
|
backend restart (new code vs old persisted DB), else the contour breaks. (memory:
|
||||||
|
`contour-schema-change-wipe`)
|
||||||
|
|
||||||
|
### 7. Telegram permission model
|
||||||
|
- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit
|
||||||
|
denies, not default-deny; inverting it broke access.
|
||||||
|
- **Check:** any change to the Telegram permission / relay logic preserves the
|
||||||
|
AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`)
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact
|
||||||
|
file:line and the fix. If every touched class passes, say so plainly and name the
|
||||||
|
post-deploy live check to run (not just "CI green").
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# VK Mini App / VK Games — integration reference
|
||||||
|
|
||||||
|
Captured research + our implementation map, so a future session does not need to re-fetch
|
||||||
|
the VK docs. Authoritative external source: <https://dev.vk.com/> (the `dev.vk.com` portal
|
||||||
|
does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM
|
||||||
|
reference repos cited at the end and verified against our own Go implementation).
|
||||||
|
|
||||||
|
A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside
|
||||||
|
vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android).
|
||||||
|
We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/`
|
||||||
|
entry — the single-origin, path-routed model.
|
||||||
|
|
||||||
|
## 1. Embedding model
|
||||||
|
|
||||||
|
- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid
|
||||||
|
cert required), appending the signed launch parameters as the **URL query string**.
|
||||||
|
- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same).
|
||||||
|
- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the
|
||||||
|
configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the
|
||||||
|
clickjacking note in §Security.)
|
||||||
|
- URL must match the settings exactly (scheme, host, no stray `www`/whitespace).
|
||||||
|
|
||||||
|
## 2. Launch parameters (URL query)
|
||||||
|
|
||||||
|
VK appends these to the iframe `src`. The `vk_*` set is what the signature covers.
|
||||||
|
|
||||||
|
| Param | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `vk_user_id` | signed-in VK user numeric id — **the identity** |
|
||||||
|
| `vk_app_id` | our registered app id |
|
||||||
|
| `vk_is_app_user` | 0/1 — user authorized/installed the app |
|
||||||
|
| `vk_are_notifications_enabled` | 0/1 |
|
||||||
|
| `vk_language` | 2-letter UI language (`ru`, `en`, …) |
|
||||||
|
| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … |
|
||||||
|
| `vk_ts` | unix seconds when VK generated the params |
|
||||||
|
| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) |
|
||||||
|
| `vk_access_token_settings` | comma-separated granted scopes (often empty) |
|
||||||
|
| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual |
|
||||||
|
| `sign` | **the signature** (see §3) — NOT part of the signed set |
|
||||||
|
|
||||||
|
Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`.
|
||||||
|
|
||||||
|
The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via
|
||||||
|
`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only.
|
||||||
|
|
||||||
|
## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex
|
||||||
|
|
||||||
|
Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference):
|
||||||
|
|
||||||
|
1. Collect the query params whose key starts with `vk_` (exclude `sign`).
|
||||||
|
2. Sort by key (alphabetical).
|
||||||
|
3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's
|
||||||
|
reference serialization for the constrained launch-param charset).
|
||||||
|
4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»**
|
||||||
|
(protected / secure key, a.k.a. client_secret) from the app settings.
|
||||||
|
5. **base64url, no padding** (`+`→`-`, `/`→`_`, strip `=`).
|
||||||
|
6. Constant-time compare against `sign`.
|
||||||
|
|
||||||
|
VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce
|
||||||
|
freshness — the minted server session is the short-lived credential; a replay only
|
||||||
|
re-authenticates the same `vk_user_id`.
|
||||||
|
|
||||||
|
Verified against the official doc <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
|
||||||
|
(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by
|
||||||
|
independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret
|
||||||
|
`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does
|
||||||
|
NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our
|
||||||
|
`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors,
|
||||||
|
incl. the `%2C` comma case for `vk_access_token_settings`).
|
||||||
|
|
||||||
|
## 4. VK Bridge (client SDK)
|
||||||
|
|
||||||
|
`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use:
|
||||||
|
|
||||||
|
- `VKWebAppInit` — **required**: tells VK the Mini App loaded (dismisses VK's loading cover).
|
||||||
|
- `VKWebAppGetUserInfo` — `{ id, first_name, last_name, photo_200, … }`; no extra scope needed.
|
||||||
|
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
|
||||||
|
verification — read `window.location.search` instead, which carries `sign`).
|
||||||
|
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
|
||||||
|
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
|
||||||
|
in the desktop VK iframe). **Used.**
|
||||||
|
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
|
||||||
|
blocked. **Used** as the copy-code / copy-link path.
|
||||||
|
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
|
||||||
|
"auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
|
||||||
|
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used —
|
||||||
|
the bottom home-bar safe area is handled by CSS `env(safe-area-inset-*)` (viewport-fit=cover).
|
||||||
|
|
||||||
|
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
|
||||||
|
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
|
||||||
|
it **lazily** so the pure URL helpers stay node-test-importable.
|
||||||
|
|
||||||
|
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
|
||||||
|
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
|
||||||
|
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
|
||||||
|
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
|
||||||
|
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
|
||||||
|
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
|
||||||
|
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
|
||||||
|
channel (e.g. an invite API) ever delivers a payload.
|
||||||
|
|
||||||
|
## 5. Test mode (to verify before moderation)
|
||||||
|
|
||||||
|
1. App already registered (we have the App ID).
|
||||||
|
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<id>`):
|
||||||
|
- Category = **Игра** (Game).
|
||||||
|
- **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing,
|
||||||
|
prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same.
|
||||||
|
- Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret).
|
||||||
|
- Add own VK id to **testers**; open in test mode.
|
||||||
|
3. Test mode = visible only to admins/testers, no payments processed.
|
||||||
|
|
||||||
|
## 6. Auth / identity (our model)
|
||||||
|
|
||||||
|
- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`,
|
||||||
|
auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the
|
||||||
|
display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty).
|
||||||
|
- No VK access token / VK API call needed for the launch+login MVP.
|
||||||
|
|
||||||
|
## 7. Payments / monetization
|
||||||
|
|
||||||
|
VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned.
|
||||||
|
|
||||||
|
## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит»)
|
||||||
|
|
||||||
|
- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru),
|
||||||
|
a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game,
|
||||||
|
NOT "Scrabble". The repo name is internal and irrelevant to moderation.
|
||||||
|
- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data:
|
||||||
|
`vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description.
|
||||||
|
- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a
|
||||||
|
dictionary game, flag if moderation asks.
|
||||||
|
- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered.
|
||||||
|
- Moderation reviews after submission (commonly ~24–72h); rejects on violence/hate/sexual/illegal
|
||||||
|
content or IP infringement — none apply.
|
||||||
|
|
||||||
|
## 9. Platforms
|
||||||
|
|
||||||
|
Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge
|
||||||
|
methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation
|
||||||
|
without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client
|
||||||
|
(not reproducible in Playwright — like the iOS gesture caveats).
|
||||||
|
|
||||||
|
## 10. Our implementation map (what to touch for VK)
|
||||||
|
|
||||||
|
- **Wire**: `pkg/fbs/scrabble.fbs` → `VKLoginRequest{ params, browser_tz, display_name }`
|
||||||
|
(regen: `make -C pkg fbs` + `pnpm -C ui codegen`).
|
||||||
|
- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk`
|
||||||
|
(registered via `WithVKAuth(secret)` option; `DomainCode` → `invalid_vk_params`),
|
||||||
|
`internal/backendclient` `VKAuth` → `POST /api/v1/internal/sessions/vk`,
|
||||||
|
config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`.
|
||||||
|
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
|
||||||
|
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
|
||||||
|
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
|
||||||
|
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
|
||||||
|
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
|
||||||
|
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
|
||||||
|
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
|
||||||
|
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
|
||||||
|
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
|
||||||
|
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
|
||||||
|
(`PROD_…` secret, deploy-main).
|
||||||
|
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
|
||||||
|
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
|
||||||
|
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- VKCOM/vk-bridge — <https://github.com/VKCOM/vk-bridge>
|
||||||
|
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
|
||||||
|
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
|
||||||
|
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
|
||||||
|
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
|
||||||
@@ -31,7 +31,7 @@ on:
|
|||||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||||
env:
|
env:
|
||||||
DICT_VERSION: v1.3.0
|
DICT_VERSION: v1.3.1
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||||
@@ -261,6 +261,9 @@ jobs:
|
|||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||||
|
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||||
|
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
||||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ jobs:
|
|||||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||||
|
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
||||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||||
@@ -136,6 +137,7 @@ jobs:
|
|||||||
export APP_VERSION='$TAG'
|
export APP_VERSION='$TAG'
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||||
EOF
|
EOF
|
||||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import (
|
|||||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
// Identity kinds recognised by the backend. Telegram and VK are platform identities,
|
||||||
// alongside platform identities; its confirmed flag is driven by the email
|
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
|
||||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
|
||||||
// robot opponent is a durable account bound to one robot identity.
|
// each pooled robot opponent is a durable account bound to one robot identity.
|
||||||
const (
|
const (
|
||||||
KindTelegram = "telegram"
|
KindTelegram = "telegram"
|
||||||
|
KindVK = "vk"
|
||||||
KindEmail = "email"
|
KindEmail = "email"
|
||||||
KindRobot = "robot"
|
KindRobot = "robot"
|
||||||
)
|
)
|
||||||
@@ -185,6 +186,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
|
|||||||
return acc, created, err
|
return acc, created, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
|
||||||
|
// whether this call created it (first contact). On first contact only, it seeds the new
|
||||||
|
// account's preferred language from the VK languageCode (vk_language, when it maps to a
|
||||||
|
// supported language) and its display name sanitized from displayName — the name read
|
||||||
|
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
|
||||||
|
// falling back to a generated placeholder when it yields no letters; an already-existing
|
||||||
|
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||||
|
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
|
||||||
|
// Pre-check whether the identity already exists so the caller can act on first
|
||||||
|
// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
|
||||||
|
// that one call.
|
||||||
|
_, err := s.findByIdentity(ctx, KindVK, externalID)
|
||||||
|
created := errors.Is(err, ErrNotFound)
|
||||||
|
if err != nil && !created {
|
||||||
|
return Account{}, false, err
|
||||||
|
}
|
||||||
|
seed := vkSeed(languageCode, displayName)
|
||||||
|
seed.timeZone = seedZone(browserTZ)
|
||||||
|
acc, err := s.provision(ctx, KindVK, externalID, seed)
|
||||||
|
return acc, created, err
|
||||||
|
}
|
||||||
|
|
||||||
// provision finds the account for (kind, externalID) or creates it with seed,
|
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||||
// collapsing a concurrent-create race on the identity unique constraint into a
|
// collapsing a concurrent-create race on the identity unique constraint into a
|
||||||
// re-read of the winner's account.
|
// re-read of the winner's account.
|
||||||
@@ -258,6 +281,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
|||||||
return seed
|
return seed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vkSeed derives the create-time seed from VK launch fields: a supported preferred
|
||||||
|
// language from languageCode (vk_language, normally a 2-letter code) and a display name
|
||||||
|
// from displayName (sanitized to the editable format), falling back to a generated
|
||||||
|
// placeholder in the seeded language when the name yields no usable letters. Unlike
|
||||||
|
// telegramSeed there is no @username fallback — VK provides only the name.
|
||||||
|
func vkSeed(languageCode, displayName string) provisionSeed {
|
||||||
|
var seed provisionSeed
|
||||||
|
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||||
|
seed.preferredLanguage = lang
|
||||||
|
}
|
||||||
|
name := sanitizeDisplayName(displayName)
|
||||||
|
if name == "" {
|
||||||
|
name = placeholderDisplayName(seed.preferredLanguage)
|
||||||
|
}
|
||||||
|
seed.displayName = name
|
||||||
|
return seed
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
||||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||||
@@ -421,7 +462,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
|||||||
table.Identities.Kind,
|
table.Identities.Kind,
|
||||||
table.Identities.ExternalID,
|
table.Identities.ExternalID,
|
||||||
table.Identities.Confirmed,
|
table.Identities.Confirmed,
|
||||||
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
|
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
|
||||||
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
|||||||
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVKSeed covers the pure mapping from VK launch fields to the create-time account
|
||||||
|
// seed: supported-language detection from vk_language (bare and region-tagged) and the
|
||||||
|
// display name sanitized from the client-supplied name. Unlike Telegram there is no
|
||||||
|
// @username fallback — VK provides only the name.
|
||||||
|
func TestVKSeed(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
languageCode, displayName string
|
||||||
|
wantLang, wantName string
|
||||||
|
}{
|
||||||
|
"ru bare": {"ru", "Иван", "ru", "Иван"},
|
||||||
|
"en region-tagged": {"en-US", "John", "en", "John"},
|
||||||
|
"full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"},
|
||||||
|
"unknown language": {"uk", "Тарас", "", "Тарас"},
|
||||||
|
"empty language": {"", "Neo", "", "Neo"},
|
||||||
|
"trimmed": {" RU ", " Anna ", "ru", "Anna"},
|
||||||
|
"emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"},
|
||||||
|
}
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
got := vkSeed(tc.languageCode, tc.displayName)
|
||||||
|
if got.preferredLanguage != tc.wantLang {
|
||||||
|
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
|
||||||
|
}
|
||||||
|
if got.displayName != tc.wantName {
|
||||||
|
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a
|
||||||
|
// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
|
||||||
|
func TestVKSeedPlaceholder(t *testing.T) {
|
||||||
|
cases := map[string]struct {
|
||||||
|
languageCode, displayName string
|
||||||
|
wantRe string
|
||||||
|
}{
|
||||||
|
"en empty": {"en", "", `^Player-\d{5}$`},
|
||||||
|
"ru empty": {"ru", "", `^Игрок-\d{5}$`},
|
||||||
|
"default en": {"uk", "", `^Player-\d{5}$`},
|
||||||
|
"name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`},
|
||||||
|
}
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
got := vkSeed(tc.languageCode, tc.displayName).displayName
|
||||||
|
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
|
||||||
|
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
|||||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||||
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||||
|
|||||||
@@ -142,6 +142,11 @@
|
|||||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .VKID}}
|
||||||
|
<section class="panel"><h2>VK</h2>
|
||||||
|
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
<section class="panel"><h2>Games</h2>
|
<section class="panel"><h2>Games</h2>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||||
|
|||||||
@@ -162,7 +162,11 @@ type UserDetailView struct {
|
|||||||
Stats StatsRow
|
Stats StatsRow
|
||||||
Identities []IdentityRow
|
Identities []IdentityRow
|
||||||
Games []GameRow
|
Games []GameRow
|
||||||
|
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
||||||
|
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
||||||
|
// user id with a link to the VK profile (there is no VK messaging to drive).
|
||||||
TelegramID string
|
TelegramID string
|
||||||
|
VKID string
|
||||||
ConnectorEnabled bool
|
ConnectorEnabled bool
|
||||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||||
// time (min/mean/max), empty when the account has no timed move.
|
// time (min/mean/max), empty when the account has no timed move.
|
||||||
|
|||||||
@@ -154,6 +154,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
|
||||||
|
// language, display name and time zone from the launch fields / detected offset, records
|
||||||
|
// the vk identity as confirmed (a platform identity), and never overwrites an existing
|
||||||
|
// account on a later launch. It also exercises the widened identities.kind CHECK — a
|
||||||
|
// 'vk' row must insert.
|
||||||
|
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
ext := "vk-" + uuid.NewString()
|
||||||
|
|
||||||
|
acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision vk: %v", err)
|
||||||
|
}
|
||||||
|
if !created {
|
||||||
|
t.Error("created = false on first contact, want true")
|
||||||
|
}
|
||||||
|
if acc.PreferredLanguage != "ru" {
|
||||||
|
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||||
|
}
|
||||||
|
if acc.DisplayName != "Иван Петров" {
|
||||||
|
t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
|
||||||
|
}
|
||||||
|
if acc.TimeZone != "+03:00" {
|
||||||
|
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
|
||||||
|
}
|
||||||
|
// A VK identity is a platform identity: confirmed on insert.
|
||||||
|
if !identityConfirmed(t, account.KindVK, ext) {
|
||||||
|
t.Error("vk identity must be confirmed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later launch with different fields returns the same account, unchanged.
|
||||||
|
again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-provision vk: %v", err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
t.Error("created = true on a repeat launch, want false")
|
||||||
|
}
|
||||||
|
if again.ID != acc.ID {
|
||||||
|
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||||
|
}
|
||||||
|
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
|
||||||
|
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
||||||
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
||||||
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
|
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Admit the 'vk' platform identity (VK Mini App users) into the identities.kind check
|
||||||
|
-- constraint, alongside telegram/email/robot. Expand-contract: widening the allowed set
|
||||||
|
-- is backward-compatible, so a backend image rollback stays DB-safe — the older code
|
||||||
|
-- simply never writes a 'vk' row. The table shape is unchanged (only the CHECK), so the
|
||||||
|
-- generated go-jet model is not regenerated.
|
||||||
|
|
||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||||
|
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'vk'::text, 'email'::text, 'robot'::text])));
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||||
|
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text])));
|
||||||
@@ -27,6 +27,7 @@ func (s *Server) registerRoutes() {
|
|||||||
if s.sessions != nil && s.accounts != nil {
|
if s.sessions != nil && s.accounts != nil {
|
||||||
in := s.internal
|
in := s.internal
|
||||||
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
||||||
|
in.POST("/sessions/vk", s.handleVKAuth)
|
||||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||||
|
|||||||
@@ -362,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||||
view.TelegramID = tg
|
view.TelegramID = tg
|
||||||
}
|
}
|
||||||
|
if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
|
||||||
|
view.VKID = vk
|
||||||
|
}
|
||||||
if games, err := s.games.ListForAccount(ctx, id); err == nil {
|
if games, err := s.games.ListForAccount(ctx, id); err == nil {
|
||||||
for _, g := range games {
|
for _, g := range games {
|
||||||
view.Games = append(view.Games, gameRow(g))
|
view.Games = append(view.Games, gameRow(g))
|
||||||
|
|||||||
@@ -65,6 +65,37 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
||||||
|
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
||||||
|
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
||||||
|
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
|
||||||
|
// offset) seeds its time zone. All seeds apply on first contact only.
|
||||||
|
type vkAuthRequest struct {
|
||||||
|
ExternalID string `json:"external_id"`
|
||||||
|
LanguageCode string `json:"language_code"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
||||||
|
// session for it, seeding a new account's language and display name from the supplied VK
|
||||||
|
// fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation
|
||||||
|
// or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the
|
||||||
|
// MVP carries no launch deep link.
|
||||||
|
func (s *Server) handleVKAuth(c *gin.Context) {
|
||||||
|
var req vkAuthRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||||
|
abortBadRequest(c, "external_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ)
|
||||||
|
if err != nil {
|
||||||
|
s.abortErr(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mintSession(c, acc)
|
||||||
|
}
|
||||||
|
|
||||||
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
||||||
type pushTargetRequest struct {
|
type pushTargetRequest struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
|||||||
@@ -55,3 +55,9 @@ TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a vari
|
|||||||
TELEGRAM_MINIAPP_URL= # required
|
TELEGRAM_MINIAPP_URL= # required
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|
||||||
|
# --- VK Mini App ------------------------------------------------------------
|
||||||
|
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||||
|
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||||
|
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||||
|
GATEWAY_VK_APP_SECRET=
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
||||||
# every operator surface under /_gm (the backend-rendered admin console and the
|
# every operator surface under /_gm (the backend-rendered admin console and the
|
||||||
# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to
|
# Grafana subpath); the game SPA (/app/, /telegram/, /vk/) and the Connect edge go to
|
||||||
# the gateway; the catch-all — notably the public landing at / — goes to the
|
# the gateway; the catch-all — notably the public landing at / — goes to the
|
||||||
# static landing container, so stray traffic never reaches the Go edge.
|
# static landing container, so stray traffic never reaches the Go edge.
|
||||||
# Mirrors ../galaxy-game's /_gm model.
|
# Mirrors ../galaxy-game's /_gm model.
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||||
@gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/*
|
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /scrabble.edge.v1.Gateway/*
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
header_up -X-Scrabble-Honeypot
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ services:
|
|||||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||||
# Telegram auth validates against the home validator (plaintext, internal).
|
# Telegram auth validates against the home validator (plaintext, internal).
|
||||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||||
|
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||||
|
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||||
|
# the VK auth path (auth.vk is then unregistered).
|
||||||
|
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
||||||
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
||||||
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
||||||
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||||
|
|||||||
+25
-11
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
|
|||||||
users, a weighted fair rotation — §10),
|
users, a weighted fair rotation — §10),
|
||||||
and a client **board-style** setting (bonus-label
|
and a client **board-style** setting (bonus-label
|
||||||
mode). The visual/interaction design system is documented in
|
mode). The visual/interaction design system is documented in
|
||||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
|
||||||
|
tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
|
||||||
|
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
|
||||||
|
Telegram's native **Settings** button into the in-app settings, and syncs the
|
||||||
|
device-independent display preferences (theme, reduce-motion, board labels — **not** the
|
||||||
|
interface language) across the user's Telegram devices via **CloudStorage**.
|
||||||
- **`platform/telegram`** — the Telegram side-service (module
|
- **`platform/telegram`** — the Telegram side-service (module
|
||||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||||
(**one bot**, one optional game channel, §3):
|
(**one bot**, one optional game channel, §3):
|
||||||
@@ -144,15 +149,23 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
|
|
||||||
- The gateway validates the originating credential **once** — Telegram `initData`
|
- The gateway validates the originating credential **once** — Telegram `initData`
|
||||||
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
|
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
|
||||||
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
|
the HMAC secret — so it never reaches the gateway), a **VK Mini App** launch (verified
|
||||||
|
**in-process** by `gateway/internal/vkauth`: HMAC-SHA256 over the signed `vk_*` params
|
||||||
|
under the VK app's protected key `GATEWAY_VK_APP_SECRET`, base64url — a pure offline check,
|
||||||
|
as VK signing needs no API round-trip, so no side-service), an email-code login, or a guest
|
||||||
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
||||||
Telegram contact seeds the new account's language (from the launch `language_code`)
|
Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
|
||||||
and display name (§4). The validator runs on the main host and never reaches the Bot
|
`vk_language`) and display name (§4; VK omits the name from the signed params, so the client
|
||||||
API, so login does not depend on Telegram or the remote bot being up (§10, §12).
|
reads it via `VKWebAppGetUserInfo` as an unsigned, cosmetic seed). The validator runs on the
|
||||||
|
main host and never reaches the Bot API, so login does not depend on Telegram or the remote
|
||||||
|
bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram's
|
||||||
|
`auth_date`), so freshness is not enforced — the minted session is the short-lived credential,
|
||||||
|
and a replay only re-authenticates the same `vk_user_id`.
|
||||||
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
||||||
game channel), split into a home **validator** and a remote **bot** that share the
|
game channel), split into a home **validator** and a remote **bot** that share the
|
||||||
token. `ValidateInitData` (the validator) validates `initData` against that single
|
token. `ValidateInitData` (the validator) validates `initData` against that single
|
||||||
token and returns only the Telegram user identity — there is no per-bot "service
|
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
|
||||||
|
user identity — there is no per-bot "service
|
||||||
language" and no supported-languages set on the wire. The bot's chat messages and
|
language" and no supported-languages set on the wire. The bot's chat messages and
|
||||||
out-of-app push are
|
out-of-app push are
|
||||||
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
||||||
@@ -972,7 +985,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
|||||||
| Concern | Enforced by |
|
| Concern | Enforced by |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
|
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
@@ -1040,10 +1053,11 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
|
|||||||
Single public origin, path-routed. The Vite build has two entries: a lightweight
|
Single public origin, path-routed. The Vite build has two entries: a lightweight
|
||||||
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
|
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
|
||||||
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
||||||
`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||||
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
||||||
of redirecting away); a stray hit on the gateway's `/`
|
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
||||||
308-redirects to `/app/`. The **landing** ships in its own static container: the
|
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
|
||||||
|
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
||||||
@@ -1052,7 +1066,7 @@ static file serving and never reaches the Go edge. Hash-named `/assets/*` are se
|
|||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||||
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
||||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||||
**admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the
|
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
|
||||||
catch-all — notably the landing at `/` — goes to the landing container. The
|
catch-all — notably the landing at `/` — goes to the landing container. The
|
||||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||||
|
|||||||
+12
-4
@@ -22,7 +22,7 @@ costs nothing when the rack has no legal move. The word-check accepts only the
|
|||||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
A public **landing page** at the site root introduces the game, switches language and
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
||||||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||||
|
|
||||||
### Identity & sessions
|
### Identity & sessions
|
||||||
@@ -30,10 +30,15 @@ A player arrives from a platform (Telegram first), via email login, or as an
|
|||||||
ephemeral guest. The gateway validates the credential once and mints a thin
|
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||||||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||||||
the Telegram colours, and — on first contact — seeds the new account's interface
|
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
|
||||||
|
the device safe-area, and — on first contact — seeds the new account's interface
|
||||||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||||||
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||||||
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||||||
|
A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters
|
||||||
|
(verified by the gateway), and on first contact seeds the new account's interface language from
|
||||||
|
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
||||||
|
the name in the signed launch). The same quiet-retry "couldn't load" screen applies inside VK.
|
||||||
Telegram runs a **single bot**: every player uses
|
Telegram runs a **single bot**: every player uses
|
||||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
@@ -120,7 +125,7 @@ and any incidental perpendicular words are ignored and not scored — while on i
|
|||||||
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
||||||
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
||||||
formed by inviting players from the friend list (an invitation, like a friend code,
|
formed by inviting players from the friend list (an invitation, like a friend code,
|
||||||
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
|
is shareable as a Telegram or VK deep link that opens it directly): the inviter chooses the
|
||||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
expires after seven days.
|
expires after seven days.
|
||||||
|
|
||||||
@@ -249,7 +254,10 @@ is first created — so robot games are timed correctly before you ever open thi
|
|||||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||||
merge".
|
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||||
|
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||||
|
reduce-motion — not the interface language, which follows your account) sync across your
|
||||||
|
Telegram devices.
|
||||||
|
|
||||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||||
|
|||||||
+15
-5
@@ -23,7 +23,7 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||||
|
|
||||||
### Личность и сессии
|
### Личность и сессии
|
||||||
@@ -31,11 +31,17 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
|
||||||
|
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
|
||||||
|
интерфейса нового аккаунта по
|
||||||
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||||
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||||
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||||
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
|
||||||
|
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
|
||||||
|
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
|
||||||
|
так как VK не кладёт имя в подписанный запуск). Тот же экран тихого повтора «не удалось
|
||||||
|
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||||
@@ -125,7 +131,7 @@ _Вход сейчас только через провайдера, поэто
|
|||||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
можно отправить deep-link'ом в Telegram или VK, который откроет его сразу): инициатор
|
||||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||||
ответа приглашение протухает через семь дней.
|
ответа приглашение протухает через семь дней.
|
||||||
|
|
||||||
@@ -255,7 +261,11 @@ UTC; при создании аккаунта она подставляется
|
|||||||
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
||||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
|
||||||
|
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||||
|
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||||
|
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||||
|
Telegram.
|
||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
|
|||||||
+11
-3
@@ -7,7 +7,7 @@ thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
|||||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||||
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
|
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||||
in the deployed contour the front caddy owns `/_gm` (see
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
@@ -29,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
|
|||||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||||
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
|
||||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
|
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/
|
||||||
```
|
```
|
||||||
|
|
||||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||||
@@ -54,7 +54,14 @@ them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). Wh
|
|||||||
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
|
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
|
||||||
is unset the bot channel (out-of-app push + admin relay) is disabled.
|
is unset the bot channel (out-of-app push + admin relay) is disabled.
|
||||||
|
|
||||||
The message-type catalog: `auth.telegram`, `auth.guest`,
|
`auth.vk` validates a VK Mini App launch **in-process** (`internal/vkauth`): it verifies the
|
||||||
|
signed `vk_*` launch parameters against the VK app's protected key (`GATEWAY_VK_APP_SECRET`) —
|
||||||
|
HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API
|
||||||
|
round-trip, then forwards the trusted `vk_user_id` (and the client-read display name, which VK omits
|
||||||
|
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||||
|
(`auth.vk` is unregistered).
|
||||||
|
|
||||||
|
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
||||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||||
live events
|
live events
|
||||||
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
|||||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
||||||
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
||||||
|
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
||||||
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
||||||
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
||||||
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
||||||
}
|
}
|
||||||
|
|
||||||
registry := transcode.NewRegistry(backend, validator)
|
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
|||||||
@@ -201,6 +201,23 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new
|
||||||
|
// account's preferred language from languageCode (the vk_language hint), its display
|
||||||
|
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
||||||
|
// name from the signed launch params) and its time zone from browserTz (the client's
|
||||||
|
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
||||||
|
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
|
||||||
|
var out SessionResp
|
||||||
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||||
|
map[string]string{
|
||||||
|
"external_id": externalID,
|
||||||
|
"language_code": languageCode,
|
||||||
|
"display_name": displayName,
|
||||||
|
"browser_tz": browserTz,
|
||||||
|
}, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
||||||
// external_id (empty when they have no Telegram identity), preferred language, and
|
// external_id (empty when they have no Telegram identity), preferred language, and
|
||||||
// whether they confined notifications to the in-app stream.
|
// whether they confined notifications to the in-app stream.
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ type Config struct {
|
|||||||
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
||||||
// Login Widget data. Empty disables the telegram auth path.
|
// Login Widget data. Empty disables the telegram auth path.
|
||||||
ValidatorAddr string
|
ValidatorAddr string
|
||||||
|
// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
|
||||||
|
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
|
||||||
|
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
||||||
|
VKAppSecret string
|
||||||
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
||||||
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||||
BotLink BotLinkConfig
|
BotLink BotLinkConfig
|
||||||
@@ -169,6 +173,7 @@ func Load() (Config, error) {
|
|||||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
Abuse: DefaultAbuse(),
|
Abuse: DefaultAbuse(),
|
||||||
|
|||||||
@@ -183,14 +183,15 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
// does not serve the app shell at the operator path.
|
// does not serve the app shell at the operator path.
|
||||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||||
}
|
}
|
||||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram
|
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
// priority, and each mount falls back to the app shell (index.html) for the hash
|
||||||
// public landing lives in its own static container behind the contour caddy,
|
// router. The public landing lives in its own static container behind the contour
|
||||||
// so the catch-all redirects a stray root hit to the app shell — which
|
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
|
||||||
// keeps a local no-caddy run usable.
|
// local no-caddy run usable.
|
||||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||||
|
mux.Handle("/vk/", webui.Handler("/vk/", "index.html"))
|
||||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||||
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import (
|
|||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
|
"scrabble/gateway/internal/vkauth"
|
||||||
fb "scrabble/pkg/fbs/scrabblefb"
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Message types in the vertical slice.
|
// Message types in the vertical slice.
|
||||||
const (
|
const (
|
||||||
MsgAuthTelegram = "auth.telegram"
|
MsgAuthTelegram = "auth.telegram"
|
||||||
|
MsgAuthVK = "auth.vk"
|
||||||
MsgAuthGuest = "auth.guest"
|
MsgAuthGuest = "auth.guest"
|
||||||
MsgAuthEmailReq = "auth.email.request"
|
MsgAuthEmailReq = "auth.email.request"
|
||||||
MsgAuthEmailLogin = "auth.email.login"
|
MsgAuthEmailLogin = "auth.email.login"
|
||||||
@@ -89,8 +91,9 @@ type TelegramValidator interface {
|
|||||||
|
|
||||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
// NewRegistry builds the slice's message-type catalog over the backend client.
|
||||||
// The Telegram auth op is registered only when a validator is supplied (the
|
// The Telegram auth op is registered only when a validator is supplied (the
|
||||||
// connector is configured); otherwise auth.telegram is simply unknown.
|
// connector is configured); otherwise auth.telegram is simply unknown. Optional ops
|
||||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
|
// (e.g. WithVKAuth) are applied last from opts.
|
||||||
|
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry {
|
||||||
r := &Registry{ops: make(map[string]Op)}
|
r := &Registry{ops: make(map[string]Op)}
|
||||||
if tg != nil {
|
if tg != nil {
|
||||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||||
@@ -126,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
|
|||||||
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
||||||
registerSocialOps(r, backend)
|
registerSocialOps(r, backend)
|
||||||
registerLinkOps(r, backend, tg)
|
registerLinkOps(r, backend, tg)
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(r, backend)
|
||||||
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option configures an optional registry operation at construction. It is kept out of
|
||||||
|
// NewRegistry's positional signature so existing call sites stay unaffected.
|
||||||
|
type Option func(r *Registry, backend *backendclient.Client)
|
||||||
|
|
||||||
|
// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params
|
||||||
|
// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so
|
||||||
|
// the op is simply unknown wherever VK is not configured.
|
||||||
|
func WithVKAuth(secret string) Option {
|
||||||
|
return func(r *Registry, backend *backendclient.Client) {
|
||||||
|
if secret != "" {
|
||||||
|
r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lookup returns the operation for messageType, and whether it is registered.
|
// Lookup returns the operation for messageType, and whether it is registered.
|
||||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||||
op, ok := r.ops[messageType]
|
op, ok := r.ops[messageType]
|
||||||
@@ -146,6 +167,9 @@ func DomainCode(err error) (string, bool) {
|
|||||||
if errors.Is(err, connector.ErrInvalidInitData) {
|
if errors.Is(err, connector.ErrInvalidInitData) {
|
||||||
return "invalid_init_data", true
|
return "invalid_init_data", true
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, vkauth.ErrInvalid) {
|
||||||
|
return "invalid_vk_params", true
|
||||||
|
}
|
||||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||||
return "invalid_login_widget", true
|
return "invalid_login_widget", true
|
||||||
}
|
}
|
||||||
@@ -175,6 +199,25 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_*
|
||||||
|
// params under the app secret) and provisions/finds the bound account. Unlike Telegram,
|
||||||
|
// VK omits the user's name from the signed params, so the client-supplied display_name
|
||||||
|
// rides the wire as a cosmetic seed for a brand-new account.
|
||||||
|
func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
in := fb.GetRootAsVKLoginRequest(req.Payload, 0)
|
||||||
|
user, err := vkauth.Verify(string(in.Params()), secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeSession(sess), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func authGuestHandler(backend *backendclient.Client) Handler {
|
func authGuestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
// The guest bootstrap historically carried no payload; the detected zone is
|
// The guest bootstrap historically carried no payload; the detected zone is
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vkTestSecret = "wv527nm6mvf3rgomfhd6"
|
||||||
|
// vkGoldenSign is the base64url HMAC-SHA256 of the unsigned params below under
|
||||||
|
// vkTestSecret, computed independently (a Python reference) — so a green test
|
||||||
|
// exercises VK's real algorithm end to end, not a self-consistent fake.
|
||||||
|
vkGoldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vkParams is the canonical VK launch-parameter set (without the sign) that
|
||||||
|
// vkGoldenSign covers.
|
||||||
|
func vkParams() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"vk_access_token_settings": {""},
|
||||||
|
"vk_app_id": {"6736218"},
|
||||||
|
"vk_are_notifications_enabled": {"0"},
|
||||||
|
"vk_is_app_user": {"1"},
|
||||||
|
"vk_is_favorite": {"0"},
|
||||||
|
"vk_language": {"ru"},
|
||||||
|
"vk_platform": {"android"},
|
||||||
|
"vk_ref": {"other"},
|
||||||
|
"vk_ts": {"1546961916"},
|
||||||
|
"vk_user_id": {"494075"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vkLoginPayload(params, browserTz, displayName string) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(0)
|
||||||
|
p := b.CreateString(params)
|
||||||
|
tz := b.CreateString(browserTz)
|
||||||
|
dn := b.CreateString(displayName)
|
||||||
|
fb.VKLoginRequestStart(b)
|
||||||
|
fb.VKLoginRequestAddParams(b, p)
|
||||||
|
fb.VKLoginRequestAddBrowserTz(b, tz)
|
||||||
|
fb.VKLoginRequestAddDisplayName(b, dn)
|
||||||
|
b.Finish(fb.VKLoginRequestEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVKAuthForwardsSeedFields(t *testing.T) {
|
||||||
|
var gotBody map[string]string
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/internal/sessions/vk" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||||
|
_, _ = w.Write([]byte(`{"token":"tok-vk","user_id":"u-vk","is_guest":false,"display_name":"Иван"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||||
|
op, ok := reg.Lookup(transcode.MsgAuthVK)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("auth.vk not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
signed := vkParams()
|
||||||
|
signed.Set("sign", vkGoldenSign)
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(signed.Encode(), "+03:00", "Иван Петров")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
sess := fb.GetRootAsSession(payload, 0)
|
||||||
|
if string(sess.Token()) != "tok-vk" || string(sess.UserId()) != "u-vk" {
|
||||||
|
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
|
||||||
|
}
|
||||||
|
// The verified vk_user_id and vk_language plus the client-supplied display name are
|
||||||
|
// forwarded so the backend can seed a brand-new account.
|
||||||
|
if gotBody["external_id"] != "494075" || gotBody["language_code"] != "ru" || gotBody["display_name"] != "Иван Петров" {
|
||||||
|
t.Errorf("forwarded body = %+v, want external_id=494075 language_code=ru display_name=Иван Петров", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVKAuthInvalidSign confirms a bad signature is a domain failure (invalid_vk_params)
|
||||||
|
// and the backend is never called.
|
||||||
|
func TestVKAuthInvalidSign(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||||
|
t.Error("backend must not be called when the sign is invalid")
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||||
|
op, _ := reg.Lookup(transcode.MsgAuthVK)
|
||||||
|
|
||||||
|
tampered := vkParams()
|
||||||
|
tampered.Set("sign", "deadbeef")
|
||||||
|
_, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(tampered.Encode(), "", "")})
|
||||||
|
if code, ok := transcode.DomainCode(err); !ok || code != "invalid_vk_params" {
|
||||||
|
t.Errorf("DomainCode = (%q, %v), want (invalid_vk_params, true)", code, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVKAuthDisabledWithoutSecret confirms a blank VK app secret leaves auth.vk
|
||||||
|
// unregistered.
|
||||||
|
func TestVKAuthDisabledWithoutSecret(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||||
|
defer cleanup()
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
if _, ok := reg.Lookup(transcode.MsgAuthVK); ok {
|
||||||
|
t.Error("auth.vk should be unregistered without a VK app secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Package vkauth verifies VK Mini App launch parameters in-process. VK signs the
|
||||||
|
// launch query string with the app's protected ("secure") key; the gateway holds that
|
||||||
|
// secret (GATEWAY_VK_APP_SECRET) and validates the `sign` itself rather than calling a
|
||||||
|
// side-service, because the check is a pure offline HMAC with no VK API round-trip
|
||||||
|
// (unlike the Telegram validator, which lives in a separate process to isolate the bot
|
||||||
|
// token). See docs/ARCHITECTURE.md §12.
|
||||||
|
package vkauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalid is returned when launch params fail signature verification, are
|
||||||
|
// malformed, carry no signed vk_* parameters, or lack the vk_user_id.
|
||||||
|
var ErrInvalid = errors.New("vkauth: invalid vk launch params")
|
||||||
|
|
||||||
|
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
||||||
|
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
||||||
|
// seeds a brand-new account's preferred language.
|
||||||
|
type Identity struct {
|
||||||
|
ExternalID string
|
||||||
|
Language string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
||||||
|
// returns the launching user's identity. Per VK's documented algorithm the signature
|
||||||
|
// is HMAC-SHA256 over the vk_*-prefixed parameters — sorted by key and serialized as a
|
||||||
|
// URL-encoded query string (url.Values.Encode mirrors VK's reference serialization for
|
||||||
|
// the constrained launch-parameter charset) — under the app secret, then base64url
|
||||||
|
// without padding; the comparison is constant-time.
|
||||||
|
//
|
||||||
|
// VK launch params carry no built-in expiry (unlike Telegram's auth_date), so freshness
|
||||||
|
// is deliberately not enforced here: the gateway mints its own short-lived session, and
|
||||||
|
// a replay only re-authenticates the same vk_user_id.
|
||||||
|
func Verify(params, secret string) (Identity, error) {
|
||||||
|
values, err := url.ParseQuery(params)
|
||||||
|
if err != nil {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
sign := values.Get("sign")
|
||||||
|
if sign == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
// Only vk_*-prefixed parameters are signed; any other query parameter the client
|
||||||
|
// appended is outside the signature and must be excluded from the check.
|
||||||
|
signed := url.Values{}
|
||||||
|
for k, v := range values {
|
||||||
|
if strings.HasPrefix(k, "vk_") {
|
||||||
|
signed[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(signed) == 0 {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
mac.Write([]byte(signed.Encode()))
|
||||||
|
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
if subtle.ConstantTimeCompare([]byte(want), []byte(sign)) != 1 {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
externalID := values.Get("vk_user_id")
|
||||||
|
if externalID == "" {
|
||||||
|
return Identity{}, ErrInvalid
|
||||||
|
}
|
||||||
|
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package vkauth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/vkauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testSecret = "wv527nm6mvf3rgomfhd6"
|
||||||
|
// goldenSign is HMAC-SHA256(sorted vk_* query, testSecret) base64url without
|
||||||
|
// padding, computed independently from a Python reference over goldenParams — so a
|
||||||
|
// green test proves Verify matches VK's documented algorithm, not merely itself.
|
||||||
|
goldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||||
|
)
|
||||||
|
|
||||||
|
// goldenParams is the canonical VK launch-parameter set the golden signature covers
|
||||||
|
// (a representative real launch, including the empty vk_access_token_settings).
|
||||||
|
func goldenParams() url.Values {
|
||||||
|
return url.Values{
|
||||||
|
"vk_access_token_settings": {""},
|
||||||
|
"vk_app_id": {"6736218"},
|
||||||
|
"vk_are_notifications_enabled": {"0"},
|
||||||
|
"vk_is_app_user": {"1"},
|
||||||
|
"vk_is_favorite": {"0"},
|
||||||
|
"vk_language": {"ru"},
|
||||||
|
"vk_platform": {"android"},
|
||||||
|
"vk_ref": {"other"},
|
||||||
|
"vk_ts": {"1546961916"},
|
||||||
|
"vk_user_id": {"494075"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedParams() url.Values {
|
||||||
|
p := goldenParams()
|
||||||
|
p.Set("sign", goldenSign)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyValid(t *testing.T) {
|
||||||
|
id, err := vkauth.Verify(signedParams().Encode(), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "494075" || id.Language != "ru" {
|
||||||
|
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
||||||
|
// value: vk_access_token_settings can carry a comma (e.g. "email,phone"), which VK's
|
||||||
|
// signer and our url.Values.Encode both escape to %2C. The golden sign was computed
|
||||||
|
// independently (Node crypto) over these params under testSecret — so a green test
|
||||||
|
// proves Go's encoding matches VK's for that character.
|
||||||
|
func TestVerifyCommaValue(t *testing.T) {
|
||||||
|
p := url.Values{
|
||||||
|
"vk_access_token_settings": {"email,phone"},
|
||||||
|
"vk_app_id": {"6736218"},
|
||||||
|
"vk_language": {"ru"},
|
||||||
|
"vk_platform": {"mobile_web"},
|
||||||
|
"vk_ts": {"1546961916"},
|
||||||
|
"vk_user_id": {"494075"},
|
||||||
|
"sign": {"6g9FKCAfHfT-fBUdBAcJ5QK1xUm-vKMvGZrQ63aPgnQ"},
|
||||||
|
}
|
||||||
|
id, err := vkauth.Verify(p.Encode(), testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "494075" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyIgnoresForeignParams asserts a non-vk_ query parameter the client may
|
||||||
|
// append (e.g. a tracking tag) is outside the signed set and does not break the check.
|
||||||
|
func TestVerifyIgnoresForeignParams(t *testing.T) {
|
||||||
|
id, err := vkauth.Verify(signedParams().Encode()+"&utm_source=catalog", testSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verify: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if id.ExternalID != "494075" {
|
||||||
|
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyRejects(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
secret string
|
||||||
|
}{
|
||||||
|
{name: "tampered param", secret: testSecret, raw: func() string {
|
||||||
|
p := signedParams()
|
||||||
|
p.Set("vk_user_id", "1") // user id changed; the golden sign no longer matches
|
||||||
|
return p.Encode()
|
||||||
|
}()},
|
||||||
|
{name: "wrong secret", secret: "not-the-secret", raw: signedParams().Encode()},
|
||||||
|
{name: "missing sign", secret: testSecret, raw: goldenParams().Encode()},
|
||||||
|
{name: "no vk params", secret: testSecret, raw: url.Values{"sign": {goldenSign}, "foo": {"bar"}}.Encode()},
|
||||||
|
{name: "malformed query", secret: testSecret, raw: "%zz=bad"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if _, err := vkauth.Verify(tt.raw, tt.secret); !errors.Is(err, vkauth.ErrInvalid) {
|
||||||
|
t.Fatalf("Verify error = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,20 @@ table TelegramLoginRequest {
|
|||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
||||||
|
// signed vk_* launch parameters plus the sign, which the gateway verifies in-process
|
||||||
|
// (HMAC-SHA256 over the sorted vk_* params under the app secret, base64url) before
|
||||||
|
// forwarding the extracted vk_user_id to the backend. display_name is the player's
|
||||||
|
// name read client-side via VKWebAppGetUserInfo — VK omits it from the signed params,
|
||||||
|
// so it is an untrusted, cosmetic seed for a brand-new account's display name.
|
||||||
|
// browser_tz is the client's detected UTC offset ("±HH:MM"), seeded into a brand-new
|
||||||
|
// account's time zone (see TelegramLoginRequest.browser_tz; first contact only).
|
||||||
|
table VKLoginRequest {
|
||||||
|
params:string;
|
||||||
|
browser_tz:string;
|
||||||
|
display_name:string;
|
||||||
|
}
|
||||||
|
|
||||||
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
|
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
|
||||||
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
||||||
// guest account's time zone (see TelegramLoginRequest.browser_tz).
|
// guest account's time zone (see TelegramLoginRequest.browser_tz).
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VKLoginRequest struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &VKLoginRequest{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &VKLoginRequest{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) Params() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) BrowserTz() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *VKLoginRequest) DisplayName() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func VKLoginRequestStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(3)
|
||||||
|
}
|
||||||
|
func VKLoginRequestAddParams(builder *flatbuffers.Builder, params flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(params), 0)
|
||||||
|
}
|
||||||
|
func VKLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||||
|
}
|
||||||
|
func VKLoginRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(displayName), 0)
|
||||||
|
}
|
||||||
|
func VKLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||||
// the hash, is malformed, or is older than the freshness window.
|
// the hash, is malformed, is older than the freshness window, or identifies a bot
|
||||||
|
// user (is_bot), which is denied.
|
||||||
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
||||||
|
|
||||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||||
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
|
|||||||
}
|
}
|
||||||
var u struct {
|
var u struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
IsBot bool `json:"is_bot"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
@@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) {
|
|||||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||||
return User{}, ErrInvalidInitData
|
return User{}, ErrInvalidInitData
|
||||||
}
|
}
|
||||||
|
// Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true
|
||||||
|
// is Telegram itself attesting the launching user is a bot. A real user opening the Mini App
|
||||||
|
// never carries it, so reject defensively rather than provision an account for a bot.
|
||||||
|
if u.IsBot {
|
||||||
|
return User{}, ErrInvalidInitData
|
||||||
|
}
|
||||||
return User{
|
return User{
|
||||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
|
|||||||
@@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateBotUser(t *testing.T) {
|
||||||
|
t.Run("is_bot true is denied", func(t *testing.T) {
|
||||||
|
initData := signInitData(testToken, map[string]string{
|
||||||
|
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
|
"user": `{"id":42,"is_bot":true,"first_name":"Robo"}`,
|
||||||
|
})
|
||||||
|
if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) {
|
||||||
|
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("is_bot false is allowed", func(t *testing.T) {
|
||||||
|
initData := signInitData(testToken, map[string]string{
|
||||||
|
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||||
|
"user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`,
|
||||||
|
})
|
||||||
|
u, err := NewHMACValidator(testToken).Validate(initData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate: %v", err)
|
||||||
|
}
|
||||||
|
if u.ExternalID != "42" || u.FirstName != "Thomas" {
|
||||||
|
t.Errorf("user = %+v, want {42 Thomas}", u)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@bufbuild/protobuf": "^2.12.0",
|
"@bufbuild/protobuf": "^2.12.0",
|
||||||
"@connectrpc/connect": "^2.1.0",
|
"@connectrpc/connect": "^2.1.0",
|
||||||
"@connectrpc/connect-web": "^2.1.0",
|
"@connectrpc/connect-web": "^2.1.0",
|
||||||
|
"@vkontakte/vk-bridge": "^3.0.2",
|
||||||
"flatbuffers": "^25.9.23"
|
"flatbuffers": "^25.9.23"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+22
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@connectrpc/connect-web':
|
'@connectrpc/connect-web':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))
|
version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))
|
||||||
|
'@vkontakte/vk-bridge':
|
||||||
|
specifier: ^3.0.2
|
||||||
|
version: 3.0.2
|
||||||
flatbuffers:
|
flatbuffers:
|
||||||
specifier: ^25.9.23
|
specifier: ^25.9.23
|
||||||
version: 25.9.23
|
version: 25.9.23
|
||||||
@@ -413,6 +416,9 @@ packages:
|
|||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
vite: ^6.0.0
|
vite: ^6.0.0
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.23':
|
||||||
|
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@@ -462,6 +468,9 @@ packages:
|
|||||||
'@vitest/utils@3.2.6':
|
'@vitest/utils@3.2.6':
|
||||||
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
||||||
|
|
||||||
|
'@vkontakte/vk-bridge@3.0.2':
|
||||||
|
resolution: {integrity: sha512-MTp+nl0/jH4Sa2TXDyxNfy9VkhOhRQR1oQ4yhvthVDA4aWUa26npns7bRgfTuR3xDVGFiqW7zAwLnwcv3mL+dA==}
|
||||||
|
|
||||||
acorn@8.16.0:
|
acorn@8.16.0:
|
||||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@@ -689,6 +698,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
typescript@5.4.5:
|
typescript@5.4.5:
|
||||||
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -1022,6 +1034,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.23':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
@@ -1086,6 +1102,10 @@ snapshots:
|
|||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vkontakte/vk-bridge@3.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.23
|
||||||
|
|
||||||
acorn@8.16.0: {}
|
acorn@8.16.0: {}
|
||||||
|
|
||||||
aria-query@5.3.1: {}
|
aria-query@5.3.1: {}
|
||||||
@@ -1318,6 +1338,8 @@ snapshots:
|
|||||||
|
|
||||||
tinyspy@4.0.4: {}
|
tinyspy@4.0.4: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
typescript@5.4.5: {}
|
typescript@5.4.5: {}
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|||||||
+10
-3
@@ -46,9 +46,16 @@
|
|||||||
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
||||||
content-safe-area inset, 0 elsewhere. */
|
content-safe-area inset, 0 elsewhere. */
|
||||||
--tg-content-top: 0px;
|
--tg-content-top: 0px;
|
||||||
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
|
/* Device safe-area top (the notch); inside Telegram TG's own nav controls sit between it and
|
||||||
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
|
--tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from
|
||||||
--tg-safe-top: 0px;
|
the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini
|
||||||
|
App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */
|
||||||
|
--tg-safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
/* Device safe-area bottom / sides (home indicator; landscape notch) — the screen pads its bottom
|
||||||
|
and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */
|
||||||
|
--tg-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--tg-safe-left: env(safe-area-inset-left, 0px);
|
||||||
|
--tg-safe-right: env(safe-area-inset-right, 0px);
|
||||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||||
"Noto Sans", "Liberation Sans", sans-serif;
|
"Noto Sans", "Liberation Sans", sans-serif;
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
scroll = true,
|
scroll = true,
|
||||||
growNav = false,
|
growNav = false,
|
||||||
column = false,
|
column = false,
|
||||||
|
selfInset = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
back?: string;
|
back?: string;
|
||||||
@@ -21,6 +22,10 @@
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
scroll?: boolean;
|
scroll?: boolean;
|
||||||
growNav?: boolean;
|
growNav?: boolean;
|
||||||
|
// selfInset: the content paints the bottom home-indicator inset itself (a tab-bar-less screen
|
||||||
|
// whose own bottom element owns the strip, e.g. the landscape game's left-panel controls), so
|
||||||
|
// the shell does not add the detached .content padding-bottom below it.
|
||||||
|
selfInset?: boolean;
|
||||||
// column lays the content out as a flex column so a child can own the vertical fit
|
// column lays the content out as a flex column so a child can own the vertical fit
|
||||||
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||||
column?: boolean;
|
column?: boolean;
|
||||||
@@ -87,7 +92,7 @@
|
|||||||
|
|
||||||
<div class="screen">
|
<div class="screen">
|
||||||
<Header {title} {back} grow={growNav} />
|
<Header {title} {back} grow={growNav} />
|
||||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
<main class="content" class:scroll class:fill={!growNav} class:column class:selfinset={selfInset}>{@render children?.()}</main>
|
||||||
{#if tabbar}
|
{#if tabbar}
|
||||||
<nav class="tabbar">{@render tabbar()}</nav>
|
<nav class="tabbar">{@render tabbar()}</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -101,6 +106,12 @@
|
|||||||
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
||||||
scrolling; falls back to the full height where the var is unset. */
|
scrolling; falls back to the full height where the var is unset. */
|
||||||
height: var(--vvh, 100%);
|
height: var(--vvh, 100%);
|
||||||
|
/* Clear the landscape notch sides inside Telegram (0 elsewhere). The top inset is owned by the
|
||||||
|
header; the home-indicator (bottom) inset is owned by the bottom bar — the .tabbar paints its
|
||||||
|
own chrome into it, and a screen with no tab bar pads its content (.content:last-child) — so
|
||||||
|
the strip takes the bar's colour rather than the detached content background. */
|
||||||
|
padding-left: var(--tg-safe-left, 0px);
|
||||||
|
padding-right: var(--tg-safe-right, 0px);
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
@@ -116,7 +127,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
/* No tab bar → the content is the bottom-most element: pad it by the device home-indicator inset
|
||||||
|
so it clears the cut-out, the strip taking the content's own background. With a tab bar the
|
||||||
|
.tabbar owns that inset instead (and content is not the last child, so this does not apply). */
|
||||||
|
.content:last-child {
|
||||||
|
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||||
|
}
|
||||||
|
/* selfInset: the content's own bottom element paints the home-indicator strip (the landscape
|
||||||
|
game's left-panel controls), so the shell does not add a detached padding strip below it. */
|
||||||
|
.content.selfinset:last-child {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
.tabbar {
|
.tabbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
||||||
|
inside Telegram (the inset is 0 elsewhere), so the safe-area strip reads as part of the bar
|
||||||
|
instead of the content background showing through. */
|
||||||
|
background: var(--bg-elev);
|
||||||
|
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
||||||
|
import { router } from '../lib/router.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { botUsername } from '../lib/deeplink';
|
import { botUsername } from '../lib/deeplink';
|
||||||
import { telegramOpenLink } from '../lib/telegram';
|
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
|
||||||
|
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
// The single bot's @username, for the deep link.
|
// The single bot's @username, for the deep link.
|
||||||
@@ -19,9 +21,30 @@
|
|||||||
}
|
}
|
||||||
dismissStaleInvite();
|
dismissStaleInvite();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native path: when the notice fires inside the Mini App with native popups, present Telegram's
|
||||||
|
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||||
|
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||||
|
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears the
|
||||||
|
// notice; the `shown` guard fires it once per notice.
|
||||||
|
// The notice is raised during boot; wait until the loading cover for the current route is gone —
|
||||||
|
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||||
|
// the native popup never appears over the splash.
|
||||||
|
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||||
|
let shown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (app.staleInvite && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
shown = true;
|
||||||
|
void telegramShowPopup(
|
||||||
|
botInfoPopup(t('friends.staleInviteTitle'), t('friends.staleInvite'), username ?? '', t('common.ok')),
|
||||||
|
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissStaleInvite()));
|
||||||
|
} else if (!app.staleInvite) {
|
||||||
|
shown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.staleInvite}
|
{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||||
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
|
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
|
||||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||||
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
|
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
||||||
|
import { router } from '../lib/router.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { botUsername } from '../lib/deeplink';
|
import { botUsername } from '../lib/deeplink';
|
||||||
import { telegramOpenLink } from '../lib/telegram';
|
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
|
||||||
|
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
|
||||||
import Modal from './Modal.svelte';
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
// The single bot's @username, for the deep link.
|
// The single bot's @username, for the deep link.
|
||||||
@@ -22,9 +24,30 @@
|
|||||||
}
|
}
|
||||||
dismissWelcomeRedeem();
|
dismissWelcomeRedeem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Native path: when the greeting fires inside the Mini App with native popups, present Telegram's
|
||||||
|
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||||
|
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||||
|
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears it;
|
||||||
|
// the `shown` guard fires it once per greeting.
|
||||||
|
// The greeting is raised during boot; wait until the loading cover for the current route is gone —
|
||||||
|
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||||
|
// the native popup never appears over the splash.
|
||||||
|
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||||
|
let shown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (app.welcomeRedeem && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
shown = true;
|
||||||
|
void telegramShowPopup(
|
||||||
|
botInfoPopup(t('friends.welcomeRedeemTitle'), t('friends.welcomeRedeem', { name }), username ?? '', t('common.ok')),
|
||||||
|
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissWelcomeRedeem()));
|
||||||
|
} else if (!app.welcomeRedeem) {
|
||||||
|
shown = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.welcomeRedeem}
|
{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||||
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
||||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||||
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
||||||
|
|||||||
+86
-11
@@ -105,18 +105,27 @@
|
|||||||
prevZoom = on;
|
prevZoom = on;
|
||||||
prevRecenter = rc;
|
prevRecenter = rc;
|
||||||
if (landscape) {
|
if (landscape) {
|
||||||
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board
|
// A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
|
||||||
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target
|
// HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
|
||||||
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each
|
// moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
|
||||||
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like
|
// until the board is wider than the viewport): the board positioning "in two steps". Drive
|
||||||
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it
|
// BOTH axes by one progress q — how far the board has grown past the viewport width, the point
|
||||||
// stops overflowing.)
|
// at which a horizontal pan first becomes possible. Until then q is 0 and the board simply
|
||||||
|
// zooms centred; past it both axes pan together in one diagonal motion. The full-zoom focus
|
||||||
|
// target is finalSL/ST on zoom-in and the current position on zoom-out (where final is 0), so
|
||||||
|
// q running 1→0 unwinds the scroll before the board shrinks back below the viewport.
|
||||||
if (toggled || (recentered && on && f)) {
|
if (toggled || (recentered && on && f)) {
|
||||||
const t0 = performance.now();
|
const scaler = vp.firstElementChild as HTMLElement | null;
|
||||||
let raf = requestAnimationFrame(function tick(now: number) {
|
const targetSL = on ? finalSL : vp.scrollLeft;
|
||||||
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth));
|
const targetST = on ? finalST : vp.scrollTop;
|
||||||
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight));
|
const span = Math.max(1, fullSide - clientW);
|
||||||
if (now - t0 < 320) raf = requestAnimationFrame(tick);
|
let raf = requestAnimationFrame(function tick() {
|
||||||
|
const side = scaler ? scaler.getBoundingClientRect().width : fullSide;
|
||||||
|
const q = Math.max(0, Math.min(1, (side - clientW) / span));
|
||||||
|
vp.scrollLeft = targetSL * q;
|
||||||
|
vp.scrollTop = targetST * q;
|
||||||
|
const settled = on ? side >= fullSide - 1 : side <= fitSide + 1;
|
||||||
|
if (!settled) raf = requestAnimationFrame(tick);
|
||||||
});
|
});
|
||||||
return () => cancelAnimationFrame(raf);
|
return () => cancelAnimationFrame(raf);
|
||||||
}
|
}
|
||||||
@@ -204,6 +213,69 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mouse drag-to-pan the zoomed board. Touch scrolls the overflow:auto viewport natively, but a
|
||||||
|
// mouse cannot drag-scroll it, so on desktop / the landscape iframe the magnified board could not
|
||||||
|
// be moved at all. Active only while zoomed, for a primary-button drag that does NOT start on a
|
||||||
|
// pending tile (those own their pointer for relocation), and only once the pointer passes a small
|
||||||
|
// threshold — so a plain click still places a rack tile or toggles zoom. A completed pan swallows
|
||||||
|
// the trailing click so it does not also act on the cell under the release.
|
||||||
|
$effect(() => {
|
||||||
|
const vp = viewport;
|
||||||
|
if (!vp) return;
|
||||||
|
let armed = false;
|
||||||
|
let panning = false;
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let sl = 0;
|
||||||
|
let st = 0;
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return;
|
||||||
|
if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return;
|
||||||
|
armed = true;
|
||||||
|
panning = false;
|
||||||
|
sx = e.clientX;
|
||||||
|
sy = e.clientY;
|
||||||
|
sl = vp.scrollLeft;
|
||||||
|
st = vp.scrollTop;
|
||||||
|
};
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
if (!armed) return;
|
||||||
|
const dx = e.clientX - sx;
|
||||||
|
const dy = e.clientY - sy;
|
||||||
|
if (!panning) {
|
||||||
|
if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
|
||||||
|
panning = true;
|
||||||
|
vp.setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
vp.scrollLeft = sl - dx;
|
||||||
|
vp.scrollTop = st - dy;
|
||||||
|
};
|
||||||
|
const onUp = (e: PointerEvent) => {
|
||||||
|
armed = false;
|
||||||
|
if (!panning) return;
|
||||||
|
panning = false;
|
||||||
|
if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
|
||||||
|
// Swallow the click the drag would otherwise produce (capture phase, before it reaches the
|
||||||
|
// cell); self-remove on that click, and clean up on the next tick if none fired.
|
||||||
|
const swallow = (ev: Event) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
vp.removeEventListener('click', swallow, true);
|
||||||
|
};
|
||||||
|
vp.addEventListener('click', swallow, true);
|
||||||
|
setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
|
||||||
|
};
|
||||||
|
vp.addEventListener('pointerdown', onDown);
|
||||||
|
vp.addEventListener('pointermove', onMove);
|
||||||
|
vp.addEventListener('pointerup', onUp);
|
||||||
|
vp.addEventListener('pointercancel', onUp);
|
||||||
|
return () => {
|
||||||
|
vp.removeEventListener('pointerdown', onDown);
|
||||||
|
vp.removeEventListener('pointermove', onMove);
|
||||||
|
vp.removeEventListener('pointerup', onUp);
|
||||||
|
vp.removeEventListener('pointercancel', onUp);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
|
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
|
||||||
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
|
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
|
||||||
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
|
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
|
||||||
@@ -287,6 +359,9 @@
|
|||||||
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
|
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
|
||||||
board cannot be dragged past its content into empty space — it just stops at the edge. */
|
board cannot be dragged past its content into empty space — it just stops at the edge. */
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
/* The zoom effect drives scrollLeft/Top itself while the board grows/shrinks; turn off the
|
||||||
|
browser's scroll-anchoring so it does not fight those writes mid-transition. */
|
||||||
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
|
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
|
||||||
— a magnifying-glass zoom. */
|
— a magnifying-glass zoom. */
|
||||||
|
|||||||
+32
-4
@@ -24,7 +24,7 @@
|
|||||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||||
import { patchLobbyGame } from '../lib/lobbycache';
|
import { patchLobbyGame } from '../lib/lobbycache';
|
||||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||||
import { telegramHaptic } from '../lib/telegram';
|
import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram';
|
||||||
import {
|
import {
|
||||||
BLANK,
|
BLANK,
|
||||||
newPlacement,
|
newPlacement,
|
||||||
@@ -712,6 +712,16 @@
|
|||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// onResignClick: inside the Mini App (and online) confirm with Telegram's native dialog and resign
|
||||||
|
// on accept; otherwise open the in-app confirm modal (which also carries the offline-disabled action).
|
||||||
|
async function onResignClick(): Promise<void> {
|
||||||
|
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
if (await telegramShowConfirm(t('game.confirmResign'))) doResign();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resignOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function doResign() {
|
async function doResign() {
|
||||||
resignOpen = false;
|
resignOpen = false;
|
||||||
busy = true;
|
busy = true;
|
||||||
@@ -1064,6 +1074,7 @@
|
|||||||
column
|
column
|
||||||
scroll={false}
|
scroll={false}
|
||||||
tabbar={landscape ? undefined : bottomBar}
|
tabbar={landscape ? undefined : bottomBar}
|
||||||
|
selfInset={landscape}
|
||||||
>
|
>
|
||||||
{#if view}
|
{#if view}
|
||||||
{#if landscape}
|
{#if landscape}
|
||||||
@@ -1149,7 +1160,7 @@
|
|||||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||||
@@ -1534,6 +1545,14 @@
|
|||||||
.make:disabled {
|
.make:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
|
||||||
|
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
|
||||||
|
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
|
||||||
|
so it sits inside the freed slot with its right edge level with the rack's right edge — the
|
||||||
|
mirror of the first tile's left edge. */
|
||||||
|
.game-land .make {
|
||||||
|
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
|
||||||
|
}
|
||||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||||
.hhead {
|
.hhead {
|
||||||
@@ -1720,8 +1739,10 @@
|
|||||||
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
/* The left-column children carry their own horizontal --pad (matching portrait), so the
|
/* The left-column children carry their own horizontal --pad (matching portrait), so the
|
||||||
grid only pads its right edge; the board centres in the right pane. */
|
grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
|
||||||
padding: 4px var(--pad) 6px 0;
|
controls bar and the board reach the screen edge — the controls bar owns the home-indicator
|
||||||
|
inset (see the .leftpane .tabbar rule), the board runs under the thin indicator. */
|
||||||
|
padding: 4px var(--pad) 0 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.leftpane {
|
.leftpane {
|
||||||
@@ -1729,6 +1750,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
/* The home-indicator inset on the controls side: the left panel's controls bar paints its own
|
||||||
|
chrome (--bg-elev) into it so the buttons clear the cut-out (the shell's detached content strip
|
||||||
|
is suppressed here via Screen selfInset); the board pane runs to the edge under the thin
|
||||||
|
indicator. */
|
||||||
|
.game-land .leftpane :global(.tabbar) {
|
||||||
|
padding-bottom: calc(8px + var(--tg-safe-bottom, 0px));
|
||||||
|
}
|
||||||
.rightpane {
|
.rightpane {
|
||||||
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
|
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
|
||||||
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
|
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
|
||||||
|
|||||||
+23
-3
@@ -112,12 +112,32 @@
|
|||||||
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
||||||
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
||||||
viewport-width measure that would overflow seven tiles in a column. */
|
viewport-width measure that would overflow seven tiles in a column. */
|
||||||
|
.rack.landscape {
|
||||||
|
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
|
||||||
|
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
|
||||||
|
resolves unreliably, so the rack — which has a definite width — is the query container. A
|
||||||
|
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
|
||||||
|
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
.rack.landscape .tile {
|
.rack.landscape .tile {
|
||||||
flex: 1 1 0;
|
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
|
||||||
width: auto;
|
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
|
||||||
min-width: 0;
|
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
|
||||||
|
tiles leave. */
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: calc((100% - 6 * 5px) / 7);
|
||||||
max-width: 46px;
|
max-width: 46px;
|
||||||
}
|
}
|
||||||
|
.rack.landscape .letter {
|
||||||
|
font-size: 6cqw;
|
||||||
|
}
|
||||||
|
.rack.landscape .val {
|
||||||
|
font-size: 3.1cqw;
|
||||||
|
}
|
||||||
|
.rack.landscape .star {
|
||||||
|
font-size: 7.6cqw;
|
||||||
|
}
|
||||||
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
||||||
tile width plus the rack gap), so the drop position is visible. */
|
tile width plus the rack gap), so the drop position is visible. */
|
||||||
.rack.reordering .tile {
|
.rack.reordering .tile {
|
||||||
|
|||||||
@@ -71,5 +71,6 @@ export { TargetRequest } from './scrabblefb/target-request.js';
|
|||||||
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
|
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
|
||||||
export { TileRecord } from './scrabblefb/tile-record.js';
|
export { TileRecord } from './scrabblefb/tile-record.js';
|
||||||
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
|
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
|
||||||
|
export { VKLoginRequest } from './scrabblefb/vklogin-request.js';
|
||||||
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
||||||
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class VKLoginRequest {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):VKLoginRequest {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest {
|
||||||
|
return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
params():string|null
|
||||||
|
params(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
params(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
browserTz():string|null
|
||||||
|
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName():string|null
|
||||||
|
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
displayName(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startVKLoginRequest(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addParams(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(0, paramsOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, displayNameOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endVKLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createVKLoginRequest(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
VKLoginRequest.startVKLoginRequest(builder);
|
||||||
|
VKLoginRequest.addParams(builder, paramsOffset);
|
||||||
|
VKLoginRequest.addBrowserTz(builder, browserTzOffset);
|
||||||
|
VKLoginRequest.addDisplayName(builder, displayNameOffset);
|
||||||
|
return VKLoginRequest.endVKLoginRequest(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
-25
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
|
|||||||
import { navigate, router } from './router.svelte';
|
import { navigate, router } from './router.svelte';
|
||||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||||
import { languageNeedsServerSync } from './language';
|
import { languageNeedsServerSync } from './language';
|
||||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
collectTelegramDiag,
|
collectTelegramDiag,
|
||||||
@@ -18,15 +18,22 @@ import {
|
|||||||
hasLaunchFragment,
|
hasLaunchFragment,
|
||||||
loadTelegramSDK,
|
loadTelegramSDK,
|
||||||
telegramColorScheme,
|
telegramColorScheme,
|
||||||
|
telegramThemeParams,
|
||||||
telegramContentSafeAreaTop,
|
telegramContentSafeAreaTop,
|
||||||
telegramSafeAreaTop,
|
telegramSafeAreaInset,
|
||||||
telegramDisableVerticalSwipes,
|
telegramDisableVerticalSwipes,
|
||||||
|
telegramShowSettingsButton,
|
||||||
telegramHaptic,
|
telegramHaptic,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
type TelegramLaunch,
|
type TelegramLaunch,
|
||||||
telegramOnEvent,
|
telegramOnEvent,
|
||||||
telegramSetChrome,
|
telegramSetChrome,
|
||||||
|
telegramCloudAvailable,
|
||||||
|
telegramCloudGet,
|
||||||
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
|
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk';
|
||||||
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
import { parseStartParam } from './deeplink';
|
import { parseStartParam } from './deeplink';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||||
@@ -527,17 +534,25 @@ function syncTelegramChrome(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
|
* syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top
|
||||||
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
|
* (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top
|
||||||
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
|
* (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the
|
||||||
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
|
* title in its band), and the device safe-area insets — notch / status bar (top), home indicator
|
||||||
|
* (bottom) and the landscape notch sides (left / right) — into --tg-safe-top / --tg-safe-bottom /
|
||||||
|
* --tg-safe-left / --tg-safe-right, so the header, rack and screen edges clear the device cut-outs.
|
||||||
|
* Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||||
*/
|
*/
|
||||||
function syncTelegramSafeArea(): void {
|
function syncTelegramSafeArea(): void {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
const root = document.documentElement;
|
||||||
const top = telegramContentSafeAreaTop();
|
const top = telegramContentSafeAreaTop();
|
||||||
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
|
const safe = telegramSafeAreaInset();
|
||||||
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
|
root.style.setProperty('--tg-content-top', `${top}px`);
|
||||||
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
|
root.style.setProperty('--tg-safe-top', `${safe.top}px`);
|
||||||
|
root.style.setProperty('--tg-safe-bottom', `${safe.bottom}px`);
|
||||||
|
root.style.setProperty('--tg-safe-left', `${safe.left}px`);
|
||||||
|
root.style.setProperty('--tg-safe-right', `${safe.right}px`);
|
||||||
|
root.classList.toggle('tg-fullscreen', top > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -554,20 +569,31 @@ function syncViewportHeight(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
|
* syncTelegramTheme re-applies Telegram's theme integration — the themeParams token overrides,
|
||||||
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
|
* Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's
|
||||||
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
|
* current themeParams when omitted. Called on launch with the launch snapshot and live on the
|
||||||
* bootstrap and a manual launch retry call it.
|
* themeChanged event, so switching Telegram's light/dark theme while the app is open is picked up
|
||||||
|
* without a relaunch.
|
||||||
*/
|
*/
|
||||||
function applyTelegramChrome(launch: TelegramLaunch): void {
|
function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void {
|
||||||
if (launch.theme) applyTelegramTheme(launch.theme);
|
if (theme) applyTelegramTheme(theme);
|
||||||
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
|
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
|
||||||
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
|
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
|
||||||
// back to the stored preference when the SDK omits it.
|
// back to the stored preference when the SDK omits it.
|
||||||
applyTheme(telegramColorScheme() ?? app.theme);
|
applyTheme(telegramColorScheme() ?? app.theme);
|
||||||
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile
|
|
||||||
// drag / board scroll.
|
|
||||||
syncTelegramChrome();
|
syncTelegramChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
|
||||||
|
* colour scheme and theme (syncTelegramTheme), the matching header / background / bottom chrome,
|
||||||
|
* the safe-area insets, and the swipe-down-to-minimise guard. It is idempotent, so both the
|
||||||
|
* initial bootstrap and a manual launch retry call it.
|
||||||
|
*/
|
||||||
|
function applyTelegramChrome(launch: TelegramLaunch): void {
|
||||||
|
syncTelegramTheme(launch.theme);
|
||||||
|
// Mirror the safe-area insets and stop Telegram's swipe-down-to-minimise from fighting tile drag
|
||||||
|
// / board scroll.
|
||||||
syncTelegramSafeArea();
|
syncTelegramSafeArea();
|
||||||
telegramDisableVerticalSwipes();
|
telegramDisableVerticalSwipes();
|
||||||
}
|
}
|
||||||
@@ -621,15 +647,49 @@ export async function bootstrap(): Promise<void> {
|
|||||||
if (insideTelegram()) {
|
if (insideTelegram()) {
|
||||||
const launch = telegramLaunch();
|
const launch = telegramLaunch();
|
||||||
applyTelegramChrome(launch);
|
applyTelegramChrome(launch);
|
||||||
|
// Pull the device-independent display prefs (theme / reduce-motion / board labels) from
|
||||||
|
// CloudStorage in the background so a change on another device follows the user here; the local
|
||||||
|
// values applied above render instantly, so this reconciles without blocking launch.
|
||||||
|
void reconcileCloudPrefs();
|
||||||
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
|
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
|
||||||
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||||
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
||||||
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
||||||
|
// Re-apply the theme live when the user switches Telegram's light/dark mode while the app is open.
|
||||||
|
telegramOnEvent('themeChanged', () => syncTelegramTheme());
|
||||||
|
// Telegram's native Settings button (Bot API 7.0) opens our Settings screen; the in-app gear
|
||||||
|
// entry stays the primary path. No-op on clients predating the button.
|
||||||
|
telegramShowSettingsButton(() => navigate('/settings'));
|
||||||
await bootTelegram(launch);
|
await bootTelegram(launch);
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then
|
||||||
|
// authenticate from the signed launch parameters in the URL — the display name comes from
|
||||||
|
// VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK
|
||||||
|
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
|
||||||
|
if (onVKPath() && insideVK()) {
|
||||||
|
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
|
||||||
|
// VK mobile webview's prefers-color-scheme does not track it).
|
||||||
|
void vkOnScheme((scheme) => {
|
||||||
|
if (app.theme === 'auto') applyTheme(scheme);
|
||||||
|
});
|
||||||
|
// Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
|
||||||
|
// CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
|
||||||
|
void vkOnInsets((i) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
|
||||||
|
root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
|
||||||
|
root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
|
||||||
|
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
|
||||||
|
});
|
||||||
|
await vkInit();
|
||||||
|
await bootVK();
|
||||||
|
app.ready = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const saved = await loadSession();
|
const saved = await loadSession();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
await adoptSession(saved);
|
await adoptSession(saved);
|
||||||
@@ -640,10 +700,10 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.ready = true;
|
app.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall
|
// Inside a Mini App the only identity is the platform session (Telegram or VK), so a failed launch
|
||||||
// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a
|
// must never fall back to the web login screen. A transient backend outage (a deploy rolling over)
|
||||||
// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the
|
// is retried a few times in silence; only then does the boot-error screen surface, from which Retry
|
||||||
// same path (retryTelegramBoot).
|
// re-runs the same path (retryMiniAppBoot).
|
||||||
const TELEGRAM_BOOT_RETRIES = 2;
|
const TELEGRAM_BOOT_RETRIES = 2;
|
||||||
const TELEGRAM_BOOT_RETRY_MS = 1200;
|
const TELEGRAM_BOOT_RETRY_MS = 1200;
|
||||||
|
|
||||||
@@ -680,14 +740,54 @@ async function bootTelegram(launch: TelegramLaunch): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It
|
* bootVK authenticates a VK Mini App launch from the signed launch parameters in the URL, seeding a
|
||||||
* clears the error and shows the loading state again, then runs the same retrying boot; on success
|
* brand-new account's display name from VKWebAppGetUserInfo. Like bootTelegram it retries a few
|
||||||
* the app renders normally, otherwise the boot-error screen returns.
|
* times on a transient failure before raising the boot-error screen, and a blocked account is
|
||||||
|
* terminal. This MVP carries no VK deep-link routing.
|
||||||
*/
|
*/
|
||||||
export async function retryTelegramBoot(): Promise<void> {
|
async function bootVK(): Promise<void> {
|
||||||
|
const params = vkLaunchParams();
|
||||||
|
const displayName = await vkUserName();
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await adoptSession(await gateway.authVK(params, displayName));
|
||||||
|
// VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
|
||||||
|
// (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
|
||||||
|
// confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
|
||||||
|
// launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
|
||||||
|
// deep-link channel, and routes the lobby for an empty payload today.
|
||||||
|
if (!app.blocked) await routeStartParam(vkStartParam());
|
||||||
|
app.bootError = false;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GatewayError && err.code === 'account_blocked') {
|
||||||
|
await enterBlocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt >= TELEGRAM_BOOT_RETRIES) {
|
||||||
|
app.bootError = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(TELEGRAM_BOOT_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retryMiniAppBoot re-attempts a Mini App launch from the boot-error screen's Retry button — the VK
|
||||||
|
* boot on the /vk/ entry, the Telegram boot otherwise. It clears the error and shows the loading
|
||||||
|
* state again, then runs the same retrying boot; on success the app renders normally, otherwise the
|
||||||
|
* boot-error screen returns.
|
||||||
|
*/
|
||||||
|
export async function retryMiniAppBoot(): Promise<void> {
|
||||||
app.bootError = false;
|
app.bootError = false;
|
||||||
app.ready = false;
|
app.ready = false;
|
||||||
|
if (onVKPath()) {
|
||||||
|
await vkInit();
|
||||||
|
await bootVK();
|
||||||
|
} else {
|
||||||
await bootTelegram(telegramLaunch());
|
await bootTelegram(telegramLaunch());
|
||||||
|
}
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,6 +911,42 @@ function persistPrefs(): void {
|
|||||||
reduceMotion: app.reduceMotion,
|
reduceMotion: app.reduceMotion,
|
||||||
boardLabels: app.boardLabels,
|
boardLabels: app.boardLabels,
|
||||||
});
|
});
|
||||||
|
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
||||||
|
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
||||||
|
// syncs via the durable account (Profile.preferredLanguage) instead.
|
||||||
|
void telegramCloudSet(
|
||||||
|
CLOUD_PREFS_KEY,
|
||||||
|
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reconcileCloudPrefs pulls the device-independent display prefs (theme / reduce-motion / board
|
||||||
|
* labels) from Telegram CloudStorage and applies any that differ from the current values, so a
|
||||||
|
* change made on another Telegram device follows the user here. The local store is the
|
||||||
|
* instant-render cache (read synchronously at boot); this runs once on launch after it and persists
|
||||||
|
* what it applied. Theme is not re-applied visually — inside Telegram the colour scheme is
|
||||||
|
* Telegram's to decide — only its stored value is updated. A no-op outside Telegram or when
|
||||||
|
* CloudStorage is unavailable; locale is never synced this way (it has its own server reconciler).
|
||||||
|
*/
|
||||||
|
async function reconcileCloudPrefs(): Promise<void> {
|
||||||
|
if (!telegramCloudAvailable()) return;
|
||||||
|
const cloud = decodeClientPrefs(await telegramCloudGet(CLOUD_PREFS_KEY));
|
||||||
|
let changed = false;
|
||||||
|
if (cloud.theme !== undefined && cloud.theme !== app.theme) {
|
||||||
|
app.theme = cloud.theme;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (cloud.reduceMotion !== undefined && cloud.reduceMotion !== app.reduceMotion) {
|
||||||
|
app.reduceMotion = cloud.reduceMotion;
|
||||||
|
applyReduceMotion(app.reduceMotion);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (cloud.boardLabels !== undefined && cloud.boardLabels !== app.boardLabels) {
|
||||||
|
app.boardLabels = cloud.boardLabels;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(theme: ThemePref): void {
|
export function setTheme(theme: ThemePref): void {
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export type Unsubscribe = () => void;
|
|||||||
export interface GatewayClient {
|
export interface GatewayClient {
|
||||||
// --- auth (unauthenticated) ---
|
// --- auth (unauthenticated) ---
|
||||||
authTelegram(initData: string): Promise<Session>;
|
authTelegram(initData: string): Promise<Session>;
|
||||||
|
/** Authenticate a VK Mini App launch: params is the signed vk_* launch query string (the gateway
|
||||||
|
* verifies its sign); displayName is the client-read VKWebAppGetUserInfo name (an unsigned,
|
||||||
|
* cosmetic seed for a brand-new account). */
|
||||||
|
authVK(params: string, displayName: string): Promise<Session>;
|
||||||
authGuest(locale?: string): Promise<Session>;
|
authGuest(locale?: string): Promise<Session>;
|
||||||
authEmailRequest(email: string): Promise<void>;
|
authEmailRequest(email: string): Promise<void>;
|
||||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||||
|
|
||||||
|
describe('cloudprefs', () => {
|
||||||
|
it('round-trips the synced client prefs', () => {
|
||||||
|
const p = { theme: 'dark', reduceMotion: true, boardLabels: 'classic' } as const;
|
||||||
|
expect(decodeClientPrefs(encodeClientPrefs(p))).toEqual(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never encodes the locale (it syncs via the durable account instead)', () => {
|
||||||
|
const raw = encodeClientPrefs({ theme: 'light', reduceMotion: false, boardLabels: 'none' });
|
||||||
|
expect(raw).not.toContain('locale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty partial for missing or malformed input', () => {
|
||||||
|
expect(decodeClientPrefs(null)).toEqual({});
|
||||||
|
expect(decodeClientPrefs(undefined)).toEqual({});
|
||||||
|
expect(decodeClientPrefs('')).toEqual({});
|
||||||
|
expect(decodeClientPrefs('not json')).toEqual({});
|
||||||
|
expect(decodeClientPrefs('[1,2,3]')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only valid fields and drops unknown or mistyped ones', () => {
|
||||||
|
const raw = JSON.stringify({ theme: 'neon', reduceMotion: 'yes', boardLabels: 'classic', locale: 'ru' });
|
||||||
|
expect(decodeClientPrefs(raw)).toEqual({ boardLabels: 'classic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the CloudStorage key', () => {
|
||||||
|
expect(CLOUD_PREFS_KEY).toBe('prefs');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Telegram CloudStorage sync for the device-independent client display preferences — theme,
|
||||||
|
// reduce-motion and board labels — so they follow the user across their Telegram devices. The
|
||||||
|
// interface language is intentionally excluded: it has its own server-side sync
|
||||||
|
// (Profile.preferredLanguage) plus an on-launch reconciler, and mixing it in here would fight that.
|
||||||
|
// The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the node
|
||||||
|
// environment; the CloudStorage transport wrappers live in telegram.ts and the wiring (mirror on
|
||||||
|
// save, reconcile on launch) in app.svelte.ts.
|
||||||
|
|
||||||
|
import type { ThemePref } from './theme';
|
||||||
|
import type { BoardLabelMode } from './boardlabels';
|
||||||
|
|
||||||
|
/** ClientPrefs is the subset of preferences synced across devices via Telegram CloudStorage. */
|
||||||
|
export interface ClientPrefs {
|
||||||
|
theme: ThemePref;
|
||||||
|
reduceMotion: boolean;
|
||||||
|
boardLabels: BoardLabelMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLOUD_PREFS_KEY is the Telegram CloudStorage key holding the JSON-encoded ClientPrefs. */
|
||||||
|
export const CLOUD_PREFS_KEY = 'prefs';
|
||||||
|
|
||||||
|
/** encodeClientPrefs serialises the synced client prefs (and only those — never the locale). */
|
||||||
|
export function encodeClientPrefs(p: ClientPrefs): string {
|
||||||
|
return JSON.stringify({ theme: p.theme, reduceMotion: p.reduceMotion, boardLabels: p.boardLabels });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* decodeClientPrefs parses a CloudStorage payload into a partial ClientPrefs, keeping only valid
|
||||||
|
* fields and dropping anything unknown, mistyped or malformed — so a value written by a newer or
|
||||||
|
* older build, or a corrupt entry, never throws and never applies a bad setting. A missing field
|
||||||
|
* stays absent, so the caller leaves the corresponding local value untouched.
|
||||||
|
*/
|
||||||
|
export function decodeClientPrefs(raw: string | null | undefined): Partial<ClientPrefs> {
|
||||||
|
if (!raw) return {};
|
||||||
|
let o: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
o = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (!o || typeof o !== 'object') return {};
|
||||||
|
const out: Partial<ClientPrefs> = {};
|
||||||
|
if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme;
|
||||||
|
if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion;
|
||||||
|
if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') {
|
||||||
|
out.boardLabels = o.boardLabels;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
encodeTarget,
|
encodeTarget,
|
||||||
encodeTelegramLogin,
|
encodeTelegramLogin,
|
||||||
encodeUpdateProfile,
|
encodeUpdateProfile,
|
||||||
|
encodeVKLogin,
|
||||||
} from './codec';
|
} from './codec';
|
||||||
|
|
||||||
describe('codec', () => {
|
describe('codec', () => {
|
||||||
@@ -94,6 +95,13 @@ describe('codec', () => {
|
|||||||
);
|
);
|
||||||
expect(email.email()).toBe('a@example.com');
|
expect(email.email()).toBe('a@example.com');
|
||||||
expect(email.browserTz()).toBe('+00:00');
|
expect(email.browserTz()).toBe('+00:00');
|
||||||
|
|
||||||
|
const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
|
||||||
|
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
|
||||||
|
);
|
||||||
|
expect(vk.params()).toBe('vk_user_id=494075&vk_ts=1&sign=abc');
|
||||||
|
expect(vk.browserTz()).toBe('+03:00');
|
||||||
|
expect(vk.displayName()).toBe('Иван Петров');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('round-trips a feedback submit and decodes state + unread', () => {
|
it('round-trips a feedback submit and decodes state + unread', () => {
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ export function encodeTelegramLogin(initData: string, browserTz: string): Uint8A
|
|||||||
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeVKLogin(params: string, browserTz: string, displayName: string): Uint8Array {
|
||||||
|
const b = new Builder(512);
|
||||||
|
const p = b.createString(params);
|
||||||
|
const tz = b.createString(browserTz);
|
||||||
|
const dn = b.createString(displayName);
|
||||||
|
fb.VKLoginRequest.startVKLoginRequest(b);
|
||||||
|
fb.VKLoginRequest.addParams(b, p);
|
||||||
|
fb.VKLoginRequest.addBrowserTz(b, tz);
|
||||||
|
fb.VKLoginRequest.addDisplayName(b, dn);
|
||||||
|
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
|
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
|
||||||
const b = new Builder(64);
|
const b = new Builder(64);
|
||||||
const l = b.createString(locale);
|
const l = b.createString(locale);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
|
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink, vkShareLink } from './deeplink';
|
||||||
|
|
||||||
describe('parseStartParam', () => {
|
describe('parseStartParam', () => {
|
||||||
it('classifies game / invitation / friend code', () => {
|
it('classifies game / invitation / friend code', () => {
|
||||||
@@ -37,6 +37,20 @@ describe('shareLink', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('vkShareLink', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('returns null outside a VK launch (no vk_app_id)', () => {
|
||||||
|
vi.stubGlobal('location', { search: '' });
|
||||||
|
expect(vkShareLink()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the plain vk.com/app link (VK drops a custom query payload, so the code is not carried)', () => {
|
||||||
|
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
|
||||||
|
expect(vkShareLink()).toBe('https://vk.com/app6736218');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('botUsername', () => {
|
describe('botUsername', () => {
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
// f<6-digit code> redeem that friend code
|
// f<6-digit code> redeem that friend code
|
||||||
// An empty or unrecognised parameter opens the lobby.
|
// An empty or unrecognised parameter opens the lobby.
|
||||||
|
|
||||||
|
import { vkAppId } from './vk';
|
||||||
|
|
||||||
export type DeepLink =
|
export type DeepLink =
|
||||||
| { kind: 'lobby' }
|
| { kind: 'lobby' }
|
||||||
| { kind: 'game'; id: string }
|
| { kind: 'game'; id: string }
|
||||||
@@ -74,3 +76,16 @@ export function shareLink(param: string): string | null {
|
|||||||
const sep = base.includes('?') ? '&' : '?';
|
const sep = base.includes('?') ? '&' : '?';
|
||||||
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
|
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkShareLink returns the VK Mini App link (https://vk.com/app<id>) to share on VK, or null when the
|
||||||
|
* VK app id is unknown (outside a VK launch). VK forwards no custom payload through the app link to
|
||||||
|
* the iframe — the '#' form is eaten by the vk.com SPA and a '?' query is dropped (confirmed on the
|
||||||
|
* contour: only the signed vk_* params arrive) — so the link cannot carry the friend code; the
|
||||||
|
* recipient enters the copied code by hand.
|
||||||
|
*/
|
||||||
|
export function vkShareLink(): string | null {
|
||||||
|
const id = vkAppId();
|
||||||
|
if (!id) return null;
|
||||||
|
return `https://vk.com/app${id}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.exportGcg': 'Экспорт GCG',
|
'game.exportGcg': 'Экспорт GCG',
|
||||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||||
'game.addFriendShort': 'В друзья?',
|
'game.addFriendShort': 'В друзья?',
|
||||||
'game.blockShort': 'Блокируем?',
|
'game.blockShort': 'В бан?',
|
||||||
|
|
||||||
'time.minutes': '{n} мин',
|
'time.minutes': '{n} мин',
|
||||||
'time.hours': '{n} ч',
|
'time.hours': '{n} ч',
|
||||||
|
|||||||
@@ -142,6 +142,9 @@ export class MockGateway implements GatewayClient {
|
|||||||
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
|
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
|
||||||
return { ...SESSION, isGuest: false };
|
return { ...SESSION, isGuest: false };
|
||||||
}
|
}
|
||||||
|
async authVK(): Promise<Session> {
|
||||||
|
return { ...SESSION, isGuest: false };
|
||||||
|
}
|
||||||
async authGuest(): Promise<Session> {
|
async authGuest(): Promise<Session> {
|
||||||
return { ...SESSION };
|
return { ...SESSION };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { BOT_BUTTON_ID, botInfoPopup } from './nativedialogs';
|
||||||
|
|
||||||
|
describe('botInfoPopup', () => {
|
||||||
|
it('inlines the bot handle and adds an open-bot button', () => {
|
||||||
|
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', 'erudit_bot', 'OK');
|
||||||
|
expect(p.title).toBe('Title');
|
||||||
|
expect(p.message).toBe('Open the bot @erudit_bot to play.');
|
||||||
|
expect(p.buttons).toEqual([
|
||||||
|
{ id: BOT_BUTTON_ID, text: '@erudit_bot' },
|
||||||
|
{ id: 'ok', text: 'OK' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the token and the open-bot button when no username is known', () => {
|
||||||
|
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', '', 'OK');
|
||||||
|
expect(p.message).toBe('Open the bot to play.');
|
||||||
|
expect(p.buttons).toEqual([{ id: 'ok', text: 'OK' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves newlines in the message', () => {
|
||||||
|
const p = botInfoPopup('Hi', 'Welcome!\n\nUse {bot} now.', 'b', 'OK');
|
||||||
|
expect(p.message).toBe('Welcome!\n\nUse @b now.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Pure builder for the Telegram native popup (showPopup) used by the deep-link info modals
|
||||||
|
// (StaleInviteModal / WelcomeRedeemModal) inside the Mini App. Kept free of the SDK and the DOM so
|
||||||
|
// it unit-tests in the node environment; the showPopup transport wrapper lives in telegram.ts and
|
||||||
|
// the wiring (native inside Telegram, the in-app Modal elsewhere) in the modal components.
|
||||||
|
|
||||||
|
import type { TelegramPopupParams } from './telegram';
|
||||||
|
|
||||||
|
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
|
||||||
|
export const BOT_BUTTON_ID = 'bot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
|
||||||
|
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
|
||||||
|
* in-app Modal), plus an "open bot" button when a username is known and a closing OK button. With
|
||||||
|
* no username it is the message (token removed) and OK only.
|
||||||
|
*/
|
||||||
|
export function botInfoPopup(title: string, message: string, username: string, okText: string): TelegramPopupParams {
|
||||||
|
const handle = username ? `@${username}` : '';
|
||||||
|
const text = (username ? message.replace('{bot}', handle) : message.replace('{bot}', '').replace(' ', ' ')).trim();
|
||||||
|
const buttons = username
|
||||||
|
? [{ id: BOT_BUTTON_ID, text: handle }, { id: 'ok', text: okText }]
|
||||||
|
: [{ id: 'ok', text: okText }];
|
||||||
|
return { title, message: text, buttons };
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ import {
|
|||||||
routeExternalLinkInTelegram,
|
routeExternalLinkInTelegram,
|
||||||
telegramLaunch,
|
telegramLaunch,
|
||||||
telegramOpenExternalLink,
|
telegramOpenExternalLink,
|
||||||
|
telegramThemeParams,
|
||||||
|
telegramSafeAreaInset,
|
||||||
|
telegramShowSettingsButton,
|
||||||
|
telegramDialogsAvailable,
|
||||||
|
telegramShowConfirm,
|
||||||
|
telegramShowPopup,
|
||||||
} from './telegram';
|
} from './telegram';
|
||||||
|
|
||||||
function stubWebApp(initData: string, startParam?: string) {
|
function stubWebApp(initData: string, startParam?: string) {
|
||||||
@@ -44,6 +50,20 @@ describe('telegram launch detection', () => {
|
|||||||
expect(launch.startParam).toBe('g123');
|
expect(launch.startParam).toBe('g123');
|
||||||
expect(launch.theme?.bg_color).toBe('#101418');
|
expect(launch.theme?.bg_color).toBe('#101418');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('telegramThemeParams reads the live palette (undefined outside Telegram)', () => {
|
||||||
|
expect(telegramThemeParams()).toBeUndefined();
|
||||||
|
stubWebApp('query_id=abc');
|
||||||
|
expect(telegramThemeParams()?.bg_color).toBe('#101418');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramSafeAreaInset returns zeros outside Telegram and the SDK insets inside', () => {
|
||||||
|
expect(telegramSafeAreaInset()).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Telegram: { WebApp: { initData: 'x', safeAreaInset: { top: 59, bottom: 34, left: 0, right: 0 } } },
|
||||||
|
});
|
||||||
|
expect(telegramSafeAreaInset()).toEqual({ top: 59, bottom: 34, left: 0, right: 0 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('telegramOpenExternalLink', () => {
|
describe('telegramOpenExternalLink', () => {
|
||||||
@@ -61,6 +81,56 @@ describe('telegramOpenExternalLink', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('telegramShowSettingsButton', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('shows the native Settings button and wires its click inside Telegram', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const show = vi.fn();
|
||||||
|
const handler = vi.fn();
|
||||||
|
vi.stubGlobal('window', { Telegram: { WebApp: { SettingsButton: { onClick, show } } } });
|
||||||
|
telegramShowSettingsButton(handler);
|
||||||
|
expect(onClick).toHaveBeenCalledWith(handler);
|
||||||
|
expect(show).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op without the SDK button (older client / outside Telegram)', () => {
|
||||||
|
expect(() => telegramShowSettingsButton(() => {})).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('native dialogs', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('telegramDialogsAvailable reflects showPopup presence', () => {
|
||||||
|
expect(telegramDialogsAvailable()).toBe(false);
|
||||||
|
vi.stubGlobal('window', { Telegram: { WebApp: { showPopup: () => {} } } });
|
||||||
|
expect(telegramDialogsAvailable()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowConfirm resolves the user choice inside Telegram', async () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Telegram: { WebApp: { showConfirm: (_m: string, cb: (ok: boolean) => void) => cb(true) } },
|
||||||
|
});
|
||||||
|
await expect(telegramShowConfirm('Sure?')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowConfirm resolves false without the SDK dialog', async () => {
|
||||||
|
await expect(telegramShowConfirm('Sure?')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowPopup resolves the pressed button id', async () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
Telegram: { WebApp: { showPopup: (_p: unknown, cb: (id: string) => void) => cb('bot') } },
|
||||||
|
});
|
||||||
|
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBe('bot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telegramShowPopup resolves null without the SDK', async () => {
|
||||||
|
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('routeExternalLinkInTelegram', () => {
|
describe('routeExternalLinkInTelegram', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
|||||||
+115
-6
@@ -6,6 +6,20 @@
|
|||||||
|
|
||||||
import type { TelegramThemeParams } from './theme';
|
import type { TelegramThemeParams } from './theme';
|
||||||
|
|
||||||
|
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
|
||||||
|
export interface TelegramPopupButton {
|
||||||
|
id?: string;
|
||||||
|
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TelegramPopupParams configures a native showPopup (title optional, message required, up to 3 buttons). */
|
||||||
|
export interface TelegramPopupParams {
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
buttons?: TelegramPopupButton[];
|
||||||
|
}
|
||||||
|
|
||||||
interface TelegramWebApp {
|
interface TelegramWebApp {
|
||||||
initData: string;
|
initData: string;
|
||||||
initDataUnsafe?: { start_param?: string };
|
initDataUnsafe?: { start_param?: string };
|
||||||
@@ -41,6 +55,18 @@ interface TelegramWebApp {
|
|||||||
onClick?: (cb: () => void) => void;
|
onClick?: (cb: () => void) => void;
|
||||||
offClick?: (cb: () => void) => void;
|
offClick?: (cb: () => void) => void;
|
||||||
};
|
};
|
||||||
|
SettingsButton?: {
|
||||||
|
show?: () => void;
|
||||||
|
hide?: () => void;
|
||||||
|
onClick?: (cb: () => void) => void;
|
||||||
|
offClick?: (cb: () => void) => void;
|
||||||
|
};
|
||||||
|
CloudStorage?: {
|
||||||
|
getItem?: (key: string, cb: (err: string | null, value?: string) => void) => void;
|
||||||
|
setItem?: (key: string, value: string, cb?: (err: string | null, ok?: boolean) => void) => void;
|
||||||
|
};
|
||||||
|
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
||||||
|
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function webApp(): TelegramWebApp | undefined {
|
function webApp(): TelegramWebApp | undefined {
|
||||||
@@ -243,6 +269,15 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
|||||||
return webApp()?.colorScheme;
|
return webApp()?.colorScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramThemeParams returns Telegram's current theme palette (WebApp.themeParams), or undefined
|
||||||
|
* outside Telegram. It reads the live value rather than a launch snapshot, so the themeChanged
|
||||||
|
* event can re-apply the palette when the user switches Telegram's light/dark theme mid-session.
|
||||||
|
*/
|
||||||
|
export function telegramThemeParams(): TelegramThemeParams | undefined {
|
||||||
|
return webApp()?.themeParams;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
||||||
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
||||||
@@ -265,13 +300,15 @@ export function telegramContentSafeAreaTop(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* telegramSafeAreaTop returns the device safe-area top inset (px) — the notch / status bar
|
* telegramSafeAreaInset returns the device safe-area insets (px) — the notch / status bar (top),
|
||||||
* (Bot API 8.0). Telegram's own nav controls sit in the band between it and
|
* the home indicator (bottom) and, in landscape, the notch sides (left / right) — from the SDK's
|
||||||
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
|
* safeAreaInset (Bot API 8.0). All 0 outside Telegram or on a client predating it, so callers can
|
||||||
* outside Telegram or on older clients.
|
* pad defensively. Telegram's own nav controls sit in the band between the top inset and
|
||||||
|
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them.
|
||||||
*/
|
*/
|
||||||
export function telegramSafeAreaTop(): number {
|
export function telegramSafeAreaInset(): { top: number; bottom: number; left: number; right: number } {
|
||||||
return webApp()?.safeAreaInset?.top ?? 0;
|
const i = webApp()?.safeAreaInset;
|
||||||
|
return { top: i?.top ?? 0, bottom: i?.bottom ?? 0, left: i?.left ?? 0, right: i?.right ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -282,6 +319,78 @@ export function telegramDisableVerticalSwipes(): void {
|
|||||||
webApp()?.disableVerticalSwipes?.();
|
webApp()?.disableVerticalSwipes?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramShowSettingsButton reveals Telegram's native Settings button (in the Mini App's ⋮ menu,
|
||||||
|
* Bot API 7.0) and routes its taps to handler. A no-op outside Telegram or on a client predating
|
||||||
|
* the button, so the app's own in-app settings entry stays the primary path. The app registers it
|
||||||
|
* once per launch (Telegram hides the button when the Mini App closes), so there is no offClick.
|
||||||
|
*/
|
||||||
|
export function telegramShowSettingsButton(handler: () => void): void {
|
||||||
|
const b = webApp()?.SettingsButton;
|
||||||
|
if (!b?.show) return;
|
||||||
|
b.onClick?.(handler);
|
||||||
|
b.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** telegramCloudAvailable reports whether Telegram CloudStorage (Bot API 6.9) is usable. */
|
||||||
|
export function telegramCloudAvailable(): boolean {
|
||||||
|
return !!webApp()?.CloudStorage?.getItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramCloudGet reads a value from Telegram CloudStorage, resolving null when the key is absent,
|
||||||
|
* CloudStorage is unavailable (outside Telegram / a client predating Bot API 6.9), or the read
|
||||||
|
* errors — so the caller can fall back to the local value.
|
||||||
|
*/
|
||||||
|
export function telegramCloudGet(key: string): Promise<string | null> {
|
||||||
|
const cs = webApp()?.CloudStorage;
|
||||||
|
if (!cs?.getItem) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
cs.getItem!(key, (err, value) => resolve(err ? null : (value ?? null)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramCloudSet writes a value to Telegram CloudStorage, resolving once the write settles. It is
|
||||||
|
* best-effort: a no-op outside Telegram / on an older client, and it swallows write errors, since
|
||||||
|
* the local store remains the source of truth.
|
||||||
|
*/
|
||||||
|
export function telegramCloudSet(key: string, value: string): Promise<void> {
|
||||||
|
const cs = webApp()?.CloudStorage;
|
||||||
|
if (!cs?.setItem) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
cs.setItem!(key, value, () => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** telegramDialogsAvailable reports whether Telegram's native dialogs (showConfirm / showPopup, Bot
|
||||||
|
* API 6.2) are usable, so a caller can choose the native path over its own modal. */
|
||||||
|
export function telegramDialogsAvailable(): boolean {
|
||||||
|
return !!webApp()?.showPopup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramShowConfirm shows Telegram's native confirm dialog and resolves true when the user
|
||||||
|
* accepts. Resolves false outside Telegram or on a client predating the dialog, so callers should
|
||||||
|
* gate on telegramDialogsAvailable and fall back to their own modal otherwise.
|
||||||
|
*/
|
||||||
|
export function telegramShowConfirm(message: string): Promise<boolean> {
|
||||||
|
const w = webApp();
|
||||||
|
if (!w?.showConfirm) return Promise.resolve(false);
|
||||||
|
return new Promise((resolve) => w.showConfirm!(message, (ok) => resolve(!!ok)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
||||||
|
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
||||||
|
* predating the popup, so callers can fall back to their own modal.
|
||||||
|
*/
|
||||||
|
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
||||||
|
const w = webApp();
|
||||||
|
if (!w?.showPopup) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
/** Haptic is the set of feedbacks the app triggers. */
|
/** Haptic is the set of feedbacks the app triggers. */
|
||||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async authTelegram(initData) {
|
async authTelegram(initData) {
|
||||||
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
|
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
|
||||||
},
|
},
|
||||||
|
async authVK(params, displayName) {
|
||||||
|
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
|
||||||
|
},
|
||||||
async authGuest(locale) {
|
async authGuest(locale) {
|
||||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam } from './vk';
|
||||||
|
|
||||||
|
describe('vk launch detection', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('is not inside VK and not on the VK path without a location (node / SSR)', () => {
|
||||||
|
expect(onVKPath()).toBe(false);
|
||||||
|
expect(insideVK()).toBe(false);
|
||||||
|
expect(vkLaunchParams()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects the dedicated /vk/ entry path', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '' });
|
||||||
|
expect(onVKPath()).toBe(true);
|
||||||
|
vi.stubGlobal('location', { pathname: '/app/', search: '' });
|
||||||
|
expect(onVKPath()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the signed launch query only when a sign is present', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&vk_ts=2&sign=abc' });
|
||||||
|
expect(vkLaunchParams()).toBe('vk_user_id=1&vk_ts=2&sign=abc');
|
||||||
|
expect(insideVK()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a URL carrying no sign as not a VK launch', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?utm_source=catalog' });
|
||||||
|
expect(vkLaunchParams()).toBe('');
|
||||||
|
expect(insideVK()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vk launch params', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('reads the VK app id from the launch query', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_app_id=6736218&sign=x' });
|
||||||
|
expect(vkAppId()).toBe('6736218');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the direct-link deep-link payload from the hash query param', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x&hash=f123456' });
|
||||||
|
expect(vkStartParam()).toBe('f123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty app id / start param when absent', () => {
|
||||||
|
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x' });
|
||||||
|
expect(vkAppId()).toBe('');
|
||||||
|
expect(vkStartParam()).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// VK Mini App SDK access via @vkontakte/vk-bridge. The bridge is imported lazily inside the
|
||||||
|
// functions that need it — not at module top level — because the SDK reads browser globals on
|
||||||
|
// import: the lazy import keeps the pure URL helpers below importable in the node test environment,
|
||||||
|
// and code-splits the bridge into a chunk loaded only on the /vk/ entry. This wraps the subset the
|
||||||
|
// app uses: launch detection, the signed launch parameters (for auth.vk) and the user's display name
|
||||||
|
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
|
||||||
|
// call outside VK.
|
||||||
|
|
||||||
|
async function bridge() {
|
||||||
|
return (await import('@vkontakte/vk-bridge')).default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onVKPath reports whether the app is served under the dedicated VK entry path (/vk/).
|
||||||
|
*/
|
||||||
|
export function onVKPath(): boolean {
|
||||||
|
if (typeof location === 'undefined') return false;
|
||||||
|
return location.pathname.startsWith('/vk/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkLaunchParams returns the raw signed VK launch query string (the vk_* parameters plus sign) from
|
||||||
|
* the page URL — the exact form the gateway verifies — or '' when the URL carries no signed launch
|
||||||
|
* (an ordinary browser tab, or the /vk/ path opened directly).
|
||||||
|
*/
|
||||||
|
export function vkLaunchParams(): string {
|
||||||
|
if (typeof location === 'undefined') return '';
|
||||||
|
const query = location.search.replace(/^\?/, '');
|
||||||
|
return new URLSearchParams(query).has('sign') ? query : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* insideVK reports whether the app launched as a VK Mini App — the URL carries signed launch
|
||||||
|
* parameters (an ordinary browser tab has none).
|
||||||
|
*/
|
||||||
|
export function insideVK(): boolean {
|
||||||
|
return vkLaunchParams() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkInit signals to the VK client that the Mini App has loaded (VKWebAppInit), dismissing VK's own
|
||||||
|
* loading cover. Best-effort: it resolves even if the bridge is unavailable (outside VK), so the
|
||||||
|
* caller can await it unconditionally.
|
||||||
|
*/
|
||||||
|
export async function vkInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppInit', {});
|
||||||
|
} catch {
|
||||||
|
// Outside VK there is no client to receive it; the launch continues regardless.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it
|
||||||
|
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
|
||||||
|
* to a generated placeholder. The value is a cosmetic seed only — being unsigned, the gateway never
|
||||||
|
* trusts it for identity.
|
||||||
|
*/
|
||||||
|
export async function vkUserName(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const u = await (await bridge()).send('VKWebAppGetUserInfo', {});
|
||||||
|
return [u.first_name, u.last_name].filter(Boolean).join(' ').trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL —
|
||||||
|
* so the app can build its own vk.com/app<id> links without a VK API call — or '' outside a VK launch.
|
||||||
|
*/
|
||||||
|
export function vkAppId(): string {
|
||||||
|
if (typeof location === 'undefined') return '';
|
||||||
|
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a
|
||||||
|
* vk.com/app<id>#<payload> link to the app as the `hash` query parameter (it is also in location.hash,
|
||||||
|
* but that collides with the app's hash router, so the query parameter is the safe source). Empty when
|
||||||
|
* the launch carried no deep link.
|
||||||
|
*/
|
||||||
|
export function vkStartParam(): string {
|
||||||
|
if (typeof location === 'undefined') return '';
|
||||||
|
return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for
|
||||||
|
* navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was
|
||||||
|
* handled, false on any failure or outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkShare(link: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppShare', { link });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
|
||||||
|
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkCopyText(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await (await bridge()).send('VKWebAppCopyText', { text });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped
|
||||||
|
* 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's
|
||||||
|
* "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme.
|
||||||
|
* A no-op outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
const b = await bridge();
|
||||||
|
b.subscribe((e) => {
|
||||||
|
const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail;
|
||||||
|
if (detail?.type !== 'VKWebAppUpdateConfig') return;
|
||||||
|
const scheme = detail.data?.scheme ?? detail.data?.appearance ?? '';
|
||||||
|
handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Outside VK / bridge unavailable: leave the app on its own theme.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VKInsets is the device safe-area the VK client reports (px). */
|
||||||
|
export interface VKInsets {
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change.
|
||||||
|
* VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK
|
||||||
|
* mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them
|
||||||
|
* from the bridge to clear the home bar (notably on Android). A no-op outside VK.
|
||||||
|
*/
|
||||||
|
export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise<void> {
|
||||||
|
try {
|
||||||
|
const b = await bridge();
|
||||||
|
b.subscribe((e) => {
|
||||||
|
const d = (e as { detail?: { type?: string; data?: { insets?: Partial<VKInsets> } } }).detail;
|
||||||
|
if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return;
|
||||||
|
const i = d.data?.insets;
|
||||||
|
if (!i) return;
|
||||||
|
handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 });
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was
|
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was
|
||||||
// briefly down during a deploy). Inside Telegram there is no web login to fall back to, so this
|
// briefly down during a deploy). Inside a Mini App (Telegram or VK) there is no web login to fall
|
||||||
// terminal screen offers a manual Retry that re-runs the launch (app.svelte retryTelegramBoot).
|
// back to, so this terminal screen offers a manual Retry that re-runs the launch (app.svelte
|
||||||
import { retryTelegramBoot } from '../lib/app.svelte';
|
// retryMiniAppBoot).
|
||||||
|
import { retryMiniAppBoot } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
let retrying = $state(false);
|
let retrying = $state(false);
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
if (retrying) return;
|
if (retrying) return;
|
||||||
retrying = true;
|
retrying = true;
|
||||||
try {
|
try {
|
||||||
await retryTelegramBoot();
|
await retryMiniAppBoot();
|
||||||
} finally {
|
} finally {
|
||||||
retrying = false;
|
retrying = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { GatewayError } from '../lib/client';
|
import { GatewayError } from '../lib/client';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
import { friendCodeParam, shareLink, vkShareLink } from '../lib/deeplink';
|
||||||
import { shareTelegramLink } from '../lib/telegram';
|
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
||||||
|
import { insideVK, vkCopyText, vkShare } from '../lib/vk';
|
||||||
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
||||||
|
|
||||||
let friends = $state<AccountRef[]>([]);
|
let friends = $state<AccountRef[]>([]);
|
||||||
@@ -66,20 +67,36 @@
|
|||||||
|
|
||||||
// confirmBlock / confirmUnfriend run the pending action once its modal is
|
// confirmBlock / confirmUnfriend run the pending action once its modal is
|
||||||
// accepted, then clear the target and the revealed row.
|
// accepted, then clear the target and the revealed row.
|
||||||
function confirmBlock(): void {
|
function confirmBlock(target = blockTarget): void {
|
||||||
const target = blockTarget;
|
|
||||||
blockTarget = null;
|
blockTarget = null;
|
||||||
revealedId = null;
|
revealedId = null;
|
||||||
if (target) void blockUser(target.accountId);
|
if (target) void blockUser(target.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmUnfriend(): void {
|
function confirmUnfriend(target = unfriendTarget): void {
|
||||||
const target = unfriendTarget;
|
|
||||||
unfriendTarget = null;
|
unfriendTarget = null;
|
||||||
revealedId = null;
|
revealedId = null;
|
||||||
if (target) void remove(target.accountId);
|
if (target) void remove(target.accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onBlockClick / onUnfriendClick: inside the Mini App (and online) confirm with Telegram's native
|
||||||
|
// dialog and act on accept; otherwise open the in-app confirm modal (the offline / web path).
|
||||||
|
async function onBlockClick(f: AccountRef): Promise<void> {
|
||||||
|
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
if (await telegramShowConfirm(`${t('friends.blockConfirm')}\n${f.displayName}`)) confirmBlock(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blockTarget = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnfriendClick(f: AccountRef): Promise<void> {
|
||||||
|
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||||
|
if (await telegramShowConfirm(`${t('friends.unfriendConfirm')}\n${f.displayName}`)) confirmUnfriend(f);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unfriendTarget = f;
|
||||||
|
}
|
||||||
|
|
||||||
// While a friend row is slid open, a tap anywhere outside its action buttons
|
// While a friend row is slid open, a tap anywhere outside its action buttons
|
||||||
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
|
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -124,6 +141,11 @@
|
|||||||
|
|
||||||
async function copyCode() {
|
async function copyCode() {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
|
// Inside the VK iframe navigator.clipboard is blocked, so copy via VK Bridge there.
|
||||||
|
if (insideVK()) {
|
||||||
|
if (await vkCopyText(code.code)) showToast(t('friends.codeCopied'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code.code);
|
await navigator.clipboard.writeText(code.code);
|
||||||
showToast(t('friends.codeCopied'));
|
showToast(t('friends.codeCopied'));
|
||||||
@@ -137,6 +159,13 @@
|
|||||||
// copies the link to the clipboard.
|
// copies the link to the clipboard.
|
||||||
async function shareInvite(url: string) {
|
async function shareInvite(url: string) {
|
||||||
const text = t('friends.inviteText');
|
const text = t('friends.inviteText');
|
||||||
|
// Inside VK navigator.share is absent (desktop iframe) and the clipboard is blocked: share via VK
|
||||||
|
// Bridge, falling back to a VK-Bridge clipboard copy if the share is dismissed or unavailable.
|
||||||
|
if (insideVK()) {
|
||||||
|
if (await vkShare(url)) return;
|
||||||
|
if (await vkCopyText(url)) showToast(t('friends.linkCopied'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shareTelegramLink(url, text)) return;
|
if (shareTelegramLink(url, text)) return;
|
||||||
if (typeof navigator !== 'undefined' && navigator.share) {
|
if (typeof navigator !== 'undefined' && navigator.share) {
|
||||||
try {
|
try {
|
||||||
@@ -172,7 +201,7 @@
|
|||||||
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
||||||
</div>
|
</div>
|
||||||
{#if code}
|
{#if code}
|
||||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
{@const shareUrl = insideVK() ? vkShareLink() : shareLink(friendCodeParam(code.code))}
|
||||||
<div class="code" data-testid="friend-code">
|
<div class="code" data-testid="friend-code">
|
||||||
<div class="coderow">
|
<div class="coderow">
|
||||||
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
||||||
@@ -181,8 +210,8 @@
|
|||||||
<span class="codehint">
|
<span class="codehint">
|
||||||
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
||||||
</span>
|
</span>
|
||||||
{#if tg}
|
{#if shareUrl}
|
||||||
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
|
<button type="button" class="link" onclick={() => shareInvite(shareUrl)}>{t('friends.shareTelegram')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -216,8 +245,8 @@
|
|||||||
{#each friends as f (f.accountId)}
|
{#each friends as f (f.accountId)}
|
||||||
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
|
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
|
||||||
<div class="acts">
|
<div class="acts">
|
||||||
<button class="iconbtn" onclick={() => (blockTarget = f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
|
<button class="iconbtn" onclick={() => onBlockClick(f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
|
||||||
<button class="iconbtn" onclick={() => (unfriendTarget = f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
|
<button class="iconbtn" onclick={() => onUnfriendClick(f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="who">{f.displayName}</span>
|
<span class="who">{f.displayName}</span>
|
||||||
@@ -264,7 +293,7 @@
|
|||||||
<p class="confirm-name">{blockTarget.displayName}</p>
|
<p class="confirm-name">{blockTarget.displayName}</p>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
|
||||||
<button class="danger" onclick={confirmBlock} disabled={!connection.online}>{t('friends.block')}</button>
|
<button class="danger" onclick={() => confirmBlock()} disabled={!connection.online}>{t('friends.block')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -273,7 +302,7 @@
|
|||||||
<p class="confirm-name">{unfriendTarget.displayName}</p>
|
<p class="confirm-name">{unfriendTarget.displayName}</p>
|
||||||
<div class="confirm-row">
|
<div class="confirm-row">
|
||||||
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
|
||||||
<button class="danger" onclick={confirmUnfriend} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
<button class="danger" onclick={() => confirmUnfriend()} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user