chore(history): squash 67 commit(s) from 2026-09-25

- feat(accounts): persist device links with opaque hashed tokens, list and revoke endpoints
- feat(frontend): app shell, routing and landing page with the chat-command hero
- feat(frontend): Cyrillic-first fonts (Unbounded, Onest, JetBrains Mono); add i18next and motion
- docs: free mod, bilingual site, one-click download, theme editor, public profiles, rich landing in plans
- feat(accounts): internal gRPC AuthenticateDevice guarded by internal key
- feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch
- feat(accounts): GET /me profile endpoint
- feat(gateway): scaffold crate with config validation and health check
- feat(gateway): reverse proxy to accounts and configs services
- feat(gateway): resolve identity once from access JWT or device token via gRPC
- feat(gateway): per-route and global rate limits with Retry-After
- feat(gateway): CORS for the site origin; docs for gateway and internal contract
- feat(configs): scaffold service with schema, config validation and health check
- feat(configs): four config slots per account with list, get and save
- feat(configs): permanent share codes with regenerate and public load-by-code
- feat(accounts): GetPublicProfiles gRPC for showcase author info
- style(accounts,common): apply rustfmt to existing sources
- feat(configs): public showcase with publish, browse, detail and copy-to-slot
- feat(backend): public profile endpoint and showcase author filter
- fix(gateway): silence clippy collapsible-if and needless-ref warnings
- docs(backend): configs-service implemented; Подсистема 1 backend complete
- feat(mod): add Optimize module skeleton with OptimizeState holder
- feat(mod): gate glass blur behind Optimize no_glass knob
- feat(mod): cut MotionBlur and DoF sample counts behind lite_post knob
- feat(mod): trim procedural sky noise behind lite_sky knob
- feat(mod): drop fade gradients and digit rolls behind lean_hud knob
- docs(todo): mark Optimize module phase 9.2 complete
- refactor(mod): drop dead Renderer2D compatibility shims
- refactor(mod): prune unreachable Renderer2D overload towers
- refactor(mod): remove unused Renderer2D overloads and imports
- docs(todo): mark Renderer2D giant-splitting done (2179 to 1597)
- refactor(mod): extract shader id constants from LoVisualRenderPipelines
- docs(todo): record registry wave 2026-09-25 (Renderer2D, pipelines)
- refactor(mod): move Renderer2D instance state into base class
- refactor(mod): extract Renderer2DRounded drawing family
- refactor(mod): extract Renderer2DPath connector and chamfer family
- refactor(mod): extract Renderer2DShapes circle line and texture primitives
- refactor(mod): extract Renderer2DGlass and Renderer2DItem families
- refactor(mod): prune Renderer2D imports after facade split
- docs(todo): record Renderer2D facade inheritance split (1597 to 475)
- docs: easter eggs — .env honeypot, konami troll mode, devtools banner, IDDQD config, breakable 404 block, 418 teapot
- feat(mod): introduce surface style system core (SurfaceStyle, StyleSpec, StyleConfig, SurfaceRenderer)
- refactor(mod): delegate HudRenderUtil liquid glass draws to SurfaceRenderer (dedupe glass constants)
- refactor(mod): route bespoke glass call sites through SurfaceRenderer.plateSpec
- feat(mod): add Auto option to HUD bg effects via shared HudBgStyles resolution
- feat(mod): flat fallback for no-glass optimize mode and persist global HUD config
- feat(mod): default HUD bg effects to Auto so the global surface style drives widgets
- feat(mod): add global cycle-style hotkey with surface style notification
- feat(mod): add surface style swatch strip under the global style picker
- feat(gateway): reject ambiguous paths and answer .env probes with a honeypot
- fix(gateway): charge failed credentials against the rate limit, allow stale ones on /auth
- feat(frontend): ClickGui theme pipeline generated from the mod, live site theming
- feat(frontend): landing v2 hero — voxel/particle backdrop, live ClickGui, theme strip
- docs(todo): drop the FPS A/B measurement from phase 9.3, close phase 9
- feat(gateway): answer /coffee with a 418 teapot
- feat(frontend): land the rest of landing v2 — HUD, module wall, showcase, FAQ, footer
- feat(frontend): one-click download from GitHub releases, changelog page, release CI
- feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links
- fix(frontend): landing HUD playground now shows real mod widgets (fps, coordinates, module list, keybinds, ping)
- style(frontend): apply ClickGui glass effect to landing HUD playground widgets
- fix(frontend): prevent color field row overflow in theme editor grid
- fix(frontend): never attach stale bearer token to /auth/* requests
- fix(configs): unpublish/publish can no longer bypass moderation
- refactor(accounts): shrink auth/handlers.rs under the 250-line cap
- fix(accounts): tolerate concurrent refresh without killing every session
- fix(gateway): minor hardening from the backend review
- feat(configs): IDDQD easter egg config
This commit is contained in:
loki5512344 2026-09-25 20:22:13 +02:00
parent 72bc4c7148
commit 7f4b532f99
257 changed files with 13085 additions and 6582 deletions

55
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,55 @@
name: Release
# Publish a mod build on every version tag. Every release carries both a
# versioned jar and a stable `lovisual.jar`, so the site's one-click download
# link (releases/latest/download/lovisual.jar) always resolves.
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 25
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- name: Grant the Gradle wrapper the exec bit
run: chmod +x ./gradlew
- name: Build the mod
working-directory: mod
run: ./gradlew --no-daemon build
- name: Collect the remapped jar
id: jar
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
jar="$(find mod/build/libs -maxdepth 1 -type f -name '*.jar' \
! -name '*-sources.jar' ! -name '*-javadoc.jar' \
-printf '%s %p\n' | sort -rn | head -1 | cut -d' ' -f2-)"
test -n "$jar"
cp "$jar" lovisual-"$version".jar
cp "$jar" lovisual.jar
echo "version=$version" >>"$GITHUB_OUTPUT"
echo "Packaged $jar as lovisual-$version.jar and lovisual.jar"
- name: Publish the GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
generate_release_notes: true
files: |
lovisual-${{ steps.jar.outputs.version }}.jar
lovisual.jar

42
TODO.md
View file

@ -95,11 +95,25 @@ boilerplate на класс), а в Rust/TypeScript тот же объём ло
- [ ] Фаза 8.5: Аудит структуры ≤200/≤4 — 8.5.1 (папки >4) ЗАВЕРШЕНА 2026-09-10 - [ ] Фаза 8.5: Аудит структуры ≤200/≤4 — 8.5.1 (папки >4) ЗАВЕРШЕНА 2026-09-10
(0 папок >4, коммиты 8846d53…303b17e); далее 8.5.2 (гиганты >600), (0 папок >4, коммиты 8846d53…303b17e); далее 8.5.2 (гиганты >600),
план в `mod/TODO.md` план в `mod/TODO.md`
- [ ] Фаза 9: Оптимизация FPS — 9.1 (мёртвые грузы) ЗАВЕРШЕНА 2026-09-11 (137121e…4d60d3d); - [x] Фаза 9: Оптимизация FPS — 9.1 (мёртвые грузы) ЗАВЕРШЕНА 2026-09-11 (137121e…4d60d3d);
9.2 Optimize гейтится Фазой 8.5, 9.3 A/B-мерка needs запуск на Windows 9.2 Optimize ЗАВЕРШЕНА 2026-09-25 (ade82f06…8746074d, 4 тумблера + OptimizeState);
9.3 A/B-мерка убрана из плана (решение владельца 2026-09-25: нужен запуск
Minecraft на Windows, задачей не является) — фаза закрыта
- [ ] Фаза 10: Платформа (сайт + бэкенд) — Подсистемы 1/2/3 спроектированы 2026-09-23 - [ ] Фаза 10: Платформа (сайт + бэкенд) — Подсистемы 1/2/3 спроектированы 2026-09-23
(см. ниже). Реализация: план backend Подсистемы 1 в `backend/PLAN.md` (см. ниже). Реализация: план backend Подсистемы 1 в `backend/PLAN.md`
(написан 2026-09-23), frontend/Подсистема 2/3 — планы позже. (написан 2026-09-23), frontend/Подсистема 2/3 — планы позже.
Бэкенд 2026-09-25: Подсистема 1 backend ЗАВЕРШЕНА — `accounts-service`,
`common`, `gateway` (identity, рейт-лимиты, CORS, refresh-ротация,
device links) и `configs-service` (4 слота, share-коды, витрина
publish/browse/detail/copy, публичный `/users/{id}` с бейджем `early`);
планы: `backend/PLAN.md`, `backend/gateway/PLAN.md`,
`backend/configs-service/PLAN.md`; e2e через gateway проверен вручную.
Следующий шаг — фронтенд (`frontend/PLAN.md`) и интеграция мода.
Решения 2026-09-25: мод бесплатный (никаких цен/подписок/ключей на сайте);
сайт двуязычный ru/en (i18n); кнопка «Скачать» — прямая ссылка на
`releases/latest/download/lovisual.jar` в GitHub; в план сайта добавлены
редактор тем в браузере, страница «Скачать» + ченджлог, публичные профили;
лендинг — насыщенный (живой ClickGui/HUD, частицы), см. `frontend/PLAN.md`.
--- ---
@ -192,7 +206,9 @@ boilerplate на класс), а в Rust/TypeScript тот же объём ло
> Прим.: лаунчер и старый backend (Rust+axum) были удалены ранее (см. шапку файла) — > Прим.: лаунчер и старый backend (Rust+axum) были удалены ранее (см. шапку файла) —
> это НЕ восстановление старого кода, а новый дизайн с нуля под текущие требования. > это НЕ восстановление старого кода, а новый дизайн с нуля под текущие требования.
> Статус: дизайн готов, реализация backend начинается — план в `backend/PLAN.md`. > Статус: дизайн готов, backend Подсистемы 1 реализован 2026-09-25 (планы в
> `backend/PLAN.md`, `backend/gateway/PLAN.md`, `backend/configs-service/PLAN.md`);
> дальше — фронтенд (`frontend/PLAN.md`).
> Код — в выделенных `backend/` и `frontend/` (созданы 2026-09-23), отдельно от `mod/`. > Код — в выделенных `backend/` и `frontend/` (созданы 2026-09-23), отдельно от `mod/`.
### Границы (декомпозиция) ### Границы (декомпозиция)
@ -284,6 +300,26 @@ S3-совместимом хранилище (MinIO), не в Postgres.
- `device_token` хранится хэшированным в БД, как пароль - `device_token` хранится хэшированным в БД, как пароль
- Share-код — криптографически случайный, не инкрементный ID (не перебираем) - Share-код — криптографически случайный, не инкрементный ID (не перебираем)
## Пасхалки (решение владельца 2026-09-25)
1. **`.env`-ловушка** (gateway): запросы на `.env` (включая через `../`) → 200 + фейковый
шуточный `.env` («nice_try_skiddie…»), в апстрим не уходят, IP в лог. Делается вместе
с фиксом обхода лимитов через dot-segments.
2. **Konami code на сайте** (↑↑↓↓←→←→BA): ClickGui на главной включает TrollfaceMask,
всё уходит в радужную тему.
3. **Сообщение в консоли DevTools**: ASCII-логотип LoVisual + «Шаришь? Исходники
аддонов — на витрине».
4. **`IDDQD`**: `%config load IDDQD` в моде и `/configs/shared/IDDQD` на сайте отдают
шуточный конфиг «God mode» — всё выключено, кроме ChinaHat. Код не пересекается с
настоящими (в алфавите share-кодов нет `I`, длина 5 ≠ 8).
5. **404 = пустой мир**: падает блок, по клику «ломается» с частицами, как в игре.
6. **`/coffee` → 418 I'm a teapot** (gateway) со ссылкой на мод.
Планы: `frontend/PLAN.md` Task 11, `backend/configs-service/PLAN.md` Task 7,
`backend/gateway/PLAN.md` Task 12; мод-часть `%config load IDDQD` — в плане интеграции мода.
---
## Подсистема 2: RPC-чат + друзья + виджеты-телеметрия (дизайн от 2026-09-23) ## Подсистема 2: RPC-чат + друзья + виджеты-телеметрия (дизайн от 2026-09-23)
> Статус: спроектировано, реализация не начата. Требует realtime-слой > Статус: спроектировано, реализация не начата. Требует realtime-слой

View file

@ -2,6 +2,7 @@ DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/accounts_db
# at least 32 random bytes, e.g. `openssl rand -hex 32` # at least 32 random bytes, e.g. `openssl rand -hex 32`
JWT_SECRET= JWT_SECRET=
PORT=8081 PORT=8081
GRPC_PORT=50051
S3_ENDPOINT=http://localhost:9000 S3_ENDPOINT=http://localhost:9000
S3_BUCKET=lovisual-avatars S3_BUCKET=lovisual-avatars
S3_ACCESS_KEY=minioadmin S3_ACCESS_KEY=minioadmin
@ -10,3 +11,13 @@ S3_SECRET_KEY=minioadmin
INTERNAL_KEY= INTERNAL_KEY=
# Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev # Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev
COOKIE_SECURE=false COOKIE_SECURE=false
# gateway
GATEWAY_PORT=8080
ACCOUNTS_HTTP_URL=http://127.0.0.1:8081
ACCOUNTS_GRPC_URL=http://127.0.0.1:50051
CONFIGS_HTTP_URL=http://127.0.0.1:8082
SITE_ORIGIN=http://localhost:5173
TRUST_PROXY=false
# configs-service
CONFIGS_DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/configs_db
CONFIGS_PORT=8082

337
backend/Cargo.lock generated
View file

@ -13,7 +13,7 @@ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
"axum-test", "axum-test",
"base64", "base64 0.22.1",
"chrono", "chrono",
"common", "common",
"dashmap", "dashmap",
@ -27,7 +27,9 @@ dependencies = [
"sqlx", "sqlx",
"time", "time",
"tokio", "tokio",
"tower-http", "tokio-stream",
"tonic",
"tower-http 0.7.1",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
@ -725,6 +727,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "base64-simd" name = "base64-simd"
version = "0.8.0" version = "0.8.0"
@ -900,6 +908,27 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "configs-service"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum-test",
"chrono",
"common",
"dotenvy",
"rand 0.10.3",
"serde",
"serde_json",
"sqlx",
"tokio",
"tonic",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@ -1505,6 +1534,17 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.34" version = "0.3.34"
@ -1517,6 +1557,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.34" version = "0.3.34"
@ -1525,6 +1571,7 @@ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
"memchr", "memchr",
@ -1532,6 +1579,28 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "gateway"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum-test",
"common",
"dotenvy",
"governor",
"jsonwebtoken",
"reqwest",
"serde_json",
"tokio",
"tonic",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.9" version = "0.14.9"
@ -1554,6 +1623,20 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@ -1562,10 +1645,33 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
] ]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.5",
"smallvec",
"spinning_top",
"web-time",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@ -1879,7 +1985,7 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
@ -2117,7 +2223,7 @@ version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"ed25519-dalek", "ed25519-dalek",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac 0.12.1", "hmac 0.12.1",
@ -2319,6 +2425,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@ -2613,6 +2725,12 @@ dependencies = [
"miniz_oxide 0.8.9", "miniz_oxide 0.8.9",
] ]
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.6" version = "0.1.6"
@ -2754,6 +2872,21 @@ version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "2.0.1" version = "2.0.1"
@ -2769,6 +2902,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "6.0.0" version = "6.0.0"
@ -2782,10 +2921,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.3" version = "0.10.3"
@ -2807,6 +2956,16 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.6.4" version = "0.6.4"
@ -2816,12 +2975,30 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.10.1" version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@ -2866,6 +3043,38 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [
"base64 0.23.1",
"bytes",
"futures-core",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"hyper 1.11.1",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tokio-util",
"tower",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
]
[[package]] [[package]]
name = "reserve-port" name = "reserve-port"
version = "2.5.0" version = "2.5.0"
@ -3313,6 +3522,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "spki" name = "spki"
version = "0.7.3" version = "0.7.3"
@ -3342,7 +3560,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"bytes", "bytes",
"cfg-if", "cfg-if",
"chrono", "chrono",
@ -3448,7 +3666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64 0.22.1",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono", "chrono",
@ -3554,6 +3772,9 @@ name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]] [[package]]
name = "synstructure" name = "synstructure"
@ -3734,7 +3955,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"base64", "base64 0.22.1",
"bytes", "bytes",
"h2 0.4.19", "h2 0.4.19",
"http 1.5.0", "http 1.5.0",
@ -3813,6 +4034,24 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"url",
]
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.7.1" version = "0.7.1"
@ -4056,6 +4295,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.128" version = "0.2.128"
@ -4069,6 +4317,16 @@ dependencies = [
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.128" version = "0.2.128"
@ -4101,6 +4359,39 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-streams"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.9" version = "1.0.9"
@ -4116,6 +4407,28 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@ -4257,6 +4570,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"

View file

@ -3,11 +3,11 @@ resolver = "2"
members = [ members = [
"accounts-service", "accounts-service",
"common", "common",
"configs-service",
"gateway",
] ]
# Planned members, added when their own implementation plan starts # Planned members, added when their own implementation plan starts
# (see backend/STRUCTURE.md and TODO.md Фаза 10 / Подсистемы 1-3): # (see backend/STRUCTURE.md and TODO.md Фаза 10 / Подсистемы 1-3):
# "gateway" — API gateway: routing + single JWT check point + rate limits
# "configs-service" — 4 config slots, share codes, showcase (Подсистема 1)
# "chat-service" — RPC chat, friends, presence, telemetry widgets (Подсистема 2) # "chat-service" — RPC chat, friends, presence, telemetry widgets (Подсистема 2)
# "addons-registry" — addon marketplace backend (Подсистема 3) # "addons-registry" — addon marketplace backend (Подсистема 3)

View file

@ -8,12 +8,15 @@ Rust workspace (`backend/Cargo.toml`), один сервис — один кре
backend/ backend/
Cargo.toml # workspace root — members растёт по мере реализации Cargo.toml # workspace root — members растёт по мере реализации
STRUCTURE.md # этот файл STRUCTURE.md # этот файл
accounts-service/ # РЕАЛИЗУЕТСЯ — backend/PLAN.md (Подсистема 1, часть 1) common/ # РЕАЛИЗОВАН — общий контракт: JWT, внутренние заголовки,
gateway/ # ПЛАН: gateway/PLAN.md — routing + единая точка JWT-проверки # proto/accounts.proto (gRPC AuthenticateDevice,
# + рейт-лимиты (TODO.md §6). Начинается после # GetPublicProfiles для авторов витрины)
# accounts-service, т.к. фронтит уже готовый сервис. accounts-service/ # РЕАЛИЗОВАН — backend/PLAN.md (Подсистема 1, часть 1)
configs-service/ # ПЛАН: configs-service/PLAN.md — 4 слота конфигов, share-коды, gateway/ # РЕАЛИЗОВАН — gateway/PLAN.md: единственная публичная
# витрина (Подсистема 1, часть 2) # точка, identity, рейт-лимиты, CORS, reverse proxy
configs-service/ # РЕАЛИЗОВАН — configs-service/PLAN.md: 4 слота конфигов,
# share-коды, витрина publish/browse/detail/copy
# (Подсистема 1, часть 2)
chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence, chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence,
# виджеты-телеметрия (Подсистема 2). Единственный # виджеты-телеметрия (Подсистема 2). Единственный
# сервис с WebSocket, а не только REST. # сервис с WebSocket, а не только REST.
@ -21,6 +24,14 @@ backend/
# публикация/модерация (Подсистема 3) # публикация/модерация (Подсистема 3)
``` ```
## Внутренний контракт (gateway ↔ сервисы)
Гейтвей — единственная публичная точка: он один раз резолвит вызывавшего
(JWT-access или `lvd_`-device-токен через gRPC `AuthenticateDevice`) и кладёт
аккаунт в заголовок `x-lovisual-account-id`; каждый запрос несёт общий секрет
`x-lovisual-internal-key`. Сервисы отвергают всё без этого ключа (403), поэтому
напрямую в них стучаться бесполезно. Снаружи — только REST/JSON на `api.<domain>`,
внутри — тот же REST сервисов + gRPC там, где публичного REST нет.
## Почему не добавлены в `[workspace] members` сразу ## Почему не добавлены в `[workspace] members` сразу
Пустой крейт без реализации — то же самое "без пустышек", что запрещено Пустой крейт без реализации — то же самое "без пустышек", что запрещено
правилами мода (см. `TODO.md`) — просто в Rust-обёртке: `cargo build правилами мода (см. `TODO.md`) — просто в Rust-обёртке: `cargo build
@ -28,13 +39,14 @@ backend/
сервис входит в `members` в своём implementation-плане первым шагом (как сервис входит в `members` в своём implementation-плане первым шагом (как
`accounts-service` в Task 1 `backend/PLAN.md`), не раньше. `accounts-service` в Task 1 `backend/PLAN.md`), не раньше.
## Общий код между сервисами (`common`, пока не создан) ## Общий код между сервисами (`common`, создан 2026-09-24)
Кандидаты на вынос в `backend/common/` **когда появится второй реальный Второй реальный потребитель появился вместе с `gateway`, поэтому общий код
потребитель** (не раньше — YAGNI): JWT `Claims`/`verify_token` (сейчас уже вынесен: JWT `Claims`/`issue_access_token`/`verify_token`/`bearer_token`,
только в `accounts-service`, но `gateway` тоже будет его проверять — при внутренний контракт (`require_internal_key`, `GatewayIdentity`, gRPC-перехватчики
старте `gateway` вынести в `common`), типовой `AppError`. До этого момента ключа) и `proto/accounts.proto`. Следующий кандидат — типовой `AppError`:
дублирование двух сервисов — не проблема, преждевременная общая библиотека сознательно НЕ вынесен, т.к. маппинг `From<sqlx::Error>` сервис-специфичен,
между одним реальным потребителем — проблема. а `sqlx` не должен попадать в `gateway` (см. Global Constraints
`configs-service/PLAN.md`).
## Модульная структура внутри сервиса (эталон — `accounts-service`) ## Модульная структура внутри сервиса (эталон — `accounts-service`)
Домен, не технический слой: `auth/`, `accounts/`, `device/`, `avatars/` — Домен, не технический слой: `auth/`, `accounts/`, `device/`, `avatars/` —

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,8 @@ aws-config = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
anyhow = "1" anyhow = "1"
dotenvy = "0.15" dotenvy = "0.15"
tonic = "0.14"
[dev-dependencies] [dev-dependencies]
axum-test = "21" axum-test = "21"
tokio-stream = { version = "0.1", features = ["net"] }

View file

@ -0,0 +1,2 @@
-- Device tokens are looked up by hash on every mod request (via gateway gRPC).
CREATE UNIQUE INDEX device_links_token_hash_idx ON device_links (device_token_hash);

View file

@ -0,0 +1,14 @@
-- Tracks when a refresh token was revoked specifically *by rotation* (as
-- opposed to logout or reuse-detection), so a short grace window can
-- tolerate two concurrent refreshes of the same token (e.g. two tabs)
-- without treating the second one as token theft.
ALTER TABLE refresh_tokens ADD COLUMN rotated_at TIMESTAMPTZ;
-- Used by rotate_refresh to decide whether a just-rotated token is still
-- within the grace window.
CREATE INDEX idx_refresh_tokens_rotated_at ON refresh_tokens(rotated_at)
WHERE rotated_at IS NOT NULL;
-- Used by the accounts::repo "early" badge (count of accounts created
-- before a given account's created_at).
CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at);

View file

@ -0,0 +1,88 @@
use super::repo;
use crate::error::AppError;
use axum::{
Json,
extract::{Path, State},
};
use chrono::{DateTime, Utc};
use common::internal::GatewayIdentity;
use serde::Serialize;
use uuid::Uuid;
#[derive(Clone)]
pub struct AccountsState {
pub pool: sqlx::PgPool,
pub avatar_base_url: String,
}
#[derive(Serialize)]
pub struct MeResponse {
pub id: Uuid,
pub email: String,
pub display_nick: String,
pub role: String,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
}
pub async fn me(
State(state): State<AccountsState>,
identity: GatewayIdentity,
) -> Result<Json<MeResponse>, AppError> {
let account = repo::find_by_id(&state.pool, identity.account_id)
.await?
.ok_or(AppError::Unauthorized)?;
let avatar_url = repo::avatar_key(&state.pool, account.id)
.await?
.map(|key| format!("{}/{key}", state.avatar_base_url));
Ok(Json(MeResponse {
id: account.id,
email: account.email,
display_nick: account.display_nick,
role: account.role,
avatar_url,
created_at: account.created_at,
}))
}
/// Public profile for the site's `/u/:id` page: no identity required, and no
/// private fields (email, role) — only what showcase viewers may see.
#[derive(Serialize)]
pub struct PublicProfileResponse {
pub id: Uuid,
pub display_nick: String,
pub avatar_url: Option<String>,
pub created_at: DateTime<Utc>,
pub badges: Vec<String>,
}
/// Accounts are handed out in `created_at` order, so the first 1000 to sign
/// up get the `early` badge.
const EARLY_ADOPTER_LIMIT: i64 = 1000;
pub async fn public_profile(
State(state): State<AccountsState>,
Path(raw): Path<String>,
) -> Result<Json<PublicProfileResponse>, AppError> {
let not_found = || AppError::NotFound("no such account".into());
let id = Uuid::parse_str(&raw).map_err(|_| not_found())?;
let account = repo::find_by_id(&state.pool, id)
.await?
.ok_or_else(not_found)?;
let avatar_url = repo::avatar_key(&state.pool, account.id)
.await?
.map(|key| format!("{}/{key}", state.avatar_base_url));
let rank = repo::account_rank(&state.pool, account.created_at).await?;
let badges = if rank < EARLY_ADOPTER_LIMIT {
vec!["early".to_owned()]
} else {
Vec::new()
};
Ok(Json(PublicProfileResponse {
id: account.id,
display_nick: account.display_nick,
avatar_url,
created_at: account.created_at,
badges,
}))
}

View file

@ -1,2 +1,3 @@
pub mod handlers;
pub mod model; pub mod model;
pub mod repo; pub mod repo;

View file

@ -1,3 +1,5 @@
use crate::auth::password::MAX_PASSWORD_BYTES;
use crate::error::AppError;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
@ -13,3 +15,106 @@ pub struct Account {
pub can_publish_addons: bool, pub can_publish_addons: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// Validates and normalizes a registration request. Returns the trimmed
/// `(email, nick)` on success. Lives here (rather than `auth::handlers`) so
/// that handler file stays small and this stays testable independent of axum.
pub fn validate_register(
email: &str,
password: &str,
nick: &str,
) -> Result<(String, String), AppError> {
let email = email.trim();
if email.is_empty() {
return Err(AppError::Validation("email must not be empty".into()));
}
if email.len() > 254 {
return Err(AppError::Validation(
"email must be at most 254 characters".into(),
));
}
let mut parts = email.split('@');
let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
return Err(AppError::Validation("email must contain '@'".into()));
};
if parts.next().is_some() || local.is_empty() || domain.is_empty() {
return Err(AppError::Validation(
"email must have exactly one '@' with non-empty parts".into(),
));
}
let nick = nick.trim();
let nick_len = nick.chars().count();
if nick_len == 0 || nick_len > 32 {
return Err(AppError::Validation(
"nick must be 1 to 32 characters".into(),
));
}
if nick.chars().any(|c| c.is_control()) {
return Err(AppError::Validation(
"nick must not contain control characters".into(),
));
}
if password.chars().count() < 8 {
return Err(AppError::Validation(
"password must be at least 8 characters".into(),
));
}
if password.len() > MAX_PASSWORD_BYTES {
return Err(AppError::Validation(format!(
"password must be at most {MAX_PASSWORD_BYTES} bytes"
)));
}
Ok((email.to_string(), nick.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn valid() -> (String, String, String) {
(
"user@example.com".into(),
"password123".into(),
"Rider".into(),
)
}
#[test]
fn valid_request_passes() {
let (email, password, nick) = valid();
assert!(validate_register(&email, &password, &nick).is_ok());
}
#[test]
fn short_password_is_rejected() {
let (email, _, nick) = valid();
assert!(validate_register(&email, "short12", &nick).is_err());
}
#[test]
fn empty_email_is_rejected() {
let (_, password, nick) = valid();
assert!(validate_register(" ", &password, &nick).is_err());
}
#[test]
fn email_without_at_is_rejected() {
let (_, password, nick) = valid();
assert!(validate_register("not-an-email", &password, &nick).is_err());
}
#[test]
fn empty_nick_is_rejected() {
let (email, password, _) = valid();
assert!(validate_register(&email, &password, " ").is_err());
}
#[test]
fn thirty_three_char_nick_is_rejected() {
let (email, password, _) = valid();
assert!(validate_register(&email, &password, &"a".repeat(33)).is_err());
}
}

View file

@ -58,6 +58,40 @@ pub async fn set_avatar(pool: &PgPool, account_id: Uuid, s3_key: &str) -> Result
Ok(()) Ok(())
} }
pub async fn avatar_key(pool: &PgPool, account_id: Uuid) -> Result<Option<String>, sqlx::Error> {
sqlx::query_scalar("SELECT s3_key FROM avatars WHERE account_id = $1")
.bind(account_id)
.fetch_optional(pool)
.await
}
/// Nick + avatar key for the given accounts (showcase authors etc.).
/// Missing ids are simply absent from the result.
pub async fn public_profiles(
pool: &PgPool,
ids: &[Uuid],
) -> Result<Vec<(Uuid, String, Option<String>)>, sqlx::Error> {
sqlx::query_as(
"SELECT a.id, a.display_nick, av.s3_key FROM accounts a
LEFT JOIN avatars av ON av.account_id = a.id
WHERE a.id = ANY($1)",
)
.bind(ids)
.fetch_all(pool)
.await
}
/// How many accounts existed before `created_at`; 0 means the very first one.
pub async fn account_rank(
pool: &PgPool,
created_at: chrono::DateTime<chrono::Utc>,
) -> Result<i64, sqlx::Error> {
sqlx::query_scalar("SELECT count(*) FROM accounts WHERE created_at < $1")
.bind(created_at)
.fetch_one(pool)
.await
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -66,7 +100,10 @@ mod tests {
let url = std::env::var("DATABASE_URL") let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into()); .unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = PgPool::connect(&url).await.expect("connect"); let pool = PgPool::connect(&url).await.expect("connect");
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate"); sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrate");
pool pool
} }
@ -80,7 +117,10 @@ mod tests {
assert_eq!(created.role, "user"); assert_eq!(created.role, "user");
assert!(created.can_publish_addons); assert!(created.can_publish_addons);
let found = find_by_email(&pool, &email).await.unwrap().expect("must exist"); let found = find_by_email(&pool, &email)
.await
.unwrap()
.expect("must exist");
assert_eq!(found.id, created.id); assert_eq!(found.id, created.id);
sqlx::query("DELETE FROM accounts WHERE id = $1") sqlx::query("DELETE FROM accounts WHERE id = $1")
@ -93,7 +133,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn find_by_email_returns_none_for_missing() { async fn find_by_email_returns_none_for_missing() {
let pool = test_pool().await; let pool = test_pool().await;
let result = find_by_email(&pool, "does-not-exist@example.com").await.unwrap(); let result = find_by_email(&pool, "does-not-exist@example.com")
.await
.unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
@ -123,7 +165,9 @@ mod tests {
async fn duplicate_email_differing_only_by_case_is_rejected() { async fn duplicate_email_differing_only_by_case_is_rejected() {
let pool = test_pool().await; let pool = test_pool().await;
let tag = Uuid::new_v4(); let tag = Uuid::new_v4();
let first = create(&pool, &format!("dup-{tag}@example.com"), "h", "A").await.unwrap(); let first = create(&pool, &format!("dup-{tag}@example.com"), "h", "A")
.await
.unwrap();
let second = create(&pool, &format!("DUP-{tag}@EXAMPLE.com"), "h", "B").await; let second = create(&pool, &format!("DUP-{tag}@EXAMPLE.com"), "h", "B").await;
let err = second.expect_err("case-variant duplicate must violate the unique index"); let err = second.expect_err("case-variant duplicate must violate the unique index");
@ -144,12 +188,21 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn set_avatar_inserts_then_updates_in_place() { async fn set_avatar_inserts_then_updates_in_place() {
let pool = test_pool().await; let pool = test_pool().await;
let created = create(&pool, &format!("av-{}@example.com", Uuid::new_v4()), "h", "N") let created = create(
&pool,
&format!("av-{}@example.com", Uuid::new_v4()),
"h",
"N",
)
.await
.unwrap();
set_avatar(&pool, created.id, "avatars/one.png")
.await
.unwrap();
set_avatar(&pool, created.id, "avatars/two.png")
.await .await
.unwrap(); .unwrap();
set_avatar(&pool, created.id, "avatars/one.png").await.unwrap();
set_avatar(&pool, created.id, "avatars/two.png").await.unwrap();
let rows: Vec<(String,)> = let rows: Vec<(String,)> =
sqlx::query_as("SELECT s3_key FROM avatars WHERE account_id = $1") sqlx::query_as("SELECT s3_key FROM avatars WHERE account_id = $1")

View file

@ -1,7 +1,8 @@
use crate::accounts::model::validate_register;
use crate::accounts::repo; use crate::accounts::repo;
use crate::auth::{password, tokens}; use crate::auth::{password, tokens};
use crate::error::{AppError, AppJson}; use crate::error::{AppError, AppJson};
use axum::{extract::State, http::StatusCode, Json}; use axum::{Json, extract::State, http::StatusCode};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use common::jwt; use common::jwt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -49,52 +50,16 @@ pub struct RegisterResponse {
pub display_nick: String, pub display_nick: String,
} }
/// Validates and normalizes a register request. Returns the trimmed
/// `(email, nick)` on success.
fn validate_register(req: &RegisterRequest) -> Result<(String, String), AppError> {
let email = req.email.trim();
if email.is_empty() {
return Err(AppError::Validation("email must not be empty".into()));
}
if email.len() > 254 {
return Err(AppError::Validation("email must be at most 254 characters".into()));
}
let mut parts = email.split('@');
let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
return Err(AppError::Validation("email must contain '@'".into()));
};
if parts.next().is_some() || local.is_empty() || domain.is_empty() {
return Err(AppError::Validation("email must have exactly one '@' with non-empty parts".into()));
}
let nick = req.nick.trim();
let nick_len = nick.chars().count();
if nick_len == 0 || nick_len > 32 {
return Err(AppError::Validation("nick must be 1 to 32 characters".into()));
}
if nick.chars().any(|c| c.is_control()) {
return Err(AppError::Validation("nick must not contain control characters".into()));
}
if req.password.chars().count() < 8 {
return Err(AppError::Validation("password must be at least 8 characters".into()));
}
if req.password.len() > password::MAX_PASSWORD_BYTES {
return Err(AppError::Validation(format!(
"password must be at most {} bytes",
password::MAX_PASSWORD_BYTES
)));
}
Ok((email.to_string(), nick.to_string()))
}
pub async fn register( pub async fn register(
State(state): State<AuthState>, State(state): State<AuthState>,
AppJson(req): AppJson<RegisterRequest>, AppJson(req): AppJson<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>), AppError> { ) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
let (email, nick) = validate_register(&req)?; let (email, nick) = validate_register(&req.email, &req.password, &req.nick)?;
let hash = state.hasher.hash(req.password.clone()).await.map_err(AppError::Internal)?; let hash = state
.hasher
.hash(req.password.clone())
.await
.map_err(AppError::Internal)?;
let account = repo::create(&state.pool, &email, &hash, &nick).await?; let account = repo::create(&state.pool, &email, &hash, &nick).await?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
@ -142,7 +107,9 @@ pub async fn login(
let refresh = tokens::store_refresh(&state.pool, account.id).await?; let refresh = tokens::store_refresh(&state.pool, account.id).await?;
Ok(( Ok((
jar.add(tokens::refresh_cookie(refresh, state.cookie_secure)), jar.add(tokens::refresh_cookie(refresh, state.cookie_secure)),
Json(LoginResponse { access_token: jwt::issue_access_token(account.id, &state.jwt_secret) }), Json(LoginResponse {
access_token: jwt::issue_access_token(account.id, &state.jwt_secret),
}),
)) ))
} }
@ -150,11 +117,19 @@ pub async fn refresh(
State(state): State<AuthState>, State(state): State<AuthState>,
jar: CookieJar, jar: CookieJar,
) -> Result<(CookieJar, Json<LoginResponse>), AppError> { ) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
let token = jar.get(tokens::REFRESH_COOKIE).map(|c| c.value().to_owned()).ok_or(AppError::Unauthorized)?; let token = jar
.get(tokens::REFRESH_COOKIE)
.map(|c| c.value().to_owned())
.ok_or(AppError::Unauthorized)?;
match tokens::rotate_refresh(&state.pool, &token).await? { match tokens::rotate_refresh(&state.pool, &token).await? {
tokens::RotateOutcome::Rotated { account_id, new_token } => Ok(( tokens::RotateOutcome::Rotated {
account_id,
new_token,
} => Ok((
jar.add(tokens::refresh_cookie(new_token, state.cookie_secure)), jar.add(tokens::refresh_cookie(new_token, state.cookie_secure)),
Json(LoginResponse { access_token: jwt::issue_access_token(account_id, &state.jwt_secret) }), Json(LoginResponse {
access_token: jwt::issue_access_token(account_id, &state.jwt_secret),
}),
)), )),
tokens::RotateOutcome::Invalid => Err(AppError::Unauthorized), tokens::RotateOutcome::Invalid => Err(AppError::Unauthorized),
} }
@ -168,60 +143,7 @@ pub async fn logout(
tokens::revoke_refresh(&state.pool, cookie.value()).await?; tokens::revoke_refresh(&state.pool, cookie.value()).await?;
} }
Ok(( Ok((
jar.remove(axum_extra::extract::cookie::Cookie::build(tokens::REFRESH_COOKIE).path("/auth")), jar.add(tokens::removal_cookie(state.cookie_secure)),
StatusCode::NO_CONTENT, StatusCode::NO_CONTENT,
)) ))
} }
#[cfg(test)]
mod tests {
use super::*;
fn valid_request() -> RegisterRequest {
RegisterRequest {
email: "user@example.com".into(),
password: "password123".into(),
nick: "Rider".into(),
}
}
#[test]
fn valid_request_passes() {
assert!(validate_register(&valid_request()).is_ok());
}
#[test]
fn short_password_is_rejected() {
let mut req = valid_request();
req.password = "short12".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn empty_email_is_rejected() {
let mut req = valid_request();
req.email = " ".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn email_without_at_is_rejected() {
let mut req = valid_request();
req.email = "not-an-email".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn empty_nick_is_rejected() {
let mut req = valid_request();
req.nick = " ".into();
assert!(validate_register(&req).is_err());
}
#[test]
fn thirty_three_char_nick_is_rejected() {
let mut req = valid_request();
req.nick = "a".repeat(33);
assert!(validate_register(&req).is_err());
}
}

View file

@ -1,3 +1,3 @@
pub mod handlers;
pub mod password; pub mod password;
pub mod tokens; pub mod tokens;
pub mod handlers;

View file

@ -64,7 +64,9 @@ impl PasswordHasher {
let permits = std::thread::available_parallelism() let permits = std::thread::available_parallelism()
.map(|n| n.get()) .map(|n| n.get())
.unwrap_or(2); .unwrap_or(2);
PasswordHasher { permits: Arc::new(Semaphore::new(permits)) } PasswordHasher {
permits: Arc::new(Semaphore::new(permits)),
}
} }
/// Runs Argon2 hashing off the async workers, bounded by the permit count. /// Runs Argon2 hashing off the async workers, bounded by the permit count.

View file

@ -1,10 +1,17 @@
use axum_extra::extract::cookie::{Cookie, SameSite}; use axum_extra::extract::cookie::{Cookie, SameSite};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use rand::RngExt; use rand::RngExt;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
/// A refresh token revoked by rotation less than this long ago is treated as
/// a benign race between two concurrent refreshes of the same token (e.g.
/// two open tabs), not token theft: the second caller gets a plain 401
/// without the reuse-detection cascade that would kill every session.
const ROTATION_GRACE: chrono::Duration = chrono::Duration::seconds(10);
pub const REFRESH_COOKIE: &str = "lv_refresh"; pub const REFRESH_COOKIE: &str = "lv_refresh";
/// 256-bit random token: nothing to brute-force, so a slow hash would only /// 256-bit random token: nothing to brute-force, so a slow hash would only
@ -39,8 +46,8 @@ pub enum RotateOutcome {
pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome, sqlx::Error> { pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome, sqlx::Error> {
let mut tx = pool.begin().await?; let mut tx = pool.begin().await?;
let row: Option<(Uuid, bool, bool)> = sqlx::query_as( let row: Option<(Uuid, bool, bool, Option<DateTime<Utc>>)> = sqlx::query_as(
"SELECT account_id, revoked_at IS NOT NULL, expires_at <= now() "SELECT account_id, revoked_at IS NOT NULL, expires_at <= now(), rotated_at
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE", FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
) )
.bind(hash_token(token)) .bind(hash_token(token))
@ -49,8 +56,16 @@ pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome,
let outcome = match row { let outcome = match row {
None => RotateOutcome::Invalid, None => RotateOutcome::Invalid,
Some((account_id, true, _)) => { Some((_, true, _, Some(rotated_at))) if Utc::now() - rotated_at < ROTATION_GRACE => {
// Reuse of a rotated token: someone else holds a copy. Kill all sessions. // Two concurrent refreshes of the same token (e.g. two tabs): the
// first already rotated it moments ago. Reject this one without
// the reuse-detection cascade, so the first caller's new token
// (and every other session) stays valid.
RotateOutcome::Invalid
}
Some((account_id, true, _, _)) => {
// Reuse of a rotated token outside the grace window: someone else
// holds a copy. Kill all sessions.
sqlx::query( sqlx::query(
"UPDATE refresh_tokens SET revoked_at = now() "UPDATE refresh_tokens SET revoked_at = now()
WHERE account_id = $1 AND revoked_at IS NULL", WHERE account_id = $1 AND revoked_at IS NULL",
@ -60,12 +75,15 @@ pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome,
.await?; .await?;
RotateOutcome::Invalid RotateOutcome::Invalid
} }
Some((_, false, true)) => RotateOutcome::Invalid, Some((_, false, true, _)) => RotateOutcome::Invalid,
Some((account_id, false, false)) => { Some((account_id, false, false, _)) => {
sqlx::query("UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1") sqlx::query(
.bind(hash_token(token)) "UPDATE refresh_tokens SET revoked_at = now(), rotated_at = now()
.execute(&mut *tx) WHERE token_hash = $1",
.await?; )
.bind(hash_token(token))
.execute(&mut *tx)
.await?;
let new_token = new_opaque_token("lvr_"); let new_token = new_opaque_token("lvr_");
sqlx::query( sqlx::query(
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at) "INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
@ -75,7 +93,10 @@ pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome,
.bind(hash_token(&new_token)) .bind(hash_token(&new_token))
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
RotateOutcome::Rotated { account_id, new_token } RotateOutcome::Rotated {
account_id,
new_token,
}
} }
}; };
tx.commit().await?; tx.commit().await?;
@ -104,6 +125,19 @@ pub fn refresh_cookie(token: String, secure: bool) -> Cookie<'static> {
.build() .build()
} }
/// Removal cookie for logout: same attributes as `refresh_cookie` (minus the
/// value/max-age) so the browser actually matches and clears it — a cookie
/// removal with mismatched attributes is silently ignored.
pub fn removal_cookie(secure: bool) -> Cookie<'static> {
Cookie::build((REFRESH_COOKIE, ""))
.http_only(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/auth")
.max_age(time::Duration::ZERO)
.build()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -1,10 +1,10 @@
use crate::accounts::repo; use crate::accounts::repo;
use crate::avatars::processing::{process_avatar, AvatarImageError}; use crate::avatars::processing::{AvatarImageError, process_avatar};
use crate::avatars::storage::S3Storage; use crate::avatars::storage::S3Storage;
use crate::error::AppError; use crate::error::AppError;
use axum::{ use axum::{
extract::{Multipart, State},
Json, Json,
extract::{Multipart, State},
}; };
use common::internal::GatewayIdentity; use common::internal::GatewayIdentity;
use serde::Serialize; use serde::Serialize;
@ -56,8 +56,14 @@ pub async fn upload(
})?; })?;
let key = format!("avatars/{account_id}.png"); let key = format!("avatars/{account_id}.png");
state.storage.put(&key, png, "image/png").await.map_err(AppError::Internal)?; state
.storage
.put(&key, png, "image/png")
.await
.map_err(AppError::Internal)?;
repo::set_avatar(&state.pool, account_id, &key).await?; repo::set_avatar(&state.pool, account_id, &key).await?;
Ok(Json(AvatarResponse { avatar_url: format!("{}/{key}", state.base_url) })) Ok(Json(AvatarResponse {
avatar_url: format!("{}/{key}", state.base_url),
}))
} }

View file

@ -1,4 +1,4 @@
use image::{imageops::FilterType, ImageFormat, ImageReader, Limits}; use image::{ImageFormat, ImageReader, Limits, imageops::FilterType};
use std::io::Cursor; use std::io::Cursor;
pub const AVATAR_SIZE: u32 = 256; pub const AVATAR_SIZE: u32 = 256;
@ -60,12 +60,18 @@ mod tests {
let out = process_avatar(&png_of(300, 100)).unwrap(); let out = process_avatar(&png_of(300, 100)).unwrap();
assert!(out.starts_with(&[0x89, b'P', b'N', b'G'])); assert!(out.starts_with(&[0x89, b'P', b'N', b'G']));
let decoded = image::load_from_memory(&out).unwrap(); let decoded = image::load_from_memory(&out).unwrap();
assert_eq!((decoded.width(), decoded.height()), (AVATAR_SIZE, AVATAR_SIZE)); assert_eq!(
(decoded.width(), decoded.height()),
(AVATAR_SIZE, AVATAR_SIZE)
);
} }
#[test] #[test]
fn non_image_bytes_are_unsupported() { fn non_image_bytes_are_unsupported() {
assert_eq!(process_avatar(b"not an image"), Err(AvatarImageError::Unsupported)); assert_eq!(
process_avatar(b"not an image"),
Err(AvatarImageError::Unsupported)
);
} }
#[test] #[test]
@ -85,6 +91,9 @@ mod tests {
#[test] #[test]
fn image_wider_than_the_limit_is_rejected() { fn image_wider_than_the_limit_is_rejected() {
assert_eq!(process_avatar(&png_of(MAX_DIMENSION + 1, 1)), Err(AvatarImageError::Invalid)); assert_eq!(
process_avatar(&png_of(MAX_DIMENSION + 1, 1)),
Err(AvatarImageError::Invalid)
);
} }
} }

View file

@ -1,5 +1,5 @@
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::ByteStream;
#[derive(Clone)] #[derive(Clone)]
pub struct S3Storage { pub struct S3Storage {
@ -11,7 +11,8 @@ impl S3Storage {
/// Builds the S3 client synchronously (no I/O happens here — connections /// Builds the S3 client synchronously (no I/O happens here — connections
/// are made lazily on first request). /// are made lazily on first request).
pub fn from_config(endpoint: &str, access_key: &str, secret_key: &str, bucket: String) -> Self { pub fn from_config(endpoint: &str, access_key: &str, secret_key: &str, bucket: String) -> Self {
let creds = aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "static"); let creds =
aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "static");
let config = aws_sdk_s3::config::Builder::new() let config = aws_sdk_s3::config::Builder::new()
.endpoint_url(endpoint) .endpoint_url(endpoint)
.credentials_provider(creds) .credentials_provider(creds)
@ -19,7 +20,10 @@ impl S3Storage {
.force_path_style(true) .force_path_style(true)
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest()) .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
.build(); .build();
S3Storage { client: Client::from_conf(config), bucket } S3Storage {
client: Client::from_conf(config),
bucket,
}
} }
pub async fn put(&self, key: &str, bytes: Vec<u8>, content_type: &str) -> anyhow::Result<()> { pub async fn put(&self, key: &str, bytes: Vec<u8>, content_type: &str) -> anyhow::Result<()> {

View file

@ -6,6 +6,7 @@ pub struct Config {
pub jwt_secret: String, pub jwt_secret: String,
pub internal_key: String, pub internal_key: String,
pub port: u16, pub port: u16,
pub grpc_port: u16,
pub s3_endpoint: String, pub s3_endpoint: String,
pub s3_bucket: String, pub s3_bucket: String,
pub s3_access_key: String, pub s3_access_key: String,
@ -16,25 +17,24 @@ pub struct Config {
impl Config { impl Config {
pub fn from_env() -> Result<Config> { pub fn from_env() -> Result<Config> {
Ok(Config { Ok(Config {
database_url: std::env::var("DATABASE_URL") database_url: std::env::var("DATABASE_URL").context("DATABASE_URL not set")?,
.context("DATABASE_URL not set")?, jwt_secret: std::env::var("JWT_SECRET").context("JWT_SECRET not set")?,
jwt_secret: std::env::var("JWT_SECRET") internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?,
.context("JWT_SECRET not set")?,
internal_key: std::env::var("INTERNAL_KEY")
.context("INTERNAL_KEY not set")?,
port: std::env::var("PORT") port: std::env::var("PORT")
.unwrap_or_else(|_| "8081".into()) .unwrap_or_else(|_| "8081".into())
.parse() .parse()
.context("PORT must be a number")?, .context("PORT must be a number")?,
s3_endpoint: std::env::var("S3_ENDPOINT") grpc_port: std::env::var("GRPC_PORT")
.context("S3_ENDPOINT not set")?, .unwrap_or_else(|_| "50051".into())
s3_bucket: std::env::var("S3_BUCKET") .parse()
.context("S3_BUCKET not set")?, .context("GRPC_PORT must be a number")?,
s3_access_key: std::env::var("S3_ACCESS_KEY") s3_endpoint: std::env::var("S3_ENDPOINT").context("S3_ENDPOINT not set")?,
.context("S3_ACCESS_KEY not set")?, s3_bucket: std::env::var("S3_BUCKET").context("S3_BUCKET not set")?,
s3_secret_key: std::env::var("S3_SECRET_KEY") s3_access_key: std::env::var("S3_ACCESS_KEY").context("S3_ACCESS_KEY not set")?,
.context("S3_SECRET_KEY not set")?, s3_secret_key: std::env::var("S3_SECRET_KEY").context("S3_SECRET_KEY not set")?,
cookie_secure: std::env::var("COOKIE_SECURE").map(|v| v != "false").unwrap_or(true), cookie_secure: std::env::var("COOKIE_SECURE")
.map(|v| v != "false")
.unwrap_or(true),
}) })
} }
} }
@ -56,7 +56,11 @@ impl Config {
/// Public prefix of stored avatars: `<endpoint>/<bucket>`. /// Public prefix of stored avatars: `<endpoint>/<bucket>`.
pub fn avatar_base_url(&self) -> String { pub fn avatar_base_url(&self) -> String {
format!("{}/{}", self.s3_endpoint.trim_end_matches('/'), self.s3_bucket) format!(
"{}/{}",
self.s3_endpoint.trim_end_matches('/'),
self.s3_bucket
)
} }
} }
@ -70,6 +74,7 @@ mod tests {
jwt_secret: secret.into(), jwt_secret: secret.into(),
internal_key: "k".repeat(32), internal_key: "k".repeat(32),
port: 0, port: 0,
grpc_port: 0,
s3_endpoint: String::new(), s3_endpoint: String::new(),
s3_bucket: String::new(), s3_bucket: String::new(),
s3_access_key: String::new(), s3_access_key: String::new(),

View file

@ -1,14 +1,20 @@
use crate::device::store::{self, DeviceStore, PollResult}; use crate::device::{
links,
store::{self, DeviceStore, PollResult},
};
use crate::error::{AppError, AppJson}; use crate::error::{AppError, AppJson};
use axum::{extract::State, http::StatusCode, Json}; use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use common::internal::GatewayIdentity; use common::internal::GatewayIdentity;
use common::jwt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone)] #[derive(Clone)]
pub struct DeviceState { pub struct DeviceState {
pub store: DeviceStore, pub store: DeviceStore,
pub jwt_secret: String, pub pool: sqlx::PgPool,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -24,7 +30,11 @@ pub async fn create_code(
let (device_code, user_code) = state.store.create().ok_or(AppError::TooManyRequests)?; let (device_code, user_code) = state.store.create().ok_or(AppError::TooManyRequests)?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(DeviceCodeResponse { device_code, user_code, expires_in: store::TTL.as_secs() }), Json(DeviceCodeResponse {
device_code,
user_code,
expires_in: store::TTL.as_secs(),
}),
)) ))
} }
@ -63,11 +73,29 @@ pub async fn token(
PollResult::Unknown => Err(AppError::NotFound("unknown or expired device_code".into())), PollResult::Unknown => Err(AppError::NotFound("unknown or expired device_code".into())),
PollResult::Pending => Ok((StatusCode::ACCEPTED, Json(None))), PollResult::Pending => Ok((StatusCode::ACCEPTED, Json(None))),
PollResult::Confirmed(account_id) => { PollResult::Confirmed(account_id) => {
// The long-lived device_token is just a refresh-style JWT for now; // The long-lived device token is an opaque random secret, stored
// the gateway plan is where per-device revocation via // hashed in device_links so the gateway can revoke it per device.
// device_links.device_token_hash gets enforced on every request. let device_token = links::create(&state.pool, account_id).await?;
let device_token = jwt::issue_refresh_token(account_id, &state.jwt_secret);
Ok((StatusCode::OK, Json(Some(TokenResponse { device_token })))) Ok((StatusCode::OK, Json(Some(TokenResponse { device_token }))))
} }
} }
} }
pub async fn list_links(
State(state): State<DeviceState>,
identity: GatewayIdentity,
) -> Result<Json<Vec<links::DeviceLink>>, AppError> {
Ok(Json(links::list(&state.pool, identity.account_id).await?))
}
pub async fn revoke_link(
State(state): State<DeviceState>,
identity: GatewayIdentity,
Path(link_id): Path<uuid::Uuid>,
) -> Result<StatusCode, AppError> {
if links::revoke(&state.pool, identity.account_id, link_id).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound("no such device link".into()))
}
}

View file

@ -0,0 +1,54 @@
use crate::auth::tokens::{hash_token, new_opaque_token};
use chrono::{DateTime, Utc};
use common::internal::DEVICE_TOKEN_PREFIX;
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct DeviceLink {
pub id: Uuid,
pub linked_at: DateTime<Utc>,
pub last_seen: Option<DateTime<Utc>>,
}
pub async fn create(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Error> {
let token = new_opaque_token(DEVICE_TOKEN_PREFIX);
sqlx::query("INSERT INTO device_links (account_id, device_token_hash) VALUES ($1, $2)")
.bind(account_id)
.bind(hash_token(&token))
.execute(pool)
.await?;
Ok(token)
}
/// Resolves a device token to its account and bumps `last_seen`. Returns
/// `None` for unknown tokens (and for nothing else — revocation deletes the
/// row, so revoked tokens are just unknown).
pub async fn authenticate(pool: &PgPool, token: &str) -> Result<Option<Uuid>, sqlx::Error> {
sqlx::query_scalar(
"UPDATE device_links SET last_seen = now() WHERE device_token_hash = $1 RETURNING account_id",
)
.bind(hash_token(token))
.fetch_optional(pool)
.await
}
pub async fn list(pool: &PgPool, account_id: Uuid) -> Result<Vec<DeviceLink>, sqlx::Error> {
sqlx::query_as(
"SELECT id, linked_at, last_seen FROM device_links WHERE account_id = $1 ORDER BY linked_at DESC",
)
.bind(account_id)
.fetch_all(pool)
.await
}
/// Deletes the link only if it belongs to `account_id`; `false` otherwise.
pub async fn revoke(pool: &PgPool, account_id: Uuid, link_id: Uuid) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM device_links WHERE id = $1 AND account_id = $2")
.bind(link_id)
.bind(account_id)
.execute(pool)
.await?;
Ok(result.rows_affected() == 1)
}

View file

@ -1,2 +1,3 @@
pub mod handlers; pub mod handlers;
pub mod links;
pub mod store; pub mod store;

View file

@ -33,7 +33,9 @@ fn random_user_code() -> String {
const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 confusion const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 confusion
let mut rng = rand::rng(); let mut rng = rand::rng();
let mut part = |n: usize| -> String { let mut part = |n: usize| -> String {
(0..n).map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char).collect() (0..n)
.map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
.collect()
}; };
format!("{}-{}", part(4), part(4)) format!("{}-{}", part(4), part(4))
} }
@ -42,7 +44,8 @@ impl DeviceStore {
/// Returns `(device_code, user_code)`, or `None` when the store is full. /// Returns `(device_code, user_code)`, or `None` when the store is full.
pub fn create(&self) -> Option<(String, String)> { pub fn create(&self) -> Option<(String, String)> {
let now = Instant::now(); let now = Instant::now();
self.by_device_code.retain(|_, entry| entry.expires_at > now); self.by_device_code
.retain(|_, entry| entry.expires_at > now);
if self.by_device_code.len() >= MAX_PENDING { if self.by_device_code.len() >= MAX_PENDING {
return None; return None;
} }
@ -130,7 +133,11 @@ mod tests {
assert!(store.confirm(&user_code, Uuid::new_v4())); assert!(store.confirm(&user_code, Uuid::new_v4()));
assert!(matches!(store.poll(&device_code), PollResult::Confirmed(_))); assert!(matches!(store.poll(&device_code), PollResult::Confirmed(_)));
assert_eq!(store.poll(&device_code), PollResult::Unknown, "replay must fail"); assert_eq!(
store.poll(&device_code),
PollResult::Unknown,
"replay must fail"
);
} }
#[test] #[test]
@ -161,7 +168,10 @@ mod tests {
let attacker = Uuid::new_v4(); let attacker = Uuid::new_v4();
assert!(store.confirm(&user_code, first)); assert!(store.confirm(&user_code, first));
assert!(!store.confirm(&user_code, attacker), "re-confirm must be refused"); assert!(
!store.confirm(&user_code, attacker),
"re-confirm must be refused"
);
assert_eq!(store.poll(&device_code), PollResult::Confirmed(first)); assert_eq!(store.poll(&device_code), PollResult::Confirmed(first));
} }
@ -180,7 +190,10 @@ mod tests {
for _ in 0..MAX_PENDING { for _ in 0..MAX_PENDING {
assert!(store.create().is_some()); assert!(store.create().is_some());
} }
assert!(store.create().is_none(), "store must refuse beyond MAX_PENDING"); assert!(
store.create().is_none(),
"store must refuse beyond MAX_PENDING"
);
// Force everything to be expired; the next create purges and succeeds. // Force everything to be expired; the next create purges and succeeds.
for mut entry in store.by_device_code.iter_mut() { for mut entry in store.by_device_code.iter_mut() {

View file

@ -1,8 +1,8 @@
use axum::{ use axum::{
extract::{rejection::JsonRejection, FromRequest}, Json,
extract::{FromRequest, rejection::JsonRejection},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use serde_json::json; use serde_json::json;
@ -23,7 +23,10 @@ impl IntoResponse for AppError {
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg), AppError::Conflict(msg) => (StatusCode::CONFLICT, msg),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "too many pending device codes".into()), AppError::TooManyRequests => (
StatusCode::TOO_MANY_REQUESTS,
"too many pending device codes".into(),
),
AppError::Internal(err) => { AppError::Internal(err) => {
tracing::error!("internal error: {err:?}"); tracing::error!("internal error: {err:?}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())

View file

@ -0,0 +1,215 @@
use common::internal::GrpcKeyCheck;
use common::pb::accounts::accounts_internal_server::{AccountsInternal, AccountsInternalServer};
use common::pb::accounts::{
AuthenticateDeviceReply, AuthenticateDeviceRequest, GetPublicProfilesReply,
GetPublicProfilesRequest, PublicProfile,
};
use sqlx::PgPool;
use tonic::{Request, Response, Status, service::interceptor::InterceptedService};
use uuid::Uuid;
const MAX_PROFILE_IDS: usize = 100;
pub struct AccountsGrpc {
pool: PgPool,
avatar_base_url: String,
}
impl AccountsGrpc {
pub fn new(pool: PgPool, avatar_base_url: String) -> Self {
AccountsGrpc {
pool,
avatar_base_url,
}
}
}
/// Builds the internal `AccountsInternal` gRPC service, guarded by
/// `GrpcKeyCheck` so only callers holding the shared internal key (i.e. the
/// gateway) can reach it.
pub fn server(
pool: PgPool,
avatar_base_url: String,
internal_key: &str,
) -> InterceptedService<AccountsInternalServer<AccountsGrpc>, GrpcKeyCheck> {
AccountsInternalServer::with_interceptor(
AccountsGrpc::new(pool, avatar_base_url),
GrpcKeyCheck::new(internal_key),
)
}
#[tonic::async_trait]
impl AccountsInternal for AccountsGrpc {
async fn authenticate_device(
&self,
request: Request<AuthenticateDeviceRequest>,
) -> Result<Response<AuthenticateDeviceReply>, Status> {
let token = request.into_inner().device_token;
match crate::device::links::authenticate(&self.pool, &token).await {
Ok(Some(account_id)) => Ok(Response::new(AuthenticateDeviceReply {
account_id: account_id.to_string(),
})),
Ok(None) => Err(Status::unauthenticated("unknown or revoked device token")),
Err(err) => {
tracing::error!("authenticate_device: {err:?}");
Err(Status::internal("internal error"))
}
}
}
async fn get_public_profiles(
&self,
request: Request<GetPublicProfilesRequest>,
) -> Result<Response<GetPublicProfilesReply>, Status> {
let raw = request.into_inner().account_ids;
if raw.len() > MAX_PROFILE_IDS {
return Err(Status::invalid_argument("at most 100 account ids per call"));
}
let ids: Vec<Uuid> = raw.iter().filter_map(|s| Uuid::parse_str(s).ok()).collect();
let rows = crate::accounts::repo::public_profiles(&self.pool, &ids)
.await
.map_err(|err| {
tracing::error!("get_public_profiles: {err:?}");
Status::internal("internal error")
})?;
let profiles = rows
.into_iter()
.map(|(id, nick, key)| PublicProfile {
account_id: id.to_string(),
display_nick: nick,
avatar_url: key
.map(|k| format!("{}/{k}", self.avatar_base_url))
.unwrap_or_default(),
})
.collect();
Ok(Response::new(GetPublicProfilesReply { profiles }))
}
}
#[cfg(test)]
mod tests {
use super::*;
use common::internal::GrpcKeyAttach;
use common::pb::accounts::accounts_internal_client::AccountsInternalClient;
const KEY: &str = "internal-key-internal-key-internal!!";
const CDN: &str = "http://cdn/avatars";
async fn pool() -> PgPool {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = PgPool::connect(&url).await.expect("connect");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrate");
pool
}
#[tokio::test]
async fn authenticate_device_over_grpc() {
let pool = pool().await;
let account = crate::accounts::repo::create(
&pool,
&format!("g-{}@example.com", Uuid::new_v4()),
"x",
"Grpc",
)
.await
.unwrap();
let token = crate::device::links::create(&pool, account.id)
.await
.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(
tonic::transport::Server::builder()
.add_service(server(pool.clone(), CDN.into(), KEY))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)),
);
let channel = tonic::transport::Endpoint::from_shared(format!("http://{addr}"))
.unwrap()
.connect_lazy();
let mut client = AccountsInternalClient::with_interceptor(
channel.clone(),
GrpcKeyAttach::new(KEY).unwrap(),
);
let reply = client
.authenticate_device(AuthenticateDeviceRequest {
device_token: token,
})
.await
.unwrap()
.into_inner();
assert_eq!(reply.account_id, account.id.to_string());
let err = client
.authenticate_device(AuthenticateDeviceRequest {
device_token: "lvd_unknown".into(),
})
.await
.unwrap_err();
assert_eq!(err.code(), tonic::Code::Unauthenticated);
let mut no_key = AccountsInternalClient::new(channel);
let err = no_key
.authenticate_device(AuthenticateDeviceRequest {
device_token: "x".into(),
})
.await
.unwrap_err();
assert_eq!(err.code(), tonic::Code::PermissionDenied);
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(account.id)
.execute(&pool)
.await
.unwrap();
}
#[tokio::test]
async fn public_profiles_over_grpc() {
let pool = pool().await;
let a = crate::accounts::repo::create(
&pool,
&format!("p-{}@example.com", Uuid::new_v4()),
"x",
"Alice",
)
.await
.unwrap();
let svc = AccountsGrpc::new(pool.clone(), CDN.into());
let reply = svc
.get_public_profiles(tonic::Request::new(GetPublicProfilesRequest {
account_ids: vec![
a.id.to_string(),
Uuid::new_v4().to_string(),
"garbage".into(),
],
}))
.await
.unwrap()
.into_inner();
assert_eq!(reply.profiles.len(), 1);
assert_eq!(reply.profiles[0].display_nick, "Alice");
assert_eq!(reply.profiles[0].avatar_url, "");
let too_many: Vec<String> = (0..101).map(|_| Uuid::new_v4().to_string()).collect();
let err = svc
.get_public_profiles(tonic::Request::new(GetPublicProfilesRequest {
account_ids: too_many,
}))
.await
.unwrap_err();
assert_eq!(err.code(), tonic::Code::InvalidArgument);
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(a.id)
.execute(&pool)
.await
.unwrap();
}
}

View file

@ -4,22 +4,25 @@ pub mod avatars;
pub mod config; pub mod config;
pub mod device; pub mod device;
pub mod error; pub mod error;
pub mod grpc;
use accounts::handlers::AccountsState;
use auth::handlers::AuthState; use auth::handlers::AuthState;
use avatars::{handlers::AvatarState, storage::S3Storage}; use avatars::{handlers::AvatarState, storage::S3Storage};
use axum::{ use axum::{
extract::DefaultBodyLimit,
Router, Router,
extract::DefaultBodyLimit,
routing::{get, post}, routing::{get, post},
}; };
use config::Config; use config::Config;
use device::{handlers::DeviceState, store::DeviceStore}; use device::{handlers::DeviceState, store::DeviceStore};
pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router { pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
let auth_state = let auth_state = AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure);
AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure); let device_state = DeviceState {
let device_state = store: DeviceStore::default(),
DeviceState { store: DeviceStore::default(), jwt_secret: cfg.jwt_secret.clone() }; pool: pool.clone(),
};
let avatar_state = AvatarState { let avatar_state = AvatarState {
pool: pool.clone(), pool: pool.clone(),
storage: S3Storage::from_config( storage: S3Storage::from_config(
@ -30,6 +33,10 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
), ),
base_url: cfg.avatar_base_url(), base_url: cfg.avatar_base_url(),
}; };
let accounts_state = AccountsState {
pool: pool.clone(),
avatar_base_url: cfg.avatar_base_url(),
};
let auth_routes = Router::new() let auth_routes = Router::new()
.route("/auth/register", post(auth::handlers::register)) .route("/auth/register", post(auth::handlers::register))
@ -42,6 +49,11 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
.route("/device/code", post(device::handlers::create_code)) .route("/device/code", post(device::handlers::create_code))
.route("/device/confirm", post(device::handlers::confirm)) .route("/device/confirm", post(device::handlers::confirm))
.route("/device/token", post(device::handlers::token)) .route("/device/token", post(device::handlers::token))
.route("/device/links", get(device::handlers::list_links))
.route(
"/device/links/{id}",
axum::routing::delete(device::handlers::revoke_link),
)
.with_state(device_state); .with_state(device_state);
let avatar_routes = Router::new() let avatar_routes = Router::new()
@ -50,6 +62,11 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
.layer(DefaultBodyLimit::max(6 * 1024 * 1024)) .layer(DefaultBodyLimit::max(6 * 1024 * 1024))
.with_state(avatar_state); .with_state(avatar_state);
let accounts_routes = Router::new()
.route("/me", get(accounts::handlers::me))
.route("/users/{id}", get(accounts::handlers::public_profile))
.with_state(accounts_state);
// Everything except /health is internal-only: reachable solely through // Everything except /health is internal-only: reachable solely through
// the gateway, which authenticates the caller and forwards the identity // the gateway, which authenticates the caller and forwards the identity
// header. Direct traffic (or spoofed headers) is rejected here. // header. Direct traffic (or spoofed headers) is rejected here.
@ -57,6 +74,7 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router {
.merge(auth_routes) .merge(auth_routes)
.merge(device_routes) .merge(device_routes)
.merge(avatar_routes) .merge(avatar_routes)
.merge(accounts_routes)
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
common::internal::InternalKey::new(cfg.internal_key.clone()), common::internal::InternalKey::new(cfg.internal_key.clone()),
common::internal::require_internal_key, common::internal::require_internal_key,

View file

@ -14,9 +14,26 @@ async fn main() -> anyhow::Result<()> {
sqlx::migrate!("./migrations").run(&pool).await?; sqlx::migrate!("./migrations").run(&pool).await?;
let http = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
let grpc_addr = std::net::SocketAddr::from(([0, 0, 0, 0], cfg.grpc_port));
tracing::info!(
"accounts-service listening on {} (http) and {} (grpc)",
cfg.port,
grpc_addr
);
let grpc = tonic::transport::Server::builder()
.add_service(accounts_service::grpc::server(
pool.clone(),
cfg.avatar_base_url(),
&cfg.internal_key,
))
.serve(grpc_addr);
let app = build_app(pool, &cfg); let app = build_app(pool, &cfg);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
tracing::info!("accounts-service listening on {}", cfg.port); tokio::try_join!(
axum::serve(listener, app).await?; async { axum::serve(http, app).await.map_err(anyhow::Error::from) },
async { grpc.await.map_err(anyhow::Error::from) },
)?;
Ok(()) Ok(())
} }

View file

@ -16,7 +16,9 @@ async fn register_then_login_succeeds() {
let register_response = server let register_response = server
.post("/auth/register") .post("/auth/register")
.json(&json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Rider" })) .json(
&json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Rider" }),
)
.await; .await;
register_response.assert_status(axum::http::StatusCode::CREATED); register_response.assert_status(axum::http::StatusCode::CREATED);
@ -27,7 +29,10 @@ async fn register_then_login_succeeds() {
login_response.assert_status_ok(); login_response.assert_status_ok();
let body: serde_json::Value = login_response.json(); let body: serde_json::Value = login_response.json();
assert!(body["access_token"].is_string()); assert!(body["access_token"].is_string());
assert!(body["refresh_token"].is_null(), "refresh token must be in the httpOnly cookie, not the body"); assert!(
body["refresh_token"].is_null(),
"refresh token must be in the httpOnly cookie, not the body"
);
sqlx::query("DELETE FROM accounts WHERE email = $1") sqlx::query("DELETE FROM accounts WHERE email = $1")
.bind(&email) .bind(&email)
@ -141,7 +146,10 @@ async fn malformed_json_body_returns_400_with_json_error_shape() {
.await; .await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST); response.assert_status(axum::http::StatusCode::BAD_REQUEST);
let body: serde_json::Value = response.json(); let body: serde_json::Value = response.json();
assert!(body["error"].is_string(), "expected {{\"error\": ...}}, got {body}"); assert!(
body["error"].is_string(),
"expected {{\"error\": ...}}, got {body}"
);
} }
#[tokio::test] #[tokio::test]
@ -160,3 +168,24 @@ async fn register_rejects_oversized_password_with_400() {
.await; .await;
response.assert_status(axum::http::StatusCode::BAD_REQUEST); response.assert_status(axum::http::StatusCode::BAD_REQUEST);
} }
#[tokio::test]
async fn me_returns_profile_without_password_hash() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (id, email) = common::register_account(&server).await;
let res = server
.get("/me")
.add_header(common::ACCOUNT_ID_HEADER, id.to_string())
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["email"], email);
assert_eq!(body["display_nick"], "Tester");
assert_eq!(body["role"], "user");
assert!(body["avatar_url"].is_null());
assert!(body.get("password_hash").is_none());
server.get("/me").await.assert_status_unauthorized();
}

View file

@ -13,8 +13,14 @@ async fn login(server: &axum_test::TestServer, email: &str) -> (String, String)
assert!(cookie.http_only().unwrap_or(false)); assert!(cookie.http_only().unwrap_or(false));
assert_eq!(cookie.path(), Some("/auth")); assert_eq!(cookie.path(), Some("/auth"));
let body: serde_json::Value = res.json(); let body: serde_json::Value = res.json();
assert!(body.get("refresh_token").is_none(), "refresh token must not be in the JSON body"); assert!(
(body["access_token"].as_str().unwrap().to_owned(), cookie.value().to_owned()) body.get("refresh_token").is_none(),
"refresh token must not be in the JSON body"
);
(
body["access_token"].as_str().unwrap().to_owned(),
cookie.value().to_owned(),
)
} }
#[tokio::test] #[tokio::test]
@ -47,6 +53,9 @@ async fn reusing_a_rotated_refresh_token_revokes_all_sessions() {
.cookie("lv_refresh") .cookie("lv_refresh")
.value() .value()
.to_owned(); .to_owned();
// Wait out the rotation grace window: a replay this long after rotation
// is genuine reuse, not a benign concurrent-refresh race.
tokio::time::sleep(std::time::Duration::from_secs(11)).await;
// Attacker replays the old token -> rejected, and the legit new one dies too. // Attacker replays the old token -> rejected, and the legit new one dies too.
server server
.post("/auth/refresh") .post("/auth/refresh")
@ -60,6 +69,36 @@ async fn reusing_a_rotated_refresh_token_revokes_all_sessions() {
.assert_status(StatusCode::UNAUTHORIZED); .assert_status(StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn concurrent_refresh_within_the_grace_window_does_not_kill_the_session() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (_, email) = common::register_account(&server).await;
let (_, first) = login(&server, &email).await;
// Two tabs racing to refresh the same cookie: the first wins and rotates.
let second = server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first.clone()))
.await
.cookie("lv_refresh")
.value()
.to_owned();
// The second tab's request lands moments later with the now-stale cookie:
// it must be rejected...
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", first))
.await
.assert_status(StatusCode::UNAUTHORIZED);
// ...but the first tab's freshly-rotated token must keep working.
server
.post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", second))
.await
.assert_status_ok();
}
#[tokio::test] #[tokio::test]
async fn logout_revokes_the_refresh_token() { async fn logout_revokes_the_refresh_token() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
@ -67,11 +106,14 @@ async fn logout_revokes_the_refresh_token() {
let (_, email) = common::register_account(&server).await; let (_, email) = common::register_account(&server).await;
let (_, refresh) = login(&server, &email).await; let (_, refresh) = login(&server, &email).await;
server let res = server
.post("/auth/logout") .post("/auth/logout")
.add_cookie(Cookie::new("lv_refresh", refresh.clone())) .add_cookie(Cookie::new("lv_refresh", refresh.clone()))
.await .await;
.assert_status(StatusCode::NO_CONTENT); res.assert_status(StatusCode::NO_CONTENT);
let removal = res.cookie("lv_refresh");
assert_eq!(removal.value(), "");
assert_eq!(removal.max_age(), Some(time::Duration::ZERO));
server server
.post("/auth/refresh") .post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", refresh)) .add_cookie(Cookie::new("lv_refresh", refresh))
@ -83,7 +125,10 @@ async fn logout_revokes_the_refresh_token() {
async fn refresh_without_cookie_or_with_garbage_is_401() { async fn refresh_without_cookie_or_with_garbage_is_401() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config())); let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
server.post("/auth/refresh").await.assert_status(StatusCode::UNAUTHORIZED); server
.post("/auth/refresh")
.await
.assert_status(StatusCode::UNAUTHORIZED);
server server
.post("/auth/refresh") .post("/auth/refresh")
.add_cookie(Cookie::new("lv_refresh", "lvr_garbage")) .add_cookie(Cookie::new("lv_refresh", "lvr_garbage"))

View file

@ -12,13 +12,19 @@ fn png_bytes() -> Vec<u8> {
} }
fn form(bytes: Vec<u8>) -> MultipartForm { fn form(bytes: Vec<u8>) -> MultipartForm {
MultipartForm::new().add_part("file", Part::bytes(bytes).file_name("a.png").mime_type("image/png")) MultipartForm::new().add_part(
"file",
Part::bytes(bytes).file_name("a.png").mime_type("image/png"),
)
} }
#[tokio::test] #[tokio::test]
async fn valid_upload_stores_avatar_and_returns_url() { async fn valid_upload_stores_avatar_and_returns_url() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config())); let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await; let (account_id, email) = common::register_account(&server).await;
let response = server let response = server
@ -27,8 +33,14 @@ async fn valid_upload_stores_avatar_and_returns_url() {
.multipart(form(png_bytes())) .multipart(form(png_bytes()))
.await; .await;
response.assert_status_ok(); response.assert_status_ok();
let url = response.json::<serde_json::Value>()["avatar_url"].as_str().unwrap().to_string(); let url = response.json::<serde_json::Value>()["avatar_url"]
assert!(url.starts_with("http://localhost:9000/lovisual-avatars-test/avatars/"), "{url}"); .as_str()
.unwrap()
.to_string();
assert!(
url.starts_with("http://localhost:9000/lovisual-avatars-test/avatars/"),
"{url}"
);
assert!(url.ends_with(".png"), "{url}"); assert!(url.ends_with(".png"), "{url}");
let stored: (String,) = sqlx::query_as( let stored: (String,) = sqlx::query_as(
@ -50,7 +62,10 @@ async fn valid_upload_stores_avatar_and_returns_url() {
#[tokio::test] #[tokio::test]
async fn non_image_upload_is_rejected_with_400() { async fn non_image_upload_is_rejected_with_400() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config())); let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await; let (account_id, email) = common::register_account(&server).await;
server server
@ -70,7 +85,10 @@ async fn non_image_upload_is_rejected_with_400() {
#[tokio::test] #[tokio::test]
async fn oversized_upload_is_rejected_with_400() { async fn oversized_upload_is_rejected_with_400() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config())); let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await; let (account_id, email) = common::register_account(&server).await;
server server
@ -90,7 +108,10 @@ async fn oversized_upload_is_rejected_with_400() {
#[tokio::test] #[tokio::test]
async fn upload_without_identity_is_401() { async fn upload_without_identity_is_401() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config())); let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
server server
.post("/avatars") .post("/avatars")

View file

@ -11,8 +11,13 @@ pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
pub async fn test_pool() -> sqlx::PgPool { pub async fn test_pool() -> sqlx::PgPool {
let url = std::env::var("DATABASE_URL") let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into()); .unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
let pool = sqlx::PgPool::connect(&url).await.expect("connect to test database"); let pool = sqlx::PgPool::connect(&url)
sqlx::migrate!("./migrations").run(&pool).await.expect("run migrations"); .await
.expect("connect to test database");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
pool pool
} }
@ -22,6 +27,7 @@ pub fn test_config() -> Config {
jwt_secret: "test-secret-test-secret-test-secret!".into(), jwt_secret: "test-secret-test-secret-test-secret!".into(),
internal_key: TEST_INTERNAL_KEY.into(), internal_key: TEST_INTERNAL_KEY.into(),
port: 0, port: 0,
grpc_port: 0,
s3_endpoint: "http://localhost:9000".into(), s3_endpoint: "http://localhost:9000".into(),
s3_bucket: "lovisual-avatars-test".into(), s3_bucket: "lovisual-avatars-test".into(),
s3_access_key: "minioadmin".into(), s3_access_key: "minioadmin".into(),
@ -48,5 +54,8 @@ pub async fn register_account(server: &TestServer) -> (Uuid, String) {
.await; .await;
res.assert_status(axum::http::StatusCode::CREATED); res.assert_status(axum::http::StatusCode::CREATED);
let body: serde_json::Value = res.json(); let body: serde_json::Value = res.json();
(Uuid::parse_str(body["id"].as_str().unwrap()).unwrap(), email) (
Uuid::parse_str(body["id"].as_str().unwrap()).unwrap(),
email,
)
} }

View file

@ -1,4 +1,8 @@
mod common; mod common;
// A test root's submodules resolve against `tests/`, not the file's own
// directory — pin the path so `tests/` itself stays at 4 files.
#[path = "device_flow/links.rs"]
mod links;
use axum::http::StatusCode; use axum::http::StatusCode;
use serde_json::json; use serde_json::json;
@ -6,7 +10,10 @@ use serde_json::json;
#[tokio::test] #[tokio::test]
async fn full_device_link_flow() { async fn full_device_link_flow() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config())); let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await; let (account_id, email) = common::register_account(&server).await;
let code_response: serde_json::Value = server.post("/device/code").await.json(); let code_response: serde_json::Value = server.post("/device/code").await.json();
@ -32,7 +39,11 @@ async fn full_device_link_flow() {
.await; .await;
poll_after.assert_status_ok(); poll_after.assert_status_ok();
let token_body: serde_json::Value = poll_after.json(); let token_body: serde_json::Value = poll_after.json();
assert!(token_body["device_token"].is_string()); let device_token = token_body["device_token"].as_str().unwrap();
assert!(
device_token.starts_with("lvd_"),
"opaque device token, got {device_token}"
);
// Single use: the same device_code cannot be redeemed twice. // Single use: the same device_code cannot be redeemed twice.
server server
@ -51,7 +62,10 @@ async fn full_device_link_flow() {
#[tokio::test] #[tokio::test]
async fn confirm_without_identity_is_401() { async fn confirm_without_identity_is_401() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config())); let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let code: serde_json::Value = server.post("/device/code").await.json(); let code: serde_json::Value = server.post("/device/code").await.json();
server server
.post("/device/confirm") .post("/device/confirm")
@ -63,7 +77,10 @@ async fn confirm_without_identity_is_401() {
#[tokio::test] #[tokio::test]
async fn unknown_device_code_and_user_code_return_404() { async fn unknown_device_code_and_user_code_return_404() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool.clone(), &common::test_config())); let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account_id, email) = common::register_account(&server).await; let (account_id, email) = common::register_account(&server).await;
server server

View file

@ -0,0 +1,116 @@
use super::common;
use super::common::ACCOUNT_ID_HEADER;
use axum::http::StatusCode;
async fn link_device(server: &axum_test::TestServer, account: uuid::Uuid) -> String {
let code: serde_json::Value = server.post("/device/code").await.json();
server
.post("/device/confirm")
.add_header(ACCOUNT_ID_HEADER, account.to_string())
.json(&serde_json::json!({ "user_code": code["user_code"] }))
.await
.assert_status_ok();
let res = server
.post("/device/token")
.json(&serde_json::json!({ "device_code": code["device_code"] }))
.await;
res.assert_status_ok();
res.json::<serde_json::Value>()["device_token"]
.as_str()
.unwrap()
.to_owned()
}
#[tokio::test]
async fn confirmed_device_gets_an_opaque_token_backed_by_a_link_row() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account, _) = common::register_account(&server).await;
let token = link_device(&server, account).await;
assert!(token.starts_with("lvd_"));
let resolved = accounts_service::device::links::authenticate(&pool, &token)
.await
.unwrap();
assert_eq!(resolved, Some(account));
let links: Vec<serde_json::Value> = server
.get("/device/links")
.add_header(ACCOUNT_ID_HEADER, account.to_string())
.await
.json();
assert_eq!(links.len(), 1);
assert!(
links[0]["last_seen"].is_string(),
"authenticate must bump last_seen"
);
}
#[tokio::test]
async fn revoking_a_link_kills_its_token_only() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(
pool.clone(),
&common::test_config(),
));
let (account, _) = common::register_account(&server).await;
let t1 = link_device(&server, account).await;
let t2 = link_device(&server, account).await;
let links: Vec<serde_json::Value> = server
.get("/device/links")
.add_header(ACCOUNT_ID_HEADER, account.to_string())
.await
.json();
let first_id = links.iter().find(|l| l["id"].is_string()).unwrap()["id"]
.as_str()
.unwrap()
.to_owned();
server
.delete(&format!("/device/links/{first_id}"))
.add_header(ACCOUNT_ID_HEADER, account.to_string())
.await
.assert_status(StatusCode::NO_CONTENT);
let alive = [
accounts_service::device::links::authenticate(&pool, &t1)
.await
.unwrap(),
accounts_service::device::links::authenticate(&pool, &t2)
.await
.unwrap(),
];
assert_eq!(alive.iter().filter(|a| a.is_some()).count(), 1);
}
#[tokio::test]
async fn cannot_revoke_someone_elses_link() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (owner, _) = common::register_account(&server).await;
let (stranger, _) = common::register_account(&server).await;
link_device(&server, owner).await;
let links: Vec<serde_json::Value> = server
.get("/device/links")
.add_header(ACCOUNT_ID_HEADER, owner.to_string())
.await
.json();
let id = links[0]["id"].as_str().unwrap();
server
.delete(&format!("/device/links/{id}"))
.add_header(ACCOUNT_ID_HEADER, stranger.to_string())
.await
.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn unknown_device_token_does_not_authenticate() {
let pool = common::test_pool().await;
let r = accounts_service::device::links::authenticate(&pool, "lvd_nope")
.await
.unwrap();
assert_eq!(r, None);
}

View file

@ -7,8 +7,7 @@ async fn health_returns_ok() {
// NOTE: this test does not touch the DB — pass a pool that is never // NOTE: this test does not touch the DB — pass a pool that is never
// queried. sqlx::PgPool::connect_lazy never opens a connection until // queried. sqlx::PgPool::connect_lazy never opens a connection until
// a query runs, so this is safe without a running Postgres. // a query runs, so this is safe without a running Postgres.
let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db") let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db").expect("lazy pool");
.expect("lazy pool");
let app = accounts_service::build_app(pool, &common::test_config()); let app = accounts_service::build_app(pool, &common::test_config());
let server = TestServer::new(app); let server = TestServer::new(app);
@ -25,7 +24,10 @@ async fn migrations_create_accounts_table() {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.expect("query must succeed"); .expect("query must succeed");
assert!(row.0.is_some(), "accounts table must exist after migrations run"); assert!(
row.0.is_some(),
"accounts table must exist after migrations run"
);
} }
#[tokio::test] #[tokio::test]
@ -33,10 +35,50 @@ async fn api_routes_require_internal_key_but_health_does_not() {
let pool = common::test_pool().await; let pool = common::test_pool().await;
// A raw server: no internal key header on any request, as if the // A raw server: no internal key header on any request, as if the
// service were reached directly, bypassing the gateway. // service were reached directly, bypassing the gateway.
let server = axum_test::TestServer::new(accounts_service::build_app(pool, &common::test_config())); let server =
axum_test::TestServer::new(accounts_service::build_app(pool, &common::test_config()));
server.get("/health").await.assert_status_ok(); server.get("/health").await.assert_status_ok();
server server
.post("/device/code") .post("/device/code")
.await .await
.assert_status(axum::http::StatusCode::FORBIDDEN); .assert_status(axum::http::StatusCode::FORBIDDEN);
} }
#[tokio::test]
async fn public_profile_is_reachable_without_identity_but_never_leaks_private_fields() {
let pool = common::test_pool().await;
let server = common::test_server(accounts_service::build_app(pool, &common::test_config()));
let (id, _email) = common::register_account(&server).await;
// No identity header: public data must not need a logged-in caller.
let res = server.get(&format!("/users/{id}")).await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["id"], id.to_string());
assert_eq!(body["display_nick"], "Tester");
assert!(body["avatar_url"].is_null());
assert!(body.get("email").is_none(), "email must never be public");
assert!(body.get("role").is_none(), "role must never be public");
assert!(
body["badges"]
.as_array()
.expect("badges array")
.iter()
.any(|b| b == "early")
);
server
.get(&format!("/users/{}", uuid::Uuid::new_v4()))
.await
.assert_status(axum::http::StatusCode::NOT_FOUND);
server
.get("/users/not-a-uuid")
.await
.assert_status(axum::http::StatusCode::NOT_FOUND);
sqlx::query("DELETE FROM accounts WHERE id = $1")
.bind(id)
.execute(&common::test_pool().await)
.await
.expect("cleanup");
}

View file

@ -7,7 +7,18 @@ service AccountsInternal {
// Resolves a mod's long-lived device token to its account and bumps // Resolves a mod's long-lived device token to its account and bumps
// device_links.last_seen. UNAUTHENTICATED if the token is unknown/revoked. // device_links.last_seen. UNAUTHENTICATED if the token is unknown/revoked.
rpc AuthenticateDevice(AuthenticateDeviceRequest) returns (AuthenticateDeviceReply); rpc AuthenticateDevice(AuthenticateDeviceRequest) returns (AuthenticateDeviceReply);
// Nick + avatar for showcase authors etc. Never returns email or role.
rpc GetPublicProfiles(GetPublicProfilesRequest) returns (GetPublicProfilesReply);
} }
message AuthenticateDeviceRequest { string device_token = 1; } message AuthenticateDeviceRequest { string device_token = 1; }
message AuthenticateDeviceReply { string account_id = 1; } message AuthenticateDeviceReply { string account_id = 1; }
message GetPublicProfilesRequest { repeated string account_ids = 1; }
message PublicProfile {
string account_id = 1;
string display_nick = 2;
string avatar_url = 3; // empty string when the account has no avatar
}
message GetPublicProfilesReply { repeated PublicProfile profiles = 1; }

View file

@ -3,19 +3,19 @@
//! came through the gateway (which strips client-supplied copies of both). //! came through the gateway (which strips client-supplied copies of both).
use axum::{ use axum::{
Json,
extract::{FromRequestParts, Request, State}, extract::{FromRequestParts, Request, State},
http::{request::Parts, StatusCode}, http::{StatusCode, request::Parts},
middleware::Next, middleware::Next,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use serde_json::json; use serde_json::json;
use std::sync::Arc; use std::sync::Arc;
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use tonic::{ use tonic::{
Status,
metadata::{Ascii, MetadataValue}, metadata::{Ascii, MetadataValue},
service::Interceptor, service::Interceptor,
Status,
}; };
use uuid::Uuid; use uuid::Uuid;
@ -36,7 +36,11 @@ impl InternalKey {
} }
} }
pub async fn require_internal_key(State(key): State<InternalKey>, req: Request, next: Next) -> Response { pub async fn require_internal_key(
State(key): State<InternalKey>,
req: Request,
next: Next,
) -> Response {
let ok = req let ok = req
.headers() .headers()
.get(INTERNAL_KEY_HEADER) .get(INTERNAL_KEY_HEADER)
@ -64,7 +68,11 @@ impl<S: Send + Sync> FromRequestParts<S> for GatewayIdentity {
.and_then(|s| Uuid::parse_str(s).ok()) .and_then(|s| Uuid::parse_str(s).ok())
.map(|account_id| GatewayIdentity { account_id }) .map(|account_id| GatewayIdentity { account_id })
.ok_or_else(|| { .ok_or_else(|| {
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "unauthorized" }))).into_response() (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "unauthorized" })),
)
.into_response()
}) })
} }
} }
@ -100,7 +108,8 @@ impl GrpcKeyAttach {
impl Interceptor for GrpcKeyAttach { impl Interceptor for GrpcKeyAttach {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> { fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
req.metadata_mut().insert(INTERNAL_KEY_HEADER, self.0.clone()); req.metadata_mut()
.insert(INTERNAL_KEY_HEADER, self.0.clone());
Ok(req) Ok(req)
} }
} }
@ -108,22 +117,31 @@ impl Interceptor for GrpcKeyAttach {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use axum::{routing::get, Router}; use axum::{Router, routing::get};
use axum_test::TestServer; use axum_test::TestServer;
const KEY: &str = "internal-key-internal-key-internal!!"; const KEY: &str = "internal-key-internal-key-internal!!";
fn app() -> Router { fn app() -> Router {
Router::new() Router::new()
.route("/whoami", get(|id: GatewayIdentity| async move { id.account_id.to_string() })) .route(
"/whoami",
get(|id: GatewayIdentity| async move { id.account_id.to_string() }),
)
.route("/open", get(|| async { "open" })) .route("/open", get(|| async { "open" }))
.layer(axum::middleware::from_fn_with_state(InternalKey::new(KEY.into()), require_internal_key)) .layer(axum::middleware::from_fn_with_state(
InternalKey::new(KEY.into()),
require_internal_key,
))
} }
#[tokio::test] #[tokio::test]
async fn request_without_internal_key_is_forbidden() { async fn request_without_internal_key_is_forbidden() {
let server = TestServer::new(app()); let server = TestServer::new(app());
server.get("/open").await.assert_status(axum::http::StatusCode::FORBIDDEN); server
.get("/open")
.await
.assert_status(axum::http::StatusCode::FORBIDDEN);
} }
#[tokio::test] #[tokio::test]

View file

@ -1,15 +1,15 @@
use axum::http::{header::AUTHORIZATION, HeaderMap}; use axum::http::{HeaderMap, header::AUTHORIZATION};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
/// Distinguishes token purposes so a long-lived refresh/device token can /// Distinguishes token purposes so a token kind can never be replayed as
/// never be replayed as a short-lived access token (and vice versa). /// another. Only access tokens are JWTs today; refresh/device tokens are
/// opaque secrets. The claim stays so future kinds remain distinguishable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum TokenType { pub enum TokenType {
Access, Access,
Refresh,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -21,20 +21,23 @@ pub struct Claims {
fn issue(account_id: Uuid, secret: &str, token_type: TokenType, ttl_seconds: i64) -> String { fn issue(account_id: Uuid, secret: &str, token_type: TokenType, ttl_seconds: i64) -> String {
let exp = (chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds)).timestamp() as usize; let exp = (chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds)).timestamp() as usize;
let claims = Claims { sub: account_id.to_string(), exp, token_type }; let claims = Claims {
encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(secret.as_bytes())) sub: account_id.to_string(),
.expect("encoding a well-formed Claims struct cannot fail") exp,
token_type,
};
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.expect("encoding a well-formed Claims struct cannot fail")
} }
pub fn issue_access_token(account_id: Uuid, secret: &str) -> String { pub fn issue_access_token(account_id: Uuid, secret: &str) -> String {
issue(account_id, secret, TokenType::Access, 15 * 60) issue(account_id, secret, TokenType::Access, 15 * 60)
} }
/// Temporary — deleted in Task 4 once opaque refresh tokens land.
pub fn issue_refresh_token(account_id: Uuid, secret: &str) -> String {
issue(account_id, secret, TokenType::Refresh, 30 * 24 * 60 * 60)
}
/// Returns the claims only if the signature, expiry AND token type all match. /// Returns the claims only if the signature, expiry AND token type all match.
/// HS256 is pinned explicitly (no algorithm confusion) and expiry leeway is 0. /// HS256 is pinned explicitly (no algorithm confusion) and expiry leeway is 0.
pub fn verify_token(token: &str, secret: &str, expected: TokenType) -> Option<Claims> { pub fn verify_token(token: &str, secret: &str, expected: TokenType) -> Option<Claims> {
@ -83,19 +86,6 @@ mod tests {
assert!(verify_token("not.a.jwt", "test-secret", TokenType::Access).is_none()); assert!(verify_token("not.a.jwt", "test-secret", TokenType::Access).is_none());
} }
#[test]
fn refresh_token_is_rejected_where_access_is_expected() {
let token = issue_refresh_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "test-secret", TokenType::Access).is_none());
assert!(verify_token(&token, "test-secret", TokenType::Refresh).is_some());
}
#[test]
fn access_token_is_rejected_where_refresh_is_expected() {
let token = issue_access_token(Uuid::new_v4(), "test-secret");
assert!(verify_token(&token, "test-secret", TokenType::Refresh).is_none());
}
#[test] #[test]
fn expired_token_fails_verify() { fn expired_token_fails_verify() {
let token = issue(Uuid::new_v4(), "test-secret", TokenType::Access, -10); let token = issue(Uuid::new_v4(), "test-secret", TokenType::Access, -10);
@ -118,14 +108,6 @@ mod tests {
assert!(verify_token(&hs512, "test-secret", TokenType::Access).is_none()); assert!(verify_token(&hs512, "test-secret", TokenType::Access).is_none());
} }
use axum::http::HeaderValue;
fn headers_with(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_str(value).unwrap());
headers
}
#[test] #[test]
fn bearer_token_extracts_the_token() { fn bearer_token_extracts_the_token() {
let mut h = HeaderMap::new(); let mut h = HeaderMap::new();
@ -144,7 +126,9 @@ mod tests {
} }
#[test] #[test]
fn bearer_token_rejects_garbage_headers() { fn bearer_token_passes_opaque_value_through() {
assert_eq!(bearer_token(&headers_with("Bearer not.a.jwt")).unwrap(), "not.a.jwt"); let mut h = HeaderMap::new();
h.insert(AUTHORIZATION, "Bearer not.a.jwt".parse().unwrap());
assert_eq!(bearer_token(&h), Some("not.a.jwt"));
} }
} }

View file

@ -0,0 +1,27 @@
[package]
name = "configs-service"
version = "0.1.0"
edition = "2024"
[lib]
name = "configs_service"
path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tracing = "0.1"
tracing-subscriber = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.10"
tonic = "0.14"
anyhow = "1"
dotenvy = "0.15"
[dev-dependencies]
axum-test = "21"

View file

@ -1357,6 +1357,20 @@ git commit -m "feat(configs): public showcase with publish, browse, detail and c
--- ---
### Task 5b: Public profile data (for the site's `/u/:id`)
**Files:** accounts-service `src/accounts/handlers.rs` (+ `public_profile`), `src/accounts/repo.rs` (+ `account_rank`), `tests/auth_flow.rs`; configs-service `src/showcase/{repo,handlers}.rs` (`author` filter), `tests/showcase.rs`.
**Interfaces:**
- accounts-service `GET /users/{id}` — public, no identity needed (still behind the internal key): `200 { id, display_nick, avatar_url: string|null, created_at, badges: string[] }`, 404 for unknown/invalid ids. Never returns email or role. `badges`: `"early"` when the account is among the first 1000 by `created_at` (`SELECT count(*) FROM accounts WHERE created_at < $1` < 1000). Gateway routes `users` → accounts (already in the gateway plan's table).
- configs-service `GET /showcase?author={uuid}` — same page shape, filtered to that account's non-hidden listings; invalid uuid → 400.
- [ ] **Step 1: Failing tests** — `/users/{id}` returns nick without email and includes `early`; unknown id 404; `/showcase?author=` returns only that author's listings.
- [ ] **Step 2: Implement** (`repo::page` gains `author: Option<Uuid>` → `AND l.account_id = $3` when set).
- [ ] **Step 3: Commit** — `feat(backend): public profile endpoint and showcase author filter`
---
### Task 6: End-to-end through the gateway + docs ### Task 6: End-to-end through the gateway + docs
**Files:** **Files:**
@ -1387,3 +1401,11 @@ Expected: each call succeeds and the showcase item carries the real nick.
git add -A backend/STRUCTURE.md TODO.md backend/.env.example git add -A backend/STRUCTURE.md TODO.md backend/.env.example
git commit -m "docs(backend): configs-service implemented; Подсистема 1 backend complete" git commit -m "docs(backend): configs-service implemented; Подсистема 1 backend complete"
``` ```
---
### Task 7: `IDDQD` easter egg — done
`GET /configs/shared/{code}`: when `normalize(code) == "IDDQD"` return `200 { name: "God mode", data: <joke config>, updated_at: "1993-12-10T00:00:00Z" }` without touching the DB (DOOM's release date). The joke config data: every module disabled except `ChinaHat` (exact JSON shape = the mod's config format; until the mod-integration plan defines it, use `{ "modules": { "ChinaHat": { "enabled": true } }, "note": "god mode: только шляпа" }`). Real codes can never be `IDDQD` (no `I` in the alphabet, length 5). Test + commit `feat(configs): IDDQD easter egg config`.
Implemented in `src/sharing/handlers.rs::load_shared` (checked before hitting `repo::by_share_code`); test in `tests/sharing.rs::iddqd_is_a_god_mode_easter_egg_without_touching_the_db`.

View file

@ -0,0 +1,31 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
REVOKE ALL ON SCHEMA public FROM PUBLIC;
-- account_id has no FK: accounts live in accounts_db (separate database).
CREATE TABLE config_slots (
account_id UUID NOT NULL,
slot_index SMALLINT NOT NULL CHECK (slot_index BETWEEN 1 AND 4),
name TEXT NOT NULL,
data JSONB NOT NULL,
share_code TEXT NOT NULL UNIQUE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (account_id, slot_index)
);
CREATE TABLE showcase_listings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL,
slot_index SMALLINT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
copies_count INTEGER NOT NULL DEFAULT 0,
-- set by admin moderation (Подсистема 3); public queries always filter it
hidden BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, slot_index),
FOREIGN KEY (account_id, slot_index)
REFERENCES config_slots (account_id, slot_index) ON DELETE CASCADE
);
CREATE INDEX idx_showcase_new ON showcase_listings (published_at DESC) WHERE NOT hidden;
CREATE INDEX idx_showcase_popular ON showcase_listings (copies_count DESC, published_at DESC) WHERE NOT hidden;

View file

@ -0,0 +1,56 @@
use anyhow::{Context, Result};
#[derive(Clone)]
pub struct Config {
pub database_url: String,
pub port: u16,
pub internal_key: String,
pub accounts_grpc_url: String,
}
impl Config {
pub fn from_env() -> Result<Config> {
Ok(Config {
database_url: std::env::var("CONFIGS_DATABASE_URL")
.context("CONFIGS_DATABASE_URL not set")?,
port: std::env::var("CONFIGS_PORT")
.unwrap_or_else(|_| "8082".into())
.parse()
.context("CONFIGS_PORT")?,
internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?,
accounts_grpc_url: std::env::var("ACCOUNTS_GRPC_URL")
.context("ACCOUNTS_GRPC_URL not set")?,
})
}
pub fn validate(&self) -> Result<()> {
if self.internal_key.len() < 32 {
anyhow::bail!("INTERNAL_KEY must be at least 32 bytes");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(key: &str) -> Config {
Config {
database_url: String::new(),
port: 0,
internal_key: key.into(),
accounts_grpc_url: String::new(),
}
}
#[test]
fn strong_key_passes() {
assert!(sample(&"k".repeat(32)).validate().is_ok());
}
#[test]
fn short_key_is_rejected() {
assert!(sample("short").validate().is_err());
}
}

View file

@ -0,0 +1,83 @@
use axum::{
Json,
extract::{
FromRequest, FromRequestParts,
rejection::{JsonRejection, PathRejection, QueryRejection},
},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
Validation(String),
NotFound(String),
Conflict(String),
Forbidden(String),
Internal(anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::Validation(m) => (StatusCode::BAD_REQUEST, m),
AppError::NotFound(m) => (StatusCode::NOT_FOUND, m),
AppError::Conflict(m) => (StatusCode::CONFLICT, m),
AppError::Forbidden(m) => (StatusCode::FORBIDDEN, m),
AppError::Internal(err) => {
tracing::error!("internal error: {err:?}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl From<JsonRejection> for AppError {
fn from(rejection: JsonRejection) -> Self {
AppError::Validation(rejection.body_text())
}
}
impl From<QueryRejection> for AppError {
fn from(rejection: QueryRejection) -> Self {
AppError::Validation(rejection.body_text())
}
}
impl From<PathRejection> for AppError {
fn from(rejection: PathRejection) -> Self {
AppError::Validation(rejection.body_text())
}
}
/// Drop-in replacements for `axum::{Json, Query, Path}` that report rejections
/// (malformed body/query/path) as our `{"error": ...}` shape instead of
/// axum's plain-text default.
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(AppError))]
pub struct AppJson<T>(pub T);
impl<T> IntoResponse for AppJson<T>
where
Json<T>: IntoResponse,
{
fn into_response(self) -> Response {
Json(self.0).into_response()
}
}
#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Query), rejection(AppError))]
pub struct AppQuery<T>(pub T);
#[derive(FromRequestParts)]
#[from_request(via(axum::extract::Path), rejection(AppError))]
pub struct AppPath<T>(pub T);
impl From<sqlx::Error> for AppError {
fn from(err: sqlx::Error) -> Self {
AppError::Internal(err.into())
}
}

View file

@ -0,0 +1,55 @@
pub mod config;
pub mod error;
pub mod sharing;
pub mod showcase;
pub mod slots;
use axum::{
Router,
extract::DefaultBodyLimit,
routing::{get, post},
};
use common::internal::{InternalKey, require_internal_key};
use config::Config;
use showcase::profiles::ProfileSource;
use std::sync::Arc;
pub fn build_app(pool: sqlx::PgPool, cfg: &Config, profiles: Arc<dyn ProfileSource>) -> Router {
let slots_state = slots::handlers::SlotsState { pool: pool.clone() };
let showcase_state = showcase::handlers::ShowcaseState { pool, profiles };
let showcase_routes = Router::new()
.route(
"/configs/{slot}/publish",
post(showcase::handlers::publish).delete(showcase::handlers::unpublish),
)
.route("/showcase", get(showcase::handlers::browse))
.route("/showcase/{id}", get(showcase::handlers::detail))
.route("/showcase/{id}/copy", post(showcase::handlers::copy))
.with_state(showcase_state);
let api = Router::new()
.route("/configs", get(slots::handlers::list))
.route(
"/configs/{slot}",
get(slots::handlers::get).put(slots::handlers::save),
)
.route(
"/configs/shared/{code}",
get(sharing::handlers::load_shared),
)
.route(
"/configs/{slot}/regenerate-code",
post(sharing::handlers::regenerate),
)
.with_state(slots_state)
.merge(showcase_routes)
.layer(DefaultBodyLimit::max(
slots::handlers::MAX_DATA_BYTES + 16 * 1024,
))
.layer(axum::middleware::from_fn_with_state(
InternalKey::new(cfg.internal_key.clone()),
require_internal_key,
));
Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api)
}

View file

@ -0,0 +1,20 @@
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let cfg = configs_service::config::Config::from_env()?;
cfg.validate()?;
let pool = sqlx::PgPool::connect(&cfg.database_url).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let profiles = std::sync::Arc::new(
configs_service::showcase::profiles::GrpcProfiles::connect_lazy(
&cfg.accounts_grpc_url,
&cfg.internal_key,
)?,
);
let app = configs_service::build_app(pool, &cfg, profiles);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
tracing::info!("configs-service listening on {}", cfg.port);
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -0,0 +1,35 @@
use rand::RngExt;
/// No 0/O, 1/I/L — players type these by hand in chat.
pub const ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
pub const CODE_LEN: usize = 8;
pub fn new_code() -> String {
let mut rng = rand::rng();
(0..CODE_LEN)
.map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
.collect()
}
pub fn normalize(code: &str) -> String {
code.trim().to_uppercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_use_only_the_unambiguous_alphabet() {
for _ in 0..200 {
let c = new_code();
assert_eq!(c.len(), CODE_LEN);
assert!(c.bytes().all(|b| ALPHABET.contains(&b)), "{c}");
}
}
#[test]
fn normalize_trims_and_uppercases() {
assert_eq!(normalize(" ab3k9mzq \n"), "AB3K9MZQ");
}
}

View file

@ -0,0 +1,52 @@
use super::codes::normalize;
use crate::error::{AppError, AppPath};
use crate::slots::handlers::{SlotsState, validate_slot};
use crate::slots::repo::{self, SharedConfig};
use axum::{Json, extract::State};
use common::internal::GatewayIdentity;
use serde::Serialize;
#[derive(Serialize)]
pub struct ShareCodeResponse {
pub share_code: String,
}
/// Easter egg: `IDDQD` (DOOM's god-mode cheat) never touches the DB — real
/// share codes are drawn from an alphabet without `I` and can never equal it.
fn god_mode() -> SharedConfig {
SharedConfig {
name: "God mode".into(),
data: serde_json::json!({
"modules": { "ChinaHat": { "enabled": true } },
"note": "god mode: только шляпа"
}),
// DOOM's release date.
updated_at: "1993-12-10T00:00:00Z".parse().expect("valid RFC3339 literal"),
}
}
pub async fn load_shared(
State(state): State<SlotsState>,
AppPath(code): AppPath<String>,
) -> Result<Json<SharedConfig>, AppError> {
let code = normalize(&code);
if code == "IDDQD" {
return Ok(Json(god_mode()));
}
repo::by_share_code(&state.pool, &code)
.await?
.map(Json)
.ok_or_else(|| AppError::NotFound("unknown share code".into()))
}
pub async fn regenerate(
State(state): State<SlotsState>,
id: GatewayIdentity,
AppPath(slot): AppPath<i16>,
) -> Result<Json<ShareCodeResponse>, AppError> {
let slot = validate_slot(slot)?;
repo::regenerate_code(&state.pool, id.account_id, slot)
.await?
.map(|share_code| Json(ShareCodeResponse { share_code }))
.ok_or_else(|| AppError::NotFound("slot is empty".into()))
}

View file

@ -0,0 +1,2 @@
pub mod codes;
pub mod handlers;

View file

@ -0,0 +1,174 @@
use super::profiles::{Author, ProfileSource};
use super::repo::{self, CopyOutcome, ListingRow, PAGE_SIZE, PublishOutcome, Sort};
use crate::error::{AppError, AppJson, AppPath, AppQuery};
use crate::slots::handlers::{has_control_chars, validate_slot};
use axum::{Json, extract::State, http::StatusCode};
use chrono::{DateTime, Utc};
use common::internal::GatewayIdentity;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Clone)]
pub struct ShowcaseState {
pub pool: sqlx::PgPool,
pub profiles: Arc<dyn ProfileSource>,
}
#[derive(Serialize)]
pub struct Listing {
pub id: Uuid,
pub title: String,
pub description: String,
pub config_name: String,
pub copies_count: i32,
pub published_at: DateTime<Utc>,
pub author: Option<Author>,
}
fn to_listing(row: ListingRow, author: Option<Author>) -> Listing {
Listing {
id: row.id,
title: row.title,
description: row.description,
config_name: row.name,
copies_count: row.copies_count,
published_at: row.published_at,
author,
}
}
#[derive(Deserialize)]
pub struct PublishRequest {
pub title: String,
#[serde(default)]
pub description: String,
}
pub async fn publish(
State(state): State<ShowcaseState>,
id: GatewayIdentity,
AppPath(slot): AppPath<i16>,
AppJson(req): AppJson<PublishRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let slot = validate_slot(slot)?;
let title = req.title.trim();
if title.is_empty() || title.chars().count() > 64 || has_control_chars(title) {
return Err(AppError::Validation(
"title must be 1..64 characters, no control characters".into(),
));
}
let description = req.description.trim().to_owned();
if description.chars().count() > 500 || has_control_chars(&description) {
return Err(AppError::Validation(
"description must be at most 500 characters, no control characters".into(),
));
}
match repo::publish(&state.pool, id.account_id, slot, title, &description).await? {
PublishOutcome::Published(listing) => {
Ok(Json(serde_json::json!({ "listing_id": listing })))
}
PublishOutcome::SlotEmpty => Err(AppError::NotFound("slot is empty".into())),
PublishOutcome::Hidden => Err(AppError::Forbidden(
"listing is hidden by moderation".into(),
)),
}
}
pub async fn unpublish(
State(state): State<ShowcaseState>,
id: GatewayIdentity,
AppPath(slot): AppPath<i16>,
) -> Result<StatusCode, AppError> {
let slot = validate_slot(slot)?;
if repo::unpublish(&state.pool, id.account_id, slot).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(AppError::NotFound("slot is not published".into()))
}
}
#[derive(Deserialize)]
pub struct PageQuery {
pub sort: Option<String>,
pub page: Option<i64>,
pub author: Option<Uuid>,
}
#[derive(Serialize)]
pub struct PageResponse {
pub items: Vec<Listing>,
pub page: i64,
pub has_more: bool,
}
pub async fn browse(
State(state): State<ShowcaseState>,
AppQuery(q): AppQuery<PageQuery>,
) -> Result<Json<PageResponse>, AppError> {
let sort = match q.sort.as_deref() {
None | Some("new") => Sort::New,
Some("popular") => Sort::Popular,
Some(_) => return Err(AppError::Validation("sort must be new or popular".into())),
};
let page = q.page.unwrap_or(0).clamp(0, 10_000);
let mut rows = repo::page(&state.pool, sort, page, q.author).await?;
let has_more = rows.len() as i64 > PAGE_SIZE;
rows.truncate(PAGE_SIZE as usize);
let ids: Vec<Uuid> = rows.iter().map(|r| r.account_id).collect();
let authors = state.profiles.profiles(&ids).await;
let items = rows
.into_iter()
.map(|r| {
let author = authors.get(&r.account_id).cloned();
to_listing(r, author)
})
.collect();
Ok(Json(PageResponse {
items,
page,
has_more,
}))
}
pub async fn detail(
State(state): State<ShowcaseState>,
AppPath(id): AppPath<Uuid>,
) -> Result<Json<serde_json::Value>, AppError> {
let (row, data) = repo::detail(&state.pool, id)
.await?
.ok_or_else(|| AppError::NotFound("no such listing".into()))?;
let author = state
.profiles
.profiles(&[row.account_id])
.await
.remove(&row.account_id);
let mut body =
serde_json::to_value(to_listing(row, author)).map_err(|e| AppError::Internal(e.into()))?;
body["data"] = data;
Ok(Json(body))
}
#[derive(Deserialize)]
pub struct CopyRequest {
pub slot: i16,
}
pub async fn copy(
State(state): State<ShowcaseState>,
id: GatewayIdentity,
AppPath(listing): AppPath<Uuid>,
AppJson(req): AppJson<CopyRequest>,
) -> Result<(StatusCode, Json<crate::slots::repo::Slot>), AppError> {
let slot = validate_slot(req.slot)?;
match repo::copy_into(&state.pool, listing, id.account_id, slot).await? {
CopyOutcome::ListingMissing => Err(AppError::NotFound("no such listing".into())),
CopyOutcome::SlotOccupied => Err(AppError::Conflict("target slot is not empty".into())),
CopyOutcome::Copied => {
let saved = crate::slots::repo::get(&state.pool, id.account_id, slot)
.await?
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("copied slot vanished")))?;
Ok((StatusCode::CREATED, Json(saved)))
}
}
}

View file

@ -0,0 +1,3 @@
pub mod handlers;
pub mod profiles;
pub mod repo;

View file

@ -0,0 +1,74 @@
use common::internal::GrpcKeyAttach;
use common::pb::accounts::{
GetPublicProfilesRequest, accounts_internal_client::AccountsInternalClient,
};
use serde::Serialize;
use std::{collections::HashMap, future::Future, pin::Pin};
use tonic::{
service::interceptor::InterceptedService,
transport::{Channel, Endpoint},
};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize)]
pub struct Author {
pub nick: String,
pub avatar_url: Option<String>,
}
pub type ProfilesFuture<'a> = Pin<Box<dyn Future<Output = HashMap<Uuid, Author>> + Send + 'a>>;
pub trait ProfileSource: Send + Sync + 'static {
fn profiles<'a>(&'a self, ids: &'a [Uuid]) -> ProfilesFuture<'a>;
}
pub struct GrpcProfiles {
client: AccountsInternalClient<InterceptedService<Channel, GrpcKeyAttach>>,
}
impl GrpcProfiles {
pub fn connect_lazy(url: &str, internal_key: &str) -> anyhow::Result<Self> {
let channel = Endpoint::from_shared(url.to_owned())?
.timeout(std::time::Duration::from_secs(3))
.connect_lazy();
Ok(GrpcProfiles {
client: AccountsInternalClient::with_interceptor(
channel,
GrpcKeyAttach::new(internal_key)?,
),
})
}
}
impl ProfileSource for GrpcProfiles {
fn profiles<'a>(&'a self, ids: &'a [Uuid]) -> ProfilesFuture<'a> {
Box::pin(async move {
let request = GetPublicProfilesRequest {
account_ids: ids.iter().map(Uuid::to_string).collect(),
};
match self.client.clone().get_public_profiles(request).await {
Ok(reply) => reply
.into_inner()
.profiles
.into_iter()
.filter_map(|p| {
let id = Uuid::parse_str(&p.account_id).ok()?;
let avatar_url = (!p.avatar_url.is_empty()).then_some(p.avatar_url);
Some((
id,
Author {
nick: p.display_nick,
avatar_url,
},
))
})
.collect(),
Err(status) => {
// Showcase must stay browsable when accounts-service is down.
tracing::warn!("GetPublicProfiles failed: {status}");
HashMap::new()
}
}
})
}
}

View file

@ -0,0 +1,213 @@
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, query::QueryAs, query_as};
use uuid::Uuid;
pub const PAGE_SIZE: i64 = 20;
#[derive(Debug, Clone, Copy)]
pub enum Sort {
New,
Popular,
}
#[derive(Debug, sqlx::FromRow)]
pub struct ListingRow {
pub id: Uuid,
pub account_id: Uuid,
pub title: String,
pub description: String,
pub copies_count: i32,
pub published_at: DateTime<Utc>,
pub name: String,
}
const LISTING_FROM: &str = " FROM showcase_listings l
JOIN config_slots s ON s.account_id = l.account_id AND s.slot_index = l.slot_index";
const LISTING_COLS: &str =
"l.id, l.account_id, l.title, l.description, l.copies_count, l.published_at, s.name";
pub enum PublishOutcome {
Published(Uuid),
SlotEmpty,
Hidden,
}
pub async fn publish(
pool: &PgPool,
account_id: Uuid,
slot: i16,
title: &str,
description: &str,
) -> Result<PublishOutcome, sqlx::Error> {
// The FK to config_slots makes this fail for an empty slot; check first for a clean 404.
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM config_slots WHERE account_id = $1 AND slot_index = $2)",
)
.bind(account_id)
.bind(slot)
.fetch_one(pool)
.await?;
if !exists {
return Ok(PublishOutcome::SlotEmpty);
}
let hidden: Option<bool> = sqlx::query_scalar(
"SELECT hidden FROM showcase_listings WHERE account_id = $1 AND slot_index = $2",
)
.bind(account_id)
.bind(slot)
.fetch_optional(pool)
.await?;
if hidden == Some(true) {
return Ok(PublishOutcome::Hidden);
}
let id = sqlx::query_scalar(
"INSERT INTO showcase_listings (account_id, slot_index, title, description)
VALUES ($1, $2, $3, $4)
ON CONFLICT (account_id, slot_index)
DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description
RETURNING id",
)
.bind(account_id)
.bind(slot)
.bind(title)
.bind(description)
.fetch_one(pool)
.await?;
Ok(PublishOutcome::Published(id))
}
pub async fn unpublish(pool: &PgPool, account_id: Uuid, slot: i16) -> Result<bool, sqlx::Error> {
let r = sqlx::query(
"DELETE FROM showcase_listings WHERE account_id = $1 AND slot_index = $2 AND NOT hidden",
)
.bind(account_id)
.bind(slot)
.execute(pool)
.await?;
Ok(r.rows_affected() == 1)
}
/// Returns up to PAGE_SIZE + 1 rows; the caller uses the extra one for `has_more`.
pub async fn page(
pool: &PgPool,
sort: Sort,
page: i64,
author: Option<Uuid>,
) -> Result<Vec<ListingRow>, sqlx::Error> {
// The dynamic parts are an enum-derived ORDER BY and a fixed author filter
// constant; all values stay bound parameters, so AssertSqlSafe is honest.
let order = match sort {
Sort::New => "l.published_at DESC",
Sort::Popular => "l.copies_count DESC, l.published_at DESC",
};
let author_filter = if author.is_some() {
"AND l.account_id = $3"
} else {
""
};
let sql = format!(
"SELECT {LISTING_COLS}{LISTING_FROM} WHERE NOT l.hidden {author_filter} ORDER BY {order}, l.id \
LIMIT $1 OFFSET $2"
);
let mut query: QueryAs<'_, Postgres, ListingRow, _> =
query_as::<Postgres, ListingRow>(sqlx::AssertSqlSafe(sql))
.bind(PAGE_SIZE + 1)
.bind(page * PAGE_SIZE);
if let Some(id) = author {
query = query.bind(id);
}
query.fetch_all(pool).await
}
pub async fn detail(
pool: &PgPool,
id: Uuid,
) -> Result<Option<(ListingRow, serde_json::Value)>, sqlx::Error> {
let Some((row, data)) = sqlx::query_as::<_, (Uuid, Uuid, String, String, i32, DateTime<Utc>, String, serde_json::Value)>(
"SELECT l.id, l.account_id, l.title, l.description, l.copies_count, l.published_at, s.name, s.data
FROM showcase_listings l
JOIN config_slots s ON s.account_id = l.account_id AND s.slot_index = l.slot_index
WHERE l.id = $1 AND NOT l.hidden",
)
.bind(id)
.fetch_optional(pool)
.await?
.map(|(id, account_id, title, description, copies_count, published_at, name, data)| {
(
ListingRow {
id,
account_id,
title,
description,
copies_count,
published_at,
name,
},
data,
)
})
else {
return Ok(None);
};
Ok(Some((row, data)))
}
pub enum CopyOutcome {
Copied,
ListingMissing,
SlotOccupied,
}
pub async fn copy_into(
pool: &PgPool,
listing: Uuid,
account_id: Uuid,
slot: i16,
) -> Result<CopyOutcome, sqlx::Error> {
let mut tx = pool.begin().await?;
let Some((owner, name, data)): Option<(Uuid, String, serde_json::Value)> = sqlx::query_as(
"SELECT l.account_id, s.name, s.data FROM showcase_listings l JOIN config_slots s
ON s.account_id = l.account_id AND s.slot_index = l.slot_index
WHERE l.id = $1 AND NOT l.hidden",
)
.bind(listing)
.fetch_optional(&mut *tx)
.await?
else {
return Ok(CopyOutcome::ListingMissing);
};
// Share codes are globally unique; retry a handful of times on collision
// like `save` does, rather than failing the whole copy.
let mut attempts = 0;
let inserted_rows = loop {
let result = sqlx::query(
"INSERT INTO config_slots (account_id, slot_index, name, data, share_code)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id, slot_index) DO NOTHING",
)
.bind(account_id)
.bind(slot)
.bind(&name)
.bind(&data)
.bind(crate::sharing::codes::new_code())
.execute(&mut *tx)
.await;
match result {
Err(err) if crate::slots::repo::is_share_code_collision(&err) && attempts < 3 => {
attempts += 1;
}
other => break other?.rows_affected(),
}
};
if inserted_rows == 0 {
return Ok(CopyOutcome::SlotOccupied);
}
if owner != account_id {
sqlx::query("UPDATE showcase_listings SET copies_count = copies_count + 1 WHERE id = $1")
.bind(listing)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(CopyOutcome::Copied)
}

View file

@ -0,0 +1,89 @@
use super::repo::{self, Slot, SlotSummary};
use crate::error::{AppError, AppJson, AppPath};
use axum::{Json, extract::State};
use common::internal::GatewayIdentity;
use serde::Deserialize;
pub const MAX_DATA_BYTES: usize = 256 * 1024;
const MAX_NAME_CHARS: usize = 32;
#[derive(Clone)]
pub struct SlotsState {
pub pool: sqlx::PgPool,
}
pub fn validate_slot(slot: i16) -> Result<i16, AppError> {
if (1..=4).contains(&slot) {
Ok(slot)
} else {
Err(AppError::Validation("slot must be 1..4".into()))
}
}
/// Rejects any ASCII/Unicode control character (including a bare CR/LF), which
/// would otherwise corrupt log lines or list rendering downstream.
pub fn has_control_chars(s: &str) -> bool {
s.chars().any(|c| c.is_control())
}
pub fn validate_name(name: &str) -> Result<String, AppError> {
let name = name.trim();
let len = name.chars().count();
if len == 0 || len > MAX_NAME_CHARS {
return Err(AppError::Validation(format!(
"name must be 1..{MAX_NAME_CHARS} characters"
)));
}
if has_control_chars(name) {
return Err(AppError::Validation(
"name must not contain control characters".into(),
));
}
Ok(name.to_owned())
}
#[derive(Deserialize)]
pub struct SaveRequest {
pub name: String,
pub data: serde_json::Value,
}
pub async fn list(
State(state): State<SlotsState>,
id: GatewayIdentity,
) -> Result<Json<Vec<SlotSummary>>, AppError> {
Ok(Json(repo::list(&state.pool, id.account_id).await?))
}
pub async fn get(
State(state): State<SlotsState>,
id: GatewayIdentity,
AppPath(slot): AppPath<i16>,
) -> Result<Json<Slot>, AppError> {
let slot = validate_slot(slot)?;
repo::get(&state.pool, id.account_id, slot)
.await?
.map(Json)
.ok_or_else(|| AppError::NotFound("slot is empty".into()))
}
pub async fn save(
State(state): State<SlotsState>,
id: GatewayIdentity,
AppPath(slot): AppPath<i16>,
AppJson(req): AppJson<SaveRequest>,
) -> Result<Json<Slot>, AppError> {
let slot = validate_slot(slot)?;
let name = validate_name(&req.name)?;
let size = serde_json::to_vec(&req.data)
.map_err(|e| AppError::Internal(e.into()))?
.len();
if size > MAX_DATA_BYTES {
return Err(AppError::Validation(format!(
"config data must be at most {MAX_DATA_BYTES} bytes"
)));
}
Ok(Json(
repo::save(&state.pool, id.account_id, slot, &name, &req.data).await?,
))
}

View file

@ -0,0 +1,2 @@
pub mod handlers;
pub mod repo;

View file

@ -0,0 +1,119 @@
use crate::sharing::codes::new_code;
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct SlotSummary {
pub slot: i16,
pub name: String,
pub updated_at: DateTime<Utc>,
pub share_code: String,
pub published: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Slot {
pub slot: i16,
pub name: String,
pub data: serde_json::Value,
pub updated_at: DateTime<Utc>,
pub share_code: String,
}
pub async fn list(pool: &PgPool, account_id: Uuid) -> Result<Vec<SlotSummary>, sqlx::Error> {
sqlx::query_as(
"SELECT s.slot_index AS slot, s.name, s.updated_at, s.share_code, (l.id IS NOT NULL) AS published
FROM config_slots s
LEFT JOIN showcase_listings l ON l.account_id = s.account_id AND l.slot_index = s.slot_index
WHERE s.account_id = $1 ORDER BY s.slot_index",
)
.bind(account_id)
.fetch_all(pool)
.await
}
pub async fn get(pool: &PgPool, account_id: Uuid, slot: i16) -> Result<Option<Slot>, sqlx::Error> {
sqlx::query_as(
"SELECT slot_index AS slot, name, data, updated_at, share_code
FROM config_slots WHERE account_id = $1 AND slot_index = $2",
)
.bind(account_id)
.bind(slot)
.fetch_optional(pool)
.await
}
pub(crate) fn is_share_code_collision(err: &sqlx::Error) -> bool {
matches!(err, sqlx::Error::Database(db) if db.constraint() == Some("config_slots_share_code_key"))
}
pub async fn save(
pool: &PgPool,
account_id: Uuid,
slot: i16,
name: &str,
data: &serde_json::Value,
) -> Result<Slot, sqlx::Error> {
// share_code is only used on INSERT; an update keeps the existing one.
// 31^8 codes make collisions vanishingly rare, but never fatal.
let mut attempts = 0;
loop {
let result = sqlx::query_as(
"INSERT INTO config_slots (account_id, slot_index, name, data, share_code)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id, slot_index)
DO UPDATE SET name = EXCLUDED.name, data = EXCLUDED.data, updated_at = now()
RETURNING slot_index AS slot, name, data, updated_at, share_code",
)
.bind(account_id)
.bind(slot)
.bind(name)
.bind(data)
.bind(new_code())
.fetch_one(pool)
.await;
match result {
Err(err) if is_share_code_collision(&err) && attempts < 3 => attempts += 1,
other => return other,
}
}
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct SharedConfig {
pub name: String,
pub data: serde_json::Value,
pub updated_at: DateTime<Utc>,
}
pub async fn by_share_code(pool: &PgPool, code: &str) -> Result<Option<SharedConfig>, sqlx::Error> {
sqlx::query_as("SELECT name, data, updated_at FROM config_slots WHERE share_code = $1")
.bind(code)
.fetch_optional(pool)
.await
}
pub async fn regenerate_code(
pool: &PgPool,
account_id: Uuid,
slot: i16,
) -> Result<Option<String>, sqlx::Error> {
let mut attempts = 0;
loop {
let result = sqlx::query_scalar(
"UPDATE config_slots SET share_code = $3
WHERE account_id = $1 AND slot_index = $2 RETURNING share_code",
)
.bind(account_id)
.bind(slot)
.bind(new_code())
.fetch_optional(pool)
.await;
match result {
Err(err) if is_share_code_collision(&err) && attempts < 3 => attempts += 1,
other => return other,
}
}
}

View file

@ -0,0 +1,72 @@
#![allow(dead_code)]
use axum_test::TestServer;
use configs_service::config::Config;
use uuid::Uuid;
#[allow(unused_imports)] // used by slots/showcase tests from Task 2 on
pub use ::common::internal::ACCOUNT_ID_HEADER;
pub const TEST_INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
pub async fn test_pool() -> sqlx::PgPool {
let url = std::env::var("CONFIGS_DATABASE_URL")
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/configs_db".into());
let pool = sqlx::PgPool::connect(&url)
.await
.expect("connect to configs_db");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("run migrations");
pool
}
pub fn test_config() -> Config {
Config {
database_url: String::new(),
port: 0,
internal_key: TEST_INTERNAL_KEY.into(),
accounts_grpc_url: String::new(),
}
}
pub fn test_server(app: axum::Router) -> TestServer {
let mut server = TestServer::new(app);
server.add_header(::common::internal::INTERNAL_KEY_HEADER, TEST_INTERNAL_KEY);
server
}
/// Accounts live in another service; here an account is just a fresh UUID.
pub fn new_account() -> Uuid {
Uuid::new_v4()
}
use configs_service::showcase::profiles::{Author, ProfileSource, ProfilesFuture};
use std::collections::HashMap;
use std::sync::Arc;
/// Every account is "Author-<first 4 chars of id>".
pub struct FakeProfiles;
impl ProfileSource for FakeProfiles {
fn profiles<'a>(&'a self, ids: &'a [Uuid]) -> ProfilesFuture<'a> {
Box::pin(async move {
ids.iter()
.map(|id| {
(
*id,
Author {
nick: format!("Author-{}", &id.to_string()[..4]),
avatar_url: None,
},
)
})
.collect::<HashMap<_, _>>()
})
}
}
pub fn no_profiles() -> Arc<dyn ProfileSource> {
Arc::new(FakeProfiles)
}

View file

@ -0,0 +1,93 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
async fn server() -> axum_test::TestServer {
common::test_server(configs_service::build_app(
common::test_pool().await,
&common::test_config(),
common::no_profiles(),
))
}
async fn save(s: &axum_test::TestServer, owner: &str) -> String {
let r: serde_json::Value = s
.put("/configs/1")
.add_header(common::ACCOUNT_ID_HEADER, owner)
.json(&json!({ "name": "legit", "data": { "k": 1 } }))
.await
.json();
r["share_code"].as_str().unwrap().to_owned()
}
#[tokio::test]
async fn iddqd_is_a_god_mode_easter_egg_without_touching_the_db() {
let s = server().await;
for code in ["IDDQD", "iddqd", "%20%20IdDqD%20%20"] {
let r = s.get(&format!("/configs/shared/{code}")).await;
r.assert_status_ok();
let body: serde_json::Value = r.json();
assert_eq!(body["name"], "God mode");
assert_eq!(body["data"]["modules"]["ChinaHat"]["enabled"], true);
assert_eq!(body["updated_at"], "1993-12-10T00:00:00Z");
}
}
#[tokio::test]
async fn anyone_can_load_by_code_case_insensitively_without_owner_leak() {
let s = server().await;
let code = save(&s, &common::new_account().to_string()).await;
let res = s
.get(&format!("/configs/shared/{}", code.to_lowercase()))
.await;
res.assert_status_ok();
let body: serde_json::Value = res.json();
assert_eq!(body["name"], "legit");
assert_eq!(body["data"], json!({ "k": 1 }));
assert!(body.get("account_id").is_none());
}
#[tokio::test]
async fn regenerate_invalidates_the_old_code() {
let s = server().await;
let owner = common::new_account().to_string();
let old = save(&s, &owner).await;
let res = s
.post("/configs/1/regenerate-code")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.await;
res.assert_status_ok();
let new = res.json::<serde_json::Value>()["share_code"]
.as_str()
.unwrap()
.to_owned();
assert_ne!(old, new);
s.get(&format!("/configs/shared/{old}"))
.await
.assert_status(StatusCode::NOT_FOUND);
s.get(&format!("/configs/shared/{new}"))
.await
.assert_status_ok();
}
#[tokio::test]
async fn regenerate_on_empty_slot_is_404_and_needs_identity() {
let s = server().await;
s.post("/configs/4/regenerate-code")
.add_header(common::ACCOUNT_ID_HEADER, common::new_account().to_string())
.await
.assert_status(StatusCode::NOT_FOUND);
s.post("/configs/4/regenerate-code")
.await
.assert_status_unauthorized();
}
#[tokio::test]
async fn unknown_code_is_404() {
server()
.await
.get("/configs/shared/ZZZZZZZZ")
.await
.assert_status(StatusCode::NOT_FOUND);
}

View file

@ -0,0 +1,261 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
async fn server() -> axum_test::TestServer {
common::test_server(configs_service::build_app(
common::test_pool().await,
&common::test_config(),
common::no_profiles(),
))
}
async fn publish(s: &axum_test::TestServer, owner: &str, title: &str) -> String {
s.put("/configs/1")
.add_header(common::ACCOUNT_ID_HEADER, owner)
.json(&json!({ "name": "cfg", "data": { "t": title } }))
.await
.assert_status_ok();
let r = s
.post("/configs/1/publish")
.add_header(common::ACCOUNT_ID_HEADER, owner)
.json(&json!({ "title": title, "description": "desc" }))
.await;
r.assert_status_ok();
r.json::<serde_json::Value>()["listing_id"]
.as_str()
.unwrap()
.to_owned()
}
#[tokio::test]
async fn published_listing_shows_up_publicly_with_author() {
let s = server().await;
let owner = common::new_account();
let id = publish(&s, &owner.to_string(), "Best PvP").await;
let page: serde_json::Value = s.get("/showcase?sort=new&page=0").await.json();
let item = page["items"]
.as_array()
.unwrap()
.iter()
.find(|i| i["id"] == id)
.expect("listed");
assert_eq!(item["title"], "Best PvP");
assert_eq!(item["config_name"], "cfg");
assert_eq!(
item["author"]["nick"],
format!("Author-{}", &owner.to_string()[..4])
);
assert!(item.get("account_id").is_none());
let detail: serde_json::Value = s.get(&format!("/showcase/{id}")).await.json();
assert_eq!(detail["data"], json!({ "t": "Best PvP" }));
}
#[tokio::test]
async fn unpublish_removes_listing() {
let s = server().await;
let owner = common::new_account().to_string();
let id = publish(&s, &owner, "gone").await;
s.delete("/configs/1/publish")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.await
.assert_status(StatusCode::NO_CONTENT);
s.get(&format!("/showcase/{id}"))
.await
.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn copy_goes_into_a_free_slot_and_counts() {
let s = server().await;
let id = publish(&s, &common::new_account().to_string(), "copy me").await;
let me = common::new_account().to_string();
let r = s
.post(&format!("/showcase/{id}/copy"))
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "slot": 3 }))
.await;
r.assert_status(StatusCode::CREATED);
assert_eq!(
r.json::<serde_json::Value>()["data"],
json!({ "t": "copy me" })
);
s.post(&format!("/showcase/{id}/copy"))
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "slot": 3 }))
.await
.assert_status(StatusCode::CONFLICT);
let detail: serde_json::Value = s.get(&format!("/showcase/{id}")).await.json();
assert_eq!(detail["copies_count"], 1);
}
#[tokio::test]
async fn publish_validation_and_auth() {
let s = server().await;
let owner = common::new_account().to_string();
s.post("/configs/2/publish")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.json(&json!({ "title": "x" }))
.await
.assert_status(StatusCode::NOT_FOUND);
s.put("/configs/2")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.json(&json!({ "name": "n", "data": {} }))
.await;
s.post("/configs/2/publish")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.json(&json!({ "title": " " }))
.await
.assert_status(StatusCode::BAD_REQUEST);
s.post("/configs/2/publish")
.json(&json!({ "title": "x" }))
.await
.assert_status_unauthorized();
s.get("/showcase?sort=bogus")
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn popular_sort_orders_by_copies() {
let s = server().await;
let low = publish(&s, &common::new_account().to_string(), "low").await;
let high = publish(&s, &common::new_account().to_string(), "high").await;
for _ in 0..2 {
s.post(&format!("/showcase/{high}/copy"))
.add_header(
common::ACCOUNT_ID_HEADER,
&common::new_account().to_string(),
)
.json(&json!({ "slot": 1 }))
.await
.assert_status(StatusCode::CREATED);
}
let page: serde_json::Value = s.get("/showcase?sort=popular").await.json();
let ids: Vec<&str> = page["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["id"].as_str().unwrap())
.collect();
let hi = ids.iter().position(|i| *i == high);
let lo = ids.iter().position(|i| *i == low);
assert!(
hi.is_some() && lo.is_none_or(|lo| hi.unwrap() < lo),
"high-copy listing must come first"
);
}
async fn hide(pool: &sqlx::PgPool, id: &str) {
sqlx::query("UPDATE showcase_listings SET hidden = true WHERE id = $1")
.bind(uuid::Uuid::parse_str(id).unwrap())
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn hidden_listing_is_invisible_everywhere_and_moderation_wins() {
let pool = common::test_pool().await;
let s = common::test_server(configs_service::build_app(
pool.clone(),
&common::test_config(),
common::no_profiles(),
));
let owner = common::new_account().to_string();
let id = publish(&s, &owner, "moderate me").await;
hide(&pool, &id).await;
// Invisible in browse.
let page: serde_json::Value = s.get("/showcase?sort=new&page=0").await.json();
assert!(
page["items"]
.as_array()
.unwrap()
.iter()
.all(|i| i["id"] != id),
"hidden listing must not appear in browse"
);
// Invisible in the author filter.
let page: serde_json::Value = s.get(&format!("/showcase?author={owner}")).await.json();
assert!(
page["items"]
.as_array()
.unwrap()
.iter()
.all(|i| i["id"] != id),
"hidden listing must not appear in the author filter"
);
// 404 on detail.
s.get(&format!("/showcase/{id}"))
.await
.assert_status(StatusCode::NOT_FOUND);
// 404 on copy.
s.post(&format!("/showcase/{id}/copy"))
.add_header(
common::ACCOUNT_ID_HEADER,
&common::new_account().to_string(),
)
.json(&json!({ "slot": 3 }))
.await
.assert_status(StatusCode::NOT_FOUND);
// Unpublishing a hidden listing is a 404, and it stays in the DB.
s.delete("/configs/1/publish")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.await
.assert_status(StatusCode::NOT_FOUND);
let still_there: i64 = sqlx::query_scalar("SELECT count(*) FROM showcase_listings WHERE id = $1")
.bind(uuid::Uuid::parse_str(&id).unwrap())
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(still_there, 1, "unpublish must not delete a hidden listing");
// Republishing a hidden slot never unhides it: 403, and it's still hidden.
s.post("/configs/1/publish")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.json(&json!({ "title": "sneaky", "description": "" }))
.await
.assert_status(StatusCode::FORBIDDEN);
let hidden: bool = sqlx::query_scalar("SELECT hidden FROM showcase_listings WHERE id = $1")
.bind(uuid::Uuid::parse_str(&id).unwrap())
.fetch_one(&pool)
.await
.unwrap();
assert!(hidden, "publish must never clear the hidden flag");
}
#[tokio::test]
async fn showcase_can_be_filtered_by_author() {
let s = server().await;
let owner = common::new_account().to_string();
let mine = publish(&s, &owner, "mine one").await;
let theirs = publish(&s, &common::new_account().to_string(), "someone else").await;
let page: serde_json::Value = s.get(&format!("/showcase?author={owner}")).await.json();
let ids: Vec<&str> = page["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["id"].as_str().unwrap())
.collect();
assert!(ids.contains(&mine.as_str()), "own listing must be listed");
assert!(
!ids.contains(&theirs.as_str()),
"other authors filtered out"
);
s.get("/showcase?author=not-a-uuid")
.await
.assert_status(StatusCode::BAD_REQUEST);
}

View file

@ -0,0 +1,128 @@
mod common;
use axum::http::StatusCode;
use serde_json::json;
async fn server() -> axum_test::TestServer {
common::test_server(configs_service::build_app(
common::test_pool().await,
&common::test_config(),
common::no_profiles(),
))
}
#[tokio::test]
async fn health_ok_and_api_requires_internal_key() {
let pool = common::test_pool().await;
let raw = axum_test::TestServer::new(configs_service::build_app(
pool,
&common::test_config(),
common::no_profiles(),
));
raw.get("/health").await.assert_status_ok();
raw.get("/configs")
.await
.assert_status(StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn save_then_get_then_list() {
let s = server().await;
let me = common::new_account();
let data = json!({ "modules": { "Hud": { "enabled": true } } });
let saved = s
.put("/configs/2")
.add_header(common::ACCOUNT_ID_HEADER, me.to_string())
.json(&json!({ "name": " pvp ", "data": data }))
.await;
saved.assert_status_ok();
let saved: serde_json::Value = saved.json();
assert_eq!(saved["name"], "pvp");
assert_eq!(saved["share_code"].as_str().unwrap().len(), 8);
let got: serde_json::Value = s
.get("/configs/2")
.add_header(common::ACCOUNT_ID_HEADER, me.to_string())
.await
.json();
assert_eq!(got["data"], data);
let list: Vec<serde_json::Value> = s
.get("/configs")
.add_header(common::ACCOUNT_ID_HEADER, me.to_string())
.await
.json();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["slot"], 2);
assert_eq!(list[0]["published"], false);
assert!(
list[0].get("data").is_none(),
"list must not ship full configs"
);
}
#[tokio::test]
async fn resave_keeps_share_code_and_overwrites_data() {
let s = server().await;
let me = common::new_account().to_string();
let first: serde_json::Value = s
.put("/configs/1")
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "name": "a", "data": 1 }))
.await
.json();
let second: serde_json::Value = s
.put("/configs/1")
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "name": "b", "data": 2 }))
.await
.json();
assert_eq!(first["share_code"], second["share_code"]);
assert_eq!(second["data"], 2);
}
#[tokio::test]
async fn validation_errors() {
let s = server().await;
let me = common::new_account().to_string();
for slot in ["0", "5"] {
s.put(&format!("/configs/{slot}"))
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "name": "x", "data": {} }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
s.put("/configs/1")
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "name": " ", "data": {} }))
.await
.assert_status(StatusCode::BAD_REQUEST);
s.put("/configs/1")
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "name": "x".repeat(33), "data": {} }))
.await
.assert_status(StatusCode::BAD_REQUEST);
let huge = "x".repeat(256 * 1024 + 1);
s.put("/configs/1")
.add_header(common::ACCOUNT_ID_HEADER, &me)
.json(&json!({ "name": "x", "data": huge }))
.await
.assert_status(StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn slots_are_private_and_need_identity() {
let s = server().await;
let owner = common::new_account().to_string();
s.put("/configs/3")
.add_header(common::ACCOUNT_ID_HEADER, &owner)
.json(&json!({ "name": "x", "data": {} }))
.await
.assert_status_ok();
s.get("/configs/3")
.add_header(common::ACCOUNT_ID_HEADER, common::new_account().to_string())
.await
.assert_status(StatusCode::NOT_FOUND);
s.get("/configs").await.assert_status_unauthorized();
}

View file

@ -0,0 +1,29 @@
[package]
name = "gateway"
version = "0.1.0"
edition = "2024"
[lib]
name = "gateway"
path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
axum = "0.8"
http-body-util = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
tower-http = { version = "0.7", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
reqwest = { version = "0.13", default-features = false, features = ["stream"] }
tonic = "0.14"
governor = "0.10"
anyhow = "1"
dotenvy = "0.15"
[dev-dependencies]
axum-test = "21"
tower = { version = "0.5", features = ["util"] }
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }

View file

@ -1531,7 +1531,7 @@ git commit -m "feat(gateway): scaffold crate with config validation and health c
**Interfaces:** **Interfaces:**
- Produces: `proxy::routes::{Upstream, upstream_for(&str) -> Option<Upstream>}`; `proxy::forward::{Upstreams, proxy}` where `Upstreams { client: reqwest::Client, accounts: String, configs: String, internal_key: HeaderValue }` and `Upstreams::new(&Config) -> anyhow::Result<Self>`; `async fn proxy(State<Arc<Upstreams>>, Request) -> Response` mounted as the router `fallback`. - Produces: `proxy::routes::{Upstream, upstream_for(&str) -> Option<Upstream>}`; `proxy::forward::{Upstreams, proxy}` where `Upstreams { client: reqwest::Client, accounts: String, configs: String, internal_key: HeaderValue }` and `Upstreams::new(&Config) -> anyhow::Result<Self>`; `async fn proxy(State<Arc<Upstreams>>, Request) -> Response` mounted as the router `fallback`.
- Routing table: `auth, device, avatars, me` → accounts; `configs, showcase` → configs; anything else → 404 JSON. - Routing table: `auth, device, avatars, me, users` → accounts; `configs, showcase` → configs; anything else → 404 JSON.
- [ ] **Step 1: Echo upstream helper (tests/common/mod.rs)** - [ ] **Step 1: Echo upstream helper (tests/common/mod.rs)**
@ -1627,7 +1627,7 @@ pub enum Upstream {
pub fn upstream_for(path: &str) -> Option<Upstream> { pub fn upstream_for(path: &str) -> Option<Upstream> {
match path.trim_start_matches('/').split('/').next()? { match path.trim_start_matches('/').split('/').next()? {
"auth" | "device" | "avatars" | "me" => Some(Upstream::Accounts), "auth" | "device" | "avatars" | "me" | "users" => Some(Upstream::Accounts),
"configs" | "showcase" => Some(Upstream::Configs), "configs" | "showcase" => Some(Upstream::Configs),
_ => None, _ => None,
} }
@ -2337,6 +2337,13 @@ git commit -m "feat(gateway): CORS for the site origin; docs for gateway and int
--- ---
### Task 12: Easter eggs in the gateway
- `.env` honeypot — done together with the dot-segment fix (see `.superpowers` fix list; commit "feat(gateway): .env honeypot easter egg for traversal scanners").
- `GET /coffee` (any method) → `418 I'm a teapot`, `text/plain; charset=utf-8`: «Я чайник. Кофе не варю, зато LoVisual бесплатный: https://github.com/loki5512344/LoVisual-/releases». Handled in the gateway before identity/rate limits, not forwarded. Test + commit `feat(gateway): 418 teapot at /coffee`.
---
## Deferred (not in this plan) ## Deferred (not in this plan)
- Admin role enforcement at the gateway (`/admin/*`) — Подсистема 3 plan; `Identity` will then carry the role (add `role` to the access-token claims). - Admin role enforcement at the gateway (`/admin/*`) — Подсистема 3 plan; `Identity` will then carry the role (add `role` to the access-token claims).

View file

@ -0,0 +1,89 @@
use anyhow::{Context, Result};
#[derive(Clone)]
pub struct Config {
pub port: u16,
pub jwt_secret: String,
pub internal_key: String,
pub accounts_http_url: String,
pub accounts_grpc_url: String,
pub configs_http_url: String,
pub site_origin: String,
/// Behind a reverse proxy (nginx/caddy) that appends the client IP to
/// X-Forwarded-For. Never enable when the gateway is exposed directly.
pub trust_proxy: bool,
}
fn var(name: &str) -> Result<String> {
std::env::var(name).with_context(|| format!("{name} not set"))
}
impl Config {
pub fn from_env() -> Result<Config> {
Ok(Config {
port: std::env::var("GATEWAY_PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.context("GATEWAY_PORT")?,
jwt_secret: var("JWT_SECRET")?,
internal_key: var("INTERNAL_KEY")?,
accounts_http_url: var("ACCOUNTS_HTTP_URL")?,
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
configs_http_url: var("CONFIGS_HTTP_URL")?,
site_origin: var("SITE_ORIGIN")?,
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
})
}
pub fn validate(&self) -> Result<()> {
for (name, value) in [
("JWT_SECRET", &self.jwt_secret),
("INTERNAL_KEY", &self.internal_key),
] {
if value.len() < 32 {
anyhow::bail!("{name} must be at least 32 bytes");
}
}
// Both are sent as raw header values (INTERNAL_KEY on every upstream
// call, SITE_ORIGIN in the CORS layer); checked here so a bad value
// is a startup error, not a panic deep in request handling.
axum::http::HeaderValue::from_str(&self.internal_key)
.context("INTERNAL_KEY is not a valid header value")?;
axum::http::HeaderValue::from_str(&self.site_origin)
.context("SITE_ORIGIN is not a valid header value")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
pub fn sample() -> Config {
Config {
port: 0,
jwt_secret: "j".repeat(32),
internal_key: "k".repeat(32),
accounts_http_url: String::new(),
accounts_grpc_url: String::new(),
configs_http_url: String::new(),
site_origin: "http://localhost:5173".into(),
trust_proxy: false,
}
}
#[test]
fn valid_config_passes() {
assert!(sample().validate().is_ok());
}
#[test]
fn weak_secrets_are_rejected() {
let mut c = sample();
c.internal_key = "short".into();
assert!(c.validate().is_err());
let mut c = sample();
c.jwt_secret = "short".into();
assert!(c.validate().is_err());
}
}

View file

@ -0,0 +1,60 @@
//! Easter egg for `.env` scanners: instead of a 400 they get a fake file.
//! Never forwarded upstream; answered before identity and rate limiting.
use axum::{
http::{StatusCode, header::CONTENT_TYPE},
response::{IntoResponse, Response},
};
const FAKE_ENV: &str = "\
# LoVisual production secrets — не благодари
DATABASE_URL=postgres://idi_naxui:daun_ebani@localhost:5432/tvoya_mamka
JWT_SECRET=nice_try_skiddie_tvoy_ip_uzhe_v_bane
INTERNAL_KEY=0000-0000-0000-0000-otvali
ADMIN_PASSWORD=hunter2
S3_SECRET_KEY=lovisual_luchshe_chem_tvoy_chit
# P.S. лучше скачай мод: https://github.com/loki5512344/LoVisual-/releases
";
/// Any segment named `.env` or `.env.<suffix>` (dots may be `%2e`-encoded).
pub fn is_env_probe(path: &str) -> bool {
super::segments(path).any(|raw| {
let name = super::decode_dots(raw);
name == ".env" || name.starts_with(".env.")
})
}
pub fn fake_env() -> Response {
(
StatusCode::OK,
[(CONTENT_TYPE, "text/plain; charset=utf-8")],
FAKE_ENV,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_env_segments_only() {
for hit in [
"/.env",
"/../../.env",
"/api/.env",
"/.env.local",
"/%2e%2e/%2Eenv",
] {
assert!(is_env_probe(hit), "{hit}");
}
for miss in [
"/auth/login",
"/configs/.environment",
"/x.env",
"/showcase/env",
] {
assert!(!is_env_probe(miss), "{miss}");
}
}
}

View file

@ -0,0 +1,111 @@
//! Outermost request filter: answers the gateway's own jokes and refuses paths
//! whose meaning changes once the upstream URL is built. reqwest/`url` (WHATWG)
//! resolves `..`, `%2e%2e` and backslashes, while rate-limit rules match the raw
//! path — so without this, `POST /auth/x/../login` reaches `/auth/login` without
//! its 5/min limit.
pub mod honeypot;
use crate::rate_limit::client_ip;
use axum::{
Json,
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use serde_json::json;
/// A path segment with `%2e`/`%2E` decoded — the only escape WHATWG URL
/// parsing treats as a dot when resolving dot-segments.
fn decode_dots(segment: &str) -> String {
segment.to_ascii_lowercase().replace("%2e", ".")
}
fn segments(path: &str) -> impl Iterator<Item = &str> {
path.strip_prefix('/').unwrap_or(path).split('/')
}
/// True when the upstream could see a different path than the one the
/// gateway routed and rate-limited.
pub fn is_ambiguous(path: &str) -> bool {
if path == "/" {
return false;
}
segments(path).any(|raw| {
let lower = raw.to_ascii_lowercase();
let dots = decode_dots(raw);
raw.is_empty()
|| dots == "."
|| dots == ".."
|| raw.contains('\\')
|| lower.contains("%2f")
|| lower.contains("%5c")
})
}
const TEAPOT: &str = "Я чайник. Кофе не варю, зато LoVisual бесплатный: https://github.com/loki5512344/LoVisual-/releases\n";
fn teapot() -> Response {
(
StatusCode::IM_A_TEAPOT,
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
TEAPOT,
)
.into_response()
}
pub async fn reject_ambiguous_paths(
State(trust_proxy): State<bool>,
req: Request,
next: Next,
) -> Response {
let path = req.uri().path();
if path == "/coffee" {
return teapot();
}
if honeypot::is_env_probe(path) {
tracing::info!(ip = %client_ip(&req, trust_proxy), path, "honeypot hit");
return honeypot::fake_env();
}
if is_ambiguous(path) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": "bad path" })),
)
.into_response();
}
next.run(req).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_paths() {
for bad in [
"/a/../b",
"/a/%2E%2e/b",
"/a/.%2E",
"/a//b",
"/a%2Fb",
"/a\\b",
"/a/",
] {
assert!(is_ambiguous(bad), "{bad}");
}
for ok in [
"/",
"/auth/login",
"/configs/shared/AB2C",
"/a/..b",
"/a/.x",
] {
assert!(!is_ambiguous(ok), "{ok}");
}
}
}

View file

@ -0,0 +1,66 @@
use common::internal::GrpcKeyAttach;
use common::pb::accounts::{
AuthenticateDeviceRequest, accounts_internal_client::AccountsInternalClient,
};
use std::{future::Future, pin::Pin};
use tonic::{
Code,
service::interceptor::InterceptedService,
transport::{Channel, Endpoint},
};
use uuid::Uuid;
pub enum DeviceAuth {
Valid(Uuid),
Invalid,
/// accounts-service unreachable — the gateway answers 503, not 401,
/// so the mod doesn't wrongly forget its token.
Unavailable,
}
pub type DeviceAuthFuture<'a> = Pin<Box<dyn Future<Output = DeviceAuth> + Send + 'a>>;
pub trait DeviceAuthenticator: Send + Sync + 'static {
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a>;
}
pub struct GrpcDevices {
client: AccountsInternalClient<InterceptedService<Channel, GrpcKeyAttach>>,
}
impl GrpcDevices {
/// Lazy: the gateway boots even if accounts-service is still starting.
pub fn connect_lazy(url: &str, internal_key: &str) -> anyhow::Result<Self> {
let channel = Endpoint::from_shared(url.to_owned())?
.timeout(std::time::Duration::from_secs(5))
.connect_lazy();
Ok(GrpcDevices {
client: AccountsInternalClient::with_interceptor(
channel,
GrpcKeyAttach::new(internal_key)?,
),
})
}
}
impl DeviceAuthenticator for GrpcDevices {
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a> {
Box::pin(async move {
let mut client = self.client.clone();
match client
.authenticate_device(AuthenticateDeviceRequest {
device_token: token.to_owned(),
})
.await
{
Ok(reply) => Uuid::parse_str(&reply.into_inner().account_id)
.map_or(DeviceAuth::Invalid, DeviceAuth::Valid),
Err(status) if status.code() == Code::Unauthenticated => DeviceAuth::Invalid,
Err(status) => {
tracing::warn!("AuthenticateDevice failed: {status}");
DeviceAuth::Unavailable
}
}
})
}
}

View file

@ -0,0 +1,114 @@
pub mod device;
use axum::{
Json,
extract::{Request, State},
http::{HeaderValue, StatusCode, header::AUTHORIZATION},
middleware::Next,
response::{IntoResponse, Response},
};
use common::internal::{ACCOUNT_ID_HEADER, DEVICE_TOKEN_PREFIX, INTERNAL_KEY_HEADER};
use common::jwt::{TokenType, bearer_token, verify_token};
use device::{DeviceAuth, DeviceAuthenticator};
use serde_json::json;
use std::sync::Arc;
use uuid::Uuid;
/// Who made the request, as far as the gateway could verify.
#[derive(Clone, Copy)]
pub struct Identity(pub Option<Uuid>);
/// How the caller's credentials resolved. Recorded by `identify`, acted on by
/// `authorize` — resolution must not reject on its own, because it sits before
/// rate limiting and an unknown device token already cost a gRPC round trip.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Credentials {
/// No `Authorization` header at all.
Anonymous,
/// Verified; `Identity` carries the account.
Valid,
/// Present but not verifiable (expired/garbage/unknown device token).
Invalid,
/// Device lookup failed; the caller is neither allowed nor blamed.
Unavailable,
}
#[derive(Clone)]
pub struct IdentityState {
pub jwt_secret: Arc<str>,
pub devices: Arc<dyn DeviceAuthenticator>,
}
fn reject(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
/// Public auth endpoints: stale credentials there must not lock the caller
/// out of logout/refresh — they are treated as anonymous instead of 401.
fn is_auth_path(path: &str) -> bool {
path.starts_with("/auth/")
}
/// Resolves the caller into `Identity` + `Credentials` extensions, never
/// rejecting; the gate is `authorize`.
pub async fn identify(
State(state): State<IdentityState>,
mut req: Request,
next: Next,
) -> Response {
// Client-supplied copies of trusted headers are never forwarded.
req.headers_mut().remove(ACCOUNT_ID_HEADER);
req.headers_mut().remove(INTERNAL_KEY_HEADER);
let (account, credentials) = match bearer_token(req.headers()) {
None => (None, Credentials::Anonymous),
Some(token) if token.starts_with(DEVICE_TOKEN_PREFIX) => {
match state.devices.authenticate(token).await {
DeviceAuth::Valid(id) => (Some(id), Credentials::Valid),
DeviceAuth::Invalid => (None, Credentials::Invalid),
DeviceAuth::Unavailable => (None, Credentials::Unavailable),
}
}
Some(token) => {
let id = verify_token(token, &state.jwt_secret, TokenType::Access)
.and_then(|claims| Uuid::parse_str(&claims.sub).ok());
match id {
Some(id) => (Some(id), Credentials::Valid),
None => (None, Credentials::Invalid),
}
}
};
// The client's bearer token stops here either way.
req.headers_mut().remove(AUTHORIZATION);
if let Some(id) = account
&& let Ok(value) = HeaderValue::from_str(&id.to_string())
{
req.headers_mut().insert(ACCOUNT_ID_HEADER, value);
}
req.extensions_mut().insert(Identity(account));
req.extensions_mut().insert(credentials);
next.run(req).await
}
/// Turns a failed resolution into an HTTP rejection — positioned after rate
/// limiting so that a rejected credential still costs the caller a token.
pub async fn authorize(req: Request, next: Next) -> Response {
let credentials = req
.extensions()
.get::<Credentials>()
.copied()
.unwrap_or(Credentials::Anonymous);
let allowed = credentials == Credentials::Anonymous
|| credentials == Credentials::Valid
|| is_auth_path(req.uri().path());
if !allowed {
return match credentials {
Credentials::Unavailable => {
reject(StatusCode::SERVICE_UNAVAILABLE, "auth backend unavailable")
}
_ => reject(StatusCode::UNAUTHORIZED, "unauthorized"),
};
}
next.run(req).await
}

View file

@ -0,0 +1,76 @@
pub mod config;
pub mod guard;
pub mod identity;
pub mod proxy;
pub mod rate_limit;
use axum::{
Router,
http::{
HeaderValue, Method,
header::{AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER},
},
routing::get,
};
use config::Config;
use identity::device::DeviceAuthenticator;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
pub fn build_app(cfg: &Config, devices: Arc<dyn DeviceAuthenticator>) -> Router {
let upstreams = Arc::new(proxy::forward::Upstreams::new(cfg).expect("valid upstream config"));
let identity = identity::IdentityState {
jwt_secret: cfg.jwt_secret.as_str().into(),
devices,
};
let limits = rate_limit::RateLimits::new(cfg.trust_proxy);
spawn_purger(Arc::clone(&limits));
let cors = CorsLayer::new()
// Array form = AllowOrigin::list: the header is only sent back when the
// request's Origin actually matches (the HeaderValue form would echo
// the configured origin unconditionally).
.allow_origin([
HeaderValue::from_str(&cfg.site_origin).expect("SITE_ORIGIN is a valid origin")
])
.allow_credentials(true)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([AUTHORIZATION, CONTENT_TYPE])
// Without this, browser JS can't read Retry-After on a 429 despite
// the response carrying it — cross-origin responses only expose a
// fixed default header set unless the server opts more in.
.expose_headers([RETRY_AFTER]);
// Layers run bottom-up: the last `.layer` is outermost. Request order is
// therefore cors → guard → identify (resolve only) → rate limit →
// authorize → upstream: a bad credential is counted against the caller's
// limit before it gets rejected, and CORS preflights skip all of it.
Router::new()
.route("/health", get(|| async { "ok" }))
.fallback(proxy::forward::proxy)
.with_state(upstreams)
.layer(axum::middleware::from_fn(identity::authorize))
.layer(axum::middleware::from_fn_with_state(
limits,
rate_limit::enforce,
))
.layer(axum::middleware::from_fn_with_state(
identity,
identity::identify,
))
.layer(axum::middleware::from_fn_with_state(
cfg.trust_proxy,
guard::reject_ambiguous_paths,
))
.layer(cors)
}
fn spawn_purger(limits: Arc<rate_limit::RateLimits>) {
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
tick.tick().await;
limits.purge();
}
});
}
}

View file

@ -0,0 +1,23 @@
use gateway::identity::device::GrpcDevices;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let cfg = gateway::config::Config::from_env()?;
cfg.validate()?;
let devices = Arc::new(GrpcDevices::connect_lazy(
&cfg.accounts_grpc_url,
&cfg.internal_key,
)?);
let app = gateway::build_app(&cfg, devices);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
tracing::info!("gateway listening on {}", cfg.port);
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}

View file

@ -0,0 +1,124 @@
use super::routes::{Upstream, upstream_for};
use crate::config::Config;
use axum::{
Json,
body::Body,
extract::{Request, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use common::internal::INTERNAL_KEY_HEADER;
use serde_json::json;
use std::{sync::Arc, time::Duration};
/// 5 MB avatar + multipart overhead; configs are far smaller.
pub const MAX_BODY_BYTES: usize = 6 * 1024 * 1024;
/// A slow/stalled client body must not hold a connection open forever.
const BODY_READ_TIMEOUT: Duration = Duration::from_secs(30);
const HOP_BY_HOP: [HeaderName; 7] = [
header::CONNECTION,
header::PROXY_AUTHENTICATE,
header::PROXY_AUTHORIZATION,
header::TE,
header::TRAILER,
header::TRANSFER_ENCODING,
header::UPGRADE,
];
pub struct Upstreams {
pub client: reqwest::Client,
pub accounts: String,
pub configs: String,
pub internal_key: HeaderValue,
}
impl Upstreams {
pub fn new(cfg: &Config) -> anyhow::Result<Self> {
Ok(Upstreams {
client: reqwest::Client::builder()
// Never follow redirects on behalf of the client — pass them through.
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()?,
accounts: cfg.accounts_http_url.trim_end_matches('/').to_owned(),
configs: cfg.configs_http_url.trim_end_matches('/').to_owned(),
internal_key: HeaderValue::from_str(&cfg.internal_key)?,
})
}
}
fn error(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
fn strip_hop_by_hop(headers: &mut HeaderMap) {
for name in &HOP_BY_HOP {
headers.remove(name);
}
headers.remove("keep-alive");
}
pub async fn proxy(State(up): State<Arc<Upstreams>>, req: Request) -> Response {
let Some(target) = upstream_for(req.uri().path()) else {
return error(StatusCode::NOT_FOUND, "not found");
};
let base = match target {
Upstream::Accounts => &up.accounts,
Upstream::Configs => &up.configs,
};
let path_and_query = req.uri().path_and_query().map_or("/", |p| p.as_str());
let url = format!("{base}{path_and_query}");
let (parts, body) = req.into_parts();
let bytes = match tokio::time::timeout(
BODY_READ_TIMEOUT,
axum::body::to_bytes(body, MAX_BODY_BYTES),
)
.await
{
Ok(Ok(bytes)) => bytes,
Ok(Err(err)) => {
// `to_bytes` reports both "over the limit" and "the connection
// broke while reading" the same way; only the former is 413.
let over_limit = std::error::Error::source(&err)
.is_some_and(|e| e.is::<http_body_util::LengthLimitError>());
return if over_limit {
error(StatusCode::PAYLOAD_TOO_LARGE, "payload too large")
} else {
error(StatusCode::BAD_REQUEST, "invalid request body")
};
}
Err(_) => return error(StatusCode::REQUEST_TIMEOUT, "request body read timed out"),
};
let mut headers = parts.headers;
strip_hop_by_hop(&mut headers);
headers.remove(header::HOST);
headers.insert(INTERNAL_KEY_HEADER, up.internal_key.clone());
let upstream = match up
.client
.request(parts.method, url)
.headers(headers)
.body(bytes)
.send()
.await
{
Ok(resp) => resp,
Err(err) => {
tracing::warn!("upstream {target:?} failed: {err}");
return error(StatusCode::BAD_GATEWAY, "upstream unavailable");
}
};
let mut response = Response::builder().status(upstream.status());
for (name, value) in upstream.headers() {
if !HOP_BY_HOP.contains(name) && name != "keep-alive" {
response = response.header(name, value);
}
}
response
.body(Body::from_stream(upstream.bytes_stream()))
.unwrap_or_else(|_| error(StatusCode::BAD_GATEWAY, "bad upstream response"))
}

View file

@ -0,0 +1,2 @@
pub mod forward;
pub mod routes;

View file

@ -0,0 +1,29 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Upstream {
Accounts,
Configs,
}
pub fn upstream_for(path: &str) -> Option<Upstream> {
match path.trim_start_matches('/').split('/').next()? {
"auth" | "device" | "avatars" | "me" | "users" => Some(Upstream::Accounts),
"configs" | "showcase" => Some(Upstream::Configs),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_by_first_segment_only() {
assert_eq!(upstream_for("/auth/login"), Some(Upstream::Accounts));
assert_eq!(upstream_for("/me"), Some(Upstream::Accounts));
assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs));
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
assert_eq!(upstream_for("/authx"), None);
assert_eq!(upstream_for("/"), None);
assert_eq!(upstream_for("/health"), None);
}
}

View file

@ -0,0 +1,111 @@
pub mod rules;
use crate::identity::Identity;
use axum::{
Json,
extract::{ConnectInfo, Request, State},
http::{HeaderValue, StatusCode, header::RETRY_AFTER},
middleware::Next,
response::{IntoResponse, Response},
};
use governor::{
DefaultKeyedRateLimiter, RateLimiter,
clock::{Clock, DefaultClock},
};
use rules::{KeyBy, Rule};
use serde_json::json;
use std::{net::SocketAddr, sync::Arc};
pub struct RateLimits {
rules: Vec<(Rule, DefaultKeyedRateLimiter<String>)>,
global: DefaultKeyedRateLimiter<String>,
trust_proxy: bool,
clock: DefaultClock,
}
impl RateLimits {
pub fn new(trust_proxy: bool) -> Arc<Self> {
Arc::new(RateLimits {
rules: rules::rules()
.into_iter()
.map(|r| {
let l = RateLimiter::keyed(r.quota);
(r, l)
})
.collect(),
global: RateLimiter::keyed(rules::global_quota()),
trust_proxy,
clock: DefaultClock::default(),
})
}
/// Drops idle keys so the maps don't grow forever. Call periodically.
pub fn purge(&self) {
for (_, limiter) in &self.rules {
limiter.retain_recent();
limiter.shrink_to_fit();
}
self.global.retain_recent();
self.global.shrink_to_fit();
}
fn client_ip(&self, req: &Request) -> String {
client_ip(req, self.trust_proxy)
}
}
/// The caller's IP: the rightmost X-Forwarded-For entry (the one our own
/// proxy appended) when `trust_proxy`, else the socket peer.
pub fn client_ip(req: &Request, trust_proxy: bool) -> String {
if trust_proxy
&& let Some(ip) = req
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.rsplit(',').next())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return ip.to_owned();
}
req.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map_or_else(|| "unknown".to_owned(), |c| c.0.ip().to_string())
}
fn too_many(wait: std::time::Duration) -> Response {
let secs = wait.as_secs_f64().ceil().max(1.0) as u64;
let mut res = (
StatusCode::TOO_MANY_REQUESTS,
Json(json!({ "error": "too many requests" })),
)
.into_response();
res.headers_mut()
.insert(RETRY_AFTER, HeaderValue::from(secs));
res
}
pub async fn enforce(State(limits): State<Arc<RateLimits>>, req: Request, next: Next) -> Response {
let ip_key = format!("ip:{}", limits.client_ip(&req));
let account_key = req
.extensions()
.get::<Identity>()
.and_then(|i| i.0)
.map(|id| format!("acc:{id}"));
let caller_key = account_key.unwrap_or_else(|| ip_key.clone());
let (method, path) = (req.method().clone(), req.uri().path().to_owned());
if let Some((rule, limiter)) = limits.rules.iter().find(|(r, _)| r.matches(&method, &path)) {
let key = match rule.key_by {
KeyBy::Ip => &ip_key,
KeyBy::Account => &caller_key,
};
if let Err(not_until) = limiter.check_key(key) {
return too_many(not_until.wait_time_from(limits.clock.now()));
}
}
if let Err(not_until) = limits.global.check_key(&caller_key) {
return too_many(not_until.wait_time_from(limits.clock.now()));
}
next.run(req).await
}

View file

@ -0,0 +1,85 @@
use axum::http::Method;
use governor::Quota;
use std::num::NonZeroU32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyBy {
Ip,
Account,
}
pub struct Rule {
pub method: Method,
/// Exact path, or a prefix when it ends with '*'.
pub pattern: &'static str,
pub quota: Quota,
pub key_by: KeyBy,
}
impl Rule {
pub fn matches(&self, method: &Method, path: &str) -> bool {
if self.method != *method {
return false;
}
match self.pattern.strip_suffix('*') {
Some(prefix) => path.starts_with(prefix),
None => path == self.pattern,
}
}
}
fn n(v: u32) -> NonZeroU32 {
NonZeroU32::new(v).expect("rate-limit constants are non-zero")
}
fn rule(method: Method, pattern: &'static str, quota: Quota, key_by: KeyBy) -> Rule {
Rule {
method,
pattern,
quota,
key_by,
}
}
pub fn rules() -> Vec<Rule> {
use KeyBy::*;
vec![
rule(Method::POST, "/auth/login", Quota::per_minute(n(5)), Ip),
rule(Method::POST, "/auth/register", Quota::per_hour(n(3)), Ip),
rule(Method::POST, "/auth/refresh", Quota::per_minute(n(30)), Ip),
rule(Method::POST, "/device/code", Quota::per_minute(n(10)), Ip),
// The mod polls every 2–3 s for up to 10 min: 10/min would break linking.
rule(Method::POST, "/device/token", Quota::per_minute(n(30)), Ip),
rule(Method::PUT, "/configs/*", Quota::per_minute(n(20)), Account),
rule(
Method::GET,
"/configs/shared/*",
Quota::per_minute(n(30)),
Ip,
),
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
]
}
pub fn global_quota() -> Quota {
Quota::per_minute(n(300))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_and_prefix_matching() {
let all = rules();
let find = |m: Method, p: &str| all.iter().position(|r| r.matches(&m, p));
assert_eq!(find(Method::POST, "/auth/login"), Some(0));
assert_eq!(find(Method::GET, "/auth/login"), None);
assert_eq!(find(Method::POST, "/auth/login/x"), None);
assert!(find(Method::PUT, "/configs/3").is_some());
assert!(find(Method::GET, "/configs/shared/ABCD").is_some());
assert!(find(Method::GET, "/showcase").is_some());
assert!(find(Method::GET, "/showcase/11111111-1111-1111-1111-111111111111").is_some());
}
}

View file

@ -0,0 +1,141 @@
#![allow(dead_code)]
use axum::{Json, Router, body::Bytes, extract::Request, routing::any};
use gateway::config::Config;
use gateway::identity::device::{DeviceAuth, DeviceAuthFuture, DeviceAuthenticator};
use std::sync::Arc;
use uuid::Uuid;
pub const JWT_SECRET: &str = "gateway-test-secret-gateway-test!!";
pub const INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
#[allow(unused_imports)] // used by identity.rs; other test crates don't need it
pub use ::common::jwt;
pub fn config(accounts: &str, configs: &str) -> Config {
Config {
port: 0,
jwt_secret: JWT_SECRET.into(),
internal_key: INTERNAL_KEY.into(),
accounts_http_url: accounts.into(),
accounts_grpc_url: String::new(),
configs_http_url: configs.into(),
site_origin: "http://localhost:5173".into(),
trust_proxy: true,
}
}
/// Accepts exactly one device token, mapped to one account.
pub struct FakeDevices {
pub token: String,
pub account: Uuid,
}
impl DeviceAuthenticator for FakeDevices {
fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a> {
Box::pin(async move {
if token == self.token {
DeviceAuth::Valid(self.account)
} else {
DeviceAuth::Invalid
}
})
}
}
/// accounts-service unreachable: every device token lookup fails.
pub struct DownDevices;
impl DeviceAuthenticator for DownDevices {
fn authenticate<'a>(&'a self, _token: &'a str) -> DeviceAuthFuture<'a> {
Box::pin(async { DeviceAuth::Unavailable })
}
}
/// An access JWT signed with the right key but already expired.
pub fn expired_access_token(account: Uuid) -> String {
let exp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_secs() as usize
- 60;
let claims = ::common::jwt::Claims {
sub: account.to_string(),
exp,
token_type: ::common::jwt::TokenType::Access,
};
jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
&claims,
&jsonwebtoken::EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.expect("encode test jwt")
}
pub fn no_devices() -> Arc<dyn DeviceAuthenticator> {
Arc::new(FakeDevices {
token: "lvd_none".into(),
account: Uuid::nil(),
})
}
/// Starts a fake service that echoes what it received as JSON; returns its base URL.
pub async fn spawn_echo() -> String {
async fn echo(req: Request) -> Json<serde_json::Value> {
let (parts, body) = req.into_parts();
let body: Bytes = axum::body::to_bytes(body, usize::MAX)
.await
.unwrap_or_default();
let header = |name: &str| {
parts
.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
};
Json(serde_json::json!({
"method": parts.method.as_str(),
"path": parts.uri.path(),
"query": parts.uri.query(),
"body": String::from_utf8_lossy(&body),
"account_id": header("x-lovisual-account-id"),
"internal_key": header("x-lovisual-internal-key"),
"authorization": header("authorization"),
}))
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(any(echo)))
.await
.unwrap()
});
format!("http://{addr}")
}
/// Sends a request straight into the router, bypassing the test client's URL
/// handling, so the raw path (`..`, `%2e`, `//`) reaches the gateway as-is.
pub async fn raw(
app: &axum::Router,
method: &str,
uri: &str,
forwarded_for: &str,
) -> (axum::http::StatusCode, axum::http::HeaderMap, String) {
use tower::ServiceExt;
let req = axum::http::Request::builder()
.method(method)
.uri(uri)
.header("x-forwarded-for", forwarded_for)
.body(axum::body::Body::empty())
.expect("valid raw request");
let res = app.clone().oneshot(req).await.expect("infallible router");
let (parts, body) = res.into_parts();
let bytes = axum::body::to_bytes(body, usize::MAX)
.await
.unwrap_or_default();
(
parts.status,
parts.headers,
String::from_utf8_lossy(&bytes).into_owned(),
)
}

View file

@ -0,0 +1,111 @@
mod common;
use axum::http::StatusCode;
use std::sync::Arc;
use uuid::Uuid;
async fn server(devices: common::FakeDevices) -> axum_test::TestServer {
let echo = common::spawn_echo().await;
let app = gateway::build_app(&common::config(&echo, &echo), Arc::new(devices));
axum_test::TestServer::new(app)
}
fn devices() -> common::FakeDevices {
common::FakeDevices {
token: "lvd_good".into(),
account: Uuid::new_v4(),
}
}
#[tokio::test]
async fn access_jwt_becomes_account_header_and_authorization_is_dropped() {
let account = Uuid::new_v4();
let token = common::jwt::issue_access_token(account, common::JWT_SECRET);
let echo: serde_json::Value = server(devices())
.await
.get("/me")
.authorization_bearer(token)
.await
.json();
assert_eq!(echo["account_id"], account.to_string());
assert!(echo["authorization"].is_null());
}
#[tokio::test]
async fn device_token_is_resolved_via_authenticator() {
let d = devices();
let account = d.account;
let echo: serde_json::Value = server(d)
.await
.get("/configs")
.authorization_bearer("lvd_good")
.await
.json();
assert_eq!(echo["account_id"], account.to_string());
}
#[tokio::test]
async fn bad_credentials_are_rejected_at_the_gateway() {
let s = server(devices()).await;
s.get("/me")
.authorization_bearer("lvd_bad")
.await
.assert_status(StatusCode::UNAUTHORIZED);
s.get("/me")
.authorization_bearer("not.a.jwt")
.await
.assert_status(StatusCode::UNAUTHORIZED);
let wrong_key =
common::jwt::issue_access_token(Uuid::new_v4(), "another-secret-another-secret-12345");
s.get("/me")
.authorization_bearer(wrong_key)
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn anonymous_requests_pass_without_identity() {
let echo: serde_json::Value = server(devices()).await.post("/auth/login").await.json();
assert!(echo["account_id"].is_null());
}
#[tokio::test]
async fn spoofed_identity_header_never_reaches_the_service() {
let echo: serde_json::Value = server(devices())
.await
.get("/me")
.add_header("x-lovisual-account-id", Uuid::new_v4().to_string())
.await
.json();
assert!(echo["account_id"].is_null());
}
#[tokio::test]
async fn stale_credentials_on_auth_paths_are_treated_as_anonymous() {
let s = server(devices()).await;
let expired = common::expired_access_token(Uuid::new_v4());
for token in [expired.as_str(), "lvd_bad", "not.a.jwt"] {
let res = s.post("/auth/logout").authorization_bearer(token).await;
res.assert_status_ok();
let echo: serde_json::Value = res.json();
assert_eq!(echo["path"], "/auth/logout", "reached upstream");
assert!(echo["account_id"].is_null());
assert!(echo["authorization"].is_null());
}
// Outside /auth/ the same token is still rejected.
s.get("/me")
.authorization_bearer(expired)
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn unreachable_device_backend_is_503() {
let echo = common::spawn_echo().await;
let app = gateway::build_app(&common::config(&echo, &echo), Arc::new(common::DownDevices));
axum_test::TestServer::new(app)
.get("/configs")
.authorization_bearer("lvd_whatever")
.await
.assert_status(StatusCode::SERVICE_UNAVAILABLE);
}

View file

@ -0,0 +1,89 @@
mod common;
use axum::http::StatusCode;
async fn app() -> axum::Router {
let echo = common::spawn_echo().await;
gateway::build_app(&common::config(&echo, &echo), common::no_devices())
}
#[tokio::test]
async fn dot_segments_encoded_slashes_and_empty_segments_are_400() {
let app = app().await;
for uri in [
"/auth/x/../login",
"/auth/./login",
"/auth/%2e%2e/device/token",
"/auth/%2E%2E/login",
"/auth/x/.%2e/login",
"/auth/x/%2e./login",
"/auth/%2e/login",
"/configs%2f1",
"/configs/1%5Cx",
"/configs/1\\..\\2",
"//evil.com/x",
"/auth//login",
] {
let (status, _, body) = common::raw(&app, "POST", uri, "203.0.113.50").await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{uri}");
assert!(body.contains("bad path"), "{uri}: {body}");
}
}
#[tokio::test]
async fn normal_paths_are_unaffected() {
let app = app().await;
for uri in ["/auth/login", "/configs/2?x=../y", "/showcase", "/health"] {
let (status, _, _) = common::raw(&app, "POST", uri, "203.0.113.51").await;
assert_ne!(status, StatusCode::BAD_REQUEST, "{uri}");
}
let (status, _, _) = common::raw(&app, "GET", "/", "203.0.113.51").await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn traversal_cannot_dodge_the_login_limit() {
let app = app().await;
for _ in 0..8 {
let (status, _, _) = common::raw(&app, "POST", "/auth/x/../login", "203.0.113.52").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
for _ in 0..5 {
let (status, _, _) = common::raw(&app, "POST", "/auth/login", "203.0.113.52").await;
assert_eq!(status, StatusCode::OK);
}
let (status, _, _) = common::raw(&app, "POST", "/auth/login", "203.0.113.52").await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn env_probes_get_the_honeypot_instead_of_400() {
let app = app().await;
for uri in [
"/../../.env",
"/%2e%2e/.env",
"/.env",
"/api/.env",
"/.env.local",
"/.env.production",
] {
let (status, headers, body) = common::raw(&app, "GET", uri, "203.0.113.53").await;
assert_eq!(status, StatusCode::OK, "{uri}");
assert_eq!(headers["content-type"], "text/plain; charset=utf-8");
assert!(body.contains("nice_try_skiddie"), "{uri}: {body}");
assert!(!body.contains("\"path\""), "never forwarded upstream");
}
}
#[tokio::test]
async fn coffee_is_a_teapot_for_every_method() {
let app = app().await;
for method in ["GET", "POST", "PUT"] {
let (status, headers, body) = common::raw(&app, method, "/coffee", "203.0.113.54").await;
assert_eq!(status, StatusCode::IM_A_TEAPOT, "{method}");
assert_eq!(headers["content-type"], "text/plain; charset=utf-8");
assert!(body.contains("Я чайник"), "{method}: {body}");
assert!(body.contains("LoVisual-/releases"), "{method}: {body}");
assert!(!body.contains("\"path\""), "never forwarded upstream");
}
}

View file

@ -0,0 +1,110 @@
mod common;
use axum::http::StatusCode;
async fn server() -> axum_test::TestServer {
let accounts = common::spawn_echo().await;
let configs = common::spawn_echo().await;
let app = gateway::build_app(&common::config(&accounts, &configs), common::no_devices());
axum_test::TestServer::new(app)
}
#[tokio::test]
async fn health_is_ok() {
server().await.get("/health").await.assert_status_ok();
}
#[tokio::test]
async fn forwards_method_path_query_and_body() {
let res = server().await.put("/configs/2?x=1").text("payload").await;
res.assert_status_ok();
let echo: serde_json::Value = res.json();
assert_eq!(echo["method"], "PUT");
assert_eq!(echo["path"], "/configs/2");
assert_eq!(echo["query"], "x=1");
assert_eq!(echo["body"], "payload");
}
#[tokio::test]
async fn adds_internal_key_and_strips_spoofed_identity() {
let res = server()
.await
.post("/auth/login")
.add_header("x-lovisual-account-id", uuid::Uuid::new_v4().to_string())
.add_header("x-lovisual-internal-key", "spoofed")
.await;
let echo: serde_json::Value = res.json();
assert_eq!(echo["internal_key"], common::INTERNAL_KEY);
assert!(echo["account_id"].is_null());
}
#[tokio::test]
async fn unknown_prefix_is_404() {
server()
.await
.get("/nope")
.await
.assert_status(StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dead_upstream_is_502() {
let app = gateway::build_app(
&common::config("http://127.0.0.1:1", "http://127.0.0.1:1"),
common::no_devices(),
);
axum_test::TestServer::new(app)
.get("/me")
.await
.assert_status(StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn oversized_body_is_413() {
let big = "x".repeat(6 * 1024 * 1024 + 1);
server()
.await
.put("/configs/1")
.text(big)
.await
.assert_status(StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn cors_preflight_allows_only_the_site_origin() {
let app = gateway::build_app(
&common::config("http://127.0.0.1:1", "http://127.0.0.1:1"),
common::no_devices(),
);
let s = axum_test::TestServer::new(app);
let ok = s
.method(axum::http::Method::OPTIONS, "/auth/login")
.add_header("origin", "http://localhost:5173")
.add_header("access-control-request-method", "POST")
.await;
assert_eq!(
ok.header("access-control-allow-origin"),
"http://localhost:5173"
);
assert_eq!(ok.header("access-control-allow-credentials"), "true");
let evil = s
.method(axum::http::Method::OPTIONS, "/auth/login")
.add_header("origin", "https://evil.example")
.add_header("access-control-request-method", "POST")
.await;
assert!(evil.maybe_header("access-control-allow-origin").is_none());
}
#[tokio::test]
async fn cors_exposes_retry_after_so_js_can_read_it() {
let s = server().await;
let res = s
.post("/auth/login")
.add_header("origin", "http://localhost:5173")
.await;
assert_eq!(
res.header("access-control-expose-headers"),
"retry-after"
);
}

View file

@ -0,0 +1,78 @@
mod common;
use axum::http::StatusCode;
async fn server() -> axum_test::TestServer {
let echo = common::spawn_echo().await;
axum_test::TestServer::new(gateway::build_app(
&common::config(&echo, &echo),
common::no_devices(),
))
}
#[tokio::test]
async fn sixth_login_in_a_minute_from_one_ip_is_429_with_retry_after() {
let s = server().await;
for _ in 0..5 {
s.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.7")
.await
.assert_status_ok();
}
let res = s
.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.7")
.await;
res.assert_status(StatusCode::TOO_MANY_REQUESTS);
let retry: u64 = res.header("retry-after").to_str().unwrap().parse().unwrap();
assert!((1..=60).contains(&retry));
}
#[tokio::test]
async fn limits_are_per_ip() {
let s = server().await;
for _ in 0..5 {
s.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.8")
.await;
}
s.post("/auth/login")
.add_header("x-forwarded-for", "203.0.113.9")
.await
.assert_status_ok();
}
#[tokio::test]
async fn rightmost_forwarded_for_entry_is_used() {
// A client can prepend fake entries; only the one our proxy appended counts.
let s = server().await;
for i in 0..5 {
s.post("/auth/login")
.add_header("x-forwarded-for", format!("10.0.0.{i}, 203.0.113.10"))
.await
.assert_status_ok();
}
s.post("/auth/login")
.add_header("x-forwarded-for", "1.1.1.1, 203.0.113.10")
.await
.assert_status(StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn failed_credentials_count_against_the_ip_limit() {
// Every bad device token costs accounts-service a gRPC call + DB query,
// so the per-IP global limit must apply before identity rejects it.
let s = server().await;
for _ in 0..300 {
s.get("/configs")
.authorization_bearer("lvd_bad")
.add_header("x-forwarded-for", "203.0.113.20")
.await
.assert_status(StatusCode::UNAUTHORIZED);
}
s.get("/configs")
.authorization_bearer("lvd_bad")
.add_header("x-forwarded-for", "203.0.113.20")
.await
.assert_status(StatusCode::TOO_MANY_REQUESTS);
}

2
frontend/.env.example Normal file
View file

@ -0,0 +1,2 @@
# GitHub repository (owner/name) whose releases carry the `lovisual.jar` asset.
VITE_GITHUB_REPO=loki5512344/LoVisual-

View file

@ -34,12 +34,19 @@ frontend/src/
client.ts # общий fetch-wrapper: base URL gateway, JWT в заголовке, client.ts # общий fetch-wrapper: base URL gateway, JWT в заголовке,
# обработка 401 (refresh) и 429 (rate-limit) в одном месте # обработка 401 (refresh) и 429 (rate-limit) в одном месте
types.ts # общие DTO, если совпадают на нескольких фичах types.ts # общие DTO, если совпадают на нескольких фичах
i18n/ # LanguageSwitch, common.ru.ts / common.en.ts (typed dictionaries)
pages/ # роуты верхнего уровня, тонкие — просто собирают pages/ # роуты верхнего уровня, тонкие — просто собирают
# фичи в layout, никакой бизнес-логики # фичи в layout, никакой бизнес-логики
App.tsx app/ # App.tsx, routes.tsx, i18n.ts (initI18n), i18next.d.ts (typed keys)
main.tsx main.tsx
``` ```
Каждая фича, у которой есть строки для пользователя, также владеет своей
`i18n/` подпапкой (`ru.ts`, `en.ts`) — одно пространство имён i18next на
фичу (`auth`, `landing`, ...), русские словари как источник (`as const`),
английские типизированы через `satisfies Translation<typeof ru>` из
`shared/i18n/common.ru.ts`, так что расхождение ключей — ошибка компиляции.
Правило раздела shared/feature: если код используется ровно в одной фиче — Правило раздела shared/feature: если код используется ровно в одной фиче —
он живёт в этой фиче, а не в `shared/`. Переносить в `shared/` только когда он живёт в этой фиче, а не в `shared/`. Переносить в `shared/` только когда
появился второй потребитель — не заранее "на всякий случай" (YAGNI). появился второй потребитель — не заранее "на всякий случай" (YAGNI).
@ -94,3 +101,9 @@ frontend/src/
``` ```
Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9). Источники: [Robin Wieruch — React Folder Structure Best Practices 2026](https://www.robinwieruch.de/react-folder-structure/), [Mastering Modern React + Vite Folder Structure, Medium](https://sandeshrathnayake.medium.com/mastering-modern-react-vite-folder-structure-a-production-ready-guide-for-scalable-applications-9ad8e233f8b9).
## Единственное допустимое ребро shared → feature
`shared/layout/` (шапка и каркас приложения) импортирует `useSession` из
`features/auth/session`: шапке нужно знать, вошёл ли пользователь. Это
единственный случай, когда `shared/` зависит от фичи; остальной `shared/`
от фич не зависит.

View file

@ -10,7 +10,9 @@
## Global Constraints ## Global Constraints
- All user-facing text in Russian, sentence case, active voice. Code identifiers/comments in English. - **Bilingual (ru default, en).** From Task 5A on, no user-facing string is hard-coded: every string goes through i18next (`useTranslation`), with Russian as the source dictionary and English kept key-for-key identical (enforced by types). Sentence case, active voice in both. Code identifiers/comments in English.
- **The mod is free.** No pricing, subscriptions, keys or HWID anywhere on the site. «Скачать» is the primary action everywhere.
- **Visually rich, not minimal** (owner's explicit requirement after reviewing the first landing): every public page has a real visual centerpiece that shows the product (live ClickGui/HUD replicas, particle/bloom effects matching the mod, procedural scenes), orchestrated motion via `motion` (respecting `prefers-reduced-motion`), and depth. Text-and-boxes pages are rejected.
- ≤250 lines per file, ≤4 files per folder (subfolders don't count). Tests live in a `tests/` subfolder of the feature/shared folder they cover (colocating `X.test.tsx` next to `X.tsx` would halve every folder's capacity under the 4-file rule) — Task 2 updates `ARCHITECTURE.md` accordingly. - ≤250 lines per file, ≤4 files per folder (subfolders don't count). Tests live in a `tests/` subfolder of the feature/shared folder they cover (colocating `X.test.tsx` next to `X.tsx` would halve every folder's capacity under the 4-file rule) — Task 2 updates `ARCHITECTURE.md` accordingly.
- No `any`. DTOs typed from the backend contracts (`backend/PLAN.md`, `backend/gateway/PLAN.md` Tasks 3–6, `backend/configs-service/PLAN.md`). - No `any`. DTOs typed from the backend contracts (`backend/PLAN.md`, `backend/gateway/PLAN.md` Tasks 3–6, `backend/configs-service/PLAN.md`).
- No barrel `index.ts` files. No business logic in `pages/`. - No barrel `index.ts` files. No business logic in `pages/`.
@ -37,7 +39,14 @@
| `ice` | `#5CC8E7` | the one accent: primary actions, focus, share codes | | `ice` | `#5CC8E7` | the one accent: primary actions, focus, share codes |
| `ember` | `#E8835A` | destructive actions and errors only | | `ember` | `#E8835A` | destructive actions and errors only |
**Type:** Comfortaa (the mod's own main-menu face; rounded, Cyrillic) for headings only; Inter Variable for everything else; Iosevka only where the text is literally typed in-game — share codes, the `%config load` command, device user codes. Scale (≈1.25): 14 / 16 / 20 / 25 / 31 / 39 px, hero 61 px. Body line length ≤ 70ch. **Type (revised 2026-09-25 — Cyrillic-first, commit "Cyrillic-first fonts"):** Unbounded Variable (wide geometric display face with full Cyrillic; headings, big numbers, logo) — `font-display`; Onest Variable (designed for Cyrillic; all body/UI text) — `font-sans`; JetBrains Mono Variable (has Cyrillic, unlike Iosevka; share codes, commands, chat lines, device codes) — `font-code`. Scale (≈1.25): 14 / 16 / 20 / 25 / 31 / 39 px, hero clamp(2.5rem, 6vw, 5rem). Body line length ≤ 70ch.
**Visual direction (revised 2026-09-25):** the site looks like the mod running. Signature pieces, all built in code (no stock images; no AI-image API keys are available):
- **ClickGui replica** — an HTML/CSS reproduction of the in-game ClickGui window (category column Combat / Visuals / Player / Misc, module cards with toggles and setting sliders, the theme's gradients and accent bloom), themable at runtime from the mod's real presets, with subtle 3D tilt following the pointer.
- **HUD replicas** — TargetHUD, Keybinds, Potions, Watermark, Armor widgets drawn like the mod's HUD, draggable with the pointer.
- **Particle field** — a `<canvas>` of soft glowing particles (the mod's AmbientParticles/bloom look) tinted by the active theme accent; pauses when off-screen or with reduced motion.
- **Voxel scene** — a procedural blocky landscape/sky (canvas or CSS 3D) as the backdrop the HUD sits on, so no copyrighted screenshots are needed. Real in-game screenshots from the owner can replace it later.
- Gradients are allowed where they reproduce the mod's own theme gradients; decorative gradient washes are still out.
**Layout:** Left-aligned, max width 1120px, 24px gutters (16px on phones). Top bar: logo left, three nav links (Витрина, Мои конфиги, Привязать игру), account button right. Panels (slate fill, 1px shoal border, 10px radius — the ClickGui window shape) are used only for things that *are* panels in the game: config slots, showcase entries, forms. Everything else sits directly on the page. **Layout:** Left-aligned, max width 1120px, 24px gutters (16px on phones). Top bar: logo left, three nav links (Витрина, Мои конфиги, Привязать игру), account button right. Panels (slate fill, 1px shoal border, 10px radius — the ClickGui window shape) are used only for things that *are* panels in the game: config slots, showcase entries, forms. Everything else sits directly on the page.
@ -1040,6 +1049,146 @@ git commit -m "feat(frontend): app shell, routing and landing page with the chat
--- ---
### Task 5A: i18n foundation (ru/en) and migrating existing strings
**Why now:** every later task writes strings; converting after the fact doubles the work.
**Files:**
- Move: `src/App.tsx`, `src/routes.tsx` → `src/app/App.tsx`, `src/app/routes.tsx` (src/ top level becomes `main.tsx`, `index.css` + `app/`), update imports.
- Create: `src/app/i18n.ts` (init), `src/app/i18next.d.ts` (typed keys), `src/shared/i18n/{common.ru.ts,common.en.ts,LanguageSwitch.tsx}`, `src/shared/i18n/tests/i18n.test.tsx`
- Create per feature: `src/features/<feature>/i18n/{ru.ts,en.ts}` for `auth` and `landing` now; later tasks add their own.
- Modify: every component with hard-coded text (shared/ui ShareCode, shared/api/errors.ts, shared/layout, features/auth forms + validation, features/landing, pages/public/*), `src/test/setup.ts` (init i18n with `lng: 'ru'` synchronously), `index.html` (`lang` set at runtime).
**Interfaces:**
- One i18next namespace per feature (`common`, `auth`, `landing`, later `account`, `configs`, `showcase`, `download`, `themes`, `profile`, `link`). Russian dictionaries are `as const` objects; English ones are typed `satisfies Translation<typeof ru>` where `type Translation<T> = { [K in keyof T]: T[K] extends string ? string : Translation<T[K]> }` (exported from `shared/i18n/common.ru.ts`) — a missing or extra key in `en` is a compile error.
- `app/i18n.ts`: `initI18n(lng?: 'ru' | 'en'): i18n` — `i18next.use(LanguageDetector).use(initReactI18next).init({ resources: { ru: {...namespaces}, en: {...} }, fallbackLng: 'ru', supportedLngs: ['ru','en'], ns: [...], defaultNS: 'common', interpolation: { escapeValue: false }, detection: { order: ['localStorage','navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' } })`; on `languageChanged` set `document.documentElement.lang`.
- `app/i18next.d.ts`: `declare module 'i18next' { interface CustomTypeOptions { defaultNS: 'common'; resources: { common: typeof commonRu; auth: typeof authRu; landing: typeof landingRu /* extended by later tasks */ } } }` so `t('auth:login.submit')` is type-checked.
- `LanguageSwitch`: two-state toggle «RU / EN» in the TopBar (and footer), `aria-pressed` on the active one, calls `i18n.changeLanguage`.
- `describeError(err, overrides)` takes the `t` function: `describeError(t, err, overrides)`; messages move to `common` namespace (`errors.rateLimited` with `{{seconds}}` interpolation, etc.).
- Plurals via i18next plural keys (`_one/_few/_many` for ru, `_one/_other` for en).
- [ ] **Step 1: Failing tests (`shared/i18n/tests/i18n.test.tsx`)**
```tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { expect, test } from 'vitest'
import { initI18n } from '../../../app/i18n'
import { LanguageSwitch } from '../LanguageSwitch'
function Probe() {
const { t } = useTranslation('auth')
return <p>{t('login.submit')}</p>
}
test('switching language re-renders strings and sets <html lang>', async () => {
const user = userEvent.setup()
const i18n = initI18n('ru')
render(<I18nextProvider i18n={i18n}><Probe /><LanguageSwitch /></I18nextProvider>)
expect(screen.getByText('Войти')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'EN' }))
expect(screen.getByText('Log in')).toBeInTheDocument()
expect(document.documentElement.lang).toBe('en')
})
test('russian plurals', () => {
const i18n = initI18n('ru')
expect(i18n.t('common:time.days', { count: 1 })).toBe('1 день')
expect(i18n.t('common:time.days', { count: 3 })).toBe('3 дня')
expect(i18n.t('common:time.days', { count: 5 })).toBe('5 дней')
})
```
Existing tests keep asserting Russian text — they must still pass unchanged after the migration (that's the regression check). Add one English smoke test for the landing hero headline.
- [ ] **Step 2: Implement; migrate every existing string; `grep -rnP '[А-Яа-яЁё]' src --include=*.tsx --include=*.ts | grep -v '/i18n/' | grep -v '/tests/'` must print nothing.**
- [ ] **Step 3: test/build/lint; commit** — `feat(frontend): ru/en i18n with typed per-feature dictionaries and language switch`
---
### Task 5B: Landing v2 — the product on screen
Replaces the Task 5 landing body (keep `CommandHero`'s chat line — it becomes one section). Everything is drawn in code.
**Files:** new feature folders (each ≤4 files + subfolders):
- `src/features/clickgui/` — `ClickGuiWindow.tsx` (window chrome + category column + module list), `ModuleCard.tsx` (toggle, expandable settings: slider/checkbox/color dot), `themeVars.ts` (`themeToCssVars(entry: ThemeEntry): CSSProperties` — maps the mod's 12 colors + 5 gradients to CSS custom properties `--gui-window-bg` …), `demo.ts` (scripted demo timeline); `tests/`.
- `src/features/themes/` — `presets.generated.ts` (from the mod, see Task 5D Step 1 — generate it in THIS task, 5D reuses it), `types.ts` (`ThemeEntry`, `GradientSpec`, `argbToCss(hex: string): string` for `#AARRGGBB`), `useActiveTheme.ts` (context: active preset id, `setTheme`, persisted in `localStorage` key `lv_theme`); `tests/`.
- `src/features/hud/` — `HudWidget.tsx` (draggable wrapper: pointer events, clamped to parent, keyboard-movable with arrow keys when focused), `widgets/{TargetHud,Keybinds,Potions,Watermark}.tsx`; `tests/`.
- `src/features/landing/` — `CommandHero.tsx` (existing chat line, now a section), `HowItWorks.tsx` (existing, restyled), `sections/{Hero,ThemeStrip,HudPlayground,ModuleWall,ShowcaseTeaser,FaqDownload}.tsx`, `effects/{ParticleField.tsx,VoxelScene.tsx,useInView.ts}`; `tests/`.
**Page composition (top to bottom):**
1. **Hero** — full-viewport. Backdrop: `VoxelScene` (procedural blocky hills + dusk sky, parallax on scroll) under `ParticleField` (canvas, ~120 soft particles, accent-tinted, `requestAnimationFrame`, paused via `IntersectionObserver` and when `prefers-reduced-motion`). Left: headline (Unbounded, clamp size) «Визуалы, которые видно» / en «Visuals you can see», lead «Бесплатный легит-мод для Minecraft 26.2: 52 визуальных модуля, облачные конфиги и темы, которые ты собираешь сам.», primary «Скачать бесплатно» (direct release link, Task 5C `DOWNLOAD_URL`) + secondary «Создать аккаунт». Right: `ClickGuiWindow` in 3D (`perspective` + `rotateX/Y` from pointer, max 8°, spring via `motion`), running `demo.ts`: a fake cursor moves, toggles «Trails», opens its settings, drags a slider, then switches theme; loop every ~12s; pauses on hover (user takes over — cards are clickable).
2. **ThemeStrip** — «Сотни оттенков. Твой — один.»: horizontal row of every preset from `presets.generated.ts` as swatch pills (window bg + accent + gradient); clicking one re-themes the hero ClickGui and the page accent live (CSS vars on `:root` via `useActiveTheme`) with a 400ms cross-fade; CTA «Собрать свою тему» → `/themes`.
3. **HudPlayground** — `VoxelScene` crop with 4 draggable HUD widgets; caption «Перетащи — так же, как в игре.»; «Сбросить» button restores positions.
4. **Cloud configs** — the existing `CommandHero` chat line + `HowItWorks` 2×2 slots, now animated in sequence when scrolled into view (`useInView`): `%config save 1` → slot fills with a pulse → code appears → second chat line `%config load 7KQ3M9XA` → «Конфиг загружен».
5. **ModuleWall** — «88 модулей. 52 — про красоту.»: dense wall of module names (from a list in `landing/i18n` — names stay as in-game, untranslated) in 4 category columns; hovering a visuals module shows a one-line description tooltip; the wall slowly auto-scrolls vertically in each column at different speeds (CSS `@keyframes` translateY on duplicated lists, `animation-play-state: paused` on hover and under reduced motion).
6. **ShowcaseTeaser** — top 3 popular configs from `GET /showcase?sort=popular` (TanStack Query); on error/empty shows 3 skeleton-styled example cards labelled «Пример» (never a broken section).
7. **FaqDownload** — FAQ accordion (`<details>`; 5 questions: бесплатно ли, какие версии, нужен ли аккаунт, безопасно ли / легит, как перенести настройки) + final download band with version, Minecraft version and size (from Task 5C release data).
8. **Footer** — logo, nav, GitHub link, `LanguageSwitch`, «Не связано с Mojang или Microsoft.»
**Interfaces:**
- `themeToCssVars(entry)` output keys (used by ClickGui CSS): `--gui-window-bg, --gui-header, --gui-stroke, --gui-surface, --gui-surface-hover, --gui-card-on, --gui-card-off, --gui-text, --gui-text-muted, --gui-accent, --gui-accent-soft, --gui-stroke-soft`, plus `--gui-{window,header,surface,card,stroke}-gradient` as `linear-gradient(<angle>deg, start, end)` when enabled, else the flat color.
- `demo.ts`: `type DemoStep = { at: number /*ms*/; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }`, `DEMO: DemoStep[]`, `useDemo(steps, { paused }): { cursor: {x,y}, state }`.
- `HudWidget` props: `{ id: string; initial: { x: number; y: number }; children: ReactNode; label: string }` — `aria-label={label}`, `tabIndex=0`, arrows move 8px.
- [ ] **Step 1: Failing tests** — `themeToCssVars` maps a preset exactly (ARGB `#F012191B` → `rgba(18, 25, 27, 0.941)`); clicking a ThemeStrip swatch changes `--gui-accent` on the ClickGui root; `HudWidget` moves with ArrowRight by 8px and stays inside its parent bounds; `ParticleField` renders nothing animated (no rAF scheduled) under reduced motion (mock `matchMedia`); ModuleWall lists all four category headings; ShowcaseTeaser falls back to example cards on fetch error; hero download link points to the release URL.
- [ ] **Step 2: Implement.** Performance budget: landing JS ≤ 220 kB gzip (check `bun run build` output), no layout shift from fonts (`font-display: swap` is the fontsource default; reserve hero height), 60fps particle field on a mid laptop (cap DPR at 2, ≤150 particles).
- [ ] **Step 3: Visual check** with headless Firefox screenshots at 1440 and 375 (`firefox --headless --screenshot` — note it captures before long animations settle, so also verify the reduced-motion static state), both languages. Fix overflow, contrast, clipping.
- [ ] **Step 4: commit** — `feat(frontend): landing v2 with live ClickGui, theme strip, HUD playground and module wall`
---
### Task 5C: Download (one click from GitHub Releases) + «Что нового»
**Decisions:** the download button is a plain link to `https://github.com/${VITE_GITHUB_REPO}/releases/latest/download/lovisual.jar` — GitHub redirects straight to the file, so one click downloads the latest build with no intermediate page. This requires every release to carry an asset named exactly `lovisual.jar` (Step 1 adds the CI that guarantees it). `VITE_GITHUB_REPO=loki5512344/LoVisual-` in `frontend/.env` (and `.env.example`). The repo is private today — until it is public, the link 404s for visitors; the page shows the changelog fallback message then.
**Files:**
- Create: `.github/workflows/release.yml` (repo root) — on tag `v*`: JDK 25 (match `mod/build.gradle` toolchain), `./gradlew build` in `mod/`, copy the remapped jar to `lovisual.jar`, create the release with both `lovisual-<version>.jar` and `lovisual.jar` via `softprops/action-gh-release@v2` (`generate_release_notes: true`).
- Create: `src/features/download/{api.ts,DownloadButton.tsx,ReleaseNotes.tsx}`, `src/features/download/i18n/{ru,en}.ts`, `src/features/download/tests/download.test.tsx`, `src/pages/public/…` → pages/public is full (4 files) — create `src/pages/download/DownloadPage.tsx`.
- Modify: `src/app/routes.tsx` (`/download`), TopBar (a «Скачать» primary button on the right, before login), Landing hero/FAQ band (use `DownloadButton`).
- Add dependency: `react-markdown` (release notes are Markdown; react-markdown does not render raw HTML by default — keep it that way, no `rehype-raw`).
**Interfaces:**
- `api.ts`: `DOWNLOAD_URL: string`; `type Release = { tag_name: string; name: string; published_at: string; body: string; html_url: string; assets: { name: string; size: number; download_count: number }[] }`; `fetchReleases(): Promise<Release[]>` → `GET https://api.github.com/repos/${repo}/releases?per_page=10` (unauthenticated, 60 req/h/IP is plenty; TanStack Query `staleTime: 10 min`).
- `DownloadButton` props: `{ size?: 'md' | 'lg' }` — `<a href={DOWNLOAD_URL} download>` styled as primary button, label «Скачать бесплатно», sub-label with latest version + size when releases loaded («v0.1.1 · 4,2 МБ»).
- `ReleaseNotes`: list of releases (version, date via `Intl.DateTimeFormat(i18n.language)`, Markdown body, «Все релизы на GitHub» link). Error/empty → «Список изменений появится, когда выйдет первый публичный релиз.»
- `DownloadPage` (`/download`): big button, requirements block (Minecraft 26.2, Fabric Loader 0.19.4+, Fabric API; optional Sodium/Iris — values in the download dictionary, sourced from `mod/gradle.properties`), 3-step install guide (real sequence: скачать → положить в `mods` → запустить с профилем Fabric), then `ReleaseNotes`.
- [ ] **Step 1: CI workflow** (no test; validate with `actionlint` if available, otherwise careful review). Tagging/pushing is the owner's action — do not push tags.
- [ ] **Step 2: Failing tests** — button href equals `https://github.com/loki5512344/LoVisual-/releases/latest/download/lovisual.jar` (with `vi.stubEnv('VITE_GITHUB_REPO', …)`); release notes render Markdown headings and do NOT render a raw `<script>`/`<img onerror>` from the body as HTML; fetch failure shows the fallback text.
- [ ] **Step 3: Implement; routes; TopBar button.**
- [ ] **Step 4: commit** — `feat(frontend): one-click download from GitHub releases, changelog page, release CI`
---
### Task 5D: Theme editor (`/themes`)
**Files:**
- Create: `frontend/scripts/extract-themes.ts` (bun script) + `package.json` script `"gen:themes": "bun scripts/extract-themes.ts"` — parses `mod/src/main/java/dev/loki/lovisual/features/theme/presets/ThemePresets{1,2,3}.java` (`new Theme(0x…, … 12 ints)` and `new ThemeEntry(id, name, builtin, THEME, new GradientSpec(bool, 0x…, 0x…, angle) ×5)`) into `src/features/themes/presets.generated.ts` (`export const PRESETS: ThemeEntry[]`, header comment "generated — do not edit"). If Task 5B already created it, reuse.
- Create: `src/features/themes/editor/{ThemeEditor.tsx,ColorField.tsx,GradientField.tsx,codec.ts}`, `src/features/themes/i18n/{ru,en}.ts`, tests; `src/pages/themes/ThemesPage.tsx`; route `/themes` (public).
**Interfaces:**
- `codec.ts`: `toProfileJson(entry: ThemeEntry): string` — exactly the mod's `ThemesProfileCodec.toProfileValues` map (keys `name, windowBg, windowHeader, windowStroke, surface, surfaceHover, cardEnabled, cardDisabled, textPrimary, textMuted, accent, accentSoft, strokeSoft`, and for each of `window, header, surface, card, stroke`: `<p>GradientEnabled` (bool), `<p>GradientStart`, `<p>GradientEnd` (`#AARRGGBB` uppercase), `<p>GradientAngle` (number)); `fromProfileJson(json: string): ThemeEntry | { error: string }` (tolerant like the mod: missing keys fall back to the Classic preset); `toShareHash(entry): string` (base64url of the compact JSON) and `fromShareHash(hash)`.
- Editor layout: left — live `ClickGuiWindow` + two HUD widgets on the `VoxelScene` (large); right — preset picker, 12 `ColorField`s (native `<input type="color">` + alpha slider + hex input accepting `#RRGGBB`/`#AARRGGBB`), 5 `GradientField`s (toggle, two colors, angle dial 0–360), name field. Actions: «Скопировать для мода» (JSON to clipboard), «Скачать .json», «Импорт» (paste/file), «Ссылка на тему» (URL with `#t=<hash>`; opening such a URL loads the theme). Undo/redo (Ctrl+Z / Ctrl+Shift+Z) over the last 50 edits.
- Mod side (NOT in this plan — added to the mod integration plan): `%theme import` reads the JSON from the clipboard via the same codec.
- [ ] **Step 1: Generator + test** that `PRESETS` contains Classic with accent `#FF5CC8E7` and that the count equals the number of `ThemeEntry` constants in the Java files.
- [ ] **Step 2: Failing tests** — codec round-trip (`fromProfileJson(toProfileJson(p))` deep-equals `p` for every preset); JSON keys match the mod's list exactly; editing a color updates the preview's `--gui-accent`; share hash round-trip; malformed import shows an error, keeps the current theme.
- [ ] **Step 3: Implement; commit** — `feat(frontend): theme editor with live ClickGui preview, mod-compatible export and share links`
---
### Task 5E: Public profiles (`/u/:id`) — after configs-service is live
**Backend prerequisite (added to backend plans as their own tasks):** accounts-service `GET /users/{id}` public → `{ id, display_nick, avatar_url, created_at, badges: string[] }` (badges computed: `early` for the first 1000 accounts, `sharer` when the user has ≥1 published config — the latter via configs gRPC later; start with `early` only); configs-service `GET /showcase?author={id}`.
**Files:** `src/features/profile/{api.ts,ProfileHeader.tsx,ProfileConfigs.tsx}`, `i18n/`, `tests/`, `src/pages/profile/ProfilePage.tsx`; showcase author names and TopBar nick link to `/u/:id`.
**Interfaces:** header — large avatar (or initial on an accent-gradient disc), nick (Unbounded), «С нами с {date}», badge chips with tooltips; below — the user's published configs using the showcase `ListingItem`; own profile shows «Редактировать» → `/account`. 404 → «Такого игрока нет».
- [ ] **Steps:** failing tests (renders header + configs, 404 message, own-profile edit link) → implement → commit `feat(frontend): public player profiles`
---
### Task 6: Link the game (`/link`) ### Task 6: Link the game (`/link`)
**Files:** Create `src/features/device-link/{api.ts,LinkDeviceForm.tsx}`, `src/features/device-link/tests/link.test.tsx`, `src/pages/app/LinkPage.tsx`; modify `src/routes.tsx`. **Files:** Create `src/features/device-link/{api.ts,LinkDeviceForm.tsx}`, `src/features/device-link/tests/link.test.tsx`, `src/pages/app/LinkPage.tsx`; modify `src/routes.tsx`.
@ -1265,6 +1414,20 @@ test('russian plural for copies', () => {
--- ---
### Task 11: Easter eggs (owner-approved, see TODO.md «Пасхалки»)
**Files:** `src/features/easter/{konami.ts,devtoolsBanner.ts,BreakableBlock.tsx}`, `src/features/easter/i18n/{ru,en}.ts`, `src/features/easter/tests/easter.test.tsx`; modify `pages/public/NotFoundPage.tsx`, landing Hero/ClickGui (Konami hook), `src/main.tsx` (banner).
**Interfaces / behaviour:**
- `useKonami(onTrigger)`: listens to `keydown` for ↑↑↓↓←→←→BA (`KeyB`, `KeyA`), ignores keys typed inside inputs/textareas; on trigger the landing ClickGui toggles `TrollfaceMask` on, switches to a cycling rainbow accent (hue rotates over 6s, static under reduced motion) and shows a toast «Режим тролля включён» (ru) / «Troll mode on» (en); a second Konami reverts.
- `printDevtoolsBanner()`: called once from `main.tsx` in production and dev (not in tests): `console.log('%c<ASCII LoVisual logo>', 'color:#5cc8e7;font-family:monospace')` + `console.log('%cШаришь? Исходники аддонов — на витрине: <origin>/addons', …)` (en variant by current language).
- 404 page: an empty dusk voxel world (reuse `VoxelScene`) where one grass block falls from the top with a small bounce (`motion`), then sits; clicking/pressing Enter on it plays a crack animation over 3 clicks (Minecraft-style break stages drawn with CSS), then bursts into square particles and a new block falls. Text «Такой страницы нет. Зато есть блок.» + link «На главную». Keyboard accessible (`button`), reduced motion → no fall/burst, just crack stages.
- `IDDQD`: the configs page / share-code input and showcase «загрузить по коду» (when added) accept `IDDQD` (case-insensitive) — backend serves it (configs-service Task 7); no frontend special-casing needed beyond not rejecting 5-char codes client-side.
- [ ] Tests: Konami sequence triggers the callback, inputs are ignored; banner prints two `console.log` calls with `%c`; 404 block breaks after 3 activations. Implement; commit `feat(frontend): easter eggs — konami troll mode, devtools banner, breakable 404 block`.
---
## Deferred ## Deferred
- Admin panel (`/admin`) — Подсистема 3 plan. - Admin panel (`/admin`) — Подсистема 3 plan.

View file

@ -5,13 +5,18 @@
"": { "": {
"name": "frontend", "name": "frontend",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource/comfortaa": "^5.3.0", "@fontsource-variable/onest": "^5.3.1",
"@fontsource/iosevka": "^5.3.0", "@fontsource-variable/unbounded": "^5.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"motion": "^13.4.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-i18next": "^17.0.15",
"react-markdown": "^10.1.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
}, },
@ -60,11 +65,11 @@
"@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="], "@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="],
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="],
"@fontsource/comfortaa": ["@fontsource/comfortaa@5.3.0", "", {}, "sha512-ab4DERH0q6ko8QCpEZzOV9ebK39LgQ2oM91AkLNqu0VIxxwT0nV2D3sOuI7iLirWWq4IMooaIN6/2llxrDN2JA=="], "@fontsource-variable/onest": ["@fontsource-variable/onest@5.3.1", "", {}, "sha512-xaaw6G4HDB2/RczsWLD/2bnib4ykmQlTlBUN3GOEOiyErilZukQWVJG95h9zwAy0i8KY+QzDa+COPfqhI72Acw=="],
"@fontsource/iosevka": ["@fontsource/iosevka@5.3.0", "", {}, "sha512-RcG0v/65e4PTuThR/d7n9jj6PLlex7YUGaLa4ZpL+NnqArWQmD8Z+6gYBTxPMVWxYMRcEw6OF4rdt8XisnC5zA=="], "@fontsource-variable/unbounded": ["@fontsource-variable/unbounded@5.3.0", "", {}, "sha512-ea565Ncn4r7gPdBEHcmaF31V11hDfkL7b/JCklLobQbMfbu2ZZQrVWNgdeEFWFLqNh36yhieZWOgxdAnMTzrLg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@ -196,16 +201,28 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
@ -246,6 +263,8 @@
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
"@vitest/mocker": ["@vitest/mocker@5.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q=="], "@vitest/mocker": ["@vitest/mocker@5.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q=="],
@ -260,10 +279,24 @@
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
@ -274,12 +307,18 @@
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="], "enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
@ -288,20 +327,50 @@
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"framer-motion": ["framer-motion@13.4.3", "", { "dependencies": { "motion-dom": "^13.4.2", "motion-utils": "^13.3.0", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="], "html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@ -334,22 +403,92 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="], "magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"motion": ["motion@13.4.3", "", { "dependencies": { "framer-motion": "^13.4.3", "tslib": "^2.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-Js4rk+VUxSRMKOFT4hOMyMKlxyF11ee9jomiLT3uuwMOU88EoSro0ZEbTYes4m457pqZQ2G3RsLdHQzoEDyBCQ=="],
"motion-dom": ["motion-dom@13.4.2", "", { "dependencies": { "motion-utils": "^13.3.0" } }, "sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w=="],
"motion-utils": ["motion-utils@13.3.0", "", {}, "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="], "oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@ -360,18 +499,28 @@
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-i18next": ["react-i18next@17.0.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="], "react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="], "rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="],
@ -384,12 +533,20 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@ -408,12 +565,36 @@
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="], "undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="],
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="], "vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="],
@ -432,6 +613,8 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
@ -454,6 +637,8 @@
"data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],

View file

@ -9,16 +9,22 @@
"lint": "oxlint", "lint": "oxlint",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"gen:themes": "bun scripts/extract-themes.ts"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource/comfortaa": "^5.3.0", "@fontsource-variable/onest": "^5.3.1",
"@fontsource/iosevka": "^5.3.0", "@fontsource-variable/unbounded": "^5.3.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2", "@tanstack/react-query": "^5.103.2",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"motion": "^13.4.3",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-i18next": "^17.0.15",
"react-markdown": "^10.1.0",
"react-router": "^8.4.0", "react-router": "^8.4.0",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
}, },

View file

@ -0,0 +1,165 @@
// Generates src/features/themes/presets.generated.ts from the mod's built-in theme presets.
// Usage: bun run gen:themes
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const THEME_DIR = resolve(here, '../../mod/src/main/java/dev/loki/lovisual/features/theme')
const PRESET_FILES = [1, 2, 3].map((n) => resolve(THEME_DIR, `presets/ThemePresets${n}.java`))
const OUT = resolve(here, '../src/features/themes/presets.generated.ts')
const COLOR_KEYS = [
'windowBg', 'windowHeader', 'windowStroke', 'surface', 'surfaceHover', 'cardEnabled',
'cardDisabled', 'textPrimary', 'textMuted', 'accent', 'accentSoft', 'strokeSoft',
] as const
const GRADIENT_KEYS = ['windowGradient', 'headerGradient', 'surfaceGradient', 'cardGradient', 'strokeGradient'] as const
type Colors = Record<(typeof COLOR_KEYS)[number], number>
type Gradient = { enabled: boolean; start: number; end: number; angle: number }
const stripComments = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
/** Splits a comma-separated argument list, respecting nested parentheses. */
function splitArgs(args: string): string[] {
const out: string[] = []
let depth = 0
let cur = ''
for (const ch of args) {
if (ch === '(') depth++
if (ch === ')') depth--
if (ch === ',' && depth === 0) {
out.push(cur.trim())
cur = ''
} else cur += ch
}
if (cur.trim()) out.push(cur.trim())
return out
}
/** Finds `static final <type> NAME = new <type>(...)` declarations and returns their raw argument lists. */
function declarations(src: string, type: string): Map<string, string[]> {
const found = new Map<string, string[]>()
const re = new RegExp(`static\\s+final\\s+${type}\\s+(\\w+)\\s*=\\s*new\\s+${type}\\s*\\(`, 'g')
for (let m = re.exec(src); m; m = re.exec(src)) {
let depth = 1
let i = re.lastIndex
for (; depth > 0; i++) {
if (src[i] === '(') depth++
if (src[i] === ')') depth--
}
found.set(m[1], splitArgs(src.slice(re.lastIndex, i - 1)))
}
return found
}
const parseInt32 = (lit: string) => Number.parseInt(lit.replace(/^0x/i, ''), 16) >>> 0
// Port of ThemesBlending.adjustBrightness (Java Math.round(float) == floor(x + 0.5)).
function adjustBrightness(argb: number, delta: number): number {
const ch = (v: number) => {
const moved = v + Math.floor((delta >= 0 ? 255 - v : v) * delta + 0.5)
return Math.min(255, Math.max(0, moved))
}
const a = (argb >>> 24) & 0xff
return ((a << 24) | (ch((argb >>> 16) & 0xff) << 16) | (ch((argb >>> 8) & 0xff) << 8) | ch(argb & 0xff)) >>> 0
}
function resolveColor(expr: string, themes: Map<string, Colors>): number {
if (/^0x[0-9a-f]+$/i.test(expr)) return parseInt32(expr)
const ref = /^(\w+)\.(\w+)\(\)$/.exec(expr)
const colors = ref ? themes.get(ref[1]) : undefined
if (!ref || !colors || !(ref[2] in colors)) throw new Error(`Cannot resolve color: ${expr}`)
return colors[ref[2] as keyof Colors]
}
function parseGradient(expr: string, themes: Map<string, Colors>): Gradient {
const call = /^(?:Themes\.)?(flatGradient|subtleHeaderGradient)\((.+)\)$/.exec(expr)
if (call) {
const color = resolveColor(call[2].trim(), themes)
if (call[1] === 'flatGradient') return { enabled: false, start: color, end: color, angle: 90 }
return { enabled: true, start: adjustBrightness(color, 0.08), end: adjustBrightness(color, -0.06), angle: 90 }
}
const spec = /^new\s+GradientSpec\((.+)\)$/.exec(expr)
if (!spec) throw new Error(`Unknown gradient expression: ${expr}`)
const [enabled, start, end, angle] = splitArgs(spec[1])
return {
enabled: enabled === 'true',
start: resolveColor(start, themes),
end: resolveColor(end, themes),
angle: Number.parseFloat(angle.replace(/f$/i, '')),
}
}
const hex = (argb: number) => `#${argb.toString(16).toUpperCase().padStart(8, '0')}`
export type ParsedEntry = { const: string; id: string; name: string; builtin: boolean; colors: Colors; gradients: Gradient[] }
/** Parses the preset sources (and Themes.java for id constants and display order). */
export function parsePresets(presetSources: string[], themesSource: string): ParsedEntry[] {
const themesJava = stripComments(themesSource)
const ids = new Map<string, string>()
for (const m of themesJava.matchAll(/static\s+final\s+String\s+(THEME_\w+)\s*=\s*"([^"]+)"/g)) ids.set(m[1], m[2])
const entries: ParsedEntry[] = []
for (const raw of presetSources) {
const src = stripComments(raw)
const themes = new Map<string, Colors>()
for (const [name, args] of declarations(src, 'Theme')) {
if (args.length !== COLOR_KEYS.length) throw new Error(`Theme ${name}: expected 12 colors, got ${args.length}`)
themes.set(name, Object.fromEntries(COLOR_KEYS.map((k, i) => [k, parseInt32(args[i])])) as Colors)
}
for (const [constName, args] of declarations(src, 'ThemeEntry')) {
const [idExpr, nameExpr, builtin, themeRef, ...grads] = args
const idConst = idExpr.replace(/^Themes\./, '')
const colors = themes.get(themeRef)
if (!colors) throw new Error(`ThemeEntry ${constName}: unknown theme ${themeRef}`)
entries.push({
const: constName,
id: ids.get(idConst) ?? idExpr.replace(/^"|"$/g, ''),
name: nameExpr.replace(/^"|"$/g, ''),
builtin: builtin === 'true',
colors,
gradients: grads.map((g) => parseGradient(g, themes)),
})
}
}
// Follow the mod's picker order (Themes.PRESET_LIST) where available.
const order = /PRESET_LIST\s*=\s*List\.of\(([\s\S]*?)\);/.exec(themesJava)?.[1] ?? ''
const rank = (e: ParsedEntry) => {
const i = order.indexOf(`.${e.const}`)
return i < 0 ? Number.MAX_SAFE_INTEGER : i
}
return entries.map((e, i) => ({ e, i })).sort((a, b) => rank(a.e) - rank(b.e) || a.i - b.i).map(({ e }) => e)
}
export function render(entries: ParsedEntry[]): string {
const body = entries.map((e) => {
const theme = COLOR_KEYS.map((k) => ` ${k}: '${hex(e.colors[k])}',`).join('\n')
const grads = GRADIENT_KEYS.map((k, i) => {
const g = e.gradients[i]
return ` ${k}: { enabled: ${g.enabled}, start: '${hex(g.start)}', end: '${hex(g.end)}', angle: ${g.angle} },`
}).join('\n')
return ` {\n id: '${e.id}',\n name: '${e.name}',\n builtin: ${e.builtin},\n theme: {\n${theme}\n },\n${grads}\n },`
})
return [
'// generated — do not edit. Source: mod/.../features/theme/presets/ThemePresets{1,2,3}.java',
'// Regenerate with `bun run gen:themes`.',
"import type { ThemeEntry } from './types'",
'',
'export const PRESETS: ThemeEntry[] = [',
...body,
']',
'',
].join('\n')
}
if (import.meta.main) {
const entries = parsePresets(
PRESET_FILES.map((f) => readFileSync(f, 'utf8')),
readFileSync(resolve(THEME_DIR, 'Themes.java'), 'utf8'),
)
writeFileSync(OUT, render(entries))
console.log(`Wrote ${entries.length} presets to ${OUT}`)
}

View file

@ -1,7 +0,0 @@
import { createBrowserRouter, RouterProvider } from 'react-router'
const router = createBrowserRouter([{ path: '/', element: <h1>LoVisual</h1> }])
export default function App() {
return <RouterProvider router={router} />
}

20
frontend/src/app/App.tsx Normal file
View file

@ -0,0 +1,20 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createBrowserRouter, RouterProvider } from 'react-router'
import { SessionProvider } from '../features/auth/session'
import { ActiveThemeProvider } from '../features/themes/useActiveTheme'
import { routes } from './routes'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000 } } })
const router = createBrowserRouter(routes)
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ActiveThemeProvider>
<RouterProvider router={router} />
</ActiveThemeProvider>
</SessionProvider>
</QueryClientProvider>
)
}

