From 7f4b532f99c2b563fabfdc4db32c6a760cb6183f Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Fri, 25 Sep 2026 20:22:13 +0200 Subject: [PATCH] chore(history): squash 67 commit(s) from 2026-09-25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .github/workflows/release.yml | 55 + TODO.md | 42 +- backend/.env.example | 11 + backend/Cargo.lock | 337 +- backend/Cargo.toml | 4 +- backend/STRUCTURE.md | 38 +- backend/accounts-service/Cargo.lock | 3931 ----------------- backend/accounts-service/Cargo.toml | 2 + .../0003_device_links_token_unique.sql | 2 + .../migrations/0004_refresh_rotated_at.sql | 14 + .../accounts-service/src/accounts/handlers.rs | 88 + backend/accounts-service/src/accounts/mod.rs | 1 + .../accounts-service/src/accounts/model.rs | 105 + backend/accounts-service/src/accounts/repo.rs | 69 +- backend/accounts-service/src/auth/handlers.rs | 124 +- backend/accounts-service/src/auth/mod.rs | 2 +- backend/accounts-service/src/auth/password.rs | 4 +- backend/accounts-service/src/auth/tokens.rs | 56 +- .../accounts-service/src/avatars/handlers.rs | 14 +- .../src/avatars/processing.rs | 17 +- .../accounts-service/src/avatars/storage.rs | 10 +- backend/accounts-service/src/config.rs | 37 +- .../accounts-service/src/device/handlers.rs | 46 +- backend/accounts-service/src/device/links.rs | 54 + backend/accounts-service/src/device/mod.rs | 1 + backend/accounts-service/src/device/store.rs | 23 +- backend/accounts-service/src/error.rs | 9 +- backend/accounts-service/src/grpc/mod.rs | 215 + backend/accounts-service/src/lib.rs | 28 +- backend/accounts-service/src/main.rs | 23 +- backend/accounts-service/tests/auth_flow.rs | 35 +- .../tests/auth_flow/refresh.rs | 57 +- .../accounts-service/tests/avatar_upload.rs | 35 +- backend/accounts-service/tests/common/mod.rs | 15 +- backend/accounts-service/tests/device_flow.rs | 25 +- .../tests/device_flow/links.rs | 116 + backend/accounts-service/tests/smoke.rs | 50 +- backend/common/proto/accounts.proto | 11 + backend/common/src/internal.rs | 38 +- backend/common/src/jwt.rs | 56 +- backend/configs-service/Cargo.toml | 27 + backend/configs-service/PLAN.md | 22 + .../configs-service/migrations/0001_init.sql | 31 + backend/configs-service/src/config.rs | 56 + backend/configs-service/src/error.rs | 83 + backend/configs-service/src/lib.rs | 55 + backend/configs-service/src/main.rs | 20 + backend/configs-service/src/sharing/codes.rs | 35 + .../configs-service/src/sharing/handlers.rs | 52 + backend/configs-service/src/sharing/mod.rs | 2 + .../configs-service/src/showcase/handlers.rs | 174 + backend/configs-service/src/showcase/mod.rs | 3 + .../configs-service/src/showcase/profiles.rs | 74 + backend/configs-service/src/showcase/repo.rs | 213 + backend/configs-service/src/slots/handlers.rs | 89 + backend/configs-service/src/slots/mod.rs | 2 + backend/configs-service/src/slots/repo.rs | 119 + backend/configs-service/tests/common/mod.rs | 72 + backend/configs-service/tests/sharing.rs | 93 + backend/configs-service/tests/showcase.rs | 261 ++ backend/configs-service/tests/slots.rs | 128 + backend/gateway/Cargo.toml | 29 + backend/gateway/PLAN.md | 11 +- backend/gateway/src/config.rs | 89 + backend/gateway/src/guard/honeypot.rs | 60 + backend/gateway/src/guard/mod.rs | 111 + backend/gateway/src/identity/device.rs | 66 + backend/gateway/src/identity/mod.rs | 114 + backend/gateway/src/lib.rs | 76 + backend/gateway/src/main.rs | 23 + backend/gateway/src/proxy/forward.rs | 124 + backend/gateway/src/proxy/mod.rs | 2 + backend/gateway/src/proxy/routes.rs | 29 + backend/gateway/src/rate_limit/mod.rs | 111 + backend/gateway/src/rate_limit/rules.rs | 85 + backend/gateway/tests/common/mod.rs | 141 + backend/gateway/tests/identity.rs | 111 + backend/gateway/tests/path_guard.rs | 89 + backend/gateway/tests/proxy.rs | 110 + backend/gateway/tests/rate_limit.rs | 78 + frontend/.env.example | 2 + frontend/ARCHITECTURE.md | 15 +- frontend/PLAN.md | 167 +- frontend/bun.lock | 197 +- frontend/package.json | 14 +- frontend/scripts/extract-themes.ts | 165 + frontend/src/App.tsx | 7 - frontend/src/app/App.tsx | 20 + frontend/src/app/i18n.ts | 43 + frontend/src/app/i18next.d.ts | 21 + frontend/src/app/routes.tsx | 25 + frontend/src/features/auth/RequireAuth.tsx | 4 +- .../src/features/auth/forms/LoginForm.tsx | 22 +- .../src/features/auth/forms/RegisterForm.tsx | 28 +- frontend/src/features/auth/i18n/en.ts | 36 + frontend/src/features/auth/i18n/ru.ts | 33 + frontend/src/features/auth/validation.ts | 20 +- .../src/features/clickgui/ClickGuiWindow.tsx | 130 + frontend/src/features/clickgui/ModuleCard.tsx | 59 + frontend/src/features/clickgui/demo.ts | 175 + frontend/src/features/clickgui/i18n/en.ts | 49 + frontend/src/features/clickgui/i18n/ru.ts | 46 + .../src/features/clickgui/parts/Glyphs.tsx | 70 + .../clickgui/parts/SettingControls.tsx | 92 + .../src/features/clickgui/parts/clickgui.css | 220 + .../src/features/clickgui/parts/modules.ts | 97 + .../features/clickgui/tests/clickgui.test.tsx | 47 + .../features/clickgui/tests/themeVars.test.ts | 41 + frontend/src/features/clickgui/themeVars.ts | 57 + .../src/features/download/DownloadButton.tsx | 27 + .../src/features/download/ReleaseNotes.tsx | 43 + frontend/src/features/download/api.ts | 45 + frontend/src/features/download/i18n/en.ts | 29 + frontend/src/features/download/i18n/ru.ts | 26 + .../features/download/tests/download.test.tsx | 49 + frontend/src/features/landing/CommandHero.tsx | 66 + frontend/src/features/landing/HowItWorks.tsx | 56 + .../landing/effects/ParticleField.tsx | 138 + .../src/features/landing/effects/Tilt.tsx | 47 + .../features/landing/effects/VoxelScene.tsx | 196 + .../src/features/landing/effects/useInView.ts | 26 + frontend/src/features/landing/i18n/en.ts | 103 + frontend/src/features/landing/i18n/ru.ts | 100 + .../features/landing/sections/FaqDownload.tsx | 47 + .../src/features/landing/sections/Hero.tsx | 109 + .../landing/sections/HudPlayground.tsx | 210 + .../features/landing/sections/ModuleWall.tsx | 45 + .../landing/sections/ShowcaseTeaser.tsx | 52 + .../features/landing/sections/ThemeStrip.tsx | 95 + .../src/features/landing/styles/landing.css | 211 + .../features/landing/tests/landing.test.tsx | 41 + .../features/landing/tests/sections.test.tsx | 68 + .../src/features/themes/editor/ColorField.tsx | 70 + .../features/themes/editor/EditorPreview.tsx | 28 + .../features/themes/editor/GradientField.tsx | 47 + .../features/themes/editor/ThemeEditor.tsx | 155 + frontend/src/features/themes/editor/codec.ts | 138 + .../src/features/themes/editor/editor.css | 48 + .../themes/editor/tests/codec.test.ts | 85 + .../themes/editor/tests/editor.test.tsx | 18 + .../features/themes/editor/useThemeHistory.ts | 85 + frontend/src/features/themes/i18n/en.ts | 49 + frontend/src/features/themes/i18n/ru.ts | 46 + .../src/features/themes/presets.generated.ts | 438 ++ .../themes/tests/activeTheme.test.tsx | 54 + .../src/features/themes/tests/presets.test.ts | 39 + frontend/src/features/themes/types.ts | 55 + .../src/features/themes/useActiveTheme.ts | 72 + frontend/src/index.css | 30 +- frontend/src/main.tsx | 5 +- frontend/src/pages/download/DownloadPage.tsx | 76 + frontend/src/pages/download/DownloadRoute.tsx | 12 + frontend/src/pages/public/HomePage.tsx | 23 + frontend/src/pages/public/LoginPage.tsx | 4 +- frontend/src/pages/public/NotFoundPage.tsx | 17 + frontend/src/pages/public/RegisterPage.tsx | 8 +- frontend/src/pages/themes/ThemesPage.tsx | 30 + frontend/src/pages/themes/ThemesRoute.tsx | 12 + frontend/src/shared/api/client.ts | 2 +- frontend/src/shared/api/errors.ts | 28 +- frontend/src/shared/api/tests/client.test.ts | 20 +- frontend/src/shared/i18n/LanguageSwitch.tsx | 24 + frontend/src/shared/i18n/common.en.ts | 52 + frontend/src/shared/i18n/common.ru.ts | 54 + frontend/src/shared/i18n/tests/i18n.test.tsx | 33 + frontend/src/shared/layout/AppShell.tsx | 23 + frontend/src/shared/layout/Footer.tsx | 49 + frontend/src/shared/layout/TopBar.tsx | 94 + .../src/shared/layout/tests/layout.test.tsx | 47 + frontend/src/shared/motion/reducedMotion.ts | 7 + frontend/src/shared/ui/Button.tsx | 11 +- frontend/src/shared/ui/ShareCode.tsx | 6 +- frontend/src/test/setup.ts | 6 + frontend/src/test/smoke.test.tsx | 9 - frontend/tsconfig.node.json | 2 +- frontend/vite.config.ts | 2 + mod/TODO.md | 89 +- .../main/java/dev/loki/lovisual/LoVisual.java | 5 + .../lovisual/config/io/ConfigSerializer.java | 5 +- .../lovisual/config/style/StyleConfig.java | 135 + .../chat/text/util/BetterChatDrawUtil.java | 40 +- .../render/ModulesMenuGlassRenderer.java | 27 +- .../implement/main/MainSettingsComponent.java | 8 +- .../clickgui/render/ClickGuiDrawHelpers.java | 24 +- .../i18n/I18nPreflightContributors.java | 7 + .../clickgui/settings/mode/ModeSetting.java | 5 + .../settings/mode/ModeSettingRenderer.java | 103 +- .../settings/skin/ModulesSettingsSkin.java | 21 +- .../features/gui/hud/config/HudBgStyles.java | 61 + .../gui/hud/config/HudGlobalConfig.java | 6 +- .../gui/hud/config/HudRenderUtil.java | 95 +- .../gui/hud/draggable/impl/Triangulator.java | 7 +- .../hud/draggable/impl/combat/Inventory.java | 5 +- .../draggable/impl/combat/MediaPlayer.java | 9 +- .../combat/inventory/InventoryConstants.java | 2 - .../mediaplayer/MediaPlayerConstants.java | 3 - .../draggable/impl/metrics/Coordinates.java | 10 +- .../hud/draggable/impl/metrics/GameTime.java | 10 +- .../hud/draggable/impl/metrics/SpeedBps.java | 10 +- .../draggable/impl/metrics/SystemTime.java | 10 +- .../hud/draggable/impl/panels/Keybinds.java | 5 +- .../hud/draggable/impl/panels/ModuleList.java | 4 +- .../hud/draggable/impl/panels/Scoreboard.java | 7 +- .../impl/panels/keybinds/KeybindsPalette.java | 3 +- .../panels/modulelist/ModuleListPalette.java | 7 +- .../gui/hud/draggable/impl/stats/Fps.java | 10 +- .../gui/hud/draggable/impl/stats/Memory.java | 10 +- .../gui/hud/draggable/impl/stats/Ping.java | 10 +- .../gui/hud/draggable/impl/stats/Tps.java | 10 +- .../draggable/impl/stats/fps/FpsPalette.java | 5 +- .../draggable/impl/stats/fps/FpsRender.java | 13 +- .../gui/hud/draggable/impl/status/Admins.java | 5 +- .../hud/draggable/impl/status/Cooldowns.java | 5 +- .../hud/draggable/impl/status/Potions.java | 5 +- .../impl/status/admins/AdminsPalette.java | 3 +- .../status/cooldowns/CooldownsPalette.java | 3 +- .../impl/status/potions/PotionsPalette.java | 3 +- .../draggable/impl/targethud/TargetHud.java | 12 +- .../impl/buttons/SwapTooltip.java | 31 +- .../impl/hotbar/CustomHotbar.java | 12 +- .../panel/CustomLocatorLabelPanel.java | 27 +- .../gui/hud/script/model/CompactHudParts.java | 4 +- .../modules/misc/optimize/Optimize.java | 88 + .../module/modules/visuals/fx/MotionBlur.java | 16 +- .../nametags/parts/NameTagPlateRenderer.java | 35 +- .../ReimaginedDepthOfFieldHelper.java | 7 +- .../render/engine/optimize/OptimizeState.java | 47 + .../pipeline/LoVisualRenderPipelines.java | 131 +- .../render/engine/pipeline/ShaderIds.java | 130 + .../pipeline/pipelines/ScreenPipelines.java | 6 +- .../pipeline/pipelines/UiPipelines.java | 8 +- .../pipeline/pipelines/WorldPipelines.java | 40 +- .../render/engine/renderer/Renderer2D.java | 1713 +------ .../renderer/facade/Renderer2DBase.java | 23 + .../renderer/facade/Renderer2DGlass.java | 183 + .../renderer/facade/Renderer2DPath.java | 245 + .../renderer/facade/Renderer2DRounded.java | 315 ++ .../renderer/facade/shape/Renderer2DItem.java | 124 + .../facade/shape/Renderer2DShapes.java | 313 ++ .../engine/renderer/renderer2d/fx/Blur2D.java | 40 +- .../engine/renderer/style/StyleSpec.java | 23 + .../engine/renderer/style/StyleSpecs.java | 46 + .../engine/renderer/style/SurfaceStyle.java | 34 + .../renderer/ui/blur/UiBlurResources.java | 3 + .../engine/text/glyph/GradientTexts.java | 9 + .../text/glyph/VanillaTextRenderer.java | 7 + .../impl/sky/skybox/ShaderSkyUniforms.java | 7 + .../material/impl/StyleTransition.java | 79 + .../material/impl/SurfaceRenderer.java | 166 + .../render/sky/impl/SkyboxShaderPasses.java | 14 +- .../resources/assets/lovisual/lang/en_us.json | 27 + .../resources/assets/lovisual/lang/ru_ru.json | 27 + .../lovisual/shaders/reimagined_skybox.frag | 86 +- .../lovisual/shaders/shader_sky_pulsar.frag | 4 + .../lovisual/shaders/shader_sky_thunder.frag | 4 + .../modules/misc/optimize/OptimizeTest.java | 43 + .../render/sky/SkyboxShaderPassesTest.java | 19 + 257 files changed, 13085 insertions(+), 6582 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 backend/accounts-service/Cargo.lock create mode 100644 backend/accounts-service/migrations/0003_device_links_token_unique.sql create mode 100644 backend/accounts-service/migrations/0004_refresh_rotated_at.sql create mode 100644 backend/accounts-service/src/accounts/handlers.rs create mode 100644 backend/accounts-service/src/device/links.rs create mode 100644 backend/accounts-service/src/grpc/mod.rs create mode 100644 backend/accounts-service/tests/device_flow/links.rs create mode 100644 backend/configs-service/Cargo.toml create mode 100644 backend/configs-service/migrations/0001_init.sql create mode 100644 backend/configs-service/src/config.rs create mode 100644 backend/configs-service/src/error.rs create mode 100644 backend/configs-service/src/lib.rs create mode 100644 backend/configs-service/src/main.rs create mode 100644 backend/configs-service/src/sharing/codes.rs create mode 100644 backend/configs-service/src/sharing/handlers.rs create mode 100644 backend/configs-service/src/sharing/mod.rs create mode 100644 backend/configs-service/src/showcase/handlers.rs create mode 100644 backend/configs-service/src/showcase/mod.rs create mode 100644 backend/configs-service/src/showcase/profiles.rs create mode 100644 backend/configs-service/src/showcase/repo.rs create mode 100644 backend/configs-service/src/slots/handlers.rs create mode 100644 backend/configs-service/src/slots/mod.rs create mode 100644 backend/configs-service/src/slots/repo.rs create mode 100644 backend/configs-service/tests/common/mod.rs create mode 100644 backend/configs-service/tests/sharing.rs create mode 100644 backend/configs-service/tests/showcase.rs create mode 100644 backend/configs-service/tests/slots.rs create mode 100644 backend/gateway/Cargo.toml create mode 100644 backend/gateway/src/config.rs create mode 100644 backend/gateway/src/guard/honeypot.rs create mode 100644 backend/gateway/src/guard/mod.rs create mode 100644 backend/gateway/src/identity/device.rs create mode 100644 backend/gateway/src/identity/mod.rs create mode 100644 backend/gateway/src/lib.rs create mode 100644 backend/gateway/src/main.rs create mode 100644 backend/gateway/src/proxy/forward.rs create mode 100644 backend/gateway/src/proxy/mod.rs create mode 100644 backend/gateway/src/proxy/routes.rs create mode 100644 backend/gateway/src/rate_limit/mod.rs create mode 100644 backend/gateway/src/rate_limit/rules.rs create mode 100644 backend/gateway/tests/common/mod.rs create mode 100644 backend/gateway/tests/identity.rs create mode 100644 backend/gateway/tests/path_guard.rs create mode 100644 backend/gateway/tests/proxy.rs create mode 100644 backend/gateway/tests/rate_limit.rs create mode 100644 frontend/.env.example create mode 100644 frontend/scripts/extract-themes.ts delete mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/app/App.tsx create mode 100644 frontend/src/app/i18n.ts create mode 100644 frontend/src/app/i18next.d.ts create mode 100644 frontend/src/app/routes.tsx create mode 100644 frontend/src/features/auth/i18n/en.ts create mode 100644 frontend/src/features/auth/i18n/ru.ts create mode 100644 frontend/src/features/clickgui/ClickGuiWindow.tsx create mode 100644 frontend/src/features/clickgui/ModuleCard.tsx create mode 100644 frontend/src/features/clickgui/demo.ts create mode 100644 frontend/src/features/clickgui/i18n/en.ts create mode 100644 frontend/src/features/clickgui/i18n/ru.ts create mode 100644 frontend/src/features/clickgui/parts/Glyphs.tsx create mode 100644 frontend/src/features/clickgui/parts/SettingControls.tsx create mode 100644 frontend/src/features/clickgui/parts/clickgui.css create mode 100644 frontend/src/features/clickgui/parts/modules.ts create mode 100644 frontend/src/features/clickgui/tests/clickgui.test.tsx create mode 100644 frontend/src/features/clickgui/tests/themeVars.test.ts create mode 100644 frontend/src/features/clickgui/themeVars.ts create mode 100644 frontend/src/features/download/DownloadButton.tsx create mode 100644 frontend/src/features/download/ReleaseNotes.tsx create mode 100644 frontend/src/features/download/api.ts create mode 100644 frontend/src/features/download/i18n/en.ts create mode 100644 frontend/src/features/download/i18n/ru.ts create mode 100644 frontend/src/features/download/tests/download.test.tsx create mode 100644 frontend/src/features/landing/CommandHero.tsx create mode 100644 frontend/src/features/landing/HowItWorks.tsx create mode 100644 frontend/src/features/landing/effects/ParticleField.tsx create mode 100644 frontend/src/features/landing/effects/Tilt.tsx create mode 100644 frontend/src/features/landing/effects/VoxelScene.tsx create mode 100644 frontend/src/features/landing/effects/useInView.ts create mode 100644 frontend/src/features/landing/i18n/en.ts create mode 100644 frontend/src/features/landing/i18n/ru.ts create mode 100644 frontend/src/features/landing/sections/FaqDownload.tsx create mode 100644 frontend/src/features/landing/sections/Hero.tsx create mode 100644 frontend/src/features/landing/sections/HudPlayground.tsx create mode 100644 frontend/src/features/landing/sections/ModuleWall.tsx create mode 100644 frontend/src/features/landing/sections/ShowcaseTeaser.tsx create mode 100644 frontend/src/features/landing/sections/ThemeStrip.tsx create mode 100644 frontend/src/features/landing/styles/landing.css create mode 100644 frontend/src/features/landing/tests/landing.test.tsx create mode 100644 frontend/src/features/landing/tests/sections.test.tsx create mode 100644 frontend/src/features/themes/editor/ColorField.tsx create mode 100644 frontend/src/features/themes/editor/EditorPreview.tsx create mode 100644 frontend/src/features/themes/editor/GradientField.tsx create mode 100644 frontend/src/features/themes/editor/ThemeEditor.tsx create mode 100644 frontend/src/features/themes/editor/codec.ts create mode 100644 frontend/src/features/themes/editor/editor.css create mode 100644 frontend/src/features/themes/editor/tests/codec.test.ts create mode 100644 frontend/src/features/themes/editor/tests/editor.test.tsx create mode 100644 frontend/src/features/themes/editor/useThemeHistory.ts create mode 100644 frontend/src/features/themes/i18n/en.ts create mode 100644 frontend/src/features/themes/i18n/ru.ts create mode 100644 frontend/src/features/themes/presets.generated.ts create mode 100644 frontend/src/features/themes/tests/activeTheme.test.tsx create mode 100644 frontend/src/features/themes/tests/presets.test.ts create mode 100644 frontend/src/features/themes/types.ts create mode 100644 frontend/src/features/themes/useActiveTheme.ts create mode 100644 frontend/src/pages/download/DownloadPage.tsx create mode 100644 frontend/src/pages/download/DownloadRoute.tsx create mode 100644 frontend/src/pages/public/HomePage.tsx create mode 100644 frontend/src/pages/public/NotFoundPage.tsx create mode 100644 frontend/src/pages/themes/ThemesPage.tsx create mode 100644 frontend/src/pages/themes/ThemesRoute.tsx create mode 100644 frontend/src/shared/i18n/LanguageSwitch.tsx create mode 100644 frontend/src/shared/i18n/common.en.ts create mode 100644 frontend/src/shared/i18n/common.ru.ts create mode 100644 frontend/src/shared/i18n/tests/i18n.test.tsx create mode 100644 frontend/src/shared/layout/AppShell.tsx create mode 100644 frontend/src/shared/layout/Footer.tsx create mode 100644 frontend/src/shared/layout/TopBar.tsx create mode 100644 frontend/src/shared/layout/tests/layout.test.tsx create mode 100644 frontend/src/shared/motion/reducedMotion.ts delete mode 100644 frontend/src/test/smoke.test.tsx create mode 100644 mod/src/main/java/dev/loki/lovisual/config/style/StyleConfig.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/gui/hud/config/HudBgStyles.java create mode 100644 mod/src/main/java/dev/loki/lovisual/features/module/modules/misc/optimize/Optimize.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/optimize/OptimizeState.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/pipeline/ShaderIds.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/facade/Renderer2DBase.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/facade/Renderer2DGlass.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/facade/Renderer2DPath.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/facade/Renderer2DRounded.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/facade/shape/Renderer2DItem.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/facade/shape/Renderer2DShapes.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/style/StyleSpec.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/style/StyleSpecs.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/engine/renderer/style/SurfaceStyle.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/helpers/material/impl/StyleTransition.java create mode 100644 mod/src/main/java/dev/loki/lovisual/render/helpers/material/impl/SurfaceRenderer.java create mode 100644 mod/src/test/java/dev/loki/lovisual/features/module/modules/misc/optimize/OptimizeTest.java create mode 100644 mod/src/test/java/dev/loki/lovisual/render/sky/SkyboxShaderPassesTest.java diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bd0f902 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/TODO.md b/TODO.md index be3ab0d..b975d03 100644 --- a/TODO.md +++ b/TODO.md @@ -95,11 +95,25 @@ boilerplate на класс), а в Rust/TypeScript тот же объём ло - [ ] Фаза 8.5: Аудит структуры ≤200/≤4 — 8.5.1 (папки >4) ЗАВЕРШЕНА 2026-09-10 (0 папок >4, коммиты 8846d53…303b17e); далее 8.5.2 (гиганты >600), план в `mod/TODO.md` -- [ ] Фаза 9: Оптимизация FPS — 9.1 (мёртвые грузы) ЗАВЕРШЕНА 2026-09-11 (137121e…4d60d3d); - 9.2 Optimize гейтится Фазой 8.5, 9.3 A/B-мерка needs запуск на Windows +- [x] Фаза 9: Оптимизация FPS — 9.1 (мёртвые грузы) ЗАВЕРШЕНА 2026-09-11 (137121e…4d60d3d); + 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 (см. ниже). Реализация: план backend Подсистемы 1 в `backend/PLAN.md` (написан 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 начинается — план в `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/`. ### Границы (декомпозиция) @@ -284,6 +300,26 @@ S3-совместимом хранилище (MinIO), не в Postgres. - `device_token` хранится хэшированным в БД, как пароль - 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) > Статус: спроектировано, реализация не начата. Требует realtime-слой diff --git a/backend/.env.example b/backend/.env.example index b45080c..8614a8c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,6 +2,7 @@ DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/accounts_db # at least 32 random bytes, e.g. `openssl rand -hex 32` JWT_SECRET= PORT=8081 +GRPC_PORT=50051 S3_ENDPOINT=http://localhost:9000 S3_BUCKET=lovisual-avatars S3_ACCESS_KEY=minioadmin @@ -10,3 +11,13 @@ S3_SECRET_KEY=minioadmin INTERNAL_KEY= # Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev 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 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 81c449f..6cc4a51 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ "axum", "axum-extra", "axum-test", - "base64", + "base64 0.22.1", "chrono", "common", "dashmap", @@ -27,7 +27,9 @@ dependencies = [ "sqlx", "time", "tokio", - "tower-http", + "tokio-stream", + "tonic", + "tower-http 0.7.1", "tracing", "tracing-subscriber", "uuid", @@ -725,6 +727,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -900,6 +908,27 @@ dependencies = [ "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]] name = "const-oid" version = "0.9.6" @@ -1505,6 +1534,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures-sink" version = "0.3.34" @@ -1517,6 +1557,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.34" @@ -1525,6 +1571,7 @@ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1532,6 +1579,28 @@ dependencies = [ "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]] name = "generic-array" version = "0.14.9" @@ -1554,6 +1623,20 @@ dependencies = [ "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]] name = "getrandom" version = "0.4.3" @@ -1562,10 +1645,33 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "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]] name = "group" version = "0.13.0" @@ -1879,7 +1985,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2117,7 +2223,7 @@ version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac 0.12.1", @@ -2319,6 +2425,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2613,6 +2725,12 @@ dependencies = [ "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]] name = "potential_utf" version = "0.1.6" @@ -2754,6 +2872,21 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "quick-error" version = "2.0.1" @@ -2769,6 +2902,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -2782,10 +2921,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "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]] name = "rand" version = "0.10.3" @@ -2807,6 +2956,16 @@ dependencies = [ "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]] name = "rand_core" version = "0.6.4" @@ -2816,12 +2975,30 @@ dependencies = [ "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]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "redox_syscall" version = "0.5.18" @@ -2866,6 +3043,38 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "reserve-port" version = "2.5.0" @@ -3313,6 +3522,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "spki" version = "0.7.3" @@ -3342,7 +3560,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "chrono", @@ -3448,7 +3666,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -3554,6 +3772,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -3734,7 +3955,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2 0.4.19", "http 1.5.0", @@ -3813,6 +4034,24 @@ dependencies = [ "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]] name = "tower-http" 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" 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]] name = "wasm-bindgen" version = "0.2.128" @@ -4069,6 +4317,16 @@ dependencies = [ "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]] name = "wasm-bindgen-macro" version = "0.2.128" @@ -4101,6 +4359,39 @@ dependencies = [ "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]] name = "webpki-roots" version = "1.0.9" @@ -4116,6 +4407,28 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-core" version = "0.62.2" @@ -4257,6 +4570,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.4" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7bc2fb8..ee98210 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -3,11 +3,11 @@ resolver = "2" members = [ "accounts-service", "common", + "configs-service", + "gateway", ] # Planned members, added when their own implementation plan starts # (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) # "addons-registry" — addon marketplace backend (Подсистема 3) diff --git a/backend/STRUCTURE.md b/backend/STRUCTURE.md index 7a2b8ee..2d363e8 100644 --- a/backend/STRUCTURE.md +++ b/backend/STRUCTURE.md @@ -8,12 +8,15 @@ Rust workspace (`backend/Cargo.toml`), один сервис — один кре backend/ Cargo.toml # workspace root — members растёт по мере реализации STRUCTURE.md # этот файл - accounts-service/ # РЕАЛИЗУЕТСЯ — backend/PLAN.md (Подсистема 1, часть 1) - gateway/ # ПЛАН: gateway/PLAN.md — routing + единая точка JWT-проверки - # + рейт-лимиты (TODO.md §6). Начинается после - # accounts-service, т.к. фронтит уже готовый сервис. - configs-service/ # ПЛАН: configs-service/PLAN.md — 4 слота конфигов, share-коды, - # витрина (Подсистема 1, часть 2) + common/ # РЕАЛИЗОВАН — общий контракт: JWT, внутренние заголовки, + # proto/accounts.proto (gRPC AuthenticateDevice, + # GetPublicProfiles для авторов витрины) + accounts-service/ # РЕАЛИЗОВАН — backend/PLAN.md (Подсистема 1, часть 1) + gateway/ # РЕАЛИЗОВАН — gateway/PLAN.md: единственная публичная + # точка, identity, рейт-лимиты, CORS, reverse proxy + configs-service/ # РЕАЛИЗОВАН — configs-service/PLAN.md: 4 слота конфигов, + # share-коды, витрина publish/browse/detail/copy + # (Подсистема 1, часть 2) chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence, # виджеты-телеметрия (Подсистема 2). Единственный # сервис с WebSocket, а не только REST. @@ -21,6 +24,14 @@ backend/ # публикация/модерация (Подсистема 3) ``` +## Внутренний контракт (gateway ↔ сервисы) +Гейтвей — единственная публичная точка: он один раз резолвит вызывавшего +(JWT-access или `lvd_`-device-токен через gRPC `AuthenticateDevice`) и кладёт +аккаунт в заголовок `x-lovisual-account-id`; каждый запрос несёт общий секрет +`x-lovisual-internal-key`. Сервисы отвергают всё без этого ключа (403), поэтому +напрямую в них стучаться бесполезно. Снаружи — только REST/JSON на `api.`, +внутри — тот же REST сервисов + gRPC там, где публичного REST нет. + ## Почему не добавлены в `[workspace] members` сразу Пустой крейт без реализации — то же самое "без пустышек", что запрещено правилами мода (см. `TODO.md`) — просто в Rust-обёртке: `cargo build @@ -28,13 +39,14 @@ backend/ сервис входит в `members` в своём implementation-плане первым шагом (как `accounts-service` в Task 1 `backend/PLAN.md`), не раньше. -## Общий код между сервисами (`common`, пока не создан) -Кандидаты на вынос в `backend/common/` **когда появится второй реальный -потребитель** (не раньше — YAGNI): JWT `Claims`/`verify_token` (сейчас -только в `accounts-service`, но `gateway` тоже будет его проверять — при -старте `gateway` вынести в `common`), типовой `AppError`. До этого момента -дублирование двух сервисов — не проблема, преждевременная общая библиотека -между одним реальным потребителем — проблема. +## Общий код между сервисами (`common`, создан 2026-09-24) +Второй реальный потребитель появился вместе с `gateway`, поэтому общий код +уже вынесен: JWT `Claims`/`issue_access_token`/`verify_token`/`bearer_token`, +внутренний контракт (`require_internal_key`, `GatewayIdentity`, gRPC-перехватчики +ключа) и `proto/accounts.proto`. Следующий кандидат — типовой `AppError`: +сознательно НЕ вынесен, т.к. маппинг `From` сервис-специфичен, +а `sqlx` не должен попадать в `gateway` (см. Global Constraints +`configs-service/PLAN.md`). ## Модульная структура внутри сервиса (эталон — `accounts-service`) Домен, не технический слой: `auth/`, `accounts/`, `device/`, `avatars/` — diff --git a/backend/accounts-service/Cargo.lock b/backend/accounts-service/Cargo.lock deleted file mode 100644 index ff91ad7..0000000 --- a/backend/accounts-service/Cargo.lock +++ /dev/null @@ -1,3931 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "accounts-service" -version = "0.1.0" -dependencies = [ - "anyhow", - "argon2", - "aws-config", - "aws-sdk-s3", - "axum", - "axum-test", - "chrono", - "dashmap", - "dotenvy", - "jsonwebtoken", - "rand 0.10.3", - "serde", - "serde_json", - "sqlx", - "tokio", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "argon2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" -dependencies = [ - "base64ct", - "blake2", - "cpufeatures 0.3.1", - "password-hash", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-config" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" -dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sdk-sso", - "aws-sdk-ssooidc", - "aws-sdk-sts", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "hex", - "http 1.5.0", - "sha1 0.10.7", - "time", - "tokio", - "tracing", - "url", - "zeroize", -] - -[[package]] -name = "aws-credential-types" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "zeroize", -] - -[[package]] -name = "aws-lc-rs" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "aws-runtime" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" -dependencies = [ - "aws-credential-types", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "bytes-utils", - "fastrand", - "http 1.5.0", - "http-body 1.1.0", - "percent-encoding", - "pin-project-lite", - "tracing", - "uuid", -] - -[[package]] -name = "aws-sdk-s3" -version = "1.149.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d48257fb0eb1907a02a965ff7d1b910770a6de2c59a2d03461678df989c5cdcd" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-checksums", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "bytes", - "fastrand", - "hex", - "hmac 0.13.0", - "http 1.5.0", - "http-body 1.1.0", - "lru", - "percent-encoding", - "regex-lite", - "sha2 0.11.0", - "tracing", - "url", -] - -[[package]] -name = "aws-sdk-sso" -version = "1.112.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d59c47556b283bb2d20fc0b416df7d9d9a3d6f5fa0e77867167a3f2efc23b9" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 1.5.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-ssooidc" -version = "1.114.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eceb9f31b46ba6da21f666e456395624824a2a086caed940b13d78832fa3ee02" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 1.5.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sdk-sts" -version = "1.117.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9291df9ea767ab5e77d08a53f4f22daa917909a34ed7a22c1a4b82d193a423ad" -dependencies = [ - "arc-swap", - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand", - "http 1.5.0", - "regex-lite", - "tracing", -] - -[[package]] -name = "aws-sigv4" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" -dependencies = [ - "aws-credential-types", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "crypto-bigint", - "form_urlencoded", - "hex", - "hmac 0.13.0", - "http 1.5.0", - "p256", - "percent-encoding", - "sha2 0.11.0", - "subtle", - "time", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-async" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" -dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "aws-smithy-checksums" -version = "0.65.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307" -dependencies = [ - "aws-smithy-http", - "aws-smithy-types", - "bytes", - "crc-fast", - "hex", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "md-5", - "pin-project-lite", - "sha1 0.11.0", - "sha2 0.11.0", - "tracing", -] - -[[package]] -name = "aws-smithy-eventstream" -version = "0.61.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609" -dependencies = [ - "aws-smithy-types", - "bytes", - "crc32fast", -] - -[[package]] -name = "aws-smithy-http" -version = "0.64.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" -dependencies = [ - "aws-smithy-eventstream", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", -] - -[[package]] -name = "aws-smithy-http-client" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd25384a4e437aa8d8f339afad4b69e786b936a7cb10db668a7aaf66717b1a8" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.19", - "http 0.2.12", - "http 1.5.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.11.1", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.10", - "hyper-util", - "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.45", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.5", - "tower", - "tracing", -] - -[[package]] -name = "aws-smithy-json" -version = "0.63.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", -] - -[[package]] -name = "aws-smithy-observability" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" -dependencies = [ - "aws-smithy-runtime-api", -] - -[[package]] -name = "aws-smithy-query" -version = "0.62.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "aws-smithy-xml", - "urlencoding", -] - -[[package]] -name = "aws-smithy-runtime" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "591e0024cdf2a8de8711860f7e1d5cfbca26b800ce13793a9aa2f4f5918e6d95" -dependencies = [ - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-http-client", - "aws-smithy-observability", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "bytes", - "fastrand", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "pin-project-lite", - "pin-utils", - "tokio", - "tracing", -] - -[[package]] -name = "aws-smithy-runtime-api" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf162725183ec0df77a9dc82ce22d9ba0b6ea7f376e8ec4f6b695727c70698" -dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api-macros", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.5.0", - "pin-project-lite", - "tokio", - "tracing", - "zeroize", -] - -[[package]] -name = "aws-smithy-runtime-api-macros" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "aws-smithy-schema" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-types", - "http 1.5.0", -] - -[[package]] -name = "aws-smithy-types" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ecf7989e381d0cbb7ca06104140d940bf38d8eb9004bd3a12d23df03e3d363" -dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "futures-core", - "http 0.2.12", - "http 1.5.0", - "http-body 0.4.6", - "http-body 1.1.0", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", - "tokio", - "tokio-util", -] - -[[package]] -name = "aws-smithy-xml" -version = "0.62.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" -dependencies = [ - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "xmlparser", -] - -[[package]] -name = "aws-types" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" -dependencies = [ - "aws-credential-types", - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-schema", - "aws-smithy-types", - "rustc_version", - "tracing", -] - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "hyper 1.11.1", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "multer", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http 1.5.0", - "http-body 1.1.0", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-test" -version = "21.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bf0893561659ea1365921a0aed8bbb620fae83dae00b53fe64d41fea4877640" -dependencies = [ - "anyhow", - "axum", - "bytes", - "bytesize", - "cookie", - "educe", - "expect-json", - "http 1.5.0", - "http-body-util", - "hyper 1.11.1", - "hyper-util", - "mime", - "pretty_assertions", - "reserve-port", - "rust-multipart-rfc7578_2", - "serde", - "serde_json", - "serde_urlencoded", - "tokio", - "tower", - "url", -] - -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -dependencies = [ - "serde_core", -] - -[[package]] -name = "blake2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "bytes-utils" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" -dependencies = [ - "bytes", - "either", -] - -[[package]] -name = "bytesize" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" - -[[package]] -name = "cc" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "cookie" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core_detect" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc-fast" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" -dependencies = [ - "digest 0.10.7", - "spin 0.10.1", -] - -[[package]] -name = "crc32fast" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dashmap" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.6", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - -[[package]] -name = "educe" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e451fac8dd8dece16234604bf1efce6e90fddd8ab6ad4d66eec0eca5160959dd" -dependencies = [ - "enum-ordinalize", - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" -dependencies = [ - "serde", -] - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "hkdf 0.12.4", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "encoding_rs" -version = "0.8.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" -dependencies = [ - "cfg-if", - "core_detect", - "multiversion", - "multiversion_no_op", - "rustversion", - "scopeguard", - "simdutf8", -] - -[[package]] -name = "enum-ordinalize" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" -dependencies = [ - "enum-ordinalize-derive", -] - -[[package]] -name = "enum-ordinalize-derive" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "etcetera" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "expect-json" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e80819dbfe83c8a651f5344b08910d0037dac72988aef27ee4e6bedd7ae2e33" -dependencies = [ - "chrono", - "email_address", - "expect-json-macros", - "num", - "regex", - "serde", - "serde_json", - "thiserror", - "typetag", - "uuid", -] - -[[package]] -name = "expect-json-macros" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0637949cd816934f3b7aab44ff98e7ec1fb903c379e07dcb9eac943ec33499e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "find-msvc-tools" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" - -[[package]] -name = "flume" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" -dependencies = [ - "futures-core", - "futures-sink", - "spin 0.9.9", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core 0.10.1", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.5.0", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac 0.12.1", -] - -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http 1.5.0", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http 1.5.0", - "http-body 1.1.0", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2 0.4.19", - "http 1.5.0", - "http-body 1.1.0", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" -dependencies = [ - "http 1.5.0", - "hyper 1.11.1", - "hyper-util", - "rustls 0.23.45", - "rustls-native-certs", - "tokio", - "tokio-rustls 0.26.5", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http 1.5.0", - "http-body 1.1.0", - "hyper 1.11.1", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.5", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "ipnet" -version = "2.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "11.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" -dependencies = [ - "base64", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac 0.12.1", - "js-sys", - "p256", - "p384", - "rand 0.8.8", - "rsa", - "serde", - "serde_json", - "sha2 0.10.9", - "signature", - "zeroize", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.9", -] - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libsqlite3-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "lru" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" -dependencies = [ - "hashbrown 0.17.1", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.5.0", - "httparse", - "memchr", - "mime", - "spin 0.9.9", - "version_check", -] - -[[package]] -name = "multiversion" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" -dependencies = [ - "multiversion-macros", -] - -[[package]] -name = "multiversion-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn 3.0.6", -] - -[[package]] -name = "multiversion_no_op" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.8", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "password-hash" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" -dependencies = [ - "getrandom 0.4.3", - "phc", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phc" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" -dependencies = [ - "base64ct", - "ctutils", - "getrandom 0.4.3", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reserve-port" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ea98a177596a4579881992bd2bd4af27772fc95d0e5f5668a8f9535eca6380" -dependencies = [ - "thiserror", -] - -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac 0.12.1", - "subtle", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - -[[package]] -name = "rust-multipart-rfc7578_2" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdaa068902270ca7fa8619775e1838e23a63620abac0947ce0f715819b8cec" -dependencies = [ - "bytes", - "futures-core", - "futures-util", - "http 1.5.0", - "mime", - "rand 0.10.3", - "thiserror", -] - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "aws-lc-rs", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki 0.103.15", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" -dependencies = [ - "serde", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spin" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" -dependencies = [ - "base64", - "bytes", - "cfg-if", - "chrono", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.16.1", - "hashlink", - "indexmap", - "log", - "memchr", - "percent-encoding", - "rustls 0.23.45", - "serde", - "serde_json", - "sha2 0.10.9", - "smallvec", - "thiserror", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", - "webpki-roots", -] - -[[package]] -name = "sqlx-macros" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.119", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" -dependencies = [ - "cfg-if", - "dotenvy", - "either", - "heck", - "hex", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2 0.10.9", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.119", - "thiserror", - "tokio", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" -dependencies = [ - "bitflags", - "byteorder", - "bytes", - "chrono", - "crc", - "digest 0.11.3", - "dotenvy", - "either", - "futures-core", - "futures-util", - "generic-array", - "log", - "percent-encoding", - "serde", - "sha1 0.11.0", - "sha2 0.11.0", - "sqlx-core", - "thiserror", - "tracing", - "uuid", -] - -[[package]] -name = "sqlx-postgres" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "chrono", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf 0.13.0", - "hmac 0.13.0", - "itoa", - "log", - "md-5", - "memchr", - "rand 0.10.3", - "serde", - "serde_json", - "sha2 0.11.0", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "uuid", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" -dependencies = [ - "atoi", - "chrono", - "flume", - "form_urlencoded", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "sqlx-core", - "thiserror", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.5", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" -dependencies = [ - "rustls 0.23.45", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" -dependencies = [ - "bitflags", - "bytes", - "http 1.5.0", - "http-body 1.1.0", - "percent-encoding", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "typetag" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c90e86058a30d42a1a928dfb4b49bb33c98c3a2b4909492e6b0881cd94798ec2" -dependencies = [ - "erased-serde", - "inventory", - "once_cell", - "serde", - "typetag-impl", -] - -[[package]] -name = "typetag-impl" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-ident" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "whoami" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/accounts-service/Cargo.toml b/backend/accounts-service/Cargo.toml index 152bc20..f0d6707 100644 --- a/backend/accounts-service/Cargo.toml +++ b/backend/accounts-service/Cargo.toml @@ -32,6 +32,8 @@ aws-config = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } anyhow = "1" dotenvy = "0.15" +tonic = "0.14" [dev-dependencies] axum-test = "21" +tokio-stream = { version = "0.1", features = ["net"] } diff --git a/backend/accounts-service/migrations/0003_device_links_token_unique.sql b/backend/accounts-service/migrations/0003_device_links_token_unique.sql new file mode 100644 index 0000000..a7e2859 --- /dev/null +++ b/backend/accounts-service/migrations/0003_device_links_token_unique.sql @@ -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); diff --git a/backend/accounts-service/migrations/0004_refresh_rotated_at.sql b/backend/accounts-service/migrations/0004_refresh_rotated_at.sql new file mode 100644 index 0000000..30bf69a --- /dev/null +++ b/backend/accounts-service/migrations/0004_refresh_rotated_at.sql @@ -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); diff --git a/backend/accounts-service/src/accounts/handlers.rs b/backend/accounts-service/src/accounts/handlers.rs new file mode 100644 index 0000000..577f7c5 --- /dev/null +++ b/backend/accounts-service/src/accounts/handlers.rs @@ -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, + pub created_at: DateTime, +} + +pub async fn me( + State(state): State, + identity: GatewayIdentity, +) -> Result, 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, + pub created_at: DateTime, + pub badges: Vec, +} + +/// 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, + Path(raw): Path, +) -> Result, 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, + })) +} diff --git a/backend/accounts-service/src/accounts/mod.rs b/backend/accounts-service/src/accounts/mod.rs index c466d78..b2bc91f 100644 --- a/backend/accounts-service/src/accounts/mod.rs +++ b/backend/accounts-service/src/accounts/mod.rs @@ -1,2 +1,3 @@ +pub mod handlers; pub mod model; pub mod repo; diff --git a/backend/accounts-service/src/accounts/model.rs b/backend/accounts-service/src/accounts/model.rs index 7040da0..3b55915 100644 --- a/backend/accounts-service/src/accounts/model.rs +++ b/backend/accounts-service/src/accounts/model.rs @@ -1,3 +1,5 @@ +use crate::auth::password::MAX_PASSWORD_BYTES; +use crate::error::AppError; use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; @@ -13,3 +15,106 @@ pub struct Account { pub can_publish_addons: bool, pub created_at: DateTime, } + +/// 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()); + } +} diff --git a/backend/accounts-service/src/accounts/repo.rs b/backend/accounts-service/src/accounts/repo.rs index 5dfd3bf..3cab080 100644 --- a/backend/accounts-service/src/accounts/repo.rs +++ b/backend/accounts-service/src/accounts/repo.rs @@ -58,6 +58,40 @@ pub async fn set_avatar(pool: &PgPool, account_id: Uuid, s3_key: &str) -> Result Ok(()) } +pub async fn avatar_key(pool: &PgPool, account_id: Uuid) -> Result, 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)>, 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, +) -> Result { + sqlx::query_scalar("SELECT count(*) FROM accounts WHERE created_at < $1") + .bind(created_at) + .fetch_one(pool) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -66,7 +100,10 @@ mod tests { 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"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrate"); pool } @@ -80,7 +117,10 @@ mod tests { assert_eq!(created.role, "user"); 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); sqlx::query("DELETE FROM accounts WHERE id = $1") @@ -93,7 +133,9 @@ mod tests { #[tokio::test] async fn find_by_email_returns_none_for_missing() { 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()); } @@ -123,7 +165,9 @@ mod tests { async fn duplicate_email_differing_only_by_case_is_rejected() { let pool = test_pool().await; 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 err = second.expect_err("case-variant duplicate must violate the unique index"); @@ -144,12 +188,21 @@ mod tests { #[tokio::test] async fn set_avatar_inserts_then_updates_in_place() { 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 .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,)> = sqlx::query_as("SELECT s3_key FROM avatars WHERE account_id = $1") diff --git a/backend/accounts-service/src/auth/handlers.rs b/backend/accounts-service/src/auth/handlers.rs index 5d7230e..33fd82a 100644 --- a/backend/accounts-service/src/auth/handlers.rs +++ b/backend/accounts-service/src/auth/handlers.rs @@ -1,7 +1,8 @@ +use crate::accounts::model::validate_register; use crate::accounts::repo; use crate::auth::{password, tokens}; 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 common::jwt; use serde::{Deserialize, Serialize}; @@ -49,52 +50,16 @@ pub struct RegisterResponse { 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( State(state): State, AppJson(req): AppJson, ) -> Result<(StatusCode, Json), AppError> { - let (email, nick) = validate_register(&req)?; - let hash = state.hasher.hash(req.password.clone()).await.map_err(AppError::Internal)?; + 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 account = repo::create(&state.pool, &email, &hash, &nick).await?; Ok(( StatusCode::CREATED, @@ -142,7 +107,9 @@ pub async fn login( let refresh = tokens::store_refresh(&state.pool, account.id).await?; Ok(( 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, jar: CookieJar, ) -> Result<(CookieJar, Json), 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? { - 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)), - 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), } @@ -168,60 +143,7 @@ pub async fn logout( tokens::revoke_refresh(&state.pool, cookie.value()).await?; } 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, )) } - -#[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()); - } -} diff --git a/backend/accounts-service/src/auth/mod.rs b/backend/accounts-service/src/auth/mod.rs index d6b1d68..95d327c 100644 --- a/backend/accounts-service/src/auth/mod.rs +++ b/backend/accounts-service/src/auth/mod.rs @@ -1,3 +1,3 @@ +pub mod handlers; pub mod password; pub mod tokens; -pub mod handlers; diff --git a/backend/accounts-service/src/auth/password.rs b/backend/accounts-service/src/auth/password.rs index 57027b5..342982e 100644 --- a/backend/accounts-service/src/auth/password.rs +++ b/backend/accounts-service/src/auth/password.rs @@ -64,7 +64,9 @@ impl PasswordHasher { let permits = std::thread::available_parallelism() .map(|n| n.get()) .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. diff --git a/backend/accounts-service/src/auth/tokens.rs b/backend/accounts-service/src/auth/tokens.rs index 1028446..8be6129 100644 --- a/backend/accounts-service/src/auth/tokens.rs +++ b/backend/accounts-service/src/auth/tokens.rs @@ -1,10 +1,17 @@ use axum_extra::extract::cookie::{Cookie, SameSite}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Utc}; use rand::RngExt; use sha2::{Digest, Sha256}; use sqlx::PgPool; 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"; /// 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 { let mut tx = pool.begin().await?; - let row: Option<(Uuid, bool, bool)> = sqlx::query_as( - "SELECT account_id, revoked_at IS NOT NULL, expires_at <= now() + let row: Option<(Uuid, bool, bool, Option>)> = sqlx::query_as( + "SELECT account_id, revoked_at IS NOT NULL, expires_at <= now(), rotated_at FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE", ) .bind(hash_token(token)) @@ -49,8 +56,16 @@ pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result RotateOutcome::Invalid, - Some((account_id, true, _)) => { - // Reuse of a rotated token: someone else holds a copy. Kill all sessions. + Some((_, true, _, Some(rotated_at))) if Utc::now() - rotated_at < ROTATION_GRACE => { + // 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( "UPDATE refresh_tokens SET revoked_at = now() WHERE account_id = $1 AND revoked_at IS NULL", @@ -60,12 +75,15 @@ pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result RotateOutcome::Invalid, - Some((account_id, false, false)) => { - sqlx::query("UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1") - .bind(hash_token(token)) - .execute(&mut *tx) - .await?; + Some((_, false, true, _)) => RotateOutcome::Invalid, + Some((account_id, false, false, _)) => { + sqlx::query( + "UPDATE refresh_tokens SET revoked_at = now(), rotated_at = now() + WHERE token_hash = $1", + ) + .bind(hash_token(token)) + .execute(&mut *tx) + .await?; let new_token = new_opaque_token("lvr_"); sqlx::query( "INSERT INTO refresh_tokens (account_id, token_hash, expires_at) @@ -75,7 +93,10 @@ pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result Cookie<'static> { .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)] mod tests { use super::*; diff --git a/backend/accounts-service/src/avatars/handlers.rs b/backend/accounts-service/src/avatars/handlers.rs index ec1f49e..20b8c89 100644 --- a/backend/accounts-service/src/avatars/handlers.rs +++ b/backend/accounts-service/src/avatars/handlers.rs @@ -1,10 +1,10 @@ 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::error::AppError; use axum::{ - extract::{Multipart, State}, Json, + extract::{Multipart, State}, }; use common::internal::GatewayIdentity; use serde::Serialize; @@ -56,8 +56,14 @@ pub async fn upload( })?; 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?; - Ok(Json(AvatarResponse { avatar_url: format!("{}/{key}", state.base_url) })) + Ok(Json(AvatarResponse { + avatar_url: format!("{}/{key}", state.base_url), + })) } diff --git a/backend/accounts-service/src/avatars/processing.rs b/backend/accounts-service/src/avatars/processing.rs index ec4b8c5..5400220 100644 --- a/backend/accounts-service/src/avatars/processing.rs +++ b/backend/accounts-service/src/avatars/processing.rs @@ -1,4 +1,4 @@ -use image::{imageops::FilterType, ImageFormat, ImageReader, Limits}; +use image::{ImageFormat, ImageReader, Limits, imageops::FilterType}; use std::io::Cursor; pub const AVATAR_SIZE: u32 = 256; @@ -60,12 +60,18 @@ mod tests { let out = process_avatar(&png_of(300, 100)).unwrap(); assert!(out.starts_with(&[0x89, b'P', b'N', b'G'])); 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] 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] @@ -85,6 +91,9 @@ mod tests { #[test] 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) + ); } } diff --git a/backend/accounts-service/src/avatars/storage.rs b/backend/accounts-service/src/avatars/storage.rs index bf0ae54..11ecd27 100644 --- a/backend/accounts-service/src/avatars/storage.rs +++ b/backend/accounts-service/src/avatars/storage.rs @@ -1,5 +1,5 @@ -use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::Client; +use aws_sdk_s3::primitives::ByteStream; #[derive(Clone)] pub struct S3Storage { @@ -11,7 +11,8 @@ impl S3Storage { /// Builds the S3 client synchronously (no I/O happens here — connections /// are made lazily on first request). 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() .endpoint_url(endpoint) .credentials_provider(creds) @@ -19,7 +20,10 @@ impl S3Storage { .force_path_style(true) .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest()) .build(); - S3Storage { client: Client::from_conf(config), bucket } + S3Storage { + client: Client::from_conf(config), + bucket, + } } pub async fn put(&self, key: &str, bytes: Vec, content_type: &str) -> anyhow::Result<()> { diff --git a/backend/accounts-service/src/config.rs b/backend/accounts-service/src/config.rs index f63055f..423467a 100644 --- a/backend/accounts-service/src/config.rs +++ b/backend/accounts-service/src/config.rs @@ -6,6 +6,7 @@ pub struct Config { pub jwt_secret: String, pub internal_key: String, pub port: u16, + pub grpc_port: u16, pub s3_endpoint: String, pub s3_bucket: String, pub s3_access_key: String, @@ -16,25 +17,24 @@ pub struct Config { impl Config { pub fn from_env() -> Result { Ok(Config { - database_url: std::env::var("DATABASE_URL") - .context("DATABASE_URL not set")?, - jwt_secret: std::env::var("JWT_SECRET") - .context("JWT_SECRET not set")?, - internal_key: std::env::var("INTERNAL_KEY") - .context("INTERNAL_KEY not set")?, + database_url: std::env::var("DATABASE_URL").context("DATABASE_URL not set")?, + jwt_secret: std::env::var("JWT_SECRET").context("JWT_SECRET not set")?, + internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?, port: std::env::var("PORT") .unwrap_or_else(|_| "8081".into()) .parse() .context("PORT must be a number")?, - s3_endpoint: std::env::var("S3_ENDPOINT") - .context("S3_ENDPOINT not set")?, - s3_bucket: std::env::var("S3_BUCKET") - .context("S3_BUCKET not set")?, - s3_access_key: std::env::var("S3_ACCESS_KEY") - .context("S3_ACCESS_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), + grpc_port: std::env::var("GRPC_PORT") + .unwrap_or_else(|_| "50051".into()) + .parse() + .context("GRPC_PORT must be a number")?, + s3_endpoint: std::env::var("S3_ENDPOINT").context("S3_ENDPOINT not set")?, + s3_bucket: std::env::var("S3_BUCKET").context("S3_BUCKET not set")?, + s3_access_key: std::env::var("S3_ACCESS_KEY").context("S3_ACCESS_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), }) } } @@ -56,7 +56,11 @@ impl Config { /// Public prefix of stored avatars: `/`. 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(), internal_key: "k".repeat(32), port: 0, + grpc_port: 0, s3_endpoint: String::new(), s3_bucket: String::new(), s3_access_key: String::new(), diff --git a/backend/accounts-service/src/device/handlers.rs b/backend/accounts-service/src/device/handlers.rs index 2e86e48..9ead54d 100644 --- a/backend/accounts-service/src/device/handlers.rs +++ b/backend/accounts-service/src/device/handlers.rs @@ -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 axum::{extract::State, http::StatusCode, Json}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; use common::internal::GatewayIdentity; -use common::jwt; use serde::{Deserialize, Serialize}; #[derive(Clone)] pub struct DeviceState { pub store: DeviceStore, - pub jwt_secret: String, + pub pool: sqlx::PgPool, } #[derive(Serialize)] @@ -24,7 +30,11 @@ pub async fn create_code( let (device_code, user_code) = state.store.create().ok_or(AppError::TooManyRequests)?; Ok(( 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::Pending => Ok((StatusCode::ACCEPTED, Json(None))), PollResult::Confirmed(account_id) => { - // The long-lived device_token is just a refresh-style JWT for now; - // the gateway plan is where per-device revocation via - // device_links.device_token_hash gets enforced on every request. - let device_token = jwt::issue_refresh_token(account_id, &state.jwt_secret); + // The long-lived device token is an opaque random secret, stored + // hashed in device_links so the gateway can revoke it per device. + let device_token = links::create(&state.pool, account_id).await?; Ok((StatusCode::OK, Json(Some(TokenResponse { device_token })))) } } } + +pub async fn list_links( + State(state): State, + identity: GatewayIdentity, +) -> Result>, AppError> { + Ok(Json(links::list(&state.pool, identity.account_id).await?)) +} + +pub async fn revoke_link( + State(state): State, + identity: GatewayIdentity, + Path(link_id): Path, +) -> Result { + if links::revoke(&state.pool, identity.account_id, link_id).await? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(AppError::NotFound("no such device link".into())) + } +} diff --git a/backend/accounts-service/src/device/links.rs b/backend/accounts-service/src/device/links.rs new file mode 100644 index 0000000..53e428d --- /dev/null +++ b/backend/accounts-service/src/device/links.rs @@ -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, + pub last_seen: Option>, +} + +pub async fn create(pool: &PgPool, account_id: Uuid) -> Result { + 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, 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, 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 { + 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) +} diff --git a/backend/accounts-service/src/device/mod.rs b/backend/accounts-service/src/device/mod.rs index ad824f3..27b7d29 100644 --- a/backend/accounts-service/src/device/mod.rs +++ b/backend/accounts-service/src/device/mod.rs @@ -1,2 +1,3 @@ pub mod handlers; +pub mod links; pub mod store; diff --git a/backend/accounts-service/src/device/store.rs b/backend/accounts-service/src/device/store.rs index dbb1703..6e92104 100644 --- a/backend/accounts-service/src/device/store.rs +++ b/backend/accounts-service/src/device/store.rs @@ -33,7 +33,9 @@ fn random_user_code() -> String { const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1 confusion let mut rng = rand::rng(); 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)) } @@ -42,7 +44,8 @@ impl DeviceStore { /// Returns `(device_code, user_code)`, or `None` when the store is full. pub fn create(&self) -> Option<(String, String)> { 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 { return None; } @@ -130,7 +133,11 @@ mod tests { assert!(store.confirm(&user_code, Uuid::new_v4())); 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] @@ -161,7 +168,10 @@ mod tests { let attacker = Uuid::new_v4(); 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)); } @@ -180,7 +190,10 @@ mod tests { for _ in 0..MAX_PENDING { 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. for mut entry in store.by_device_code.iter_mut() { diff --git a/backend/accounts-service/src/error.rs b/backend/accounts-service/src/error.rs index b51d762..6615b5a 100644 --- a/backend/accounts-service/src/error.rs +++ b/backend/accounts-service/src/error.rs @@ -1,8 +1,8 @@ use axum::{ - extract::{rejection::JsonRejection, FromRequest}, + Json, + extract::{FromRequest, rejection::JsonRejection}, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde_json::json; @@ -23,7 +23,10 @@ impl IntoResponse for AppError { AppError::Conflict(msg) => (StatusCode::CONFLICT, msg), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), 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) => { tracing::error!("internal error: {err:?}"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) diff --git a/backend/accounts-service/src/grpc/mod.rs b/backend/accounts-service/src/grpc/mod.rs new file mode 100644 index 0000000..bf48ba2 --- /dev/null +++ b/backend/accounts-service/src/grpc/mod.rs @@ -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, 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, + ) -> Result, 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, + ) -> Result, 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 = 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 = (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(); + } +} diff --git a/backend/accounts-service/src/lib.rs b/backend/accounts-service/src/lib.rs index 6af68b4..825e32e 100644 --- a/backend/accounts-service/src/lib.rs +++ b/backend/accounts-service/src/lib.rs @@ -4,22 +4,25 @@ pub mod avatars; pub mod config; pub mod device; pub mod error; +pub mod grpc; +use accounts::handlers::AccountsState; use auth::handlers::AuthState; use avatars::{handlers::AvatarState, storage::S3Storage}; use axum::{ - extract::DefaultBodyLimit, Router, + extract::DefaultBodyLimit, routing::{get, post}, }; use config::Config; use device::{handlers::DeviceState, store::DeviceStore}; pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router { - let auth_state = - AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure); - let device_state = - DeviceState { store: DeviceStore::default(), jwt_secret: cfg.jwt_secret.clone() }; + let auth_state = AuthState::new(pool.clone(), cfg.jwt_secret.clone(), cfg.cookie_secure); + let device_state = DeviceState { + store: DeviceStore::default(), + pool: pool.clone(), + }; let avatar_state = AvatarState { pool: pool.clone(), storage: S3Storage::from_config( @@ -30,6 +33,10 @@ pub fn build_app(pool: sqlx::PgPool, cfg: &Config) -> Router { ), 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() .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/confirm", post(device::handlers::confirm)) .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); 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)) .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 // the gateway, which authenticates the caller and forwards the identity // 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(device_routes) .merge(avatar_routes) + .merge(accounts_routes) .layer(axum::middleware::from_fn_with_state( common::internal::InternalKey::new(cfg.internal_key.clone()), common::internal::require_internal_key, diff --git a/backend/accounts-service/src/main.rs b/backend/accounts-service/src/main.rs index 2e2eea8..63b8d23 100644 --- a/backend/accounts-service/src/main.rs +++ b/backend/accounts-service/src/main.rs @@ -14,9 +14,26 @@ async fn main() -> anyhow::Result<()> { 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 listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?; - tracing::info!("accounts-service listening on {}", cfg.port); - axum::serve(listener, app).await?; + + tokio::try_join!( + async { axum::serve(http, app).await.map_err(anyhow::Error::from) }, + async { grpc.await.map_err(anyhow::Error::from) }, + )?; Ok(()) } diff --git a/backend/accounts-service/tests/auth_flow.rs b/backend/accounts-service/tests/auth_flow.rs index 204c4cc..e5e9356 100644 --- a/backend/accounts-service/tests/auth_flow.rs +++ b/backend/accounts-service/tests/auth_flow.rs @@ -16,7 +16,9 @@ async fn register_then_login_succeeds() { let register_response = server .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; register_response.assert_status(axum::http::StatusCode::CREATED); @@ -27,7 +29,10 @@ async fn register_then_login_succeeds() { login_response.assert_status_ok(); let body: serde_json::Value = login_response.json(); 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") .bind(&email) @@ -141,7 +146,10 @@ async fn malformed_json_body_returns_400_with_json_error_shape() { .await; response.assert_status(axum::http::StatusCode::BAD_REQUEST); 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] @@ -160,3 +168,24 @@ async fn register_rejects_oversized_password_with_400() { .await; 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(); +} diff --git a/backend/accounts-service/tests/auth_flow/refresh.rs b/backend/accounts-service/tests/auth_flow/refresh.rs index 47bc93c..26e5f47 100644 --- a/backend/accounts-service/tests/auth_flow/refresh.rs +++ b/backend/accounts-service/tests/auth_flow/refresh.rs @@ -13,8 +13,14 @@ async fn login(server: &axum_test::TestServer, email: &str) -> (String, String) assert!(cookie.http_only().unwrap_or(false)); assert_eq!(cookie.path(), Some("/auth")); let body: serde_json::Value = res.json(); - assert!(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()) + assert!( + 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] @@ -47,6 +53,9 @@ async fn reusing_a_rotated_refresh_token_revokes_all_sessions() { .cookie("lv_refresh") .value() .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. server .post("/auth/refresh") @@ -60,6 +69,36 @@ async fn reusing_a_rotated_refresh_token_revokes_all_sessions() { .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] async fn logout_revokes_the_refresh_token() { 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 (_, refresh) = login(&server, &email).await; - server + let res = server .post("/auth/logout") .add_cookie(Cookie::new("lv_refresh", refresh.clone())) - .await - .assert_status(StatusCode::NO_CONTENT); + .await; + 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 .post("/auth/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() { let pool = common::test_pool().await; 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 .post("/auth/refresh") .add_cookie(Cookie::new("lv_refresh", "lvr_garbage")) diff --git a/backend/accounts-service/tests/avatar_upload.rs b/backend/accounts-service/tests/avatar_upload.rs index df54176..cd93c94 100644 --- a/backend/accounts-service/tests/avatar_upload.rs +++ b/backend/accounts-service/tests/avatar_upload.rs @@ -12,13 +12,19 @@ fn png_bytes() -> Vec { } fn form(bytes: Vec) -> 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] async fn valid_upload_stores_avatar_and_returns_url() { 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 response = server @@ -27,8 +33,14 @@ async fn valid_upload_stores_avatar_and_returns_url() { .multipart(form(png_bytes())) .await; response.assert_status_ok(); - let url = response.json::()["avatar_url"].as_str().unwrap().to_string(); - assert!(url.starts_with("http://localhost:9000/lovisual-avatars-test/avatars/"), "{url}"); + let url = response.json::()["avatar_url"] + .as_str() + .unwrap() + .to_string(); + assert!( + url.starts_with("http://localhost:9000/lovisual-avatars-test/avatars/"), + "{url}" + ); assert!(url.ends_with(".png"), "{url}"); let stored: (String,) = sqlx::query_as( @@ -50,7 +62,10 @@ async fn valid_upload_stores_avatar_and_returns_url() { #[tokio::test] async fn non_image_upload_is_rejected_with_400() { 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; server @@ -70,7 +85,10 @@ async fn non_image_upload_is_rejected_with_400() { #[tokio::test] async fn oversized_upload_is_rejected_with_400() { 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; server @@ -90,7 +108,10 @@ async fn oversized_upload_is_rejected_with_400() { #[tokio::test] async fn upload_without_identity_is_401() { 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 .post("/avatars") diff --git a/backend/accounts-service/tests/common/mod.rs b/backend/accounts-service/tests/common/mod.rs index a3e5451..6820771 100644 --- a/backend/accounts-service/tests/common/mod.rs +++ b/backend/accounts-service/tests/common/mod.rs @@ -11,8 +11,13 @@ pub use ::common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER}; pub async fn test_pool() -> sqlx::PgPool { let url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into()); - let pool = sqlx::PgPool::connect(&url).await.expect("connect to test database"); - sqlx::migrate!("./migrations").run(&pool).await.expect("run migrations"); + let pool = sqlx::PgPool::connect(&url) + .await + .expect("connect to test database"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run migrations"); pool } @@ -22,6 +27,7 @@ pub fn test_config() -> Config { jwt_secret: "test-secret-test-secret-test-secret!".into(), internal_key: TEST_INTERNAL_KEY.into(), port: 0, + grpc_port: 0, s3_endpoint: "http://localhost:9000".into(), s3_bucket: "lovisual-avatars-test".into(), s3_access_key: "minioadmin".into(), @@ -48,5 +54,8 @@ pub async fn register_account(server: &TestServer) -> (Uuid, String) { .await; res.assert_status(axum::http::StatusCode::CREATED); 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, + ) } diff --git a/backend/accounts-service/tests/device_flow.rs b/backend/accounts-service/tests/device_flow.rs index 1d5f4c2..663dfee 100644 --- a/backend/accounts-service/tests/device_flow.rs +++ b/backend/accounts-service/tests/device_flow.rs @@ -1,4 +1,8 @@ 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 serde_json::json; @@ -6,7 +10,10 @@ use serde_json::json; #[tokio::test] async fn full_device_link_flow() { 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 code_response: serde_json::Value = server.post("/device/code").await.json(); @@ -32,7 +39,11 @@ async fn full_device_link_flow() { .await; poll_after.assert_status_ok(); 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. server @@ -51,7 +62,10 @@ async fn full_device_link_flow() { #[tokio::test] async fn confirm_without_identity_is_401() { 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(); server .post("/device/confirm") @@ -63,7 +77,10 @@ async fn confirm_without_identity_is_401() { #[tokio::test] async fn unknown_device_code_and_user_code_return_404() { 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; server diff --git a/backend/accounts-service/tests/device_flow/links.rs b/backend/accounts-service/tests/device_flow/links.rs new file mode 100644 index 0000000..ec97669 --- /dev/null +++ b/backend/accounts-service/tests/device_flow/links.rs @@ -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::()["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 = 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 = 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 = 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); +} diff --git a/backend/accounts-service/tests/smoke.rs b/backend/accounts-service/tests/smoke.rs index 0f321eb..233882c 100644 --- a/backend/accounts-service/tests/smoke.rs +++ b/backend/accounts-service/tests/smoke.rs @@ -7,8 +7,7 @@ async fn health_returns_ok() { // NOTE: this test does not touch the DB — pass a pool that is never // queried. sqlx::PgPool::connect_lazy never opens a connection until // a query runs, so this is safe without a running Postgres. - let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db") - .expect("lazy pool"); + let pool = sqlx::PgPool::connect_lazy("postgres://user:pass@localhost/db").expect("lazy pool"); let app = accounts_service::build_app(pool, &common::test_config()); let server = TestServer::new(app); @@ -25,7 +24,10 @@ async fn migrations_create_accounts_table() { .fetch_one(&pool) .await .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] @@ -33,10 +35,50 @@ async fn api_routes_require_internal_key_but_health_does_not() { let pool = common::test_pool().await; // A raw server: no internal key header on any request, as if the // 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 .post("/device/code") .await .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"); +} diff --git a/backend/common/proto/accounts.proto b/backend/common/proto/accounts.proto index 2e077ca..850cc73 100644 --- a/backend/common/proto/accounts.proto +++ b/backend/common/proto/accounts.proto @@ -7,7 +7,18 @@ service AccountsInternal { // Resolves a mod's long-lived device token to its account and bumps // device_links.last_seen. UNAUTHENTICATED if the token is unknown/revoked. 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 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; } diff --git a/backend/common/src/internal.rs b/backend/common/src/internal.rs index 2e74061..23c1d85 100644 --- a/backend/common/src/internal.rs +++ b/backend/common/src/internal.rs @@ -3,19 +3,19 @@ //! came through the gateway (which strips client-supplied copies of both). use axum::{ + Json, extract::{FromRequestParts, Request, State}, - http::{request::Parts, StatusCode}, + http::{StatusCode, request::Parts}, middleware::Next, response::{IntoResponse, Response}, - Json, }; use serde_json::json; use std::sync::Arc; use subtle::ConstantTimeEq; use tonic::{ + Status, metadata::{Ascii, MetadataValue}, service::Interceptor, - Status, }; use uuid::Uuid; @@ -36,7 +36,11 @@ impl InternalKey { } } -pub async fn require_internal_key(State(key): State, req: Request, next: Next) -> Response { +pub async fn require_internal_key( + State(key): State, + req: Request, + next: Next, +) -> Response { let ok = req .headers() .get(INTERNAL_KEY_HEADER) @@ -64,7 +68,11 @@ impl FromRequestParts for GatewayIdentity { .and_then(|s| Uuid::parse_str(s).ok()) .map(|account_id| GatewayIdentity { account_id }) .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 { fn call(&mut self, mut req: tonic::Request<()>) -> Result, Status> { - req.metadata_mut().insert(INTERNAL_KEY_HEADER, self.0.clone()); + req.metadata_mut() + .insert(INTERNAL_KEY_HEADER, self.0.clone()); Ok(req) } } @@ -108,22 +117,31 @@ impl Interceptor for GrpcKeyAttach { #[cfg(test)] mod tests { use super::*; - use axum::{routing::get, Router}; + use axum::{Router, routing::get}; use axum_test::TestServer; const KEY: &str = "internal-key-internal-key-internal!!"; fn app() -> Router { 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" })) - .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] async fn request_without_internal_key_is_forbidden() { 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] diff --git a/backend/common/src/jwt.rs b/backend/common/src/jwt.rs index e96ba12..37aa20b 100644 --- a/backend/common/src/jwt.rs +++ b/backend/common/src/jwt.rs @@ -1,15 +1,15 @@ -use axum::http::{header::AUTHORIZATION, HeaderMap}; -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use axum::http::{HeaderMap, header::AUTHORIZATION}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// Distinguishes token purposes so a long-lived refresh/device token can -/// never be replayed as a short-lived access token (and vice versa). +/// Distinguishes token purposes so a token kind can never be replayed as +/// 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)] #[serde(rename_all = "lowercase")] pub enum TokenType { Access, - Refresh, } #[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 { let exp = (chrono::Utc::now() + chrono::Duration::seconds(ttl_seconds)).timestamp() as usize; - let claims = Claims { sub: account_id.to_string(), exp, token_type }; - encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(secret.as_bytes())) - .expect("encoding a well-formed Claims struct cannot fail") + let claims = Claims { + sub: account_id.to_string(), + 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 { 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. /// HS256 is pinned explicitly (no algorithm confusion) and expiry leeway is 0. pub fn verify_token(token: &str, secret: &str, expected: TokenType) -> Option { @@ -83,19 +86,6 @@ mod tests { 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] fn expired_token_fails_verify() { 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()); } - 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] fn bearer_token_extracts_the_token() { let mut h = HeaderMap::new(); @@ -144,7 +126,9 @@ mod tests { } #[test] - fn bearer_token_rejects_garbage_headers() { - assert_eq!(bearer_token(&headers_with("Bearer not.a.jwt")).unwrap(), "not.a.jwt"); + fn bearer_token_passes_opaque_value_through() { + let mut h = HeaderMap::new(); + h.insert(AUTHORIZATION, "Bearer not.a.jwt".parse().unwrap()); + assert_eq!(bearer_token(&h), Some("not.a.jwt")); } } diff --git a/backend/configs-service/Cargo.toml b/backend/configs-service/Cargo.toml new file mode 100644 index 0000000..6e51e10 --- /dev/null +++ b/backend/configs-service/Cargo.toml @@ -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" diff --git a/backend/configs-service/PLAN.md b/backend/configs-service/PLAN.md index c165135..310d29b 100644 --- a/backend/configs-service/PLAN.md +++ b/backend/configs-service/PLAN.md @@ -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` → `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 **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 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: , 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`. diff --git a/backend/configs-service/migrations/0001_init.sql b/backend/configs-service/migrations/0001_init.sql new file mode 100644 index 0000000..340d7af --- /dev/null +++ b/backend/configs-service/migrations/0001_init.sql @@ -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; diff --git a/backend/configs-service/src/config.rs b/backend/configs-service/src/config.rs new file mode 100644 index 0000000..d520cf3 --- /dev/null +++ b/backend/configs-service/src/config.rs @@ -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 { + 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()); + } +} diff --git a/backend/configs-service/src/error.rs b/backend/configs-service/src/error.rs new file mode 100644 index 0000000..6267078 --- /dev/null +++ b/backend/configs-service/src/error.rs @@ -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 for AppError { + fn from(rejection: JsonRejection) -> Self { + AppError::Validation(rejection.body_text()) + } +} + +impl From for AppError { + fn from(rejection: QueryRejection) -> Self { + AppError::Validation(rejection.body_text()) + } +} + +impl From 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(pub T); + +impl IntoResponse for AppJson +where + Json: IntoResponse, +{ + fn into_response(self) -> Response { + Json(self.0).into_response() + } +} + +#[derive(FromRequestParts)] +#[from_request(via(axum::extract::Query), rejection(AppError))] +pub struct AppQuery(pub T); + +#[derive(FromRequestParts)] +#[from_request(via(axum::extract::Path), rejection(AppError))] +pub struct AppPath(pub T); + +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + AppError::Internal(err.into()) + } +} diff --git a/backend/configs-service/src/lib.rs b/backend/configs-service/src/lib.rs new file mode 100644 index 0000000..7fb7df4 --- /dev/null +++ b/backend/configs-service/src/lib.rs @@ -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) -> 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) +} diff --git a/backend/configs-service/src/main.rs b/backend/configs-service/src/main.rs new file mode 100644 index 0000000..05a3417 --- /dev/null +++ b/backend/configs-service/src/main.rs @@ -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(()) +} diff --git a/backend/configs-service/src/sharing/codes.rs b/backend/configs-service/src/sharing/codes.rs new file mode 100644 index 0000000..6f6947e --- /dev/null +++ b/backend/configs-service/src/sharing/codes.rs @@ -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"); + } +} diff --git a/backend/configs-service/src/sharing/handlers.rs b/backend/configs-service/src/sharing/handlers.rs new file mode 100644 index 0000000..a3b2ba1 --- /dev/null +++ b/backend/configs-service/src/sharing/handlers.rs @@ -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, + AppPath(code): AppPath, +) -> Result, 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, + id: GatewayIdentity, + AppPath(slot): AppPath, +) -> Result, 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())) +} diff --git a/backend/configs-service/src/sharing/mod.rs b/backend/configs-service/src/sharing/mod.rs new file mode 100644 index 0000000..2b0bc0f --- /dev/null +++ b/backend/configs-service/src/sharing/mod.rs @@ -0,0 +1,2 @@ +pub mod codes; +pub mod handlers; diff --git a/backend/configs-service/src/showcase/handlers.rs b/backend/configs-service/src/showcase/handlers.rs new file mode 100644 index 0000000..781cb5f --- /dev/null +++ b/backend/configs-service/src/showcase/handlers.rs @@ -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, +} + +#[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, + pub author: Option, +} + +fn to_listing(row: ListingRow, author: Option) -> 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, + id: GatewayIdentity, + AppPath(slot): AppPath, + AppJson(req): AppJson, +) -> Result, 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, + id: GatewayIdentity, + AppPath(slot): AppPath, +) -> Result { + 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, + pub page: Option, + pub author: Option, +} + +#[derive(Serialize)] +pub struct PageResponse { + pub items: Vec, + pub page: i64, + pub has_more: bool, +} + +pub async fn browse( + State(state): State, + AppQuery(q): AppQuery, +) -> Result, 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 = 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, + AppPath(id): AppPath, +) -> Result, 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, + id: GatewayIdentity, + AppPath(listing): AppPath, + AppJson(req): AppJson, +) -> Result<(StatusCode, Json), 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))) + } + } +} diff --git a/backend/configs-service/src/showcase/mod.rs b/backend/configs-service/src/showcase/mod.rs new file mode 100644 index 0000000..6a9b028 --- /dev/null +++ b/backend/configs-service/src/showcase/mod.rs @@ -0,0 +1,3 @@ +pub mod handlers; +pub mod profiles; +pub mod repo; diff --git a/backend/configs-service/src/showcase/profiles.rs b/backend/configs-service/src/showcase/profiles.rs new file mode 100644 index 0000000..24f97cc --- /dev/null +++ b/backend/configs-service/src/showcase/profiles.rs @@ -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, +} + +pub type ProfilesFuture<'a> = Pin> + Send + 'a>>; + +pub trait ProfileSource: Send + Sync + 'static { + fn profiles<'a>(&'a self, ids: &'a [Uuid]) -> ProfilesFuture<'a>; +} + +pub struct GrpcProfiles { + client: AccountsInternalClient>, +} + +impl GrpcProfiles { + pub fn connect_lazy(url: &str, internal_key: &str) -> anyhow::Result { + 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() + } + } + }) + } +} diff --git a/backend/configs-service/src/showcase/repo.rs b/backend/configs-service/src/showcase/repo.rs new file mode 100644 index 0000000..a84954d --- /dev/null +++ b/backend/configs-service/src/showcase/repo.rs @@ -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, + 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 { + // 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 = 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 { + 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, +) -> Result, 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::(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, sqlx::Error> { + let Some((row, data)) = sqlx::query_as::<_, (Uuid, Uuid, String, String, i32, DateTime, 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 { + 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) +} diff --git a/backend/configs-service/src/slots/handlers.rs b/backend/configs-service/src/slots/handlers.rs new file mode 100644 index 0000000..0a74f15 --- /dev/null +++ b/backend/configs-service/src/slots/handlers.rs @@ -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 { + 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 { + 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, + id: GatewayIdentity, +) -> Result>, AppError> { + Ok(Json(repo::list(&state.pool, id.account_id).await?)) +} + +pub async fn get( + State(state): State, + id: GatewayIdentity, + AppPath(slot): AppPath, +) -> Result, 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, + id: GatewayIdentity, + AppPath(slot): AppPath, + AppJson(req): AppJson, +) -> Result, 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?, + )) +} diff --git a/backend/configs-service/src/slots/mod.rs b/backend/configs-service/src/slots/mod.rs new file mode 100644 index 0000000..1f4b5dc --- /dev/null +++ b/backend/configs-service/src/slots/mod.rs @@ -0,0 +1,2 @@ +pub mod handlers; +pub mod repo; diff --git a/backend/configs-service/src/slots/repo.rs b/backend/configs-service/src/slots/repo.rs new file mode 100644 index 0000000..ccae4fc --- /dev/null +++ b/backend/configs-service/src/slots/repo.rs @@ -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, + 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, + pub share_code: String, +} + +pub async fn list(pool: &PgPool, account_id: Uuid) -> Result, 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, 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 { + // 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, +} + +pub async fn by_share_code(pool: &PgPool, code: &str) -> Result, 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, 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, + } + } +} diff --git a/backend/configs-service/tests/common/mod.rs b/backend/configs-service/tests/common/mod.rs new file mode 100644 index 0000000..35d12c6 --- /dev/null +++ b/backend/configs-service/tests/common/mod.rs @@ -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-". +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::>() + }) + } +} + +pub fn no_profiles() -> Arc { + Arc::new(FakeProfiles) +} diff --git a/backend/configs-service/tests/sharing.rs b/backend/configs-service/tests/sharing.rs new file mode 100644 index 0000000..a13e32a --- /dev/null +++ b/backend/configs-service/tests/sharing.rs @@ -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::()["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); +} diff --git a/backend/configs-service/tests/showcase.rs b/backend/configs-service/tests/showcase.rs new file mode 100644 index 0000000..dd45a59 --- /dev/null +++ b/backend/configs-service/tests/showcase.rs @@ -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::()["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::()["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); +} diff --git a/backend/configs-service/tests/slots.rs b/backend/configs-service/tests/slots.rs new file mode 100644 index 0000000..1d07cfb --- /dev/null +++ b/backend/configs-service/tests/slots.rs @@ -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 = 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(); +} diff --git a/backend/gateway/Cargo.toml b/backend/gateway/Cargo.toml new file mode 100644 index 0000000..1d47895 --- /dev/null +++ b/backend/gateway/Cargo.toml @@ -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"] } diff --git a/backend/gateway/PLAN.md b/backend/gateway/PLAN.md index d3747ff..1724a1d 100644 --- a/backend/gateway/PLAN.md +++ b/backend/gateway/PLAN.md @@ -1531,7 +1531,7 @@ git commit -m "feat(gateway): scaffold crate with config validation and health c **Interfaces:** - Produces: `proxy::routes::{Upstream, upstream_for(&str) -> Option}`; `proxy::forward::{Upstreams, proxy}` where `Upstreams { client: reqwest::Client, accounts: String, configs: String, internal_key: HeaderValue }` and `Upstreams::new(&Config) -> anyhow::Result`; `async fn proxy(State>, 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)** @@ -1627,7 +1627,7 @@ pub enum Upstream { pub fn upstream_for(path: &str) -> Option { 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), _ => 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) - Admin role enforcement at the gateway (`/admin/*`) — Подсистема 3 plan; `Identity` will then carry the role (add `role` to the access-token claims). diff --git a/backend/gateway/src/config.rs b/backend/gateway/src/config.rs new file mode 100644 index 0000000..b44cb23 --- /dev/null +++ b/backend/gateway/src/config.rs @@ -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 { + std::env::var(name).with_context(|| format!("{name} not set")) +} + +impl Config { + pub fn from_env() -> Result { + 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()); + } +} diff --git a/backend/gateway/src/guard/honeypot.rs b/backend/gateway/src/guard/honeypot.rs new file mode 100644 index 0000000..c8ebd46 --- /dev/null +++ b/backend/gateway/src/guard/honeypot.rs @@ -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.` (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}"); + } + } +} diff --git a/backend/gateway/src/guard/mod.rs b/backend/gateway/src/guard/mod.rs new file mode 100644 index 0000000..4942e08 --- /dev/null +++ b/backend/gateway/src/guard/mod.rs @@ -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 { + 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, + 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}"); + } + } +} diff --git a/backend/gateway/src/identity/device.rs b/backend/gateway/src/identity/device.rs new file mode 100644 index 0000000..96e5ea3 --- /dev/null +++ b/backend/gateway/src/identity/device.rs @@ -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 + Send + 'a>>; + +pub trait DeviceAuthenticator: Send + Sync + 'static { + fn authenticate<'a>(&'a self, token: &'a str) -> DeviceAuthFuture<'a>; +} + +pub struct GrpcDevices { + client: AccountsInternalClient>, +} + +impl GrpcDevices { + /// Lazy: the gateway boots even if accounts-service is still starting. + pub fn connect_lazy(url: &str, internal_key: &str) -> anyhow::Result { + 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 + } + } + }) + } +} diff --git a/backend/gateway/src/identity/mod.rs b/backend/gateway/src/identity/mod.rs new file mode 100644 index 0000000..b976045 --- /dev/null +++ b/backend/gateway/src/identity/mod.rs @@ -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); + +/// 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, + pub devices: Arc, +} + +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, + 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::() + .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 +} diff --git a/backend/gateway/src/lib.rs b/backend/gateway/src/lib.rs new file mode 100644 index 0000000..f525abd --- /dev/null +++ b/backend/gateway/src/lib.rs @@ -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) -> 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) { + 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(); + } + }); + } +} diff --git a/backend/gateway/src/main.rs b/backend/gateway/src/main.rs new file mode 100644 index 0000000..a8c08c2 --- /dev/null +++ b/backend/gateway/src/main.rs @@ -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::(), + ) + .await?; + Ok(()) +} diff --git a/backend/gateway/src/proxy/forward.rs b/backend/gateway/src/proxy/forward.rs new file mode 100644 index 0000000..5e6fac5 --- /dev/null +++ b/backend/gateway/src/proxy/forward.rs @@ -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 { + 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>, 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::()); + 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")) +} diff --git a/backend/gateway/src/proxy/mod.rs b/backend/gateway/src/proxy/mod.rs new file mode 100644 index 0000000..69af2f6 --- /dev/null +++ b/backend/gateway/src/proxy/mod.rs @@ -0,0 +1,2 @@ +pub mod forward; +pub mod routes; diff --git a/backend/gateway/src/proxy/routes.rs b/backend/gateway/src/proxy/routes.rs new file mode 100644 index 0000000..ed329ef --- /dev/null +++ b/backend/gateway/src/proxy/routes.rs @@ -0,0 +1,29 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Upstream { + Accounts, + Configs, +} + +pub fn upstream_for(path: &str) -> Option { + 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); + } +} diff --git a/backend/gateway/src/rate_limit/mod.rs b/backend/gateway/src/rate_limit/mod.rs new file mode 100644 index 0000000..333be60 --- /dev/null +++ b/backend/gateway/src/rate_limit/mod.rs @@ -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)>, + global: DefaultKeyedRateLimiter, + trust_proxy: bool, + clock: DefaultClock, +} + +impl RateLimits { + pub fn new(trust_proxy: bool) -> Arc { + 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::>() + .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>, req: Request, next: Next) -> Response { + let ip_key = format!("ip:{}", limits.client_ip(&req)); + let account_key = req + .extensions() + .get::() + .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 +} diff --git a/backend/gateway/src/rate_limit/rules.rs b/backend/gateway/src/rate_limit/rules.rs new file mode 100644 index 0000000..edf6a3a --- /dev/null +++ b/backend/gateway/src/rate_limit/rules.rs @@ -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 { + 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()); + } +} diff --git a/backend/gateway/tests/common/mod.rs b/backend/gateway/tests/common/mod.rs new file mode 100644 index 0000000..2d42024 --- /dev/null +++ b/backend/gateway/tests/common/mod.rs @@ -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 { + 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 { + 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(), + ) +} diff --git a/backend/gateway/tests/identity.rs b/backend/gateway/tests/identity.rs new file mode 100644 index 0000000..f84c7e7 --- /dev/null +++ b/backend/gateway/tests/identity.rs @@ -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); +} diff --git a/backend/gateway/tests/path_guard.rs b/backend/gateway/tests/path_guard.rs new file mode 100644 index 0000000..685f503 --- /dev/null +++ b/backend/gateway/tests/path_guard.rs @@ -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"); + } +} diff --git a/backend/gateway/tests/proxy.rs b/backend/gateway/tests/proxy.rs new file mode 100644 index 0000000..5f04da9 --- /dev/null +++ b/backend/gateway/tests/proxy.rs @@ -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" + ); +} diff --git a/backend/gateway/tests/rate_limit.rs b/backend/gateway/tests/rate_limit.rs new file mode 100644 index 0000000..2ed93da --- /dev/null +++ b/backend/gateway/tests/rate_limit.rs @@ -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); +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..25d2ba6 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# GitHub repository (owner/name) whose releases carry the `lovisual.jar` asset. +VITE_GITHUB_REPO=loki5512344/LoVisual- diff --git a/frontend/ARCHITECTURE.md b/frontend/ARCHITECTURE.md index fd6b2fb..597082f 100644 --- a/frontend/ARCHITECTURE.md +++ b/frontend/ARCHITECTURE.md @@ -34,12 +34,19 @@ frontend/src/ client.ts # общий fetch-wrapper: base URL gateway, JWT в заголовке, # обработка 401 (refresh) и 429 (rate-limit) в одном месте types.ts # общие DTO, если совпадают на нескольких фичах + i18n/ # LanguageSwitch, common.ru.ts / common.en.ts (typed dictionaries) pages/ # роуты верхнего уровня, тонкие — просто собирают # фичи в layout, никакой бизнес-логики - App.tsx + app/ # App.tsx, routes.tsx, i18n.ts (initI18n), i18next.d.ts (typed keys) main.tsx ``` +Каждая фича, у которой есть строки для пользователя, также владеет своей +`i18n/` подпапкой (`ru.ts`, `en.ts`) — одно пространство имён i18next на +фичу (`auth`, `landing`, ...), русские словари как источник (`as const`), +английские типизированы через `satisfies Translation` из +`shared/i18n/common.ru.ts`, так что расхождение ключей — ошибка компиляции. + Правило раздела shared/feature: если код используется ровно в одной фиче — он живёт в этой фиче, а не в `shared/`. Переносить в `shared/` только когда появился второй потребитель — не заранее "на всякий случай" (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). + +## Единственное допустимое ребро shared → feature +`shared/layout/` (шапка и каркас приложения) импортирует `useSession` из +`features/auth/session`: шапке нужно знать, вошёл ли пользователь. Это +единственный случай, когда `shared/` зависит от фичи; остальной `shared/` +от фич не зависит. diff --git a/frontend/PLAN.md b/frontend/PLAN.md index 32aa3da..58063c1 100644 --- a/frontend/PLAN.md +++ b/frontend/PLAN.md @@ -10,7 +10,9 @@ ## 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. - 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/`. @@ -37,7 +39,14 @@ | `ice` | `#5CC8E7` | the one accent: primary actions, focus, share codes | | `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 `` 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. @@ -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//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` where `type Translation = { [K in keyof T]: T[K] extends string ? string : Translation }` (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

{t('login.submit')}

+} + +test('switching language re-renders strings and sets ', async () => { + const user = userEvent.setup() + const i18n = initI18n('ru') + render() + 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 (`
`; 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(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-.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` → `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' }` — `` 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 `\n\n')], + } as unknown as Response) + + const { container } = withQuery() + expect(await screen.findByRole('heading', { level: 1, name: 'Заголовок' })).toBeInTheDocument() + expect(container.querySelector('script')).toBeNull() + expect(container.querySelector('img')).toBeNull() +}) + +test('ReleaseNotes shows the fallback when the releases fetch fails', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network down')) + withQuery() + expect(await screen.findByText(/первый публичный релиз/)).toBeInTheDocument() +}) diff --git a/frontend/src/features/landing/CommandHero.tsx b/frontend/src/features/landing/CommandHero.tsx new file mode 100644 index 0000000..6a4a6e0 --- /dev/null +++ b/frontend/src/features/landing/CommandHero.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { prefersReducedMotion } from '../../shared/motion/reducedMotion' +import { ButtonLink } from '../../shared/ui/Button' +import { useSession } from '../auth/session' + +const COMMAND = '%config load 7KQ3M9XA' +const TYPE_DELAY_MS = 60 + + +export function CommandHero() { + const { t } = useTranslation('landing') + const { status } = useSession() + + return ( +
+

+ {t('cloud.headlinePart1')} + {' '}—
+ {t('cloud.headlinePart2')} +

+

{t('cloud.subtitle')}

+
+ {status === 'authenticated' ? ( + {t('cloud.ctaAuthenticated')} + ) : ( + {t('cloud.ctaAnonymous')} + )} + + {t('cloud.ctaShowcase')} + +
+ +
+ ) +} + +function ChatBar() { + const { t } = useTranslation('landing') + const [typed, setTyped] = useState(() => (prefersReducedMotion() ? COMMAND.length : 0)) + const done = typed >= COMMAND.length + + useEffect(() => { + if (done) return + const t = setInterval(() => setTyped((n) => Math.min(n + 1, COMMAND.length)), TYPE_DELAY_MS) + return () => clearInterval(t) + }, [done]) + + return ( +
+ + {done ? ( + + ) : null} +
+ ) +} diff --git a/frontend/src/features/landing/HowItWorks.tsx b/frontend/src/features/landing/HowItWorks.tsx new file mode 100644 index 0000000..c2ab9d4 --- /dev/null +++ b/frontend/src/features/landing/HowItWorks.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router' + +export function HowItWorks() { + const { t } = useTranslation('landing') + const slots = [ + { name: t('howItWorks.slotNamePvp'), code: '7KQ3M9XA' }, + { name: t('howItWorks.slotNameStream'), code: 'R2WD8HNC' }, + { name: t('howItWorks.slotNameLazy'), code: 'L5TB4QZE' }, + { name: null, code: null }, + ] + const linkSteps = [t('howItWorks.linkStep1'), t('howItWorks.linkStep2'), t('howItWorks.linkStep3')] + + return ( +
+
+
+