43
frontend/src/app/i18n.ts Normal file
View file

@ -0,0 +1,43 @@
import i18next, { type i18n } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import { authEn } from '../features/auth/i18n/en'
import { authRu } from '../features/auth/i18n/ru'
import { clickguiEn } from '../features/clickgui/i18n/en'
import { clickguiRu } from '../features/clickgui/i18n/ru'
import { downloadEn } from '../features/download/i18n/en'
import { downloadRu } from '../features/download/i18n/ru'
import { landingEn } from '../features/landing/i18n/en'
import { landingRu } from '../features/landing/i18n/ru'
import { themesEn } from '../features/themes/i18n/en'
import { themesRu } from '../features/themes/i18n/ru'
import { commonEn } from '../shared/i18n/common.en'
import { commonRu } from '../shared/i18n/common.ru'
/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */
export function initI18n(lng?: 'ru' | 'en'): i18n {
const instance = i18next.createInstance()
instance
.use(LanguageDetector)
.use(initReactI18next)
.init({
lng,
resources: {
ru: { common: commonRu, auth: authRu, landing: landingRu, themes: themesRu, clickgui: clickguiRu, download: downloadRu },
en: { common: commonEn, auth: authEn, landing: landingEn, themes: themesEn, clickgui: clickguiEn, download: downloadEn },
},
fallbackLng: 'ru',
supportedLngs: ['ru', 'en'],
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download'],
defaultNS: 'common',
interpolation: { escapeValue: false },
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },
})
document.documentElement.lang = instance.language
instance.on('languageChanged', (next) => {
document.documentElement.lang = next
})
return instance
}