{t('howItWorks.slotsTitle')}

+

{t('howItWorks.slotsDesc')}

+
+
    + {slots.map((slot, i) => ( +
  • + {slot.name ? ( + <> + {slot.name} + {slot.code} + + ) : ( + {t('howItWorks.emptySlot')} + )} +
  • + ))} +
+
+ +
+

{t('howItWorks.linkTitle')}

+
    + {linkSteps.map((step) => ( +
  1. + {step} +
  2. + ))} +
+
+ +

+ {t('howItWorks.showcaseText')}{' '} + + {t('howItWorks.showcaseLink')} + +

+
+ ) +} diff --git a/frontend/src/features/landing/effects/ParticleField.tsx b/frontend/src/features/landing/effects/ParticleField.tsx new file mode 100644 index 0000000..c98ca22 --- /dev/null +++ b/frontend/src/features/landing/effects/ParticleField.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef } from 'react' +import { prefersReducedMotion } from '../../../shared/motion/reducedMotion' + +const MAX_PARTICLES = 150 +const MAX_DPR = 2 +const SPRITE = 64 + +type Particle = { x: number; y: number; z: number; vx: number; vy: number; r: number; phase: number; speed: number } + +/** A soft glowing dot tinted with `color`, drawn once and stamped for every particle (cheap "bloom"). */ +function makeSprite(color: string): HTMLCanvasElement { + const c = document.createElement('canvas') + c.width = c.height = SPRITE + const g = c.getContext('2d') + if (!g) return c + const h = SPRITE / 2 + const grad = g.createRadialGradient(h, h, 0, h, h, h) + grad.addColorStop(0, 'rgba(255,255,255,1)') + grad.addColorStop(0.12, color) + grad.addColorStop(0.4, color.replace(/rgb\((.+)\)/, 'rgba($1, 0.25)')) + grad.addColorStop(1, 'rgba(0,0,0,0)') + g.fillStyle = grad + g.fillRect(0, 0, SPRITE, SPRITE) + return c +} + +function spawn(w: number, h: number, anywhere: boolean): Particle { + const z = 0.35 + Math.random() * 0.65 + return { + x: Math.random() * w, + y: anywhere ? Math.random() * h : h + 20, + z, + vx: (Math.random() - 0.5) * 0.12, + vy: -(0.08 + Math.random() * 0.3) * z, + r: (2 + Math.random() * 7) * z, + phase: Math.random() * Math.PI * 2, + speed: 0.6 + Math.random() * 1.4, + } +} + +/** + * Canvas of drifting glowing particles (the mod's AmbientParticles + bloom look). + * `color` is an opaque `rgb(r, g, b)`. Pauses off-screen; a single static frame under reduced motion. + */ +export function ParticleField({ color, className = '' }: { color: string; className?: string }) { + const canvasRef = useRef(null) + const spriteRef = useRef(null) + + useEffect(() => { + spriteRef.current = makeSprite(color) + }, [color]) + + useEffect(() => { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + const reduced = prefersReducedMotion() + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR) + let w = 0 + let h = 0 + let particles: Particle[] = [] + let frame = 0 + let visible = true + let time = 0 + const pointer = { x: 0, y: 0, tx: 0, ty: 0 } + + const resize = () => { + w = canvas.clientWidth + h = canvas.clientHeight + canvas.width = Math.round(w * dpr) + canvas.height = Math.round(h * dpr) + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + const count = Math.min(MAX_PARTICLES, Math.round((w * h) / 8000)) + particles = Array.from({ length: count }, () => spawn(w, h, true)) + } + + const draw = () => { + ctx.clearRect(0, 0, w, h) + ctx.globalCompositeOperation = 'lighter' + const sprite = spriteRef.current + if (!sprite) return + for (const p of particles) { + const twinkle = 0.55 + 0.45 * Math.sin(time * 0.002 * p.speed + p.phase) + const size = p.r * 4 + ctx.globalAlpha = twinkle * (0.35 + p.z * 0.65) + ctx.drawImage(sprite, p.x + pointer.x * p.z - size / 2, p.y + pointer.y * p.z - size / 2, size, size) + } + ctx.globalAlpha = 1 + } + + const step = (now: number) => { + const dt = Math.min(48, time ? now - time : 16) / 16 + time = now + pointer.x += (pointer.tx - pointer.x) * 0.04 + pointer.y += (pointer.ty - pointer.y) * 0.04 + for (let i = 0; i < particles.length; i++) { + const p = particles[i] + p.x += (p.vx + Math.sin(now * 0.0004 + p.phase) * 0.08) * dt + p.y += p.vy * dt + if (p.y < -30 || p.x < -30 || p.x > w + 30) particles[i] = spawn(w, h, false) + } + draw() + frame = visible ? requestAnimationFrame(step) : 0 + } + + resize() + if (reduced) { + draw() + return + } + + const onPointer = (e: PointerEvent) => { + pointer.tx = (e.clientX / window.innerWidth - 0.5) * -30 + pointer.ty = (e.clientY / window.innerHeight - 0.5) * -20 + } + const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize) + ro?.observe(canvas) + const io = + typeof IntersectionObserver === 'undefined' + ? null + : new IntersectionObserver(([entry]) => { + visible = entry.isIntersecting && !document.hidden + if (visible && !frame) frame = requestAnimationFrame(step) + }) + io?.observe(canvas) + window.addEventListener('pointermove', onPointer, { passive: true }) + frame = requestAnimationFrame(step) + + return () => { + cancelAnimationFrame(frame) + ro?.disconnect() + io?.disconnect() + window.removeEventListener('pointermove', onPointer) + } + }, []) + + return
+
+ {t('faq.eyebrow')} +