21
frontend/src/app/i18next.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
import type { authRu } from '../features/auth/i18n/ru'
import type { clickguiRu } from '../features/clickgui/i18n/ru'
import type { downloadRu } from '../features/download/i18n/ru'
import type { landingRu } from '../features/landing/i18n/ru'
import type { themesRu } from '../features/themes/i18n/ru'
import type { commonRu } from '../shared/i18n/common.ru'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: {
common: typeof commonRu
auth: typeof authRu
landing: typeof landingRu
themes: typeof themesRu
clickgui: typeof clickguiRu
download: typeof downloadRu
// Later tasks extend this with their own feature namespaces.
}
}
}

View file

@ -0,0 +1,25 @@
import type { RouteObject } from 'react-router'
import { RequireAuth } from '../features/auth/RequireAuth'
import { AppShell } from '../shared/layout/AppShell'
import HomePage from '../pages/public/HomePage'
import LoginPage from '../pages/public/LoginPage'
import RegisterPage from '../pages/public/RegisterPage'
import NotFoundPage from '../pages/public/NotFoundPage'
// Code-split: the download page pulls in react-markdown, so it loads on demand.
import DownloadRoute from '../pages/download/DownloadRoute'
import ThemesRoute from '../pages/themes/ThemesRoute'
export const routes: RouteObject[] = [
{
element: <AppShell />,
children: [
{ path: '/', element: <HomePage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/download', element: <DownloadRoute /> },
{ path: '/themes', element: <ThemesRoute /> },
{ element: <RequireAuth />, children: [] }, // Tasks 6–8 add /link, /account, /configs
{ path: '*', element: <NotFoundPage /> },
],
},
]

View file

@ -1,10 +1,12 @@
import { useTranslation } from 'react-i18next'
import { Navigate, Outlet, useLocation } from 'react-router' import { Navigate, Outlet, useLocation } from 'react-router'
import { useSession } from './session' import { useSession } from './session'
export function RequireAuth() { export function RequireAuth() {
const { t } = useTranslation('auth')
const { status } = useSession() const { status } = useSession()
const location = useLocation() const location = useLocation()
if (status === 'loading') return <p className="text-frost">Проверяем вход…</p> if (status === 'loading') return <p className="text-frost">{t('checkingSession')}</p>
if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} /> if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} />
return <Outlet /> return <Outlet />
} }