{t('faq.title')}

+
+ {items.map((it) => ( +
+ + {it.q} + +

{it.a}

+
+ ))} +
+
+ +
+
+ ) +} diff --git a/frontend/src/features/landing/sections/Hero.tsx b/frontend/src/features/landing/sections/Hero.tsx new file mode 100644 index 0000000..2316ab5 --- /dev/null +++ b/frontend/src/features/landing/sections/Hero.tsx @@ -0,0 +1,109 @@ +import { MotionConfig, motion } from 'motion/react' +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { ButtonLink } from '../../../shared/ui/Button' +import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow' +import { DOWNLOAD_URL } from '../../download/api' +import { PRESETS } from '../../themes/presets.generated' +import { parseArgb } from '../../themes/types' +import { useActiveTheme } from '../../themes/useActiveTheme' +import { ParticleField } from '../effects/ParticleField' +import { Tilt } from '../effects/Tilt' +import { useInView } from '../effects/useInView' +import { VoxelScene } from '../effects/VoxelScene' +import '../styles/landing.css' + +const rise = (delay: number) => ({ + initial: { opacity: 0, y: 24 }, + animate: { opacity: 1, y: 0 }, + transition: { duration: 0.7, delay, ease: [0.22, 1, 0.36, 1] as const }, +}) + +function DownloadIcon() { + return ( + + ) +} + +export function Hero() { + const { t } = useTranslation('landing') + const { theme } = useActiveTheme() + const sectionRef = useRef(null) + const inView = useInView(sectionRef) + const { r, g, b } = parseArgb(theme.theme.accent) + const accent = `rgb(${r}, ${g}, ${b})` + const facts = [ + { n: '88', label: t('hero.facts.modules') }, + { n: '52', label: t('hero.facts.visuals') }, + { n: String(PRESETS.length), label: t('hero.facts.themes') }, + ] + + return ( + +
+ +
+
+ ) +} diff --git a/frontend/src/features/landing/sections/HudPlayground.tsx b/frontend/src/features/landing/sections/HudPlayground.tsx new file mode 100644 index 0000000..60323ea --- /dev/null +++ b/frontend/src/features/landing/sections/HudPlayground.tsx @@ -0,0 +1,210 @@ +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '../../../shared/ui/Button' +import { themeToCssVars } from '../../clickgui/themeVars' +import { parseArgb } from '../../themes/types' +import { useActiveTheme } from '../../themes/useActiveTheme' +import { VoxelScene } from '../effects/VoxelScene' +import '../styles/landing.css' + +const STEP = 8 +/** Fallback playfield size, used before the box is measured (and under jsdom). */ +const FIELD = { w: 680, h: 380 } +const WIDGET = { w: 150, h: 56 } + +type Pos = { x: number; y: number } +/** Real draggable HUD elements from the mod: features/gui/hud/draggable/impl. */ +type WidgetId = 'fps' | 'coords' | 'modules' | 'keys' | 'ping' + +const START: Record = { + fps: { x: 20, y: 20 }, + coords: { x: 20, y: 310 }, + modules: { x: FIELD.w - 190, y: 20 }, + ping: { x: FIELD.w - 170, y: 150 }, + keys: { x: FIELD.w - 140, y: FIELD.h - 120 }, +} + +const clamp = (v: number, max: number) => Math.max(0, Math.min(v, max)) + +/** Enabled modules as they would appear in the mod's ModuleList panel. */ +const MODULES_ON = ['TargetESP', 'PvpCooldowns', 'HitBubbles', 'ShaderSky'] + +/** Widgets read the accent through --gui-accent so it cross-fades with the active theme. */ +const ACCENT = 'var(--gui-accent)' + +function WidgetContent({ id, accent, unitMs }: { id: WidgetId; accent: string; unitMs: string }) { + switch (id) { + case 'fps': + return ( +
+ + 240 + + FPS +
+ ) + case 'coords': + return ( +
+ X 128  Y 74 {' '} + Z -204 +
+ ) + case 'modules': + return ( +
+ {MODULES_ON.map((m) => ( + + {m} + + ))} +
+ ) + case 'keys': { + const cap = (k: string, pressed = false) => ( + + {k} + + ) + return ( +
+ {cap('W', true)} +
+ {cap('A')} + {cap('S', true)} + {cap('D')} +
+
+ ) + } + case 'ping': + return ( +
+ + 32 {unitMs} + +
+ ) + } +} + +export function HudPlayground() { + const { t } = useTranslation('landing') + const { theme } = useActiveTheme() + const { r, g, b } = parseArgb(theme.theme.accent) + const fieldRef = useRef(null) + const drag = useRef<{ id: WidgetId; px: number; py: number; ox: number; oy: number } | null>(null) + const [pos, setPos] = useState>(START) + + const bounds = (el: HTMLElement) => { + const field = fieldRef.current + const w = field?.clientWidth || FIELD.w + const h = field?.clientHeight || FIELD.h + return { maxX: Math.max(0, w - (el.offsetWidth || WIDGET.w)), maxY: Math.max(0, h - (el.offsetHeight || WIDGET.h)) } + } + + const nudge = (id: WidgetId, dx: number, dy: number, el: HTMLElement) => { + const { maxX, maxY } = bounds(el) + setPos((prev) => ({ ...prev, [id]: { x: clamp(prev[id].x + dx, maxX), y: clamp(prev[id].y + dy, maxY) } })) + } + + const onKeyDown = (id: WidgetId) => (e: React.KeyboardEvent) => { + const deltas: Record = { + ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0], ArrowUp: [0, -STEP], ArrowDown: [0, STEP], + } + const d = deltas[e.key] + if (!d) return + e.preventDefault() + nudge(id, d[0], d[1], e.currentTarget) + } + + const onPointerDown = (id: WidgetId) => (e: React.PointerEvent) => { + e.currentTarget.setPointerCapture(e.pointerId) + drag.current = { id, px: e.clientX, py: e.clientY, ox: pos[id].x, oy: pos[id].y } + } + + const onPointerMove = (e: React.PointerEvent) => { + const d = drag.current + if (!d) return + const { maxX, maxY } = bounds(e.currentTarget) + setPos((prev) => ({ ...prev, [d.id]: { x: clamp(d.ox + e.clientX - d.px, maxX), y: clamp(d.oy + e.clientY - d.py, maxY) } })) + } + + const onPointerUp = () => { + drag.current = null + } + + const widgets: { id: WidgetId; label: string }[] = [ + { id: 'fps', label: t('hud.widgetFps') }, + { id: 'coords', label: t('hud.widgetCoords') }, + { id: 'modules', label: t('hud.widgetModules') }, + { id: 'keys', label: t('hud.widgetKeys') }, + { id: 'ping', label: t('hud.widgetPing') }, + ] + + return ( +
+
+ {t('hud.eyebrow')} +

{t('hud.title')}

+

{t('hud.lead')}

+ +
+ +
+
+ + +

+

+
+
+ ) +} diff --git a/frontend/src/features/landing/sections/ModuleWall.tsx b/frontend/src/features/landing/sections/ModuleWall.tsx new file mode 100644 index 0000000..b6b36c0 --- /dev/null +++ b/frontend/src/features/landing/sections/ModuleWall.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next' +import { CATEGORIES } from '../../clickgui/parts/modules' +import '../styles/landing.css' + +/** Each column scrolls at its own pace; the list is duplicated so the loop is seamless. */ +const SPEED_MS = [42, 30, 50, 36] + +export function ModuleWall() { + const { t } = useTranslation('landing') + const { t: tc } = useTranslation('clickgui') + + return ( +
+
+ {t('wall.eyebrow')} +

{t('wall.title')}

+

{t('wall.lead')}

+
+ +
+ {CATEGORIES.map((cat, i) => ( +
+

{cat.name}

+
+
    + {[...cat.modules, ...cat.modules].map((m, idx) => { + const desc = cat.id === 'visuals' ? tc(`desc.${m.name}`) : undefined + return ( +
  • + {m.name} +
  • + ) + })} +
+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/features/landing/sections/ShowcaseTeaser.tsx b/frontend/src/features/landing/sections/ShowcaseTeaser.tsx new file mode 100644 index 0000000..887dee7 --- /dev/null +++ b/frontend/src/features/landing/sections/ShowcaseTeaser.tsx @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router' +import { api } from '../../../shared/api/client' +import '../styles/landing.css' + +type ShowcaseItem = { id: string; name: string; author: string; downloads: number } + +/** Placeholder cards so the section never collapses while loading or when the API is down. */ +const EXAMPLES: ShowcaseItem[] = [ + { id: 'e1', name: 'Aura / Trails', author: 'example', downloads: 128 }, + { id: 'e2', name: 'Clean PvP', author: 'example', downloads: 96 }, + { id: 'e3', name: 'Stream Safe', author: 'example', downloads: 74 }, +] + +export function ShowcaseTeaser() { + const { t } = useTranslation('landing') + const { data, isError } = useQuery({ + queryKey: ['showcase', 'popular'], + queryFn: () => api.request('/showcase?sort=popular'), + }) + const real = !isError && Array.isArray(data) ? data.slice(0, 3) : [] + const cards = real.length >= 3 ? real : EXAMPLES + const isExample = real.length < 3 + + return ( +
+
+ {t('showcase.eyebrow')} +

{t('showcase.title')}

+

{t('showcase.lead')}

+
+ +
    + {cards.map((c) => ( +
  • +
    + {c.name} + {isExample ? {t('showcase.example')} : null} +
    + {t('showcase.by', { author: c.author })} + {t('showcase.downloads', { count: c.downloads })} +
  • + ))} +
+ + + {t('showcase.cta')} + +
+ ) +} diff --git a/frontend/src/features/landing/sections/ThemeStrip.tsx b/frontend/src/features/landing/sections/ThemeStrip.tsx new file mode 100644 index 0000000..351b337 --- /dev/null +++ b/frontend/src/features/landing/sections/ThemeStrip.tsx @@ -0,0 +1,95 @@ +import { motion } from 'motion/react' +import { useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { ButtonLink } from '../../../shared/ui/Button' +import { themeToCssVars } from '../../clickgui/themeVars' +import { PRESETS } from '../../themes/presets.generated' +import { argbToCss, type ThemeColors } from '../../themes/types' +import { useActiveTheme } from '../../themes/useActiveTheme' +import { useInView } from '../effects/useInView' +import '../styles/landing.css' + +const PALETTE_KEYS: (keyof ThemeColors)[] = [ + 'windowBg', 'windowHeader', 'surface', 'cardEnabled', 'accent', 'accentSoft', 'textPrimary', 'textMuted', +] + +/** Every built-in preset as a swatch; picking one re-themes the hero ClickGui and the page accent. */ +export function ThemeStrip() { + const { t } = useTranslation('landing') + const { t: tt } = useTranslation('themes') + const { theme, setTheme } = useActiveTheme() + const listRef = useRef(null) + const inView = useInView(listRef, { once: true, rootMargin: '-40px' }) + + return ( +
+
+ {t('themeStrip.eyebrow')} +

+ {t('themeStrip.title')} +

+

{t('themeStrip.lead')}

+ +
+
+ {t('themeStrip.current')} + + {theme.name} + +
+
    + {PALETTE_KEYS.map((k) => ( +
  • + {`${tt(`colors.${k}`)}: ${theme.theme[k]}`} +
  • + ))} +
+
+ + + {t('themeStrip.cta')} + +
+ +
    + {PRESETS.map((preset, i) => { + const vars = themeToCssVars(preset) + const active = preset.id === theme.id + return ( + + + + ) + })} +
+
+ ) +} diff --git a/frontend/src/features/landing/styles/landing.css b/frontend/src/features/landing/styles/landing.css new file mode 100644 index 0000000..2aa8e20 --- /dev/null +++ b/frontend/src/features/landing/styles/landing.css @@ -0,0 +1,211 @@ +/* Landing-only styling that is awkward as utility classes (layered gradients, glows, keyframes). */ + +.lv-bleed { + width: 100vw; + margin-left: calc(50% - 50vw); +} + +.lv-voxel { + background: linear-gradient(180deg, #060818 0%, #120f2e 26%, #2b1a4a 46%, #6a2d5c 62%, #c25a55 76%, #f09a5c 88%, #f7b36a 100%); +} + +.lv-hero-shade { + background: + radial-gradient(80% 60% at 20% 45%, rgb(6 9 20 / 0.72), transparent 70%), + linear-gradient(180deg, rgb(13 20 22 / 0.35) 0%, transparent 25%, transparent 70%, var(--color-abyss) 100%); +} + +.lv-hero-title { + font-size: clamp(2.5rem, 5.4vw, 4.6rem); + line-height: 1.02; + letter-spacing: -0.02em; + text-wrap: balance; + text-shadow: 0 4px 30px rgb(0 0 0 / 0.5); +} +.lv-hero-accent { + display: inline-block; + color: var(--color-ice); + background: linear-gradient(100deg, #fff 0%, var(--color-ice) 45%, var(--color-ice) 70%, #fff 100%); + background-size: 220% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + filter: drop-shadow(0 0 22px var(--page-accent-glow)); + animation: lv-sheen 6s ease-in-out infinite; +} +@keyframes lv-sheen { + 0%, 100% { background-position: 100% 0; } + 50% { background-position: 0 0; } +} + +.lv-cta-primary { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.6rem; + padding: 0.85rem 1.4rem; + border-radius: 0.6rem; + font-weight: 700; + font-size: 1rem; + color: var(--color-abyss); + background: linear-gradient(180deg, color-mix(in srgb, var(--color-ice) 75%, #fff), var(--color-ice)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-ice) 60%, #fff) inset, 0 10px 40px -8px var(--page-accent-glow), + 0 0 60px -10px var(--page-accent-glow); + transition: transform 200ms ease, box-shadow 200ms ease; +} +.lv-cta-primary:hover { + transform: translateY(-2px); + box-shadow: 0 0 0 1px #fff inset, 0 16px 50px -8px var(--page-accent-glow), 0 0 80px -6px var(--page-accent-glow); +} + +.lv-live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--color-ice); + box-shadow: 0 0 0 0 var(--color-ice); + animation: lv-pulse 2.2s ease-out infinite; +} +@keyframes lv-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-ice) 70%, transparent); } + 100% { box-shadow: 0 0 0 9px transparent; } +} + +.lv-fact { + background: linear-gradient(180deg, #fff, color-mix(in srgb, var(--color-ice) 70%, #fff)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.lv-theme-now { + background: linear-gradient(var(--gui-header-angle), var(--gui-header-from), var(--gui-header-to)); + border: 1px solid color-mix(in srgb, var(--gui-accent) 35%, transparent); + box-shadow: 0 0 40px -12px var(--gui-accent-glow); + transition: box-shadow 400ms, border-color 400ms; +} +.lv-chip { + border: 1px solid rgb(255 255 255 / 0.12); + background-image: none; + transition: background-color 400ms; +} + +.lv-swatch { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + border-radius: 10px; + text-align: left; + cursor: pointer; + border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 18%, transparent); + background: var(--gui-window-gradient); + transition: transform 220ms cubic-bezier(0.2, 0.9, 0.3, 1.2), box-shadow 300ms, border-color 300ms; +} +.lv-swatch:hover { transform: translateY(-3px); box-shadow: 0 12px 30px -10px var(--gui-accent-glow); } +.lv-swatch[data-active="true"] { + border-color: var(--gui-accent); + box-shadow: 0 0 0 1px var(--gui-accent), 0 0 30px -4px var(--gui-accent-glow); +} +.lv-swatch-header { + display: flex; + align-items: center; + gap: 6px; + height: 22px; + padding: 0 8px; + background: var(--gui-header-gradient); +} +.lv-swatch-dot { width: 8px; height: 8px; border-radius: 2px; transform: rotate(45deg); background: var(--gui-accent); box-shadow: 0 0 8px var(--gui-accent); } +.lv-swatch-bar { height: 4px; width: 38%; border-radius: 4px; background: color-mix(in srgb, var(--gui-text) 35%, transparent); } +.lv-swatch-body { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; padding: 7px 8px 0; } +.lv-swatch-card { height: 18px; border-radius: 4px; background: var(--gui-surface-hover); border: 1px solid color-mix(in srgb, var(--gui-stroke-soft) 12%, transparent); } +.lv-swatch-card-on { background: var(--gui-card-gradient); border-color: color-mix(in srgb, var(--gui-accent) 50%, transparent); box-shadow: 0 0 10px -2px var(--gui-accent-glow); } +.lv-swatch-name { padding: 7px 9px 8px; font-weight: 600; font-size: 0.9rem; color: var(--gui-text); } + +/* HUD playground: a cropped voxel field with draggable, keyboard-movable widgets. */ +.lv-hud-field { + height: clamp(300px, 46vw, 380px); + background: linear-gradient(180deg, #0b0f22, #241a3d 55%, #3a2350); + box-shadow: inset 0 0 60px -20px #000; +} +/* Same glass recipe as the ClickGui window (.lv-gui-window): themed gradient fill, + accent border via the border-box trick, backdrop blur and an accent glow. */ +.lv-hud-widget { + display: flex; + flex-direction: column; + padding: 0.55rem 0.7rem; + border-radius: 10px; + border: 1px solid transparent; + background: + linear-gradient(var(--gui-window-angle, 180deg), + color-mix(in srgb, var(--gui-window-from, #12191b) 72%, transparent), + color-mix(in srgb, var(--gui-window-to, #12191b) 72%, transparent)) padding-box, + linear-gradient(135deg, color-mix(in srgb, var(--gui-accent, #6cf) 55%, transparent), + var(--gui-stroke-from, #2a3a3f) 35%, var(--gui-stroke-to, #2a3a3f) 70%, + color-mix(in srgb, var(--gui-accent, #6cf) 30%, transparent)) border-box; + box-shadow: 0 16px 36px -18px rgb(0 0 0 / 0.8), 0 0 26px -8px var(--gui-accent-glow, transparent); + backdrop-filter: blur(12px) saturate(1.25); + cursor: grab; + outline: none; +} +.lv-hud-widget:active { cursor: grabbing; } +.lv-hud-widget:focus-visible { outline: 2px solid var(--color-ice); outline-offset: 2px; } + +/* Module wall: each column is a seamless vertical marquee (list is duplicated in the DOM). */ +.lv-wall-viewport { -webkit-mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); } +.lv-wall-track { animation: lv-wall-scroll var(--dur, 40s) linear infinite; will-change: transform; } +.lv-wall-col:hover .lv-wall-track { animation-play-state: paused; } +@keyframes lv-wall-scroll { + from { transform: translateY(0); } + to { transform: translateY(-50%); } +} + +.lv-example-tag { + font-family: var(--font-code, monospace); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-frost, #9fb0c0); + border: 1px solid rgb(255 255 255 / 0.15); + border-radius: 999px; + padding: 2px 8px; +} +.lv-config-card { transition: transform 220ms ease, border-color 220ms ease; } +.lv-config-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--color-ice) 40%, transparent); } + +.lv-download-band { + background: + radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--page-accent-glow, #6cf) 22%, transparent), transparent 60%), + linear-gradient(180deg, #0e1430, #070a18); + border: 1px solid color-mix(in srgb, var(--color-ice) 22%, transparent); +} + +.lv-faq-item > summary::-webkit-details-marker { display: none; } +.lv-faq-marker { width: 10px; height: 10px; border-right: 2px solid var(--color-ice); border-bottom: 2px solid var(--color-ice); transform: rotate(45deg); transition: transform 200ms ease; } +.lv-faq-item[open] .lv-faq-marker { transform: rotate(225deg); } + +@media (prefers-reduced-motion: reduce) { + .lv-wall-track { animation: none; } +} + +/* Download page: numbered install steps and safe Markdown changelog styling. */ +.lv-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + flex: none; + border-radius: 999px; + background: color-mix(in srgb, var(--color-ice) 18%, transparent); + color: var(--color-ice, #6cf); + font-family: var(--font-code, monospace); + font-weight: 700; +} +.lv-markdown :where(h1, h2, h3) { font-weight: 700; color: var(--color-snow, #fff); margin: 0.6em 0 0.3em; } +.lv-markdown :where(p, ul, ol) { margin: 0.4em 0; } +.lv-markdown :where(ul, ol) { padding-left: 1.3em; } +.lv-markdown :where(li) { margin: 0.15em 0; } +.lv-markdown :where(code) { font-family: var(--font-code, monospace); background: rgb(255 255 255 / 0.08); padding: 0.1em 0.4em; border-radius: 4px; } +.lv-markdown :where(a) { color: var(--color-ice, #6cf); text-decoration: underline; text-underline-offset: 3px; } diff --git a/frontend/src/features/landing/tests/landing.test.tsx b/frontend/src/features/landing/tests/landing.test.tsx new file mode 100644 index 0000000..ffe103d --- /dev/null +++ b/frontend/src/features/landing/tests/landing.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react' +import { expect, test, vi } from 'vitest' +import { I18nextProvider } from 'react-i18next' +import { CommandHero } from '../CommandHero' +import { Hero } from '../sections/Hero' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { initI18n } from '../../../app/i18n' +import { SessionProvider } from '../../auth/session' +import { json, mockFetch } from '../../../test/fetch' + +test('reduced motion shows the full command at once', async () => { + vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} })) + mockFetch({ 'POST /auth/refresh': () => json({}, 401) }) + const router = createMemoryRouter([{ path: '/', element: }]) + render( + + + , + ) + expect(await screen.findByText('%config load 7KQ3M9XA')).toBeInTheDocument() + expect(screen.getByText('[LoVisual] Конфиг «pvp» загружен')).toBeInTheDocument() + expect(screen.getByLabelText('Пример: загрузка конфига по коду в чате игры')).toBeInTheDocument() +}) + +test('english locale renders the landing hero headline', async () => { + vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} })) + mockFetch({ 'POST /auth/refresh': () => json({}, 401) }) + const i18n = initI18n('en') + const router = createMemoryRouter([{ path: '/', element: }]) + render( + + + + + + + , + ) + expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent('you can see') +}) diff --git a/frontend/src/features/landing/tests/sections.test.tsx b/frontend/src/features/landing/tests/sections.test.tsx new file mode 100644 index 0000000..0d61096 --- /dev/null +++ b/frontend/src/features/landing/tests/sections.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, expect, test, vi } from 'vitest' +import { mockFetch } from '../../../test/fetch' +import { ParticleField } from '../effects/ParticleField' +import { HudPlayground } from '../sections/HudPlayground' +import { ModuleWall } from '../sections/ModuleWall' +import { ShowcaseTeaser } from '../sections/ShowcaseTeaser' + +const reduceMotion = () => + vi.stubGlobal('matchMedia', (q: string) => ({ matches: q.includes('reduce'), addEventListener() {}, removeEventListener() {} })) + +function withQuery(ui: React.ReactElement) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const router = createMemoryRouter([{ path: '/', element: ui }]) + return render( + + + , + ) +} + +afterEach(() => vi.unstubAllGlobals()) + +test('HudWidget moves by 8px with ArrowRight', () => { + reduceMotion() + withQuery() + const widget = screen.getByRole('button', { name: /FPS/ }) + const before = Number.parseInt(widget.style.left, 10) + fireEvent.keyDown(widget, { key: 'ArrowRight' }) + expect(Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)).toBe(before + 8) +}) + +test('HudWidget stays inside the playfield bounds', () => { + reduceMotion() + withQuery() + for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' }) + const left = Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10) + expect(left).toBeGreaterThanOrEqual(0) + expect(left).toBeLessThanOrEqual(680) +}) + +test('ModuleWall lists all four category headings', () => { + reduceMotion() + withQuery() + for (const name of ['Combat', 'Visuals', 'Player', 'Misc']) { + expect(screen.getByRole('heading', { level: 3, name })).toBeInTheDocument() + } +}) + +test('ShowcaseTeaser falls back to example cards on fetch error', async () => { + reduceMotion() + mockFetch({}) // no /showcase route -> 404 -> query error + withQuery() + const tags = await screen.findAllByText('Пример') + expect(tags).toHaveLength(3) +}) + +test('ParticleField schedules no animation frame under reduced motion', () => { + const ctx = new Proxy({}, { get: () => () => ({ addColorStop() {} }), set: () => true }) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as unknown as CanvasRenderingContext2D) + reduceMotion() + const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1) + render() + expect(raf).not.toHaveBeenCalled() + ;(HTMLCanvasElement.prototype.getContext as unknown as { mockRestore: () => void }).mockRestore() +}) diff --git a/frontend/src/features/themes/editor/ColorField.tsx b/frontend/src/features/themes/editor/ColorField.tsx new file mode 100644 index 0000000..805daf4 --- /dev/null +++ b/frontend/src/features/themes/editor/ColorField.tsx @@ -0,0 +1,70 @@ +import { useId, useState } from 'react' +import { parseArgb } from '../types' + +type Props = { label: string; value: string; onChange: (hex: string) => void } + +const pad2 = (n: number) => n.toString(16).padStart(2, '0') +const toRgb6 = (hex: string) => { + const { r, g, b } = parseArgb(hex) + return `#${pad2(r)}${pad2(g)}${pad2(b)}`.toUpperCase() +} +/** Rebuild `#AARRGGBB` from a `#RRGGBB` picker value, keeping the current alpha. */ +const withAlpha = (hex: string, rgb6: string) => `#${pad2(parseArgb(hex).a)}${rgb6.slice(1)}`.toUpperCase() +/** Rebuild `#AARRGGBB` from an alpha (0–255), keeping the current rgb. */ +const withRgb = (hex: string, a: number) => `#${pad2(a)}${toRgb6(hex).slice(1)}`.toUpperCase() + +/** One editable palette slot: native color picker + alpha slider + hex field. */ +export function ColorField({ label, value, onChange }: Props) { + const id = useId() + // Local mirror so a partially-typed hex isn't clobbered mid-edit; reset when the value changes + // for another reason (React's "adjust state during render" pattern). + const [editing, setEditing] = useState<{ value: string; text: string }>({ value, text: value }) + const text = editing.value === value ? editing.text : value + + const commitText = () => { + const t = text.trim() + if (/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t)) onChange(`#${t.replace(/^#/, '').toUpperCase()}`) + else setEditing({ value, text: value }) + } + + return ( + + ) +} diff --git a/frontend/src/features/themes/editor/EditorPreview.tsx b/frontend/src/features/themes/editor/EditorPreview.tsx new file mode 100644 index 0000000..31e0aed --- /dev/null +++ b/frontend/src/features/themes/editor/EditorPreview.tsx @@ -0,0 +1,28 @@ +import { useMemo } from 'react' +import { ClickGuiWindow } from '../../clickgui/ClickGuiWindow' +import { themeToCssVars } from '../../clickgui/themeVars' +import { VoxelScene } from '../../landing/effects/VoxelScene' +import { parseArgb, type ThemeEntry } from '../types' + +/** The live left pane: a themed ClickGui window plus two HUD tiles over the voxel scene. */ +export function EditorPreview({ draft }: { draft: ThemeEntry }) { + const vars = useMemo(() => themeToCssVars(draft), [draft]) + const { r, g, b } = parseArgb(draft.theme.accent) + + return ( +
+ +
+
+ + FPS 240 + + + XYZ 12 · 64 · -8 + +
+ +
+
+ ) +} diff --git a/frontend/src/features/themes/editor/GradientField.tsx b/frontend/src/features/themes/editor/GradientField.tsx new file mode 100644 index 0000000..7d78a2a --- /dev/null +++ b/frontend/src/features/themes/editor/GradientField.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from 'react-i18next' +import type { GradientSpec } from '../types' +import { ColorField } from './ColorField' + +type Props = { title: string; value: GradientSpec; onChange: (patch: Partial) => void } + +/** A gradient slot: enable toggle, start/end colors and a 0–360° angle control. */ +export function GradientField({ title, value, onChange }: Props) { + const { t } = useTranslation('themes') + + return ( +
+ + + onChange({ start })} /> + {value.enabled && ( + <> + onChange({ end })} /> + + + )} +
+ ) +} diff --git a/frontend/src/features/themes/editor/ThemeEditor.tsx b/frontend/src/features/themes/editor/ThemeEditor.tsx new file mode 100644 index 0000000..545bf80 --- /dev/null +++ b/frontend/src/features/themes/editor/ThemeEditor.tsx @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button } from '../../../shared/ui/Button' +import { PRESETS } from '../presets.generated' +import type { GradientSpec, ThemeColors, ThemeEntry } from '../types' +import { EditorPreview } from './EditorPreview' +import { ColorField } from './ColorField' +import { COLOR_SLOTS, GRADIENT_SLOTS, fromProfileJson, toProfileJson, toShareHash } from './codec' +import { GradientField } from './GradientField' +import { useThemeHistory } from './useThemeHistory' + +/** Pack the draft name into a slug id, mirroring the codec's `slug`. */ +const nameToId = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom' + +export function ThemeEditor({ initial }: { initial: ThemeEntry }) { + const { t } = useTranslation('themes') + const { draft, set, reset, undo, redo, canUndo, canRedo } = useThemeHistory(initial) + const [notice, setNotice] = useState(null) + const fileRef = useRef(null) + const timer = useRef>(undefined) + + const flash = useCallback((text: string) => { + setNotice(text) + clearTimeout(timer.current) + timer.current = setTimeout(() => setNotice(null), 2000) + }, []) + + useEffect(() => () => clearTimeout(timer.current), []) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z') return + e.preventDefault() + if (e.shiftKey) redo() + else undo() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [undo, redo]) + + const setColor = (slot: keyof ThemeColors, hex: string) => set({ ...draft, theme: { ...draft.theme, [slot]: hex } }) + const setGradient = (key: keyof ThemeEntry, patch: Partial) => + set({ ...draft, [key]: { ...(draft[key] as GradientSpec), ...patch } }) + + const copy = async () => { + await navigator.clipboard?.writeText(toProfileJson(draft)) + flash(t('editor.copied')) + } + + const download = () => { + const url = URL.createObjectURL(new Blob([toProfileJson(draft)], { type: 'application/json' })) + const a = document.createElement('a') + a.href = url + a.download = `${draft.id}.json` + a.click() + URL.revokeObjectURL(url) + } + + const share = async () => { + await navigator.clipboard?.writeText(`${location.origin}${location.pathname}#t=${toShareHash(draft)}`) + flash(t('editor.shared')) + } + + const onFile = (file?: File) => { + if (!file) return + file.text().then((text) => { + const result = fromProfileJson(text) + if ('error' in result) flash(t('editor.importError')) + else reset(result) + }) + } + + return ( +
+ + +
+
+ + + + {notice && {notice}} +
+ + + +
+

{t('editor.presets')}

+
+ {PRESETS.map((p) => ( + + ))} +
+
+ +
+

{t('editor.colors')}

+
+ {COLOR_SLOTS.map((slot) => ( + setColor(slot, hex)} /> + ))} +
+
+ +
+