View file

@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router' import { Link, useLocation, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors' import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button' import { Button } from '../../../shared/ui/Button'
@ -7,6 +8,8 @@ import { TextField } from '../../../shared/ui/TextField'
import { useSession } from '../session' import { useSession } from '../session'
export function LoginForm() { export function LoginForm() {
const { t } = useTranslation('auth')
const { t: tc } = useTranslation('common')
const { signIn } = useSession() const { signIn } = useSession()
const navigate = useNavigate() const navigate = useNavigate()
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs' const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
@ -23,7 +26,7 @@ export function LoginForm() {
await signIn(email.trim(), password) await signIn(email.trim(), password)
navigate(from, { replace: true }) navigate(from, { replace: true })
} catch (err) { } catch (err) {
setError(describeError(err, { 401: 'Неверная почта или пароль.' })) setError(describeError(tc, err, { 401: t('login.wrongCredentials') }))
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -31,9 +34,15 @@ export function LoginForm() {
return ( return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate> <form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<TextField label="Почта" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextField <TextField
label="Пароль" label={t('login.emailLabel')}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextField
label={t('login.passwordLabel')}
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
value={password} value={password}
@ -41,10 +50,13 @@ export function LoginForm() {
/> />
{error ? <Notice tone="error">{error}</Notice> : null} {error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}> <Button type="submit" busy={busy}>
Войти {t('login.submit')}
</Button> </Button>
<p className="text-sm text-frost"> <p className="text-sm text-frost">
Нет аккаунта? <Link to="/register" className="text-ice underline-offset-4 hover:underline">Создать</Link> {t('login.noAccount')}{' '}
<Link to="/register" className="text-ice underline-offset-4 hover:underline">
{t('login.createAccount')}
</Link>
</p> </p>
</form> </form>
) )

View file

@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate } from 'react-router' import { Link, useNavigate } from 'react-router'
import { describeError } from '../../../shared/api/errors' import { describeError } from '../../../shared/api/errors'
import { Button } from '../../../shared/ui/Button' import { Button } from '../../../shared/ui/Button'
@ -8,6 +9,8 @@ import { useSession } from '../session'
import { validateEmail, validateNick, validatePassword } from '../validation' import { validateEmail, validateNick, validatePassword } from '../validation'
export function RegisterForm() { export function RegisterForm() {
const { t } = useTranslation('auth')
const { t: tc } = useTranslation('common')
const { signUp } = useSession() const { signUp } = useSession()
const navigate = useNavigate() const navigate = useNavigate()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@ -23,9 +26,9 @@ export function RegisterForm() {
e.preventDefault() e.preventDefault()
setError(undefined) setError(undefined)
const emailErr = validateEmail(email) const emailErr = validateEmail(email, t)
const nickErr = validateNick(nick) const nickErr = validateNick(nick, t)
const passwordErr = validatePassword(password) const passwordErr = validatePassword(password, t)
setEmailError(emailErr) setEmailError(emailErr)
setNickError(nickErr) setNickError(nickErr)
setPasswordError(passwordErr) setPasswordError(passwordErr)
@ -36,7 +39,7 @@ export function RegisterForm() {
await signUp(email.trim(), password, nick.trim()) await signUp(email.trim(), password, nick.trim())
navigate('/configs', { replace: true }) navigate('/configs', { replace: true })
} catch (err) { } catch (err) {
setError(describeError(err, { 409: 'Эта почта уже занята. Войди или используй другую.' })) setError(describeError(tc, err, { 409: t('register.emailTaken') }))
} finally { } finally {
setBusy(false) setBusy(false)
} }
@ -45,7 +48,7 @@ export function RegisterForm() {
return ( return (
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate> <form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
<TextField <TextField
label="Почта" label={t('register.emailLabel')}
type="email" type="email"
autoComplete="email" autoComplete="email"
value={email} value={email}
@ -53,28 +56,31 @@ export function RegisterForm() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
<TextField <TextField
label="Ник на сайте" label={t('register.nickLabel')}
autoComplete="nickname" autoComplete="nickname"
value={nick} value={nick}
error={nickError} error={nickError}
hint={nickError ? undefined : 'Не обязательно совпадает с ником в Minecraft.'} hint={nickError ? undefined : t('register.nickHint')}
onChange={(e) => setNick(e.target.value)} onChange={(e) => setNick(e.target.value)}
/> />
<TextField <TextField
label="Пароль" label={t('register.passwordLabel')}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
error={passwordError} error={passwordError}
hint={passwordError ? undefined : 'Минимум 8 символов.'} hint={passwordError ? undefined : t('register.passwordHint')}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
{error ? <Notice tone="error">{error}</Notice> : null} {error ? <Notice tone="error">{error}</Notice> : null}
<Button type="submit" busy={busy}> <Button type="submit" busy={busy}>
Создать аккаунт {t('register.submit')}
</Button> </Button>
<p className="text-sm text-frost"> <p className="text-sm text-frost">
Уже есть аккаунт? <Link to="/login" className="text-ice underline-offset-4 hover:underline">Войти</Link> {t('register.haveAccount')}{' '}
<Link to="/login" className="text-ice underline-offset-4 hover:underline">
{t('register.login')}
</Link>
</p> </p>
</form> </form>
) )

View file

@ -0,0 +1,36 @@
import type { Translation } from '../../../shared/i18n/common.ru'
import type { authRu } from './ru'
export const authEn = {
checkingSession: 'Checking session…',
login: {
title: 'Log in',
emailLabel: 'Email',
passwordLabel: 'Password',
submit: 'Log in',
noAccount: "Don't have an account?",
createAccount: 'Sign up',
wrongCredentials: 'Wrong email or password.',
},
register: {
title: 'New account',
subtitle: "You'll need an account for cloud configs and linking the game. No password required in the mod.",
emailLabel: 'Email',
nickLabel: 'Site nickname',
nickHint: "Doesn't have to match your Minecraft nick.",
passwordLabel: 'Password',
passwordHint: 'At least 8 characters.',
submit: 'Create account',
haveAccount: 'Already have an account?',
login: 'Log in',
emailTaken: 'That email is already taken. Log in or use another one.',
},
validation: {
emailRequired: 'Enter your email.',
emailInvalid: 'That email looks off.',
passwordShort: 'At least 8 characters.',
passwordTooLong: 'Password is too long.',
nickRequired: 'Pick a nickname.',
nickTooLong: 'No longer than 32 characters.',
},
} satisfies Translation<typeof authRu>

View file

@ -0,0 +1,33 @@
export const authRu = {
checkingSession: 'Проверяем вход…',
login: {
title: 'Вход',
emailLabel: 'Почта',
passwordLabel: 'Пароль',
submit: 'Войти',
noAccount: 'Нет аккаунта?',
createAccount: 'Создать',
wrongCredentials: 'Неверная почта или пароль.',
},
register: {
title: 'Новый аккаунт',
subtitle: 'Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.',
emailLabel: 'Почта',
nickLabel: 'Ник на сайте',
nickHint: 'Не обязательно совпадает с ником в Minecraft.',
passwordLabel: 'Пароль',
passwordHint: 'Минимум 8 символов.',
submit: 'Создать аккаунт',
haveAccount: 'Уже есть аккаунт?',
login: 'Войти',
emailTaken: 'Эта почта уже занята. Войди или используй другую.',
},
validation: {
emailRequired: 'Введи почту.',
emailInvalid: 'Похоже, в почте опечатка.',
passwordShort: 'Минимум 8 символов.',
passwordTooLong: 'Слишком длинный пароль.',
nickRequired: 'Придумай ник.',
nickTooLong: 'Не длиннее 32 символов.',
},
} as const

View file

@ -1,16 +1,18 @@
export function validateEmail(email: string): string | undefined { import type { TFunction } from 'i18next'
export function validateEmail(email: string, t: TFunction<'auth'>): string | undefined {
const v = email.trim() const v = email.trim()
if (!v) return 'Введи почту.' if (!v) return t('validation.emailRequired')
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return 'Похоже, в почте опечатка.' if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return t('validation.emailInvalid')
} }
export function validatePassword(password: string): string | undefined { export function validatePassword(password: string, t: TFunction<'auth'>): string | undefined {
if ([...password].length < 8) return 'Минимум 8 символов.' if ([...password].length < 8) return t('validation.passwordShort')
if (new TextEncoder().encode(password).length > 256) return 'Слишком длинный пароль.' if (new TextEncoder().encode(password).length > 256) return t('validation.passwordTooLong')
} }
export function validateNick(nick: string): string | undefined { export function validateNick(nick: string, t: TFunction<'auth'>): string | undefined {
const len = [...nick.trim()].length const len = [...nick.trim()].length
if (len === 0) return 'Придумай ник.' if (len === 0) return t('validation.nickRequired')
if (len > 32) return 'Не длиннее 32 символов.' if (len > 32) return t('validation.nickTooLong')
} }

View file

@ -0,0 +1,130 @@
import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '../../shared/motion/reducedMotion'
import { PRESETS } from '../themes/presets.generated'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'
import { DEMO, useDemo } from './demo'
import { ModuleCard } from './ModuleCard'
import './parts/clickgui.css'
import { CategoryIcon, DemoCursor, LogoMark, SearchGlyph } from './parts/Glyphs'
import { CATEGORIES, QUICK_THEMES, TABS } from './parts/modules'
import { themeToCssVars } from './themeVars'
type Props = {
/** Runs the scripted cursor demo (pauses while hovered or when `paused`). */
demo?: boolean
paused?: boolean
/** Overrides the active theme — for previews. */
theme?: ThemeEntry
className?: string
}
export function ClickGuiWindow({ demo = true, paused = false, theme: override, className = '' }: Props) {
const { t } = useTranslation('clickgui')
const { theme: active, setTheme } = useActiveTheme()
const theme = override ?? active
const rootRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
const [reduced] = useState(prefersReducedMotion)
const themeIdRef = useRef(theme.id)
useEffect(() => {
themeIdRef.current = theme.id
}, [theme.id])
const { cursor, state, dispatch } = useDemo(DEMO, {
paused: !demo || paused || hovered,
reduced,
rootRef,
nextTheme: () => {
const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number])
return QUICK_THEMES[(i + 1) % QUICK_THEMES.length]
},
onTheme: (id) => setTheme(id, 'demo'),
})
const vars = useMemo(() => themeToCssVars(theme), [theme])
const category = CATEGORIES.find((c) => c.id === state.category) ?? CATEGORIES[0]
return (
<div
ref={rootRef}
role="group"
aria-label={t('windowLabel')}
data-testid="clickgui-root"
className={`lv-gui ${className}`}
style={vars}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<div className="lv-gui-bloom" aria-hidden="true" />
<div className="lv-gui-window">
<header className="lv-gui-header">
<span className="lv-gui-logo">
<LogoMark />
LoVisual
</span>
<nav className="lv-gui-tabs" aria-hidden="true">
{TABS.map((tab, i) => (
<span key={tab} className="lv-gui-tab" data-active={i === 0}>
{tab}
</span>
))}
</nav>
<span className="lv-gui-search" aria-hidden="true">
<SearchGlyph />
</span>
</header>
<div className="lv-gui-body">
<aside className="lv-gui-cats" aria-label={t('categories')}>
{CATEGORIES.map((c) => (
<button
key={c.id}
type="button"
className="lv-gui-cat"
data-active={c.id === category.id}
aria-pressed={c.id === category.id}
data-demo={`cat:${c.id}`}
aria-label={c.name}
onClick={() => dispatch({ type: 'category', id: c.id })}
>
<CategoryIcon id={c.id} />
<span className="lv-gui-cat-name">{c.name}</span>
<span className="lv-gui-cat-count">{c.modules.length}</span>
</button>
))}
<div className="lv-gui-theme-dots">
{QUICK_THEMES.map((id) => {
const preset = PRESETS.find((p) => p.id === id)
if (!preset) return null
const dot = themeToCssVars(preset)
return (
<button
key={id}
type="button"
className="lv-gui-theme-dot"
data-active={theme.id === id}
data-demo={`theme:${id}`}
aria-label={t('theme', { name: preset.name })}
style={{ '--dot': dot['--gui-header-gradient'], '--dot-accent': dot['--gui-accent'] } as CSSProperties}
onClick={() => setTheme(id, 'user')}
/>
)
})}
</div>
</aside>
<div className="lv-gui-main">
<div className="lv-gui-grid" key={category.id}>
{category.modules.map((m) => (
<ModuleCard key={m.name} module={m} state={state} dispatch={dispatch} />
))}
</div>
</div>
</div>
</div>
<DemoCursor cursor={cursor} />
</div>
)
}

View file

@ -0,0 +1,59 @@
import type { Dispatch } from 'react'
import { useTranslation } from 'react-i18next'
import type { GuiAction, GuiState } from './demo'
import { Chevron } from './parts/Glyphs'
import type { GuiModule } from './parts/modules'
import { SettingControls } from './parts/SettingControls'
type Props = { module: GuiModule; state: GuiState; dispatch: Dispatch<GuiAction> }
/** One module card: bullet title, description, divider, bind + settings chevron + status toggle. */
export function ModuleCard({ module, state, dispatch }: Props) {
const { t } = useTranslation('clickgui')
const { name, settings } = module
const on = Boolean(state.enabled[name])
const open = state.expanded === name && Boolean(settings)
return (
<article className="lv-gui-card" data-on={on} data-open={open}>
<span className="lv-gui-card-glow" aria-hidden="true" />
<h3 className="lv-gui-card-title">
<span aria-hidden="true">• </span>
{name}
</h3>
<p className="lv-gui-card-desc">{t(`desc.${name}`)}</p>
<div className="lv-gui-card-divider" aria-hidden="true" />
<div className="lv-gui-card-foot">
<span className="lv-gui-bind">
{t('bind')}: <span>{t('bindNone')}</span>
</span>
{settings ? (
<button
type="button"
className="lv-gui-expand"
aria-expanded={open}
aria-label={t('settings', { name })}
data-demo={`expand:${name}`}
onClick={() => dispatch({ type: 'expand', module: name })}
>
<Chevron open={open} />
</button>
) : (
<span className="lv-gui-bind lv-gui-nosettings">{t('noSettings')}</span>
)}
<button
type="button"
role="switch"
aria-checked={on}
aria-label={t('toggle', { name })}
data-demo={`toggle:${name}`}
className="lv-gui-toggle"
onClick={() => dispatch({ type: 'toggle', module: name })}
>
<span className="lv-gui-toggle-knob" />
</button>
</div>
{open && settings ? <SettingControls module={name} settings={settings} state={state} dispatch={dispatch} /> : null}
</article>
)
}

View file

@ -0,0 +1,175 @@
import { type Dispatch, type RefObject, useEffect, useReducer, useRef, useState } from 'react'
import { CATEGORIES, type CategoryId, settingKey } from './parts/modules'
export type DemoStep = { at: number /* ms */; action: 'move' | 'click' | 'drag' | 'theme'; target: string; value?: number | string }
export const LOOP_MS = 12_500
/** The scripted loop: pick Visuals, switch Trails on, open it, drag Length, tick Rainbow, change theme, tidy up. */
export const DEMO: DemoStep[] = [
{ at: 400, action: 'move', target: 'cat:visuals' },
{ at: 1100, action: 'click', target: 'cat:visuals' },
{ at: 1700, action: 'move', target: 'toggle:Trails' },
{ at: 2400, action: 'click', target: 'toggle:Trails' },
{ at: 3000, action: 'move', target: 'expand:Trails' },
{ at: 3600, action: 'click', target: 'expand:Trails' },
{ at: 4300, action: 'move', target: 'knob:Trails.Length' },
{ at: 4900, action: 'drag', target: 'slider:Trails.Length', value: 85 },
{ at: 6300, action: 'move', target: 'check:Trails.Rainbow' },
{ at: 6900, action: 'click', target: 'check:Trails.Rainbow' },
{ at: 7700, action: 'move', target: 'theme:next' },
{ at: 8400, action: 'theme', target: 'theme:next' },
{ at: 9400, action: 'move', target: 'expand:Trails' },
{ at: 10000, action: 'click', target: 'expand:Trails' },
{ at: 10800, action: 'move', target: 'rest' },
]
export type GuiState = {
category: CategoryId
enabled: Record<string, boolean>
expanded: string | null
values: Record<string, number | boolean | string>
dragging: boolean
}
export type GuiAction =
| { type: 'category'; id: CategoryId }
| { type: 'toggle'; module: string }
| { type: 'expand'; module: string }
| { type: 'set'; key: string; value: number | boolean | string }
| { type: 'dragging'; on: boolean }
| { type: 'reset' }
export function initialGuiState(finished = false): GuiState {
const enabled: Record<string, boolean> = {}
const values: GuiState['values'] = {}
for (const c of CATEGORIES) {
for (const m of c.modules) {
enabled[m.name] = Boolean(m.on)
for (const s of m.settings ?? []) values[settingKey(m.name, s.name)] = s.value
}
}
if (!finished) return { category: 'combat', enabled, expanded: null, values, dragging: false }
// The resting "after the demo" frame, used under reduced motion.
enabled.Trails = true
values[settingKey('Trails', 'Length')] = 85
return { category: 'visuals', enabled, expanded: 'Trails', values, dragging: false }
}
export function guiReducer(state: GuiState, action: GuiAction): GuiState {
switch (action.type) {
case 'category':
return { ...state, category: action.id, expanded: null }
case 'toggle':
return { ...state, enabled: { ...state.enabled, [action.module]: !state.enabled[action.module] } }
case 'expand':
return { ...state, expanded: state.expanded === action.module ? null : action.module }
case 'set':
return { ...state, values: { ...state.values, [action.key]: action.value } }
case 'dragging':
return { ...state, dragging: action.on }
case 'reset':
return initialGuiState()
}
}
export type Cursor = { x: number; y: number; visible: boolean; pressed: boolean; duration: number }
type Options = {
paused: boolean
reduced: boolean
rootRef: RefObject<HTMLElement | null>
/** Resolves `theme:next` to a concrete `theme:<id>` target and applies it. */
nextTheme: () => string
onTheme: (id: string) => void
}
/** Layout position of `el` inside `root`, ignoring CSS transforms (the window is tilted in 3D). */
function offsetWithin(el: HTMLElement, root: HTMLElement): { x: number; y: number } {
let x = 0
let y = 0
let node: HTMLElement | null = el
while (node && node !== root) {
x += node.offsetLeft
y += node.offsetTop
node = node.offsetParent as HTMLElement | null
}
return { x, y }
}
export function useDemo(steps: DemoStep[], opts: Options): { cursor: Cursor; state: GuiState; dispatch: Dispatch<GuiAction> } {
const [state, dispatch] = useReducer(guiReducer, opts.reduced, initialGuiState)
const [cursor, setCursor] = useState<Cursor>({ x: 0, y: 0, visible: false, pressed: false, duration: 0 })
const [cycle, setCycle] = useState(0)
const optsRef = useRef(opts)
useEffect(() => {
optsRef.current = opts
})
useEffect(() => {
if (opts.paused || opts.reduced) return
const timers: number[] = []
const later = (ms: number, fn: () => void) => timers.push(window.setTimeout(fn, ms))
let pendingTheme = ''
const locate = (target: string, fraction?: number) => {
const root = optsRef.current.rootRef.current
if (!root) return null
if (target === 'rest') return { x: root.offsetWidth * 0.86, y: root.offsetHeight * 0.9 }
const resolved = target === 'theme:next' ? (pendingTheme ||= `theme:${optsRef.current.nextTheme()}`) : target
const el = root.querySelector<HTMLElement>(`[data-demo="${resolved}"]`)
if (!el) return null
const { x, y } = offsetWithin(el, root)
const fx = fraction ?? 0.5
return { x: x + el.offsetWidth * fx, y: y + el.offsetHeight * 0.5 }
}
const move = (target: string, duration: number, fraction?: number) => {
const p = locate(target, fraction)
if (p) setCursor((c) => ({ ...c, ...p, visible: true, duration }))
}
const click = (fn: () => void) => {
setCursor((c) => ({ ...c, pressed: true }))
later(160, () => {
setCursor((c) => ({ ...c, pressed: false }))
fn()
})
}
const run = (step: DemoStep) => {
const [kind, arg] = step.target.split(':')
if (step.action === 'move') return move(step.target, 650)
if (step.action === 'theme') {
return click(() => optsRef.current.onTheme(pendingTheme.replace('theme:', '')))
}
if (step.action === 'drag') {
const [module, setting] = arg.split('.')
const value = Number(step.value ?? 0)
setCursor((c) => ({ ...c, pressed: true }))
dispatch({ type: 'dragging', on: true })
move(step.target, 900, value / 100)
dispatch({ type: 'set', key: settingKey(module, setting), value })
return later(950, () => {
setCursor((c) => ({ ...c, pressed: false }))
dispatch({ type: 'dragging', on: false })
})
}
click(() => {
if (kind === 'cat') dispatch({ type: 'category', id: arg as CategoryId })
if (kind === 'toggle') dispatch({ type: 'toggle', module: arg })
if (kind === 'expand') dispatch({ type: 'expand', module: arg })
if (kind === 'check') dispatch({ type: 'set', key: arg, value: true })
})
}
dispatch({ type: 'reset' })
const lead = cycle === 0 ? 600 : 0
for (const step of steps) later(step.at + lead, () => run(step))
later(LOOP_MS + lead, () => setCycle((n) => n + 1))
return () => timers.forEach((t) => window.clearTimeout(t))
}, [steps, opts.paused, opts.reduced, cycle])
const idle = opts.paused || opts.reduced
return { cursor: idle ? { ...cursor, visible: false, pressed: false } : cursor, state, dispatch }
}

Some files were not shown because too many files have changed in this diff Show more