{t('editor.gradients')}

+
+ {GRADIENT_SLOTS.map((g) => ( + setGradient(g.key, patch)} + /> + ))} +
+
+ +
+ + + + + onFile(e.target.files?.[0])} /> +
+
+
+ ) +} diff --git a/frontend/src/features/themes/editor/codec.ts b/frontend/src/features/themes/editor/codec.ts new file mode 100644 index 0000000..846e4df --- /dev/null +++ b/frontend/src/features/themes/editor/codec.ts @@ -0,0 +1,138 @@ +import { PRESETS } from '../presets.generated' +import type { GradientSpec, ThemeColors, ThemeEntry } from '../types' + +// The mod serialises a theme as a FLAT map: `name`, the 12 color slots as +// `#AARRGGBB`, then for each of the 5 gradient slots `Enabled/Start/End/Angle`. +// See mod ThemesProfileCodec.toProfileValues — keep these lists in lockstep. +const COLOR_KEYS: (keyof ThemeColors)[] = [ + 'windowBg', + 'windowHeader', + 'windowStroke', + 'surface', + 'surfaceHover', + 'cardEnabled', + 'cardDisabled', + 'textPrimary', + 'textMuted', + 'accent', + 'accentSoft', + 'strokeSoft', +] + +/** The 12 editable palette slots, in the mod's profile order. */ +export const COLOR_SLOTS = COLOR_KEYS + +/** The 5 gradient slots: profile prefix + the matching `ThemeEntry` field. */ +export const GRADIENT_SLOTS = [ + { prefix: 'window', key: 'windowGradient' }, + { prefix: 'header', key: 'headerGradient' }, + { prefix: 'surface', key: 'surfaceGradient' }, + { prefix: 'card', key: 'cardGradient' }, + { prefix: 'stroke', key: 'strokeGradient' }, +] as const satisfies { prefix: string; key: keyof ThemeEntry }[] + +const GRADIENT_KEYS: [string, keyof ThemeEntry][] = [ + ['window', 'windowGradient'], + ['header', 'headerGradient'], + ['surface', 'surfaceGradient'], + ['card', 'cardGradient'], + ['stroke', 'strokeGradient'], +] + +const CLASSIC = PRESETS[0] + +type Profile = Record + +const upper = (hex: string) => hex.toUpperCase() + +/** Pack an entry into the mod's flat profile object. */ +export function toProfile(entry: ThemeEntry): Profile { + const out: Profile = { name: entry.name } + for (const k of COLOR_KEYS) out[k] = upper(entry.theme[k]) + for (const [prefix, key] of GRADIENT_KEYS) { + const g = entry[key] as GradientSpec + out[`${prefix}GradientEnabled`] = g.enabled + out[`${prefix}GradientStart`] = upper(g.start) + out[`${prefix}GradientEnd`] = upper(g.end) + out[`${prefix}GradientAngle`] = g.angle + } + return out +} + +/** The mod's profile as an ordered JSON string (human-editable, keys match the mod exactly). */ +export function toProfileJson(entry: ThemeEntry): string { + return JSON.stringify(toProfile(entry), null, 2) +} + +const str = (v: unknown, fallback: string) => (typeof v === 'string' && v ? upper(v) : fallback) +const rawStr = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v : fallback) +const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback) +const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback) + +function readGradient(map: Profile, prefix: string, fallback: GradientSpec): GradientSpec { + return { + enabled: bool(map[`${prefix}GradientEnabled`], fallback.enabled), + start: str(map[`${prefix}GradientStart`], fallback.start), + end: str(map[`${prefix}GradientEnd`], fallback.end), + angle: num(map[`${prefix}GradientAngle`], fallback.angle), + } +} + +/** Slug matching the mod's `uniqueCustomId` spirit: lowercase, non-alphanumeric → `_`. */ +const slug = (name: string) => name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') || 'custom' + +function fromProfile(map: Profile): ThemeEntry { + const theme = Object.fromEntries( + COLOR_KEYS.map((k) => [k, str(map[k], CLASSIC.theme[k])]), + ) as unknown as ThemeColors + const name = rawStr(map.name, 'Custom theme') + return { + id: slug(name), + name, + builtin: false, + theme, + windowGradient: readGradient(map, 'window', CLASSIC.windowGradient), + headerGradient: readGradient(map, 'header', CLASSIC.headerGradient), + surfaceGradient: readGradient(map, 'surface', CLASSIC.surfaceGradient), + cardGradient: readGradient(map, 'card', CLASSIC.cardGradient), + strokeGradient: readGradient(map, 'stroke', CLASSIC.strokeGradient), + } +} + +/** Rebuild an entry from JSON. Tolerant: missing keys fall back to Classic; bad input → error. */ +export function fromProfileJson(json: string): ThemeEntry | { error: string } { + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch { + return { error: 'invalidJson' } + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { error: 'invalidShape' } + return fromProfile(parsed as Profile) +} + +// base64url without padding, so the share hash is URL-fragment safe. +function b64url(text: string): string { + const b64 = btoa(unescape(encodeURIComponent(text))) + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function unb64url(b64: string): string { + const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : '' + const std = b64.replace(/-/g, '+').replace(/_/g, '/') + pad + return decodeURIComponent(escape(atob(std))) +} + +/** Compact profile → URL-safe hash for the `#t=` share link. */ +export function toShareHash(entry: ThemeEntry): string { + return b64url(JSON.stringify(toProfile(entry))) +} + +/** Decode a `#t=` hash back into an entry (or an error shape). */ +export function fromShareHash(hash: string): ThemeEntry | { error: string } { + try { + return fromProfileJson(unb64url(hash)) + } catch { + return { error: 'invalidHash' } + } +} diff --git a/frontend/src/features/themes/editor/editor.css b/frontend/src/features/themes/editor/editor.css new file mode 100644 index 0000000..b8997dc --- /dev/null +++ b/frontend/src/features/themes/editor/editor.css @@ -0,0 +1,48 @@ +/* Theme editor: preview pane, HUD tiles, preset chips and color-field affordances. */ +.lv-editor-preview { background: linear-gradient(180deg, #0b0f22, #070a18); } + +.lv-hud-tile { + display: inline-flex; + align-items: baseline; + gap: 6px; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--gui-stroke-soft, #6cf) 40%, transparent); + color: var(--gui-text, #fff); + font-family: var(--font-code, monospace); + font-size: 0.8rem; + box-shadow: 0 6px 20px -12px #000; +} +.lv-hud-tile b { color: var(--gui-accent, #6cf); font-weight: 700; } + +.lv-preset-chip { + padding: 4px 12px; + border-radius: 999px; + border: 1px solid rgb(255 255 255 / 0.15); + background: rgb(255 255 255 / 0.04); + color: var(--color-frost, #9fb0c0); + font-size: 0.8rem; + transition: border-color 160ms ease, color 160ms ease; +} +.lv-preset-chip:hover { border-color: color-mix(in srgb, var(--color-ice, #6cf) 50%, transparent); color: var(--color-snow, #fff); } +.lv-preset-chip.is-active { border-color: var(--color-ice, #6cf); color: var(--color-ice, #6cf); } + +/* Checkerboard behind the swatch so partial alpha is visible. */ +.lv-alpha-check { + background-color: #20242b; + background-image: + linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%), + linear-gradient(45deg, rgb(255 255 255 / 0.12) 25%, transparent 25%, transparent 75%, rgb(255 255 255 / 0.12) 75%); + background-size: 10px 10px; + background-position: 0 0, 5px 5px; +} + +.lv-alpha-slider::-webkit-slider-thumb, +.lv-alpha-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-ice, #6cf); + border: 2px solid #0b0f22; + cursor: pointer; +} diff --git a/frontend/src/features/themes/editor/tests/codec.test.ts b/frontend/src/features/themes/editor/tests/codec.test.ts new file mode 100644 index 0000000..d9ef92c --- /dev/null +++ b/frontend/src/features/themes/editor/tests/codec.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vitest' +import { PRESETS } from '../../presets.generated' +import { fromProfileJson, fromShareHash, toProfileJson, toShareHash } from '../codec' + +// The mod's ThemesProfileCodec.toProfileValues writes exactly these keys, in this order. +const PROFILE_KEYS = [ + 'name', + 'windowBg', + 'windowHeader', + 'windowStroke', + 'surface', + 'surfaceHover', + 'cardEnabled', + 'cardDisabled', + 'textPrimary', + 'textMuted', + 'accent', + 'accentSoft', + 'strokeSoft', + 'windowGradientEnabled', + 'windowGradientStart', + 'windowGradientEnd', + 'windowGradientAngle', + 'headerGradientEnabled', + 'headerGradientStart', + 'headerGradientEnd', + 'headerGradientAngle', + 'surfaceGradientEnabled', + 'surfaceGradientStart', + 'surfaceGradientEnd', + 'surfaceGradientAngle', + 'cardGradientEnabled', + 'cardGradientStart', + 'cardGradientEnd', + 'cardGradientAngle', + 'strokeGradientEnabled', + 'strokeGradientStart', + 'strokeGradientEnd', + 'strokeGradientAngle', +] + +const editable = (e: (typeof PRESETS)[number]) => ({ + name: e.name, + theme: e.theme, + windowGradient: e.windowGradient, + headerGradient: e.headerGradient, + surfaceGradient: e.surfaceGradient, + cardGradient: e.cardGradient, + strokeGradient: e.strokeGradient, +}) + +describe('theme profile codec', () => { + test('JSON keys match the mod profile exactly', () => { + expect(Object.keys(JSON.parse(toProfileJson(PRESETS[0])))).toEqual(PROFILE_KEYS) + }) + + test('round-trips every preset through the profile JSON', () => { + for (const preset of PRESETS) { + const result = fromProfileJson(toProfileJson(preset)) + expect('error' in result).toBe(false) + if (!('error' in result)) expect(editable(result)).toEqual(editable(preset)) + } + }) + + test('share hash round-trips', () => { + for (const preset of PRESETS) { + const result = fromShareHash(toShareHash(preset)) + if ('error' in result) throw new Error('unexpected hash error') + expect(editable(result)).toEqual(editable(preset)) + } + }) + + test('rejects malformed JSON and non-objects', () => { + expect(fromProfileJson('{ nope')).toEqual({ error: 'invalidJson' }) + expect(fromProfileJson('[1,2,3]')).toEqual({ error: 'invalidShape' }) + expect(fromShareHash('%%%not-base64%%%')).toEqual({ error: 'invalidHash' }) + }) + + test('is tolerant: missing keys fall back to Classic', () => { + const result = fromProfileJson(JSON.stringify({ name: 'Sparse' })) + if ('error' in result) throw new Error('unexpected error') + expect(result.name).toBe('Sparse') + expect(result.theme).toEqual(PRESETS[0].theme) + }) +}) diff --git a/frontend/src/features/themes/editor/tests/editor.test.tsx b/frontend/src/features/themes/editor/tests/editor.test.tsx new file mode 100644 index 0000000..560dbc8 --- /dev/null +++ b/frontend/src/features/themes/editor/tests/editor.test.tsx @@ -0,0 +1,18 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { expect, test } from 'vitest' +import { PRESETS } from '../../presets.generated' +import { ThemeEditor } from '../ThemeEditor' + +test('editor shows the initial preset name and one toggle per gradient slot', () => { + render() + const name = screen.getByDisplayValue('Classic') + expect(name).toBeInTheDocument() + expect(screen.getAllByRole('checkbox', { name: /Градиент/ })).toHaveLength(5) +}) + +test('picking a preset chip loads it into the editor', () => { + const next = PRESETS[1] + render() + fireEvent.click(screen.getByRole('button', { name: next.name })) + expect(screen.getByDisplayValue(next.name)).toBeInTheDocument() +}) diff --git a/frontend/src/features/themes/editor/useThemeHistory.ts b/frontend/src/features/themes/editor/useThemeHistory.ts new file mode 100644 index 0000000..c3fc39e --- /dev/null +++ b/frontend/src/features/themes/editor/useThemeHistory.ts @@ -0,0 +1,85 @@ +import { useCallback, useRef, useState } from 'react' +import type { ThemeEntry } from '../types' + +const LIMIT = 50 +/** Changes closer than this share one undo step, so a slider drag is a single revert. */ +const COALESCE_MS = 500 + +export type ThemeHistory = { + draft: ThemeEntry + /** Live edit; coalesced with recent edits of the same interaction. */ + set: (next: ThemeEntry) => void + /** Replace the draft and clear history (preset pick, import, share-link load). */ + reset: (next: ThemeEntry) => void + undo: () => void + redo: () => void + canUndo: boolean + canRedo: boolean +} + +/** Undo/redo ring buffer (max 50 steps) for the theme editor draft. */ +export function useThemeHistory(initial: ThemeEntry): ThemeHistory { + const [present, setPresent] = useState(initial) + const presentRef = useRef(present) + const past = useRef([]) + const future = useRef([]) + const lastEdit = useRef(0) + const [flags, setFlags] = useState({ canUndo: false, canRedo: false }) + + const commit = useCallback((next: ThemeEntry) => { + presentRef.current = next + setPresent(next) + setFlags({ canUndo: past.current.length > 0, canRedo: future.current.length > 0 }) + }, []) + + const set = useCallback( + (next: ThemeEntry) => { + const now = Date.now() + if (now - lastEdit.current > COALESCE_MS) { + past.current = [...past.current, presentRef.current].slice(-LIMIT) + future.current = [] + } + lastEdit.current = now + commit(next) + }, + [commit], + ) + + const reset = useCallback( + (next: ThemeEntry) => { + past.current = [] + future.current = [] + lastEdit.current = 0 + commit(next) + }, + [commit], + ) + + const undo = useCallback(() => { + if (past.current.length === 0) return + future.current = [presentRef.current, ...future.current].slice(0, LIMIT) + lastEdit.current = 0 + commit(past.current.at(-1) as ThemeEntry) + past.current = past.current.slice(0, -1) + setFlags({ canUndo: past.current.length > 0, canRedo: true }) + }, [commit]) + + const redo = useCallback(() => { + if (future.current.length === 0) return + past.current = [...past.current, presentRef.current].slice(-LIMIT) + lastEdit.current = 0 + commit(future.current[0] as ThemeEntry) + future.current = future.current.slice(1) + setFlags({ canUndo: true, canRedo: future.current.length > 0 }) + }, [commit]) + + return { + draft: present, + set, + reset, + undo, + redo, + canUndo: flags.canUndo, + canRedo: flags.canRedo, + } +} diff --git a/frontend/src/features/themes/i18n/en.ts b/frontend/src/features/themes/i18n/en.ts new file mode 100644 index 0000000..c36a93f --- /dev/null +++ b/frontend/src/features/themes/i18n/en.ts @@ -0,0 +1,49 @@ +import type { Translation } from '../../../shared/i18n/common.ru' +import type { themesRu } from './ru' + +export const themesEn = { + apply: 'Apply the {{name}} theme', + colors: { + windowBg: 'Window', + windowHeader: 'Header', + windowStroke: 'Stroke', + surface: 'Surface', + surfaceHover: 'Hover', + cardEnabled: 'Card on', + cardDisabled: 'Card off', + textPrimary: 'Text', + textMuted: 'Muted text', + accent: 'Accent', + accentSoft: 'Soft accent', + strokeSoft: 'Soft stroke', + }, + editor: { + title: 'Theme editor', + lead: 'Build your own theme and bring it into the mod as a single file.', + nameLabel: 'Theme name', + presets: 'Ready presets', + colors: 'Colors', + gradients: 'Gradients', + enabled: 'Gradient', + angle: 'Angle', + start: 'Start', + end: 'End', + gradient: { + window: 'Window', + header: 'Header', + surface: 'Surface', + card: 'Card', + stroke: 'Stroke', + }, + copy: 'Copy for the mod', + copied: 'Copied', + download: 'Download .json', + import: 'Import from file', + share: 'Share link', + shared: 'Link copied', + reset: 'Reset', + undo: 'Undo', + redo: 'Redo', + importError: 'Could not read the theme — the file is malformed.', + }, +} satisfies Translation diff --git a/frontend/src/features/themes/i18n/ru.ts b/frontend/src/features/themes/i18n/ru.ts new file mode 100644 index 0000000..70900d9 --- /dev/null +++ b/frontend/src/features/themes/i18n/ru.ts @@ -0,0 +1,46 @@ +export const themesRu = { + apply: 'Применить тему «{{name}}»', + colors: { + windowBg: 'Фон окна', + windowHeader: 'Шапка', + windowStroke: 'Обводка', + surface: 'Поверхность', + surfaceHover: 'Наведение', + cardEnabled: 'Карточка вкл.', + cardDisabled: 'Карточка выкл.', + textPrimary: 'Текст', + textMuted: 'Приглушённый текст', + accent: 'Акцент', + accentSoft: 'Мягкий акцент', + strokeSoft: 'Мягкая обводка', + }, + editor: { + title: 'Редактор тем', + lead: 'Собери свою тему и перенеси её в мод одним файлом.', + nameLabel: 'Название темы', + presets: 'Готовые пресеты', + colors: 'Цвета', + gradients: 'Градиенты', + enabled: 'Градиент', + angle: 'Угол', + start: 'Начало', + end: 'Конец', + gradient: { + window: 'Окно', + header: 'Шапка', + surface: 'Поверхность', + card: 'Карточка', + stroke: 'Обводка', + }, + copy: 'Скопировать для мода', + copied: 'Скопировано', + download: 'Скачать .json', + import: 'Импорт из файла', + share: 'Ссылка на тему', + shared: 'Ссылка скопирована', + reset: 'Сбросить', + undo: 'Отменить', + redo: 'Вернуть', + importError: 'Не удалось прочитать тему — файл испорчен.', + }, +} as const diff --git a/frontend/src/features/themes/presets.generated.ts b/frontend/src/features/themes/presets.generated.ts new file mode 100644 index 0000000..678a28d --- /dev/null +++ b/frontend/src/features/themes/presets.generated.ts @@ -0,0 +1,438 @@ +// 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[] = [ + { + id: 'classic', + name: 'Classic', + builtin: true, + theme: { + windowBg: '#F012191B', + windowHeader: '#F0142226', + windowStroke: '#60203030', + surface: '#16202523', + surfaceHover: '#28353A3E', + cardEnabled: '#5043A7C8', + cardDisabled: '#00334046', + textPrimary: '#FFFFFFFF', + textMuted: '#88C6D2CD', + accent: '#FF5CC8E7', + accentSoft: '#805CC8E7', + strokeSoft: '#60FFFFFF', + }, + windowGradient: { enabled: false, start: '#F012191B', end: '#F012191B', angle: 90 }, + headerGradient: { enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 }, + surfaceGradient: { enabled: false, start: '#16202523', end: '#16202523', angle: 90 }, + cardGradient: { enabled: false, start: '#5043A7C8', end: '#5043A7C8', angle: 90 }, + strokeGradient: { enabled: false, start: '#60203030', end: '#60203030', angle: 90 }, + }, + { + id: 'legacy', + name: 'Legacy', + builtin: true, + theme: { + windowBg: '#F0121314', + windowHeader: '#F0000205', + windowStroke: '#E1121314', + surface: '#FF171819', + surfaceHover: '#FF131315', + cardEnabled: '#503F464D', + cardDisabled: '#33191C20', + textPrimary: '#FFF5F5FF', + textMuted: '#88A5A5A5', + accent: '#FF767C94', + accentSoft: '#80767C94', + strokeSoft: '#60373746', + }, + windowGradient: { enabled: false, start: '#F0121314', end: '#F0121314', angle: 90 }, + headerGradient: { enabled: true, start: '#F0141619', end: '#F0000205', angle: 90 }, + surfaceGradient: { enabled: false, start: '#FF171819', end: '#FF171819', angle: 90 }, + cardGradient: { enabled: false, start: '#503F464D', end: '#503F464D', angle: 90 }, + strokeGradient: { enabled: false, start: '#E1121314', end: '#E1121314', angle: 90 }, + }, + { + id: 'amber', + name: 'Amber', + builtin: true, + theme: { + windowBg: '#F019120D', + windowHeader: '#F0201711', + windowStroke: '#60C3652C', + surface: '#FF221711', + surfaceHover: '#FF2B1E16', + cardEnabled: '#50E08C3A', + cardDisabled: '#40302620', + textPrimary: '#FFFFF7EB', + textMuted: '#88E3C9A7', + accent: '#FFE69A43', + accentSoft: '#80E69A43', + strokeSoft: '#60F5D7B0', + }, + windowGradient: { enabled: false, start: '#F019120D', end: '#F019120D', angle: 90 }, + headerGradient: { enabled: true, start: '#F0322A24', end: '#F01E1610', angle: 90 }, + surfaceGradient: { enabled: false, start: '#FF221711', end: '#FF221711', angle: 90 }, + cardGradient: { enabled: false, start: '#50E08C3A', end: '#50E08C3A', angle: 90 }, + strokeGradient: { enabled: false, start: '#60C3652C', end: '#60C3652C', angle: 90 }, + }, + { + id: 'noir', + name: 'Noir', + builtin: true, + theme: { + windowBg: '#F00C0D10', + windowHeader: '#F0121317', + windowStroke: '#60393F4A', + surface: '#FF14161B', + surfaceHover: '#FF1C1F26', + cardEnabled: '#5039424F', + cardDisabled: '#40282D35', + textPrimary: '#FFF4F4F4', + textMuted: '#88C7CBD3', + accent: '#FFE16B78', + accentSoft: '#80E16B78', + strokeSoft: '#605A6370', + }, + windowGradient: { enabled: false, start: '#F00C0D10', end: '#F00C0D10', angle: 90 }, + headerGradient: { enabled: true, start: '#F025262A', end: '#F0111216', angle: 90 }, + surfaceGradient: { enabled: false, start: '#FF14161B', end: '#FF14161B', angle: 90 }, + cardGradient: { enabled: false, start: '#5039424F', end: '#5039424F', angle: 90 }, + strokeGradient: { enabled: false, start: '#60393F4A', end: '#60393F4A', angle: 90 }, + }, + { + id: 'purple', + name: 'Purple', + builtin: true, + theme: { + windowBg: '#F0120E1A', + windowHeader: '#F0181323', + windowStroke: '#60433A5A', + surface: '#FF1C1627', + surfaceHover: '#FF261D35', + cardEnabled: '#505E4AA0', + cardDisabled: '#40312644', + textPrimary: '#FFF5F2FF', + textMuted: '#88CFC6E6', + accent: '#FF9B6BFF', + accentSoft: '#809B6BFF', + strokeSoft: '#60705A9C', + }, + windowGradient: { enabled: false, start: '#F0120E1A', end: '#F0120E1A', angle: 90 }, + headerGradient: { enabled: true, start: '#F02A2635', end: '#F0171221', angle: 90 }, + surfaceGradient: { enabled: false, start: '#FF1C1627', end: '#FF1C1627', angle: 90 }, + cardGradient: { enabled: false, start: '#505E4AA0', end: '#505E4AA0', angle: 90 }, + strokeGradient: { enabled: false, start: '#60433A5A', end: '#60433A5A', angle: 90 }, + }, + { + id: 'blue', + name: 'Blue', + builtin: true, + theme: { + windowBg: '#F0080F1E', + windowHeader: '#F00D1626', + windowStroke: '#60405A82', + surface: '#FF101B2B', + surfaceHover: '#FF162338', + cardEnabled: '#50406BB3', + cardDisabled: '#4023324A', + textPrimary: '#FFF0F5FF', + textMuted: '#88B2C4E6', + accent: '#FF2E64CC', + accentSoft: '#803A6FD6', + strokeSoft: '#60465F8E', + }, + windowGradient: { enabled: false, start: '#F0080F1E', end: '#F0080F1E', angle: 90 }, + headerGradient: { enabled: true, start: '#F0202937', end: '#F00C1524', angle: 90 }, + surfaceGradient: { enabled: false, start: '#FF101B2B', end: '#FF101B2B', angle: 90 }, + cardGradient: { enabled: false, start: '#50406BB3', end: '#50406BB3', angle: 90 }, + strokeGradient: { enabled: false, start: '#60405A82', end: '#60405A82', angle: 90 }, + }, + { + id: 'azure', + name: 'Azure', + builtin: true, + theme: { + windowBg: '#F00C1220', + windowHeader: '#F0101A2A', + windowStroke: '#60324B66', + surface: '#FF121C2C', + surfaceHover: '#FF18253A', + cardEnabled: '#503F6BAA', + cardDisabled: '#40283348', + textPrimary: '#FFF2F7FF', + textMuted: '#88BBD0EA', + accent: '#FF5DA7FF', + accentSoft: '#805DA7FF', + strokeSoft: '#60507CA8', + }, + windowGradient: { enabled: false, start: '#F00C1220', end: '#F00C1220', angle: 90 }, + headerGradient: { enabled: true, start: '#F0232C3B', end: '#F00F1827', angle: 90 }, + surfaceGradient: { enabled: false, start: '#FF121C2C', end: '#FF121C2C', angle: 90 }, + cardGradient: { enabled: false, start: '#503F6BAA', end: '#503F6BAA', angle: 90 }, + strokeGradient: { enabled: false, start: '#60324B66', end: '#60324B66', angle: 90 }, + }, + { + id: 'nitro', + name: 'Nitro', + builtin: true, + theme: { + windowBg: '#FF121624', + windowHeader: '#FF181E33', + windowStroke: '#60465580', + surface: '#FF1A1F36', + surfaceHover: '#FF232A45', + cardEnabled: '#50A24CFF', + cardDisabled: '#40303A52', + textPrimary: '#FFF3F6FF', + textMuted: '#88C7D3F3', + accent: '#FF7A5CFF', + accentSoft: '#807A5CFF', + strokeSoft: '#60829AC9', + }, + windowGradient: { enabled: true, start: '#FF36205F', end: '#FF0B7DFF', angle: 45 }, + headerGradient: { enabled: true, start: '#FF4B2FAF', end: '#FF24C1FF', angle: 45 }, + surfaceGradient: { enabled: true, start: '#FF1A1F36', end: '#FF121728', angle: 90 }, + cardGradient: { enabled: true, start: '#FF5A37D5', end: '#FF2AD1FF', angle: 45 }, + strokeGradient: { enabled: true, start: '#FF6C4BFF', end: '#FF4BD0FF', angle: 45 }, + }, + { + id: 'sunset', + name: 'Sunset', + builtin: true, + theme: { + windowBg: '#FF1A1116', + windowHeader: '#FF211319', + windowStroke: '#605C2B2B', + surface: '#FF24151C', + surfaceHover: '#FF2E1A23', + cardEnabled: '#50FF7A45', + cardDisabled: '#40261A1A', + textPrimary: '#FFFFF2E8', + textMuted: '#88E3B9A3', + accent: '#FFFF7A45', + accentSoft: '#80FF7A45', + strokeSoft: '#60F5C1A6', + }, + windowGradient: { enabled: true, start: '#FF2A1020', end: '#FFB2431F', angle: 45 }, + headerGradient: { enabled: true, start: '#FF3B1626', end: '#FFC25B2A', angle: 45 }, + surfaceGradient: { enabled: true, start: '#FF24151C', end: '#FF1E1116', angle: 90 }, + cardGradient: { enabled: true, start: '#FFFF8A4C', end: '#FFFF4B7A', angle: 45 }, + strokeGradient: { enabled: true, start: '#FFFFB27A', end: '#FFFF6A3A', angle: 45 }, + }, + { + id: 'aurora', + name: 'Aurora', + builtin: true, + theme: { + windowBg: '#FF0D1A18', + windowHeader: '#FF102320', + windowStroke: '#602B5B57', + surface: '#FF132623', + surfaceHover: '#FF1A312D', + cardEnabled: '#5034D399', + cardDisabled: '#40304742', + textPrimary: '#FFF1FFF9', + textMuted: '#8897E3D0', + accent: '#FF2DD9C3', + accentSoft: '#802DD9C3', + strokeSoft: '#6071B5A6', + }, + windowGradient: { enabled: true, start: '#FF0B2A3C', end: '#FF2A8E6B', angle: 120 }, + headerGradient: { enabled: true, start: '#FF0E3B46', end: '#FF1FB88B', angle: 120 }, + surfaceGradient: { enabled: true, start: '#FF132623', end: '#FF10201D', angle: 90 }, + cardGradient: { enabled: true, start: '#FF2DD9C3', end: '#FF5A7CFF', angle: 135 }, + strokeGradient: { enabled: true, start: '#FF78FFE5', end: '#FF6FB1FF', angle: 120 }, + }, + { + id: 'mint', + name: 'Mint', + builtin: true, + theme: { + windowBg: '#FF0E1917', + windowHeader: '#FF112320', + windowStroke: '#60255B53', + surface: '#FF132521', + surfaceHover: '#FF1A2F2B', + cardEnabled: '#5035CFA8', + cardDisabled: '#40304742', + textPrimary: '#FFF1FFF9', + textMuted: '#8897E3D0', + accent: '#FF43E0B2', + accentSoft: '#8043E0B2', + strokeSoft: '#6071B5A6', + }, + windowGradient: { enabled: true, start: '#FF0B2A24', end: '#FF1B3E36', angle: 120 }, + headerGradient: { enabled: true, start: '#FF0F3A32', end: '#FF1A6E59', angle: 120 }, + surfaceGradient: { enabled: true, start: '#FF132521', end: '#FF0F1E1B', angle: 90 }, + cardGradient: { enabled: true, start: '#FF3DE3B4', end: '#FFB7F2D9', angle: 45 }, + strokeGradient: { enabled: true, start: '#FF48E6B9', end: '#FF7EF0D6', angle: 120 }, + }, + { + id: 'peach', + name: 'Peach', + builtin: true, + theme: { + windowBg: '#FF1A1210', + windowHeader: '#FF221510', + windowStroke: '#605C3A2A', + surface: '#FF241814', + surfaceHover: '#FF2F211A', + cardEnabled: '#50F2A269', + cardDisabled: '#40382A22', + textPrimary: '#FFFFF7F0', + textMuted: '#88E3C9A7', + accent: '#FFFFA46B', + accentSoft: '#80FFB680', + strokeSoft: '#60F5D7B0', + }, + windowGradient: { enabled: true, start: '#FF2A1512', end: '#FF4B241A', angle: 45 }, + headerGradient: { enabled: true, start: '#FF3B1B14', end: '#FF6A301F', angle: 45 }, + surfaceGradient: { enabled: true, start: '#FF241814', end: '#FF1C1310', angle: 90 }, + cardGradient: { enabled: true, start: '#FFFFB47A', end: '#FFFF7FA8', angle: 45 }, + strokeGradient: { enabled: true, start: '#FFFFC28E', end: '#FFFF8A5A', angle: 45 }, + }, + { + id: 'lilac', + name: 'Lilac', + builtin: true, + theme: { + windowBg: '#FF14131E', + windowHeader: '#FF1A1728', + windowStroke: '#60433A5A', + surface: '#FF1B172A', + surfaceHover: '#FF241D36', + cardEnabled: '#506C5CBE', + cardDisabled: '#40312644', + textPrimary: '#FFF5F2FF', + textMuted: '#88CFC6E6', + accent: '#FFB79BFF', + accentSoft: '#80C8B0FF', + strokeSoft: '#60705A9C', + }, + windowGradient: { enabled: true, start: '#FF241A3B', end: '#FF1A3A55', angle: 120 }, + headerGradient: { enabled: true, start: '#FF2F214A', end: '#FF3D2F6A', angle: 120 }, + surfaceGradient: { enabled: true, start: '#FF1B172A', end: '#FF15121F', angle: 90 }, + cardGradient: { enabled: true, start: '#FFB79BFF', end: '#FF7AD9FF', angle: 45 }, + strokeGradient: { enabled: true, start: '#FFC5B0FF', end: '#FF8BB2FF', angle: 120 }, + }, + { + id: 'sand', + name: 'Sand', + builtin: true, + theme: { + windowBg: '#FF181510', + windowHeader: '#FF201A13', + windowStroke: '#6050432F', + surface: '#FF221B14', + surfaceHover: '#FF2C231B', + cardEnabled: '#50E8C98C', + cardDisabled: '#40322A21', + textPrimary: '#FFFFF4E8', + textMuted: '#88D8C4A2', + accent: '#FFE8C98C', + accentSoft: '#80F0D8A8', + strokeSoft: '#607A6A54', + }, + windowGradient: { enabled: true, start: '#FF2A2016', end: '#FF3A2F22', angle: 45 }, + headerGradient: { enabled: true, start: '#FF32261B', end: '#FF4A3A2B', angle: 45 }, + surfaceGradient: { enabled: true, start: '#FF221B14', end: '#FF1A1510', angle: 90 }, + cardGradient: { enabled: true, start: '#FFF2D29B', end: '#FFE8B67A', angle: 45 }, + strokeGradient: { enabled: true, start: '#FFF5E0B2', end: '#FFD1A36C', angle: 45 }, + }, + { + id: 'dusk', + name: 'Dusk', + builtin: true, + theme: { + windowBg: '#FF141018', + windowHeader: '#FF1B1422', + windowStroke: '#60503A4A', + surface: '#FF1D1626', + surfaceHover: '#FF271B34', + cardEnabled: '#50FF875A', + cardDisabled: '#4030263B', + textPrimary: '#FFF6F1FF', + textMuted: '#88D6C9E6', + accent: '#FFFF8A4C', + accentSoft: '#80FF9B5A', + strokeSoft: '#606B5A7A', + }, + windowGradient: { enabled: true, start: '#FF2B1630', end: '#FF6B2A1F', angle: 45 }, + headerGradient: { enabled: true, start: '#FF3B1B3A', end: '#FF8A3B24', angle: 45 }, + surfaceGradient: { enabled: true, start: '#FF1D1626', end: '#FF141018', angle: 90 }, + cardGradient: { enabled: true, start: '#FF7C4BFF', end: '#FFFF9B3D', angle: 45 }, + strokeGradient: { enabled: true, start: '#FFA775FF', end: '#FFFF7A3A', angle: 45 }, + }, + { + id: 'prism', + name: 'Prism', + builtin: true, + theme: { + windowBg: '#FF0F1220', + windowHeader: '#FF141A2B', + windowStroke: '#60435173', + surface: '#FF151C2E', + surfaceHover: '#FF1B243A', + cardEnabled: '#504B7CFF', + cardDisabled: '#4031324A', + textPrimary: '#FFF3F6FF', + textMuted: '#88C2CDEB', + accent: '#FF5AC8FA', + accentSoft: '#8074B6FF', + strokeSoft: '#606B7A9E', + }, + windowGradient: { enabled: true, start: '#FF1A243A', end: '#FF2A1E4A', angle: 135 }, + headerGradient: { enabled: true, start: '#FF223050', end: '#FF352060', angle: 135 }, + surfaceGradient: { enabled: true, start: '#FF151C2E', end: '#FF101522', angle: 90 }, + cardGradient: { enabled: true, start: '#FF59C4FF', end: '#FFB35CFF', angle: 45 }, + strokeGradient: { enabled: true, start: '#FF7AD4FF', end: '#FFE36BFF', angle: 45 }, + }, + { + id: 'cinder', + name: 'Cinder', + builtin: true, + theme: { + windowBg: '#FF120E0F', + windowHeader: '#FF181012', + windowStroke: '#604A2F32', + surface: '#FF1A1214', + surfaceHover: '#FF23171A', + cardEnabled: '#50D24A4A', + cardDisabled: '#40281A1C', + textPrimary: '#FFFFF3F3', + textMuted: '#88E3B9B9', + accent: '#FFDC4A4A', + accentSoft: '#80E06A6A', + strokeSoft: '#606B4E52', + }, + windowGradient: { enabled: true, start: '#FF1A0C0C', end: '#FF2A1416', angle: 45 }, + headerGradient: { enabled: true, start: '#FF241012', end: '#FF3A191C', angle: 45 }, + surfaceGradient: { enabled: true, start: '#FF1A1214', end: '#FF120E0F', angle: 90 }, + cardGradient: { enabled: true, start: '#FFDC4A4A', end: '#FF401010', angle: 45 }, + strokeGradient: { enabled: true, start: '#FFE26A6A', end: '#FF7A2A2A', angle: 45 }, + }, + { + id: 'forest', + name: 'Forest', + builtin: true, + theme: { + windowBg: '#FF101611', + windowHeader: '#FF141E15', + windowStroke: '#6043573D', + surface: '#FF161F18', + surfaceHover: '#FF1D2B20', + cardEnabled: '#5071C36C', + cardDisabled: '#40263225', + textPrimary: '#FFF3FFF1', + textMuted: '#88C5E3C1', + accent: '#FF7DD36B', + accentSoft: '#807DD36B', + strokeSoft: '#60607B58', + }, + windowGradient: { enabled: true, start: '#FF1A241C', end: '#FF2B3A24', angle: 120 }, + headerGradient: { enabled: true, start: '#FF1F2B21', end: '#FF2F4B2E', angle: 120 }, + surfaceGradient: { enabled: true, start: '#FF161F18', end: '#FF101611', angle: 90 }, + cardGradient: { enabled: true, start: '#FF7DD36B', end: '#FFC6E886', angle: 45 }, + strokeGradient: { enabled: true, start: '#FF9BE07C', end: '#FF6BC26A', angle: 45 }, + }, +] diff --git a/frontend/src/features/themes/tests/activeTheme.test.tsx b/frontend/src/features/themes/tests/activeTheme.test.tsx new file mode 100644 index 0000000..6b10b90 --- /dev/null +++ b/frontend/src/features/themes/tests/activeTheme.test.tsx @@ -0,0 +1,54 @@ +import { act, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { ActiveThemeProvider, useActiveTheme } from '../useActiveTheme' + +function Probe() { + const { theme, setTheme, userPicked } = useActiveTheme() + return ( + <> +

{theme.id}

+

{String(userPicked)}

+ + + + ) +} + +afterEach(() => { + window.localStorage.clear() + document.documentElement.removeAttribute('style') +}) + +test('a user pick persists to lv_theme, re-accents the page and locks out demo switches', () => { + render() + expect(screen.getByTestId('id')).toHaveTextContent('classic') + act(() => screen.getByText('demo').click()) + expect(screen.getByTestId('id')).toHaveTextContent('sunset') + expect(window.localStorage.getItem('lv_theme')).toBeNull() + act(() => screen.getByText('user').click()) + expect(screen.getByTestId('id')).toHaveTextContent('nitro') + expect(window.localStorage.getItem('lv_theme')).toBe('nitro') + expect(document.documentElement.style.getPropertyValue('--color-ice')).toBe('rgb(122, 92, 255)') + act(() => screen.getByText('demo').click()) + expect(screen.getByTestId('id')).toHaveTextContent('nitro') +}) + +test('restores the stored theme and survives blocked storage', () => { + window.localStorage.setItem('lv_theme', 'mint') + const { unmount } = render() + expect(screen.getByTestId('id')).toHaveTextContent('mint') + expect(screen.getByTestId('picked')).toHaveTextContent('true') + unmount() + + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked') + }) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked') + }) + render() + expect(screen.getByTestId('id')).toHaveTextContent('classic') + act(() => screen.getByText('user').click()) + expect(screen.getByTestId('id')).toHaveTextContent('nitro') + vi.restoreAllMocks() +}) diff --git a/frontend/src/features/themes/tests/presets.test.ts b/frontend/src/features/themes/tests/presets.test.ts new file mode 100644 index 0000000..f4eb038 --- /dev/null +++ b/frontend/src/features/themes/tests/presets.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'vitest' +import { PRESETS } from '../presets.generated' +import { argbToCss, parseArgb } from '../types' + +const javaSources = import.meta.glob( + '../../../../../mod/src/main/java/dev/loki/lovisual/features/theme/presets/ThemePresets*.java', + { query: '?raw', import: 'default', eager: true }, +) + +test('generated presets include Classic with the mod accent', () => { + const classic = PRESETS.find((p) => p.id === 'classic') + expect(classic?.name).toBe('Classic') + expect(classic?.theme.accent).toBe('#FF5CC8E7') + expect(classic?.theme.windowBg).toBe('#F012191B') + // 0x334046 in Java is a 6-digit literal: alpha 00. + expect(classic?.theme.cardDisabled).toBe('#00334046') +}) + +test('one preset per ThemeEntry constant in the mod sources', () => { + const sources = Object.values(javaSources) + expect(sources).toHaveLength(3) + const count = sources.reduce((n, src) => n + (src.match(/static\s+final\s+ThemeEntry\s+\w+\s*=/g)?.length ?? 0), 0) + expect(count).toBeGreaterThan(0) + expect(PRESETS).toHaveLength(count) + expect(new Set(PRESETS.map((p) => p.id)).size).toBe(count) +}) + +test('derived header gradient matches ThemesBlending.subtleHeaderGradient', () => { + const classic = PRESETS.find((p) => p.id === 'classic') + // 0xF0142226 lightened by 8% and darkened by 6%, as the mod computes it. + expect(classic?.headerGradient).toEqual({ enabled: true, start: '#F0273437', end: '#F0132024', angle: 90 }) +}) + +test('argbToCss converts packed ARGB', () => { + expect(argbToCss('#F012191B')).toBe('rgba(18, 25, 27, 0.941)') + expect(argbToCss('#FF5CC8E7')).toBe('rgba(92, 200, 231, 1)') + expect(argbToCss('#00334046')).toBe('rgba(51, 64, 70, 0)') + expect(parseArgb('#5CC8E7')).toEqual({ a: 255, r: 92, g: 200, b: 231 }) +}) diff --git a/frontend/src/features/themes/types.ts b/frontend/src/features/themes/types.ts new file mode 100644 index 0000000..ab744db --- /dev/null +++ b/frontend/src/features/themes/types.ts @@ -0,0 +1,55 @@ +/** The mod's 12 palette slots (`Themes.Theme` record), each as `#AARRGGBB`. */ +export type ThemeColors = { + windowBg: string + windowHeader: string + windowStroke: string + surface: string + surfaceHover: string + cardEnabled: string + cardDisabled: string + textPrimary: string + textMuted: string + accent: string + accentSoft: string + strokeSoft: string +} + +/** Mirrors `Themes.GradientSpec`: when disabled the slot renders flat with `start`. */ +export type GradientSpec = { enabled: boolean; start: string; end: string; angle: number } + +/** Mirrors `Themes.ThemeEntry`. */ +export type ThemeEntry = { + id: string + name: string + builtin: boolean + theme: ThemeColors + windowGradient: GradientSpec + headerGradient: GradientSpec + surfaceGradient: GradientSpec + cardGradient: GradientSpec + strokeGradient: GradientSpec +} + +type Rgba = { r: number; g: number; b: number; a: number } + +/** Parses `#AARRGGBB` (the mod's packed ARGB) or `#RRGGBB` (opaque). */ +export function parseArgb(hex: string): Rgba { + const clean = hex.replace(/^#/, '') + const full = clean.length === 6 ? `FF${clean}` : clean.padStart(8, '0') + const n = Number.parseInt(full, 16) >>> 0 + return { a: (n >>> 24) & 0xff, r: (n >>> 16) & 0xff, g: (n >>> 8) & 0xff, b: n & 0xff } +} + +const alpha = (a: number) => String(Number((a / 255).toFixed(3))) + +/** `#F012191B` → `rgba(18, 25, 27, 0.941)`. */ +export function argbToCss(hex: string): string { + const { r, g, b, a } = parseArgb(hex) + return `rgba(${r}, ${g}, ${b}, ${alpha(a)})` +} + +/** Same color with its alpha replaced (0–1) — for glows built from a palette slot. */ +export function withAlpha(hex: string, a: number): string { + const { r, g, b } = parseArgb(hex) + return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})` +} diff --git a/frontend/src/features/themes/useActiveTheme.ts b/frontend/src/features/themes/useActiveTheme.ts new file mode 100644 index 0000000..53a9be2 --- /dev/null +++ b/frontend/src/features/themes/useActiveTheme.ts @@ -0,0 +1,72 @@ +import { createContext, createElement, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { PRESETS } from './presets.generated' +import { parseArgb, type ThemeEntry, withAlpha } from './types' + +const STORAGE_KEY = 'lv_theme' +const FALLBACK = PRESETS[0] + +type ActiveTheme = { + theme: ThemeEntry + /** True once the visitor picked a theme themselves — demos must stop switching it then. */ + userPicked: boolean + /** `source: 'user'` persists the choice; `'demo'` is a transient showcase switch. */ + setTheme: (id: string, source?: 'user' | 'demo') => void +} + +const ActiveThemeContext = createContext(null) + +function readStored(): string | null { + try { + return window.localStorage.getItem(STORAGE_KEY) + } catch { + return null + } +} + +function store(id: string) { + try { + window.localStorage.setItem(STORAGE_KEY, id) + } catch { + // Storage can be blocked (private mode, quota); the theme still applies for this visit. + } +} + +const findPreset = (id: string | null) => PRESETS.find((p) => p.id === id) + +/** Opaque accent for page chrome (buttons, links, focus rings). */ +function pageAccent(entry: ThemeEntry): string { + const { r, g, b } = parseArgb(entry.theme.accent) + return `rgb(${r}, ${g}, ${b})` +} + +export function ActiveThemeProvider({ children, applyToPage = true }: { children: ReactNode; applyToPage?: boolean }) { + const [state, setState] = useState(() => { + const stored = findPreset(readStored()) + return { theme: stored ?? FALLBACK, userPicked: Boolean(stored) } + }) + + const setTheme = useCallback((id: string, source: 'user' | 'demo' = 'user') => { + const next = findPreset(id) + if (!next) return + if (source === 'user') store(id) + setState((prev) => { + if (source === 'demo' && prev.userPicked) return prev + return { theme: next, userPicked: prev.userPicked || source === 'user' } + }) + }, []) + + useEffect(() => { + if (!applyToPage) return + const root = document.documentElement.style + root.setProperty('--color-ice', pageAccent(state.theme)) + root.setProperty('--page-accent-glow', withAlpha(state.theme.theme.accent, 0.35)) + }, [state.theme, applyToPage]) + + const value = useMemo(() => ({ ...state, setTheme }), [state, setTheme]) + return createElement(ActiveThemeContext.Provider, { value }, children) +} + +/** The active ClickGui theme. Outside a provider it falls back to Classic with a no-op setter. */ +export function useActiveTheme(): ActiveTheme { + return useContext(ActiveThemeContext) ?? { theme: FALLBACK, userPicked: false, setTheme: () => {} } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 1d1c83a..56b8e17 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,9 +1,7 @@ @import "tailwindcss"; -@import "@fontsource-variable/inter"; -@import "@fontsource/comfortaa/400.css"; -@import "@fontsource/comfortaa/700.css"; -@import "@fontsource/iosevka/400.css"; -@import "@fontsource/iosevka/700.css"; +@import "@fontsource-variable/unbounded"; +@import "@fontsource-variable/onest"; +@import "@fontsource-variable/jetbrains-mono"; @theme { --color-abyss: #0d1416; @@ -14,22 +12,38 @@ --color-ice: #5cc8e7; --color-ember: #e8835a; - --font-display: "Comfortaa", ui-rounded, system-ui, sans-serif; - --font-sans: "Inter Variable", system-ui, sans-serif; - --font-code: "Iosevka", ui-monospace, monospace; + --font-display: "Unbounded Variable", system-ui, sans-serif; + --font-sans: "Onest Variable", system-ui, sans-serif; + --font-code: "JetBrains Mono Variable", ui-monospace, monospace; --text-hero: 3.8125rem; --text-hero--line-height: 1.05; --radius-panel: 10px; } +/* The page accent follows the active ClickGui theme (useActiveTheme sets it on :root); + registering it as a color lets theme switches cross-fade instead of snapping. */ +@property --color-ice { + syntax: ""; + inherits: true; + initial-value: #5cc8e7; +} +@property --page-accent-glow { + syntax: ""; + inherits: true; + initial-value: rgb(92 200 231 / 0.35); +} + @layer base { html { color-scheme: dark; + transition: --color-ice 400ms ease, --page-accent-glow 400ms ease; } body { @apply bg-abyss text-snow font-sans antialiased; min-height: 100dvh; + /* Full-bleed sections span 100vw; never let that turn into a horizontal scrollbar. */ + overflow-x: clip; } h1, h2, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..b1ea455 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import { initI18n } from './app/i18n' +import App from './app/App.tsx' + +initI18n() createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/download/DownloadPage.tsx b/frontend/src/pages/download/DownloadPage.tsx new file mode 100644 index 0000000..5ae3fd6 --- /dev/null +++ b/frontend/src/pages/download/DownloadPage.tsx @@ -0,0 +1,76 @@ +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { DownloadButton } from '../../features/download/DownloadButton' +import { ReleaseNotes } from '../../features/download/ReleaseNotes' +import { GITHUB_REPO, fetchReleases, releaseSize } from '../../features/download/api' + +// Requirement versions mirror mod/gradle.properties (the mod's build source of truth). +const REQUIREMENTS = [ + { key: 'reqMc', value: '26.2' }, + { key: 'reqLoader', value: '0.19.4+' }, + { key: 'reqApi', value: '0.152.2+26.2' }, +] as const + +export default function DownloadPage() { + const { t } = useTranslation('download') + const { data } = useQuery({ queryKey: ['releases'], queryFn: fetchReleases, staleTime: 10 * 60 * 1000 }) + const latest = data?.[0] + const steps = ['1', '2', '3'] as const + + return ( +
+
+

{t('page.title')}

+

{t('page.lead')}

+ +
+ +
+

{t('page.reqTitle')}

+
    + {REQUIREMENTS.map((r) => ( +
  • + {t(`page.${r.key}`)} + {r.value} +
  • + ))} +
+

+ {t('page.reqOptionalTitle')} {t('page.reqOptional')} +

+
+ +
+

{t('page.installTitle')}

+
    + {steps.map((s) => ( +
  1. + {s} + {t(`page.step${s}`)} +
  2. + ))} +
+
+ +
+
+

{t('notes.title')}

+ + {t('notes.allReleases')} + +
+ +
+
+ ) +} diff --git a/frontend/src/pages/download/DownloadRoute.tsx b/frontend/src/pages/download/DownloadRoute.tsx new file mode 100644 index 0000000..f0240fa --- /dev/null +++ b/frontend/src/pages/download/DownloadRoute.tsx @@ -0,0 +1,12 @@ +import { lazy, Suspense } from 'react' + +// Loaded on demand so react-markdown stays out of the landing bundle. +const DownloadPage = lazy(() => import('./DownloadPage')) + +export default function DownloadRoute() { + return ( + …}> + + + ) +} diff --git a/frontend/src/pages/public/HomePage.tsx b/frontend/src/pages/public/HomePage.tsx new file mode 100644 index 0000000..8f89dcf --- /dev/null +++ b/frontend/src/pages/public/HomePage.tsx @@ -0,0 +1,23 @@ +import { CommandHero } from '../../features/landing/CommandHero' +import { HowItWorks } from '../../features/landing/HowItWorks' +import { FaqDownload } from '../../features/landing/sections/FaqDownload' +import { Hero } from '../../features/landing/sections/Hero' +import { HudPlayground } from '../../features/landing/sections/HudPlayground' +import { ModuleWall } from '../../features/landing/sections/ModuleWall' +import { ShowcaseTeaser } from '../../features/landing/sections/ShowcaseTeaser' +import { ThemeStrip } from '../../features/landing/sections/ThemeStrip' + +export default function HomePage() { + return ( + <> + + + + + + + + + + ) +} diff --git a/frontend/src/pages/public/LoginPage.tsx b/frontend/src/pages/public/LoginPage.tsx index 6fdade4..90f96bd 100644 --- a/frontend/src/pages/public/LoginPage.tsx +++ b/frontend/src/pages/public/LoginPage.tsx @@ -1,9 +1,11 @@ +import { useTranslation } from 'react-i18next' import { LoginForm } from '../../features/auth/forms/LoginForm' export default function LoginPage() { + const { t } = useTranslation('auth') return (
-

Вход

+

{t('login.title')}

) diff --git a/frontend/src/pages/public/NotFoundPage.tsx b/frontend/src/pages/public/NotFoundPage.tsx new file mode 100644 index 0000000..0f9ede8 --- /dev/null +++ b/frontend/src/pages/public/NotFoundPage.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router' + +export default function NotFoundPage() { + const { t } = useTranslation('common') + return ( +
+

{t('notFound.title')}

+

+ {t('notFound.body')}{' '} + + {t('notFound.home')} + +

+
+ ) +} diff --git a/frontend/src/pages/public/RegisterPage.tsx b/frontend/src/pages/public/RegisterPage.tsx index 9779246..2cd356f 100644 --- a/frontend/src/pages/public/RegisterPage.tsx +++ b/frontend/src/pages/public/RegisterPage.tsx @@ -1,12 +1,12 @@ +import { useTranslation } from 'react-i18next' import { RegisterForm } from '../../features/auth/forms/RegisterForm' export default function RegisterPage() { + const { t } = useTranslation('auth') return (
-

Новый аккаунт

-

- Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся. -

+

{t('register.title')}

+

{t('register.subtitle')}

) diff --git a/frontend/src/pages/themes/ThemesPage.tsx b/frontend/src/pages/themes/ThemesPage.tsx new file mode 100644 index 0000000..f46a59b --- /dev/null +++ b/frontend/src/pages/themes/ThemesPage.tsx @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ThemeEditor } from '../../features/themes/editor/ThemeEditor' +import { fromShareHash } from '../../features/themes/editor/codec' +import { PRESETS } from '../../features/themes/presets.generated' +import '../../features/themes/editor/editor.css' + +/** `/themes` — pick a preset (or load one from a `#t=` share link) and edit it live. */ +export default function ThemesPage() { + const { t } = useTranslation('themes') + + const initial = useMemo(() => { + const shared = /#t=([^&]+)/.exec(window.location.hash)?.[1] + if (shared) { + const loaded = fromShareHash(shared) + if (!('error' in loaded)) return loaded + } + return PRESETS[0] + }, []) + + return ( +
+
+

{t('editor.title')}

+

{t('editor.lead')}

+
+ +
+ ) +} diff --git a/frontend/src/pages/themes/ThemesRoute.tsx b/frontend/src/pages/themes/ThemesRoute.tsx new file mode 100644 index 0000000..c86720a --- /dev/null +++ b/frontend/src/pages/themes/ThemesRoute.tsx @@ -0,0 +1,12 @@ +import { lazy, Suspense } from 'react' + +// Code-split so the editor and its CSS stay out of the landing bundle. +const ThemesPage = lazy(() => import('./ThemesPage')) + +export default function ThemesRoute() { + return ( + …}> + + + ) +} diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index bf447e6..6d7407a 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -68,7 +68,7 @@ export function createApiClient( headers.set('content-type', 'application/json') body = JSON.stringify(options.json) } - if (accessToken) headers.set('authorization', `Bearer ${accessToken}`) + if (accessToken && !path.startsWith('/auth/')) headers.set('authorization', `Bearer ${accessToken}`) return fetchImpl(`${baseUrl}${path}`, { method: options.method ?? 'GET', headers, diff --git a/frontend/src/shared/api/errors.ts b/frontend/src/shared/api/errors.ts index 6425b0e..eda97c4 100644 --- a/frontend/src/shared/api/errors.ts +++ b/frontend/src/shared/api/errors.ts @@ -1,22 +1,24 @@ +import type { TFunction } from 'i18next' import { ApiError } from './client' -const BY_STATUS: Record = { - 400: 'Проверь введённые данные.', - 401: 'Нужно войти в аккаунт.', - 403: 'Недостаточно прав.', - 404: 'Не найдено.', - 409: 'Уже существует.', -} +const BY_STATUS = { + 400: 'errors.badRequest', + 401: 'errors.unauthorized', + 403: 'errors.forbidden', + 404: 'errors.notFound', + 409: 'errors.conflict', +} as const -export function describeError(err: unknown, overrides: Partial> = {}): string { +export function describeError(t: TFunction<'common'>, err: unknown, overrides: Partial> = {}): string { if (err instanceof ApiError) { if (err.status === 429) { return err.retryAfter - ? `Слишком много попыток. Подожди ${err.retryAfter} с.` - : 'Слишком много попыток. Подожди немного.' + ? t('errors.rateLimited', { seconds: err.retryAfter }) + : t('errors.rateLimitedGeneric') } - if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.' - return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.' + if (err.status >= 500) return t('errors.serverError') + const key = BY_STATUS[err.status as keyof typeof BY_STATUS] + return overrides[err.status] ?? (key ? t(key) : t('errors.badRequest')) } - return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.' + return t('errors.network') } diff --git a/frontend/src/shared/api/tests/client.test.ts b/frontend/src/shared/api/tests/client.test.ts index d1afeef..a1bd8a0 100644 --- a/frontend/src/shared/api/tests/client.test.ts +++ b/frontend/src/shared/api/tests/client.test.ts @@ -1,8 +1,11 @@ import { expect, test } from 'vitest' +import { initI18n } from '../../../app/i18n' import { ApiError, createApiClient } from '../client' import { describeError } from '../errors' import { json, mockFetch } from '../../../test/fetch' +const t = initI18n('ru').getFixedT('ru', 'common') + test('sends json with bearer token and cookies', async () => { const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) }) const api = createApiClient() @@ -62,6 +65,15 @@ test('auth endpoints never trigger refresh', async () => { expect(fetch).toHaveBeenCalledTimes(1) }) +test('never sends a stale bearer token on /auth/* requests', async () => { + const fetch = mockFetch({ 'POST /auth/logout': () => new Response(null, { status: 204 }) }) + const api = createApiClient() + api.setAccessToken('stale') + await api.request('/auth/logout', { method: 'POST' }) + const [, init] = fetch.mock.calls[0] + expect(new Headers(init?.headers).get('authorization')).toBeNull() +}) + test('429 carries retry-after; 204 resolves undefined', async () => { mockFetch({ 'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }), @@ -73,10 +85,10 @@ test('429 carries retry-after; 204 resolves undefined', async () => { }) test('describeError covers 429, network failure and status defaults', () => { - expect(describeError(new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.') - expect(describeError(new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.') - expect(describeError(new TypeError('Failed to fetch'))).toBe( + expect(describeError(t, new ApiError(429, 'x', 42))).toBe('Слишком много попыток. Подожди 42 с.') + expect(describeError(t, new ApiError(429, 'x', null))).toBe('Слишком много попыток. Подожди немного.') + expect(describeError(t, new TypeError('Failed to fetch'))).toBe( 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.', ) - expect(describeError(new ApiError(404, 'x'))).toBe('Не найдено.') + expect(describeError(t, new ApiError(404, 'x'))).toBe('Не найдено.') }) diff --git a/frontend/src/shared/i18n/LanguageSwitch.tsx b/frontend/src/shared/i18n/LanguageSwitch.tsx new file mode 100644 index 0000000..15a7970 --- /dev/null +++ b/frontend/src/shared/i18n/LanguageSwitch.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next' + +const LANGS = ['ru', 'en'] as const + +export function LanguageSwitch() { + const { i18n, t } = useTranslation() + const current = i18n.language + + return ( +
+ {LANGS.map((lng) => ( + + ))} +
+ ) +} diff --git a/frontend/src/shared/i18n/common.en.ts b/frontend/src/shared/i18n/common.en.ts new file mode 100644 index 0000000..a2f7752 --- /dev/null +++ b/frontend/src/shared/i18n/common.en.ts @@ -0,0 +1,52 @@ +import type { commonRu, Translation } from './common.ru' + +export const commonEn = { + skipToContent: 'Skip to content', + nav: { + mainNav: 'Main navigation', + showcase: 'Showcase', + myConfigs: 'My configs', + linkGame: 'Link game', + menu: 'Menu', + login: 'Log in', + }, + notFound: { + title: 'Page not found', + body: 'This link may be outdated.', + home: 'Go home', + }, + shareCode: { + copy: 'Copy', + copied: 'Copied', + }, + languageSwitch: { + ru: 'RU', + en: 'EN', + }, + footer: { + about: 'A free visuals mod for Minecraft 26.2 on Fabric.', + sectionProduct: 'Product', + sectionProject: 'Project', + github: 'GitHub', + download: 'Download', + legalTitle: 'Legal', + disclaimer: 'Not affiliated with Mojang or Microsoft.', + }, + time: { + days_one: '{{count}} day', + days_few: '{{count}} days', + days_many: '{{count}} days', + days_other: '{{count}} days', + }, + errors: { + badRequest: 'Check what you entered.', + unauthorized: 'You need to log in.', + forbidden: 'Not enough permissions.', + notFound: 'Not found.', + conflict: 'Already exists.', + rateLimited: 'Too many attempts. Wait {{seconds}}s.', + rateLimitedGeneric: 'Too many attempts. Wait a bit.', + serverError: "The server didn't respond. Try again in a minute.", + network: 'No connection to the server. Check your internet and try again.', + }, +} satisfies Translation diff --git a/frontend/src/shared/i18n/common.ru.ts b/frontend/src/shared/i18n/common.ru.ts new file mode 100644 index 0000000..84319d3 --- /dev/null +++ b/frontend/src/shared/i18n/common.ru.ts @@ -0,0 +1,54 @@ +export const commonRu = { + skipToContent: 'Перейти к содержимому', + nav: { + mainNav: 'Основная навигация', + showcase: 'Витрина', + myConfigs: 'Мои конфиги', + linkGame: 'Привязать игру', + menu: 'Меню', + login: 'Войти', + }, + notFound: { + title: 'Такой страницы нет', + body: 'Возможно, ссылка устарела.', + home: 'На главную', + }, + shareCode: { + copy: 'Скопировать', + copied: 'Скопировано', + }, + languageSwitch: { + ru: 'RU', + en: 'EN', + }, + footer: { + about: 'Бесплатный визуальный мод для Minecraft 26.2 на Fabric.', + sectionProduct: 'Продукт', + sectionProject: 'Проект', + github: 'GitHub', + download: 'Скачать', + legalTitle: 'Правовое', + disclaimer: 'Не связано с Mojang или Microsoft.', + }, + time: { + days_one: '{{count}} день', + days_few: '{{count}} дня', + days_many: '{{count}} дней', + days_other: '{{count}} дня', + }, + errors: { + badRequest: 'Проверь введённые данные.', + unauthorized: 'Нужно войти в аккаунт.', + forbidden: 'Недостаточно прав.', + notFound: 'Не найдено.', + conflict: 'Уже существует.', + rateLimited: 'Слишком много попыток. Подожди {{seconds}} с.', + rateLimitedGeneric: 'Слишком много попыток. Подожди немного.', + serverError: 'Сервер не ответил. Попробуй через минуту.', + network: 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.', + }, +} as const + +export type Translation = { + [K in keyof T]: T[K] extends string ? string : Translation +} diff --git a/frontend/src/shared/i18n/tests/i18n.test.tsx b/frontend/src/shared/i18n/tests/i18n.test.tsx new file mode 100644 index 0000000..6ed49b3 --- /dev/null +++ b/frontend/src/shared/i18n/tests/i18n.test.tsx @@ -0,0 +1,33 @@ +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

{t('login.submit')}

+} + +test('switching language re-renders strings and sets ', async () => { + const user = userEvent.setup() + const i18n = initI18n('ru') + render( + + + + , + ) + 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 дней') +}) diff --git a/frontend/src/shared/layout/AppShell.tsx b/frontend/src/shared/layout/AppShell.tsx new file mode 100644 index 0000000..947cf2e --- /dev/null +++ b/frontend/src/shared/layout/AppShell.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from 'react-i18next' +import { Outlet } from 'react-router' +import { Footer } from './Footer' +import { TopBar } from './TopBar' + +export function AppShell() { + const { t } = useTranslation('common') + return ( + <> + + {t('skipToContent')} + + +
+ +
+