chore(history): squash 100 commit(s) from 2026-09-24
- fix(backend): case-insensitive unique email, revoke PUBLIC schema access, pin Argon2id params - test(backend): assert password length cap boundary (256 ok, 257 rejected) - docs(backend): plan — typed JWT token kinds so refresh/device tokens cannot pass as access tokens - feat(backend): JWT access/refresh token issue and verify - feat(backend): accounts repository (create/find_by_email/find_by_id) - refactor(gui): LoVisualAddonManagerScreen 989→10 файлов addon/ (8.5.2) - docs(backend): plan — fix sqlx::migrate! path in integration tests - feat(backend): POST /auth/register and /auth/login - refactor(settings): SettingsPanelComponent 715→81 + 6 helpers (8.5.2) - docs(backend): plan — harden device flow (single-use codes, bounded store, 404/429) - feat(backend): OAuth device authorization grant for mod login - fix(backend): first confirm wins for device codes - docs(backend): plan — split Task 8 (refactor) and Task 9 (avatars), harden avatar handling - refactor(mixins): LocalPlayerMixin 691→111 + 4 handlers (8.5.2) - refactor(visuals): Trails 687→130 (8.5.2) - refactor(render): ItemBatchRenderer 677->100 (8.5.2) - refactor(visuals): ReimaginedVisual 674→118 + 5 helpers (8.5.2) - refactor(config): ConfigSerializer 668→91 + 4 helpers (8.5.2) - refactor(hud): DynamicIsland 661→158 + 4 helpers (8.5.2) - refactor(render): GlStencilFramebufferSupport 666→169 (8.5.2) - refactor(gui): MenuScreen 669→128 + 4 helpers (8.5.2) - refactor(render): UiStyle 644→170 + 3 helpers (8.5.2) - refactor(gui): ModuleComponent 613→98 + 4 helpers (8.5.2) - refactor(media): MediaSessionService 616→200 + 4 helpers (8.5.2) - refactor(gui): RelationsComponent 661→59 + 4 helpers (8.5.2) - chore(license): strip GPL file headers from all Java sources - refactor(aiming): PointTracker 583→168 + 2 helpers (8.5.2) - refactor(gui): LoVisualProxyManagerScreen 591→132 + 2 helpers (8.5.2) - refactor(visuals): KillEffect 588→96 + 4 helpers (8.5.2) - refactor(render): MeshBuilder +4 helpers (8.5.2) - refactor(visuals): extract WorldParticlesRender helper (8.5.2) - refactor(world): ExplosionDamageUtil 551→116 + 2 helpers (8.5.2) - refactor(visuals): TazikHat 596->179 + Model + Palette in hats/tazik (8.5.2) - chore(license): strip GPL header from remaining 30 files and make strip script variant-aware - refactor(gui): ThemeComponent 561->166 + CardRenderer + ScrollState (8.5.2) - refactor(hud): CustomHotbar 556→178 + Renderer + Selection + SelectionGradient (8.5.2) - refactor(gui): ThemeCardRenderer perf + readability polish - refactor(clickgui): CooldownRulesSetting 596->198 + Editor + DetailRenderer (8.5.2) - refactor(hud): HudNotifier 561->200 + Painter + runtime/HudNotifierRuntime (8.5.2) - refactor(theme): Themes 555->168 + impl/Transition + impl/Blending + impl/ProfileCodec (8.5.2) - refactor(theme): EditableClickGuiTheme 205->185 + JavaDoc (8.5.2) - refactor(theme): ThemeStore 491->128 + store/ThemeStoreJson + store/ThemeStoreIO (8.5.2) - refactor(clickgui): ClickGuiRenderer 604->200 compacted one-line delegators + JavaDoc (8.5.2) - refactor(mainmenu): LoVisualMainMenuScreen 551->161 + impl/Painter + impl/Renderer + impl/TextUtil (8.5.2) - refactor(clickgui): ClickGuiTextEditorState 531->187 + impl/EditorCaret + impl/EditorPainter (8.5.2) - refactor(tab): TabListModel 525->139 + model/Collector + model/Reader + model/Signature + model/TextSplitter (8.5.2) - refactor(backend): shared bearer helper and test helpers, build_app takes Config, validate JWT secret strength - refactor(module): ModuleManager 521->198 + impl/Registrar + impl/Dispatcher (8.5.2) - feat(backend): avatar upload with decode, square crop, PNG re-encode and S3 storage - refactor(clip): ClipFunction 512->146 + impl/Geometry + impl/Debug (8.5.2) - docs(backend): implementation plans for gateway (auth hardening, gRPC, rate limits) and configs-service - refactor(iris-patch): ShaderPatchEngine 499->146 + impl/Repo (8.5.2) - chore(frontend): add router, react-query, fonts and vitest; dev proxy to gateway - refactor(hud): ScriptedListHudPanel 499->158 + panel/Props + panel/Signature (8.5.2) - refactor(hud): BaseHudElement 499->199 + impl/Registry + impl/Namer + impl/Prewarm (8.5.2) - refactor(clickgui): Setting 498->170 + impl/Localization + impl/I18n (8.5.2) - refact(viewmodel): split swing animations into camera/swing package - refact(kineticlyrics): split module into stage, playback and modes - rename(holeesp): module HoleESP -> CrystalHoles - refact(crystalholes): split module into crystal scanner, renderer and safety - refact(addonmanager): split manager into lifecycle, runtime, descriptors and profiles - refact(accountconfig): split config into store, session and value helpers - refactor(render): CustomTextRenderer 229->195, extract glyph-pass into GradientTexts helper - docs(TODO): mark AddonManager split done; close 9.2 refactor gate - refactor(media): LinuxMediaSession 441->148, split reader + track/seek state - refactor(nametags): split NameTags into facade + impl helpers - refactor(clickgui): split MainSettingsComponent into facade + scroll + model - refactor(hud): split CustomBar into facade, model and BarSettings - docs(frontend): implementation plan with design system from the mod theme - feat(frontend): design tokens from the mod theme, fonts and shared UI kit - fix(accounts): run migrations on startup, offload Argon2, validate register input, JSON error shape - docs(gateway): plan note on splitting auth handlers before refresh endpoints - feat(frontend): API client with silent refresh, error descriptions and test helpers - style(mod): group compact one-line bulk query methods in ModuleManager - feat(frontend): session restore, login and registration with client-side validation - refactor(hud): split CustomHealthBar into facade + painter + script renderer - docs(mod): record the 2026-09-24 HUD/settings split wave in TODO phase 8.5 - refactor(rhi): split GlStencilShapeClipBackend into facade + native-state + pass-lifecycle helpers - refactor(rhi): split VulkanRenderStateBridge into facade + MSAA and stencil state helpers - refactor(backtrack): split BacktrackController into facade + model + impl helpers - refactor(svg): split SvgPathParser into facade + arc geometry + command/curve helpers - refactor(mixin): split ClientPacketListenerMixin into hook-only mixin + handlers - refactor(renderer3d): un-nest batch bindings + culling into sibling impl types - refactor(renderwarp): extract static factories + geometry into impl helpers - feat(backend): add common crate with shared JWT, internal gateway contract and accounts proto - refactor(guimixin): move hook bodies into handlers, keep mixin as hooks + shadows - feat(accounts): accept only gateway traffic, read identity from gateway header - refactor(cacheduiscriptruntime): extract engine, hashing and frame stats into impl - refactor(customskyboxrenderer): extract projection, shader passes and sun into impl - refactor(betterchatstoremanager): extract persistence, key/path and hover helpers into impl - feat(accounts): rotating opaque refresh tokens in httpOnly cookie, /auth/refresh and /auth/logout - refactor(targetesp): extract crystal rendering subsystem into impl/TargetEspCrystalRenderer - refactor(betterchathovercache): extract disk codec and lookup indexing into impl/ChatHoverCacheCodec - refactor(microsoftauth): split HTTP transport, device-code and Xbox flows into impl/ - refactor(pvpcooldowns): extract local item-rule engine and defaults into impl/PvpCooldownRules - refactor(lovisual): extract HUD/world render orchestration into HudRender helper - refactor(statuseffectheuristics): extract palette/inference into ParticlePalette and color utils into ParticleColors - refactor(dropesp): extract overlay/label render subsystem into impl/DropEspOverlayRenderer - refactor(proxy): extract SOCKS handshake message builders into ProxyProtocolMessages - refactor(eagleutil): promote EdgeRecovery controller and RecoveryMode to top-level class
This commit is contained in:
parent
9d08fa910a
commit
72bc4c7148
1897 changed files with 36199 additions and 39289 deletions
|
|
@ -1,7 +1,12 @@
|
|||
DATABASE_URL=postgres://lovisual:lovisual@localhost:5432/accounts_db
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
# at least 32 random bytes, e.g. `openssl rand -hex 32`
|
||||
JWT_SECRET=
|
||||
PORT=8081
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_BUCKET=lovisual-avatars
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
# Shared secret between gateway and internal services (openssl rand -hex 32)
|
||||
INTERNAL_KEY=
|
||||
# Secure cookie flag: leave unset (or true) in production (HTTPS); false for local HTTP dev
|
||||
COOKIE_SECURE=false
|
||||
|
|
|
|||
495
backend/Cargo.lock
generated
495
backend/Cargo.lock
generated
|
|
@ -11,15 +11,21 @@ dependencies = [
|
|||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"axum-test",
|
||||
"base64",
|
||||
"chrono",
|
||||
"common",
|
||||
"dashmap",
|
||||
"dotenvy",
|
||||
"jsonwebtoken",
|
||||
"hex",
|
||||
"image",
|
||||
"rand 0.10.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
|
|
@ -27,6 +33,12 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
|
|
@ -78,6 +90,17 @@ dependencies = [
|
|||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atoi"
|
||||
version = "2.0.0"
|
||||
|
|
@ -581,6 +604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
|
|
@ -627,6 +651,39 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-test"
|
||||
version = "21.1.0"
|
||||
|
|
@ -726,12 +783,24 @@ version = "3.20.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
|
|
@ -812,6 +881,25 @@ version = "0.5.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-test",
|
||||
"chrono",
|
||||
"jsonwebtoken",
|
||||
"prost",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"tokio",
|
||||
"tonic",
|
||||
"tonic-prost",
|
||||
"tonic-prost-build",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
|
|
@ -830,6 +918,7 @@ version = "0.18.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
|
@ -1280,6 +1369,15 @@ version = "2.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
|
|
@ -1302,6 +1400,23 @@ version = "0.1.13"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
|
|
@ -1319,6 +1434,12 @@ version = "1.0.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1500,6 +1621,15 @@ version = "0.14.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
|
|
@ -1508,7 +1638,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1517,7 +1647,7 @@ version = "0.17.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1730,6 +1860,19 @@ dependencies = [
|
|||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-timeout"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
|
||||
dependencies = [
|
||||
"hyper 1.11.1",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
|
|
@ -1881,6 +2024,32 @@ dependencies = [
|
|||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
|
|
@ -1906,6 +2075,15 @@ version = "2.12.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -1986,6 +2164,12 @@ dependencies = [
|
|||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.3"
|
||||
|
|
@ -2044,6 +2228,26 @@ version = "0.3.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.3"
|
||||
|
|
@ -2055,6 +2259,16 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
|
|
@ -2072,6 +2286,12 @@ dependencies = [
|
|||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multimap"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.9.0"
|
||||
|
|
@ -2299,6 +2519,17 @@ version = "2.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phc"
|
||||
version = "0.6.1"
|
||||
|
|
@ -2310,6 +2541,26 @@ dependencies = [
|
|||
"getrandom 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
@ -2349,6 +2600,19 @@ version = "0.3.34"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
|
|
@ -2383,6 +2647,16 @@ dependencies = [
|
|||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
|
|
@ -2401,6 +2675,91 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-build"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"itertools",
|
||||
"log",
|
||||
"multimap",
|
||||
"petgraph",
|
||||
"prettyplease",
|
||||
"prost",
|
||||
"prost-types",
|
||||
"pulldown-cmark",
|
||||
"pulldown-cmark-to-cmark",
|
||||
"regex",
|
||||
"syn 2.0.119",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-types"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
|
||||
dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"memchr",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-to-cmark"
|
||||
version = "22.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60"
|
||||
dependencies = [
|
||||
"pulldown-cmark",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
|
|
@ -2584,6 +2943,19 @@ dependencies = [
|
|||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.12"
|
||||
|
|
@ -2879,6 +3251,12 @@ dependencies = [
|
|||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
|
|
@ -3188,6 +3566,19 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
|
|
@ -3335,6 +3726,74 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"h2 0.4.19",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
"hyper 1.11.1",
|
||||
"hyper-timeout",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"pin-project",
|
||||
"socket2 0.6.5",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-build"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322"
|
||||
dependencies = [
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-prost"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost",
|
||||
"tonic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tonic-prost-build"
|
||||
version = "0.14.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27"
|
||||
dependencies = [
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"tempfile",
|
||||
"tonic-build",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
|
|
@ -3343,9 +3802,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
|
|||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"indexmap",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
|
|
@ -3480,6 +3942,12 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
|
@ -3924,8 +4392,29 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
resolver = "2"
|
||||
members = [
|
||||
"accounts-service",
|
||||
"common",
|
||||
]
|
||||
|
||||
# Planned members, added when their own implementation plan starts
|
||||
|
|
|
|||
1283
backend/PLAN.md
1283
backend/PLAN.md
File diff suppressed because it is too large
Load diff
|
|
@ -9,10 +9,10 @@ backend/
|
|||
Cargo.toml # workspace root — members растёт по мере реализации
|
||||
STRUCTURE.md # этот файл
|
||||
accounts-service/ # РЕАЛИЗУЕТСЯ — backend/PLAN.md (Подсистема 1, часть 1)
|
||||
gateway/ # ПЛАН НЕ НАПИСАН — routing + единая точка JWT-проверки
|
||||
gateway/ # ПЛАН: gateway/PLAN.md — routing + единая точка JWT-проверки
|
||||
# + рейт-лимиты (TODO.md §6). Начинается после
|
||||
# accounts-service, т.к. фронтит уже готовый сервис.
|
||||
configs-service/ # ПЛАН НЕ НАПИСАН — 4 слота конфигов, share-коды,
|
||||
configs-service/ # ПЛАН: configs-service/PLAN.md — 4 слота конфигов, share-коды,
|
||||
# витрина (Подсистема 1, часть 2)
|
||||
chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence,
|
||||
# виджеты-телеметрия (Подсистема 2). Единственный
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ name = "accounts_service"
|
|||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
axum = { version = "0.8", features = ["multipart", "macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
tower-http = { version = "0.7", features = ["trace", "cors"] }
|
||||
tracing = "0.1"
|
||||
|
|
@ -19,11 +19,17 @@ sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio",
|
|||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
argon2 = "0.6"
|
||||
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
|
||||
axum-extra = { version = "0.12", features = ["cookie"] }
|
||||
time = "0.3"
|
||||
sha2 = "0.11"
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
common = { path = "../common" }
|
||||
rand = "0.10"
|
||||
dashmap = "6"
|
||||
aws-sdk-s3 = "1"
|
||||
aws-config = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
anyhow = "1"
|
||||
dotenvy = "0.15"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- Hardening: revoke implicit PUBLIC access to this schema. This Postgres
|
||||
-- instance hosts multiple services' databases (accounts_db/configs_db/
|
||||
-- chat_db per TODO.md Фаза 10) on one cluster; the app role connects as
|
||||
-- its own owner and needs no PUBLIC grant to function.
|
||||
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||
|
||||
CREATE TABLE accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
-- No plain UNIQUE on email: Postgres TEXT comparison is case-sensitive,
|
||||
-- so "User@x.com" and "user@x.com" would be treated as different rows,
|
||||
-- letting one person hold two accounts under what looks like the same
|
||||
-- email (account-confusion / signup-limit-bypass class of bug). A
|
||||
-- unique index on lower(email) enforces uniqueness case-insensitively;
|
||||
-- application code (accounts::repo) must look up/insert by
|
||||
-- `email.to_lowercase()` for this index to actually get hit.
|
||||
email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_nick TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
|
|
@ -10,6 +23,8 @@ CREATE TABLE accounts (
|
|||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX accounts_email_lower_idx ON accounts (lower(email));
|
||||
|
||||
CREATE TABLE avatars (
|
||||
account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
s3_key TEXT NOT NULL,
|
||||
|
|
|
|||
12
backend/accounts-service/migrations/0002_refresh_tokens.sql
Normal file
12
backend/accounts-service/migrations/0002_refresh_tokens.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- Opaque refresh tokens (stored hashed). Rotated on every use; presenting an
|
||||
-- already-rotated token revokes the whole account's sessions (theft signal).
|
||||
CREATE TABLE refresh_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_account_id ON refresh_tokens(account_id);
|
||||
2
backend/accounts-service/src/accounts/mod.rs
Normal file
2
backend/accounts-service/src/accounts/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod model;
|
||||
pub mod repo;
|
||||
15
backend/accounts-service/src/accounts/model.rs
Normal file
15
backend/accounts-service/src/accounts/model.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct Account {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: String,
|
||||
pub display_nick: String,
|
||||
pub role: String,
|
||||
pub can_publish_addons: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
168
backend/accounts-service/src/accounts/repo.rs
Normal file
168
backend/accounts-service/src/accounts/repo.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
use super::model::Account;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Emails are stored lowercase and compared via the `lower(email)` unique
|
||||
// index (see migrations/0001_init.sql). Every entry point normalizes here.
|
||||
pub fn normalize_email(email: &str) -> String {
|
||||
email.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
nick: &str,
|
||||
) -> Result<Account, sqlx::Error> {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"INSERT INTO accounts (email, password_hash, display_nick)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, password_hash, display_nick, role, can_publish_addons, created_at",
|
||||
)
|
||||
.bind(normalize_email(email))
|
||||
.bind(password_hash)
|
||||
.bind(nick)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<Account>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at
|
||||
FROM accounts WHERE lower(email) = $1",
|
||||
)
|
||||
.bind(normalize_email(email))
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Account>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Account>(
|
||||
"SELECT id, email, password_hash, display_nick, role, can_publish_addons, created_at
|
||||
FROM accounts WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_avatar(pool: &PgPool, account_id: Uuid, s3_key: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"INSERT INTO avatars (account_id, s3_key) VALUES ($1, $2)
|
||||
ON CONFLICT (account_id) DO UPDATE SET s3_key = EXCLUDED.s3_key, uploaded_at = now()",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(s3_key)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn test_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 create_then_find_by_email_round_trips() {
|
||||
let pool = test_pool().await;
|
||||
let email = format!("test-{}@example.com", Uuid::new_v4());
|
||||
|
||||
let created = create(&pool, &email, "hash123", "TestNick").await.unwrap();
|
||||
assert_eq!(created.email, email);
|
||||
assert_eq!(created.role, "user");
|
||||
assert!(created.can_publish_addons);
|
||||
|
||||
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")
|
||||
.bind(created.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[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();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn email_lookup_is_case_insensitive_and_stored_lowercase() {
|
||||
let pool = test_pool().await;
|
||||
let tag = Uuid::new_v4();
|
||||
let mixed = format!("MiXeD-{tag}@Example.COM");
|
||||
|
||||
let created = create(&pool, &mixed, "hash123", "Nick").await.unwrap();
|
||||
assert_eq!(created.email, mixed.to_lowercase());
|
||||
|
||||
let found = find_by_email(&pool, &mixed.to_uppercase())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("lookup must ignore case");
|
||||
assert_eq!(found.id, created.id);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE id = $1")
|
||||
.bind(created.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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 second = create(&pool, &format!("DUP-{tag}@EXAMPLE.com"), "h", "B").await;
|
||||
let err = second.expect_err("case-variant duplicate must violate the unique index");
|
||||
match err {
|
||||
sqlx::Error::Database(db) => {
|
||||
assert_eq!(db.constraint(), Some("accounts_email_lower_idx"));
|
||||
}
|
||||
other => panic!("expected a database unique violation, got {other:?}"),
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE id = $1")
|
||||
.bind(first.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[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")
|
||||
.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")
|
||||
.bind(created.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, vec![("avatars/two.png".to_string(),)]);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE id = $1")
|
||||
.bind(created.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
227
backend/accounts-service/src/auth/handlers.rs
Normal file
227
backend/accounts-service/src/auth/handlers.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
use crate::accounts::repo;
|
||||
use crate::auth::{password, tokens};
|
||||
use crate::error::{AppError, AppJson};
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use common::jwt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub pool: sqlx::PgPool,
|
||||
pub jwt_secret: String,
|
||||
pub cookie_secure: bool,
|
||||
pub hasher: password::PasswordHasher,
|
||||
// A real Argon2id hash of a throwaway string. `login` verifies against
|
||||
// it when the email is unknown so that "no such account" costs the same
|
||||
// ~100ms as "wrong password" — otherwise response time leaks which
|
||||
// emails are registered (user enumeration via timing).
|
||||
dummy_hash: String,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
pub fn new(pool: sqlx::PgPool, jwt_secret: String, cookie_secure: bool) -> Self {
|
||||
// Hashing a fixed, short constant with fixed valid params cannot
|
||||
// fail; this is not user input, so the expect is a startup invariant.
|
||||
let dummy_hash = password::hash_password("timing-equalizer-not-a-real-password")
|
||||
.expect("hashing a constant with pinned params cannot fail");
|
||||
AuthState {
|
||||
pool,
|
||||
jwt_secret,
|
||||
cookie_secure,
|
||||
hasher: password::PasswordHasher::new(),
|
||||
dummy_hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub nick: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RegisterResponse {
|
||||
pub id: uuid::Uuid,
|
||||
pub email: String,
|
||||
pub display_nick: String,
|
||||
}
|
||||
|
||||
/// Validates and normalizes a register request. Returns the trimmed
|
||||
/// `(email, nick)` on success.
|
||||
fn validate_register(req: &RegisterRequest) -> Result<(String, String), AppError> {
|
||||
let email = req.email.trim();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::Validation("email must not be empty".into()));
|
||||
}
|
||||
if email.len() > 254 {
|
||||
return Err(AppError::Validation("email must be at most 254 characters".into()));
|
||||
}
|
||||
let mut parts = email.split('@');
|
||||
let (Some(local), Some(domain)) = (parts.next(), parts.next()) else {
|
||||
return Err(AppError::Validation("email must contain '@'".into()));
|
||||
};
|
||||
if parts.next().is_some() || local.is_empty() || domain.is_empty() {
|
||||
return Err(AppError::Validation("email must have exactly one '@' with non-empty parts".into()));
|
||||
}
|
||||
|
||||
let nick = req.nick.trim();
|
||||
let nick_len = nick.chars().count();
|
||||
if nick_len == 0 || nick_len > 32 {
|
||||
return Err(AppError::Validation("nick must be 1 to 32 characters".into()));
|
||||
}
|
||||
if nick.chars().any(|c| c.is_control()) {
|
||||
return Err(AppError::Validation("nick must not contain control characters".into()));
|
||||
}
|
||||
|
||||
if req.password.chars().count() < 8 {
|
||||
return Err(AppError::Validation("password must be at least 8 characters".into()));
|
||||
}
|
||||
if req.password.len() > password::MAX_PASSWORD_BYTES {
|
||||
return Err(AppError::Validation(format!(
|
||||
"password must be at most {} bytes",
|
||||
password::MAX_PASSWORD_BYTES
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((email.to_string(), nick.to_string()))
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
State(state): State<AuthState>,
|
||||
AppJson(req): AppJson<RegisterRequest>,
|
||||
) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
|
||||
let (email, nick) = validate_register(&req)?;
|
||||
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,
|
||||
Json(RegisterResponse {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
display_nick: account.display_nick,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AuthState>,
|
||||
jar: CookieJar,
|
||||
AppJson(req): AppJson<LoginRequest>,
|
||||
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
|
||||
let Some(account) = repo::find_by_email(&state.pool, &req.email).await? else {
|
||||
// Burn the same Argon2 cost as a real check, then fail identically.
|
||||
state
|
||||
.hasher
|
||||
.verify(req.password.clone(), state.dummy_hash.clone())
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
return Err(AppError::Unauthorized);
|
||||
};
|
||||
if !state
|
||||
.hasher
|
||||
.verify(req.password.clone(), account.password_hash.clone())
|
||||
.await
|
||||
.map_err(AppError::Internal)?
|
||||
{
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
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) }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
State(state): State<AuthState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<(CookieJar, Json<LoginResponse>), AppError> {
|
||||
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((
|
||||
jar.add(tokens::refresh_cookie(new_token, state.cookie_secure)),
|
||||
Json(LoginResponse { access_token: jwt::issue_access_token(account_id, &state.jwt_secret) }),
|
||||
)),
|
||||
tokens::RotateOutcome::Invalid => Err(AppError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AuthState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<(CookieJar, StatusCode), AppError> {
|
||||
if let Some(cookie) = jar.get(tokens::REFRESH_COOKIE) {
|
||||
tokens::revoke_refresh(&state.pool, cookie.value()).await?;
|
||||
}
|
||||
Ok((
|
||||
jar.remove(axum_extra::extract::cookie::Cookie::build(tokens::REFRESH_COOKIE).path("/auth")),
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
pub mod password;
|
||||
pub mod tokens;
|
||||
pub mod handlers;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,91 @@
|
|||
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
|
||||
use argon2::password_hash::phc::PasswordHash;
|
||||
// `as _`: the trait must be in scope for `hash_password`, but the name
|
||||
// belongs to the `PasswordHasher` struct below.
|
||||
use argon2::{Algorithm, Argon2, Params, PasswordHasher as _, PasswordVerifier, Version};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
// Explicit Argon2id parameters instead of `Argon2::default()`, so a
|
||||
// dependency bump can never silently weaken the cost (the library default
|
||||
// is the OWASP *minimum*: m=19 MiB, t=2). 64 MiB / t=3 / p=1 is
|
||||
// deliberately above that floor.
|
||||
const M_COST_KIB: u32 = 64 * 1024;
|
||||
const T_COST: u32 = 3;
|
||||
const P_COST: u32 = 1;
|
||||
|
||||
// Argon2 processes the whole input, so an unbounded password is a cheap
|
||||
// CPU/memory DoS vector on both register and login. bcrypt-style 72-byte
|
||||
// truncation is not a concern for Argon2; this is purely an abuse cap.
|
||||
pub const MAX_PASSWORD_BYTES: usize = 256;
|
||||
|
||||
fn hasher() -> Result<Argon2<'static>, argon2::password_hash::Error> {
|
||||
let params = Params::new(M_COST_KIB, T_COST, P_COST, None)
|
||||
.map_err(|_| argon2::password_hash::Error::ParamsInvalid)?;
|
||||
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
|
||||
}
|
||||
|
||||
pub fn hash_password(plain: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let argon2 = Argon2::default();
|
||||
Ok(argon2.hash_password(plain.as_bytes())?.to_string())
|
||||
if plain.len() > MAX_PASSWORD_BYTES {
|
||||
return Err(argon2::password_hash::Error::PasswordInvalid);
|
||||
}
|
||||
Ok(hasher()?.hash_password(plain.as_bytes())?.to_string())
|
||||
}
|
||||
|
||||
pub fn verify_password(plain: &str, hash: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(hash) else { return false };
|
||||
if plain.len() > MAX_PASSWORD_BYTES {
|
||||
return false;
|
||||
}
|
||||
let Ok(parsed) = PasswordHash::new(hash) else {
|
||||
return false;
|
||||
};
|
||||
// Parameters are read from the stored PHC string, so hashes created
|
||||
// under older/lower settings still verify after a cost increase.
|
||||
Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Argon2 (64 MiB, ~100ms) is CPU/memory heavy; without a cap, concurrent
|
||||
/// register/login requests could starve the tokio worker pool. Sized to the
|
||||
/// number of CPUs so hashing never oversubscribes them.
|
||||
#[derive(Clone)]
|
||||
pub struct PasswordHasher {
|
||||
permits: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl Default for PasswordHasher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordHasher {
|
||||
pub fn new() -> Self {
|
||||
let permits = std::thread::available_parallelism()
|
||||
.map(|n| n.get())
|
||||
.unwrap_or(2);
|
||||
PasswordHasher { permits: Arc::new(Semaphore::new(permits)) }
|
||||
}
|
||||
|
||||
/// Runs Argon2 hashing off the async workers, bounded by the permit count.
|
||||
pub async fn hash(&self, plain: String) -> Result<String, anyhow::Error> {
|
||||
let _permit = self.permits.acquire().await?;
|
||||
let inner = tokio::task::spawn_blocking(move || hash_password(&plain))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))??;
|
||||
Ok(inner)
|
||||
}
|
||||
|
||||
/// Runs Argon2 verification off the async workers, bounded by the permit
|
||||
/// count. Used for both real and dummy (timing-equalizer) verification.
|
||||
pub async fn verify(&self, plain: String, hash: String) -> Result<bool, anyhow::Error> {
|
||||
let _permit = self.permits.acquire().await?;
|
||||
tokio::task::spawn_blocking(move || verify_password(&plain, &hash))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -34,4 +107,36 @@ mod tests {
|
|||
let hash = hash_password("secret123").unwrap();
|
||||
assert_ne!(hash, "secret123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_uses_pinned_argon2id_params() {
|
||||
let hash = hash_password("secret123").unwrap();
|
||||
assert!(
|
||||
hash.starts_with("$argon2id$v=19$m=65536,t=3,p=1$"),
|
||||
"unexpected PHC prefix: {hash}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_password_hashes_differently_each_time() {
|
||||
let a = hash_password("secret123").unwrap();
|
||||
let b = hash_password("secret123").unwrap();
|
||||
assert_ne!(a, b, "salt must be random per hash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_length_cap_boundary() {
|
||||
let at_cap = "a".repeat(MAX_PASSWORD_BYTES);
|
||||
let hash = hash_password(&at_cap).expect("exactly the cap must be accepted");
|
||||
assert!(verify_password(&at_cap, &hash));
|
||||
|
||||
let over_cap = "a".repeat(MAX_PASSWORD_BYTES + 1);
|
||||
assert!(hash_password(&over_cap).is_err(), "cap+1 must be rejected");
|
||||
assert!(!verify_password(&over_cap, &hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_hash_string_fails_verify_without_panicking() {
|
||||
assert!(!verify_password("whatever", "not-a-phc-string"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
136
backend/accounts-service/src/auth/tokens.rs
Normal file
136
backend/accounts-service/src/auth/tokens.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::RngExt;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const REFRESH_COOKIE: &str = "lv_refresh";
|
||||
|
||||
/// 256-bit random token: nothing to brute-force, so a slow hash would only
|
||||
/// add latency to every refresh — plain SHA-256 for storage is enough.
|
||||
pub fn new_opaque_token(prefix: &str) -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
format!("{prefix}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn hash_token(token: &str) -> String {
|
||||
hex::encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
pub async fn store_refresh(pool: &PgPool, account_id: Uuid) -> Result<String, sqlx::Error> {
|
||||
let token = new_opaque_token("lvr_");
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, now() + interval '30 days')",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(hash_token(&token))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub enum RotateOutcome {
|
||||
Rotated { account_id: Uuid, new_token: String },
|
||||
Invalid,
|
||||
}
|
||||
|
||||
pub async fn rotate_refresh(pool: &PgPool, token: &str) -> Result<RotateOutcome, sqlx::Error> {
|
||||
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()
|
||||
FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(hash_token(token))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let outcome = match row {
|
||||
None => RotateOutcome::Invalid,
|
||||
Some((account_id, true, _)) => {
|
||||
// Reuse of a rotated token: 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",
|
||||
)
|
||||
.bind(account_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
RotateOutcome::Invalid
|
||||
}
|
||||
Some((_, false, true)) => 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?;
|
||||
let new_token = new_opaque_token("lvr_");
|
||||
sqlx::query(
|
||||
"INSERT INTO refresh_tokens (account_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, now() + interval '30 days')",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(hash_token(&new_token))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
RotateOutcome::Rotated { account_id, new_token }
|
||||
}
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
pub async fn revoke_refresh(pool: &PgPool, token: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
"UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(hash_token(token))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The refresh token never touches JS: httpOnly, Strict, scoped to /auth,
|
||||
/// so an XSS on the site cannot exfiltrate it.
|
||||
pub fn refresh_cookie(token: String, secure: bool) -> Cookie<'static> {
|
||||
Cookie::build((REFRESH_COOKIE, token))
|
||||
.http_only(true)
|
||||
.secure(secure)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/auth")
|
||||
.max_age(time::Duration::days(30))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn opaque_tokens_are_prefixed_unique_and_long() {
|
||||
let a = new_opaque_token("lvr_");
|
||||
let b = new_opaque_token("lvr_");
|
||||
assert!(a.starts_with("lvr_"));
|
||||
assert_eq!(a.len(), 4 + 43);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_stable_hex_sha256() {
|
||||
assert_eq!(hash_token("x"), hash_token("x"));
|
||||
assert_eq!(hash_token("x").len(), 64);
|
||||
assert_ne!(hash_token("x"), hash_token("y"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_cookie_has_the_hardened_attributes() {
|
||||
let cookie = refresh_cookie("t".into(), true);
|
||||
assert_eq!(cookie.name(), REFRESH_COOKIE);
|
||||
assert_eq!(cookie.http_only(), Some(true));
|
||||
assert_eq!(cookie.secure(), Some(true));
|
||||
assert_eq!(cookie.same_site(), Some(SameSite::Strict));
|
||||
assert_eq!(cookie.path(), Some("/auth"));
|
||||
}
|
||||
}
|
||||
63
backend/accounts-service/src/avatars/handlers.rs
Normal file
63
backend/accounts-service/src/avatars/handlers.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use crate::accounts::repo;
|
||||
use crate::avatars::processing::{process_avatar, AvatarImageError};
|
||||
use crate::avatars::storage::S3Storage;
|
||||
use crate::error::AppError;
|
||||
use axum::{
|
||||
extract::{Multipart, State},
|
||||
Json,
|
||||
};
|
||||
use common::internal::GatewayIdentity;
|
||||
use serde::Serialize;
|
||||
|
||||
pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AvatarState {
|
||||
pub pool: sqlx::PgPool,
|
||||
pub storage: S3Storage,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AvatarResponse {
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
State(state): State<AvatarState>,
|
||||
identity: GatewayIdentity,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<AvatarResponse>, AppError> {
|
||||
let account_id = identity.account_id;
|
||||
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| AppError::Validation("malformed multipart body".into()))?
|
||||
.filter(|f| f.name() == Some("file"))
|
||||
.ok_or_else(|| AppError::Validation("expected a multipart part named `file`".into()))?;
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| AppError::Validation("could not read the uploaded file".into()))?;
|
||||
if bytes.len() > MAX_UPLOAD_BYTES {
|
||||
return Err(AppError::Validation("file too large (max 5MB)".into()));
|
||||
}
|
||||
|
||||
// Decoding is CPU-bound and the input is untrusted: keep it off the async workers.
|
||||
let png = tokio::task::spawn_blocking(move || process_avatar(&bytes))
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(e.into()))?
|
||||
.map_err(|e| match e {
|
||||
AvatarImageError::Unsupported => {
|
||||
AppError::Validation("unsupported image format (png, jpeg, webp)".into())
|
||||
}
|
||||
AvatarImageError::Invalid => AppError::Validation("invalid or too large image".into()),
|
||||
})?;
|
||||
|
||||
let key = format!("avatars/{account_id}.png");
|
||||
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) }))
|
||||
}
|
||||
3
backend/accounts-service/src/avatars/mod.rs
Normal file
3
backend/accounts-service/src/avatars/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod handlers;
|
||||
pub mod processing;
|
||||
pub mod storage;
|
||||
90
backend/accounts-service/src/avatars/processing.rs
Normal file
90
backend/accounts-service/src/avatars/processing.rs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
use image::{imageops::FilterType, ImageFormat, ImageReader, Limits};
|
||||
use std::io::Cursor;
|
||||
|
||||
pub const AVATAR_SIZE: u32 = 256;
|
||||
pub const MAX_DIMENSION: u32 = 8192;
|
||||
const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum AvatarImageError {
|
||||
/// Not a PNG/JPEG/WebP at all.
|
||||
Unsupported,
|
||||
/// Claims to be one of those but does not decode within the limits.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// Decodes untrusted image bytes, center-crops to a square and resizes to
|
||||
/// `AVATAR_SIZE`x`AVATAR_SIZE`, then re-encodes as PNG. The output is always
|
||||
/// a freshly generated PNG — never the original bytes — so EXIF metadata and
|
||||
/// any embedded payload never reach storage.
|
||||
pub fn process_avatar(bytes: &[u8]) -> Result<Vec<u8>, AvatarImageError> {
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| AvatarImageError::Invalid)?;
|
||||
if !matches!(
|
||||
reader.format(),
|
||||
Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP)
|
||||
) {
|
||||
return Err(AvatarImageError::Unsupported);
|
||||
}
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(MAX_DIMENSION);
|
||||
limits.max_image_height = Some(MAX_DIMENSION);
|
||||
limits.max_alloc = Some(MAX_DECODE_BYTES);
|
||||
reader.limits(limits);
|
||||
|
||||
let decoded = reader.decode().map_err(|_| AvatarImageError::Invalid)?;
|
||||
let square = decoded.resize_to_fill(AVATAR_SIZE, AVATAR_SIZE, FilterType::Lanczos3);
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
square
|
||||
.write_to(&mut out, ImageFormat::Png)
|
||||
.map_err(|_| AvatarImageError::Invalid)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::ImageFormat;
|
||||
use std::io::Cursor;
|
||||
|
||||
fn png_of(width: u32, height: u32) -> Vec<u8> {
|
||||
let img = image::RgbaImage::new(width, height);
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_image_becomes_a_256_square_png() {
|
||||
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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_image_bytes_are_unsupported() {
|
||||
assert_eq!(process_avatar(b"not an image"), Err(AvatarImageError::Unsupported));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_is_unsupported() {
|
||||
assert_eq!(
|
||||
process_avatar(b"GIF89a\x01\x00\x01\x00\x00\x00\x00;"),
|
||||
Err(AvatarImageError::Unsupported)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_png_is_invalid() {
|
||||
let mut bytes = png_of(50, 50);
|
||||
bytes.truncate(40);
|
||||
assert_eq!(process_avatar(&bytes), Err(AvatarImageError::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_wider_than_the_limit_is_rejected() {
|
||||
assert_eq!(process_avatar(&png_of(MAX_DIMENSION + 1, 1)), Err(AvatarImageError::Invalid));
|
||||
}
|
||||
}
|
||||
36
backend/accounts-service/src/avatars/storage.rs
Normal file
36
backend/accounts-service/src/avatars/storage.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::Client;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct S3Storage {
|
||||
client: Client,
|
||||
bucket: String,
|
||||
}
|
||||
|
||||
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 config = aws_sdk_s3::config::Builder::new()
|
||||
.endpoint_url(endpoint)
|
||||
.credentials_provider(creds)
|
||||
.region(aws_sdk_s3::config::Region::new("us-east-1"))
|
||||
.force_path_style(true)
|
||||
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
|
||||
.build();
|
||||
S3Storage { client: Client::from_conf(config), bucket }
|
||||
}
|
||||
|
||||
pub async fn put(&self, key: &str, bytes: Vec<u8>, content_type: &str) -> anyhow::Result<()> {
|
||||
self.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from(bytes))
|
||||
.content_type(content_type)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,13 @@ use anyhow::{Context, Result};
|
|||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub internal_key: String,
|
||||
pub port: u16,
|
||||
pub s3_endpoint: String,
|
||||
pub s3_bucket: String,
|
||||
pub s3_access_key: String,
|
||||
pub s3_secret_key: String,
|
||||
pub cookie_secure: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
@ -18,6 +20,8 @@ impl Config {
|
|||
.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()
|
||||
|
|
@ -30,6 +34,72 @@ impl Config {
|
|||
.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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_JWT_SECRET_BYTES: usize = 32;
|
||||
|
||||
impl Config {
|
||||
/// Fail fast at startup on a secret too weak to sign HS256 tokens with
|
||||
/// (the `.env.example` placeholder must never reach production).
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.jwt_secret.len() < MIN_JWT_SECRET_BYTES {
|
||||
anyhow::bail!("JWT_SECRET must be at least {MIN_JWT_SECRET_BYTES} bytes");
|
||||
}
|
||||
if self.internal_key.len() < MIN_JWT_SECRET_BYTES {
|
||||
anyhow::bail!("INTERNAL_KEY must be at least {MIN_JWT_SECRET_BYTES} bytes");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Public prefix of stored avatars: `<endpoint>/<bucket>`.
|
||||
pub fn avatar_base_url(&self) -> String {
|
||||
format!("{}/{}", self.s3_endpoint.trim_end_matches('/'), self.s3_bucket)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config_with_secret(secret: &str) -> Config {
|
||||
Config {
|
||||
database_url: String::new(),
|
||||
jwt_secret: secret.into(),
|
||||
internal_key: "k".repeat(32),
|
||||
port: 0,
|
||||
s3_endpoint: String::new(),
|
||||
s3_bucket: String::new(),
|
||||
s3_access_key: String::new(),
|
||||
s3_secret_key: String::new(),
|
||||
cookie_secure: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_internal_key_is_rejected() {
|
||||
let mut cfg = config_with_secret(&"x".repeat(32));
|
||||
cfg.internal_key = "short".into();
|
||||
assert!(cfg.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_jwt_secret_is_rejected() {
|
||||
assert!(config_with_secret("too-short").validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_jwt_secret_is_accepted() {
|
||||
assert!(config_with_secret(&"x".repeat(32)).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avatar_base_url_joins_endpoint_and_bucket_without_double_slash() {
|
||||
let mut cfg = config_with_secret(&"x".repeat(32));
|
||||
cfg.s3_endpoint = "http://localhost:9000/".into();
|
||||
cfg.s3_bucket = "avatars".into();
|
||||
assert_eq!(cfg.avatar_base_url(), "http://localhost:9000/avatars");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
backend/accounts-service/src/device/handlers.rs
Normal file
73
backend/accounts-service/src/device/handlers.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use crate::device::store::{self, DeviceStore, PollResult};
|
||||
use crate::error::{AppError, AppJson};
|
||||
use axum::{extract::State, http::StatusCode, Json};
|
||||
use common::internal::GatewayIdentity;
|
||||
use common::jwt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceState {
|
||||
pub store: DeviceStore,
|
||||
pub jwt_secret: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
pub async fn create_code(
|
||||
State(state): State<DeviceState>,
|
||||
) -> Result<(StatusCode, Json<DeviceCodeResponse>), AppError> {
|
||||
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() }),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConfirmRequest {
|
||||
pub user_code: String,
|
||||
}
|
||||
|
||||
pub async fn confirm(
|
||||
State(state): State<DeviceState>,
|
||||
identity: GatewayIdentity,
|
||||
AppJson(req): AppJson<ConfirmRequest>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if state.store.confirm(&req.user_code, identity.account_id) {
|
||||
Ok(StatusCode::OK)
|
||||
} else {
|
||||
Err(AppError::NotFound("unknown or expired user_code".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TokenResponse {
|
||||
pub device_token: String,
|
||||
}
|
||||
|
||||
pub async fn token(
|
||||
State(state): State<DeviceState>,
|
||||
AppJson(req): AppJson<TokenRequest>,
|
||||
) -> Result<(StatusCode, Json<Option<TokenResponse>>), AppError> {
|
||||
match state.store.poll(&req.device_code) {
|
||||
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);
|
||||
Ok((StatusCode::OK, Json(Some(TokenResponse { device_token }))))
|
||||
}
|
||||
}
|
||||
}
|
||||
2
backend/accounts-service/src/device/mod.rs
Normal file
2
backend/accounts-service/src/device/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod handlers;
|
||||
pub mod store;
|
||||
192
backend/accounts-service/src/device/store.rs
Normal file
192
backend/accounts-service/src/device/store.rs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceCodeEntry {
|
||||
pub user_code: String,
|
||||
pub confirmed_account_id: Option<Uuid>,
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DeviceStore {
|
||||
by_device_code: Arc<DashMap<String, DeviceCodeEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PollResult {
|
||||
Unknown,
|
||||
Pending,
|
||||
Confirmed(Uuid),
|
||||
}
|
||||
|
||||
pub(crate) const TTL: Duration = Duration::from_secs(600);
|
||||
// /device/code is unauthenticated, so the store must be bounded or anyone can
|
||||
// grow process memory without limit. Expired entries are purged on every
|
||||
// create, so the cap only bites under sustained abuse.
|
||||
const MAX_PENDING: usize = 10_000;
|
||||
|
||||
fn random_user_code() -> String {
|
||||
use rand::RngExt;
|
||||
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()
|
||||
};
|
||||
format!("{}-{}", part(4), part(4))
|
||||
}
|
||||
|
||||
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);
|
||||
if self.by_device_code.len() >= MAX_PENDING {
|
||||
return None;
|
||||
}
|
||||
let device_code = Uuid::new_v4().to_string();
|
||||
let user_code = random_user_code();
|
||||
self.by_device_code.insert(
|
||||
device_code.clone(),
|
||||
DeviceCodeEntry {
|
||||
user_code: user_code.clone(),
|
||||
confirmed_account_id: None,
|
||||
expires_at: now + TTL,
|
||||
},
|
||||
);
|
||||
Some((device_code, user_code))
|
||||
}
|
||||
|
||||
/// Returns true if a matching, unexpired entry was found and confirmed.
|
||||
/// The user types this code by hand, so it is trimmed and upper-cased.
|
||||
pub fn confirm(&self, user_code: &str, account_id: Uuid) -> bool {
|
||||
let wanted = user_code.trim().to_uppercase();
|
||||
let now = Instant::now();
|
||||
for mut entry in self.by_device_code.iter_mut() {
|
||||
// First confirm wins: an already-confirmed code cannot be
|
||||
// re-bound to a different account before the device collects it.
|
||||
if entry.user_code == wanted
|
||||
&& entry.expires_at > now
|
||||
&& entry.confirmed_account_id.is_none()
|
||||
{
|
||||
entry.confirmed_account_id = Some(account_id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A confirmed entry is CONSUMED by the first poll that sees it: the
|
||||
/// token can be collected exactly once, replaying the same device_code
|
||||
/// afterwards yields `Unknown`. Expired entries are dropped and Unknown.
|
||||
pub fn poll(&self, device_code: &str) -> PollResult {
|
||||
let now = Instant::now();
|
||||
let removed = self.by_device_code.remove_if(device_code, |_, entry| {
|
||||
entry.confirmed_account_id.is_some() || entry.expires_at <= now
|
||||
});
|
||||
if let Some((_, entry)) = removed {
|
||||
return match entry.confirmed_account_id {
|
||||
Some(id) if entry.expires_at > now => PollResult::Confirmed(id),
|
||||
_ => PollResult::Unknown,
|
||||
};
|
||||
}
|
||||
if self.by_device_code.contains_key(device_code) {
|
||||
PollResult::Pending
|
||||
} else {
|
||||
PollResult::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn create_returns_distinct_codes() {
|
||||
let store = DeviceStore::default();
|
||||
let (dc1, uc1) = store.create().unwrap();
|
||||
let (dc2, uc2) = store.create().unwrap();
|
||||
assert_ne!(dc1, dc2);
|
||||
assert_ne!(uc1, uc2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_then_poll_returns_account_id() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
let account_id = Uuid::new_v4();
|
||||
|
||||
assert!(store.confirm(&user_code, account_id));
|
||||
assert_eq!(store.poll(&device_code), PollResult::Confirmed(account_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_code_can_only_be_collected_once() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_before_confirm_is_pending_and_repeatable() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, _user_code) = store.create().unwrap();
|
||||
assert_eq!(store.poll(&device_code), PollResult::Pending);
|
||||
assert_eq!(store.poll(&device_code), PollResult::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poll_unknown_code_is_unknown() {
|
||||
let store = DeviceStore::default();
|
||||
assert_eq!(store.poll("does-not-exist"), PollResult::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_unknown_user_code_returns_false() {
|
||||
let store = DeviceStore::default();
|
||||
assert!(!store.confirm("ZZZZ-ZZZZ", Uuid::new_v4()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_confirm_cannot_overwrite_the_first_account() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
let first = Uuid::new_v4();
|
||||
let attacker = Uuid::new_v4();
|
||||
|
||||
assert!(store.confirm(&user_code, first));
|
||||
assert!(!store.confirm(&user_code, attacker), "re-confirm must be refused");
|
||||
assert_eq!(store.poll(&device_code), PollResult::Confirmed(first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_accepts_lowercase_and_surrounding_whitespace() {
|
||||
let store = DeviceStore::default();
|
||||
let (device_code, user_code) = store.create().unwrap();
|
||||
let typed = format!(" {} ", user_code.to_lowercase());
|
||||
assert!(store.confirm(&typed, Uuid::new_v4()));
|
||||
assert!(matches!(store.poll(&device_code), PollResult::Confirmed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_entries_are_purged_and_store_is_bounded() {
|
||||
let store = DeviceStore::default();
|
||||
for _ in 0..MAX_PENDING {
|
||||
assert!(store.create().is_some());
|
||||
}
|
||||
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() {
|
||||
entry.expires_at = Instant::now() - Duration::from_secs(1);
|
||||
}
|
||||
assert!(store.create().is_some());
|
||||
assert_eq!(store.by_device_code.len(), 1);
|
||||
}
|
||||
}
|
||||
66
backend/accounts-service/src/error.rs
Normal file
66
backend/accounts-service/src/error.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use axum::{
|
||||
extract::{rejection::JsonRejection, FromRequest},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum AppError {
|
||||
Validation(String),
|
||||
Conflict(String),
|
||||
Unauthorized,
|
||||
NotFound(String),
|
||||
TooManyRequests,
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
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::Internal(err) => {
|
||||
tracing::error!("internal error: {err:?}");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
if let sqlx::Error::Database(ref db_err) = err
|
||||
&& db_err.constraint() == Some("accounts_email_lower_idx")
|
||||
{
|
||||
return AppError::Conflict("email already registered".into());
|
||||
}
|
||||
AppError::Internal(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsonRejection> for AppError {
|
||||
fn from(rejection: JsonRejection) -> Self {
|
||||
AppError::Validation(rejection.body_text())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-in replacement for `axum::Json` that reports malformed/invalid JSON
|
||||
/// bodies as our `{"error": ...}` shape instead of axum's plain-text default.
|
||||
#[derive(FromRequest)]
|
||||
#[from_request(via(axum::Json), rejection(AppError))]
|
||||
pub struct AppJson<T>(pub T);
|
||||
|
||||
impl<T> IntoResponse for AppJson<T>
|
||||
where
|
||||
Json<T>: IntoResponse,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
Json(self.0).into_response()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,68 @@
|
|||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod avatars;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod error;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use auth::handlers::AuthState;
|
||||
use avatars::{handlers::AvatarState, storage::S3Storage};
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use config::Config;
|
||||
use device::{handlers::DeviceState, store::DeviceStore};
|
||||
|
||||
pub fn build_app(_pool: sqlx::PgPool) -> Router {
|
||||
Router::new().route("/health", get(|| async { "ok" }))
|
||||
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 avatar_state = AvatarState {
|
||||
pool: pool.clone(),
|
||||
storage: S3Storage::from_config(
|
||||
&cfg.s3_endpoint,
|
||||
&cfg.s3_access_key,
|
||||
&cfg.s3_secret_key,
|
||||
cfg.s3_bucket.clone(),
|
||||
),
|
||||
base_url: cfg.avatar_base_url(),
|
||||
};
|
||||
|
||||
let auth_routes = Router::new()
|
||||
.route("/auth/register", post(auth::handlers::register))
|
||||
.route("/auth/login", post(auth::handlers::login))
|
||||
.route("/auth/refresh", post(auth::handlers::refresh))
|
||||
.route("/auth/logout", post(auth::handlers::logout))
|
||||
.with_state(auth_state);
|
||||
|
||||
let device_routes = Router::new()
|
||||
.route("/device/code", post(device::handlers::create_code))
|
||||
.route("/device/confirm", post(device::handlers::confirm))
|
||||
.route("/device/token", post(device::handlers::token))
|
||||
.with_state(device_state);
|
||||
|
||||
let avatar_routes = Router::new()
|
||||
.route("/avatars", post(avatars::handlers::upload))
|
||||
// Hard transport cap slightly above the 5 MB business limit (413 beyond it).
|
||||
.layer(DefaultBodyLimit::max(6 * 1024 * 1024))
|
||||
.with_state(avatar_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.
|
||||
let api = Router::new()
|
||||
.merge(auth_routes)
|
||||
.merge(device_routes)
|
||||
.merge(avatar_routes)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
common::internal::InternalKey::new(cfg.internal_key.clone()),
|
||||
common::internal::require_internal_key,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ async fn main() -> anyhow::Result<()> {
|
|||
tracing_subscriber::fmt::init();
|
||||
dotenvy::dotenv().ok();
|
||||
let cfg = Config::from_env()?;
|
||||
cfg.validate()?;
|
||||
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect(&cfg.database_url)
|
||||
.await?;
|
||||
|
||||
let app = build_app(pool);
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
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?;
|
||||
|
|
|
|||
162
backend/accounts-service/tests/auth_flow.rs
Normal file
162
backend/accounts-service/tests/auth_flow.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
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 = "auth_flow/refresh.rs"]
|
||||
mod refresh;
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_then_login_succeeds() {
|
||||
let pool = common::test_pool().await;
|
||||
let app = accounts_service::build_app(pool.clone(), &common::test_config());
|
||||
let server = common::test_server(app);
|
||||
let email = format!("flow-{}@example.com", Uuid::new_v4());
|
||||
|
||||
let register_response = server
|
||||
.post("/auth/register")
|
||||
.json(&json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Rider" }))
|
||||
.await;
|
||||
register_response.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let login_response = server
|
||||
.post("/auth/login")
|
||||
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
|
||||
.await;
|
||||
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");
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE email = $1")
|
||||
.bind(&email)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_wrong_password_returns_401() {
|
||||
let pool = common::test_pool().await;
|
||||
let app = accounts_service::build_app(pool.clone(), &common::test_config());
|
||||
let server = common::test_server(app);
|
||||
let email = format!("wrongpw-{}@example.com", Uuid::new_v4());
|
||||
|
||||
server
|
||||
.post("/auth/register")
|
||||
.json(&json!({ "email": email, "password": "right-password", "nick": "Rider" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let login_response = server
|
||||
.post("/auth/login")
|
||||
.json(&json!({ "email": email, "password": "wrong-password" }))
|
||||
.await;
|
||||
login_response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE email = $1")
|
||||
.bind(&email)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_email_registration_returns_409() {
|
||||
let pool = common::test_pool().await;
|
||||
let app = accounts_service::build_app(pool.clone(), &common::test_config());
|
||||
let server = common::test_server(app);
|
||||
let email = format!("dup-{}@example.com", Uuid::new_v4());
|
||||
|
||||
server
|
||||
.post("/auth/register")
|
||||
.json(&json!({ "email": email, "password": "password123", "nick": "First" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
let second = server
|
||||
.post("/auth/register")
|
||||
.json(&json!({ "email": email, "password": "password456", "nick": "Second" }))
|
||||
.await;
|
||||
second.assert_status(axum::http::StatusCode::CONFLICT);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE email = $1")
|
||||
.bind(&email)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_unknown_email_returns_401() {
|
||||
let pool = common::test_pool().await;
|
||||
let app = accounts_service::build_app(pool.clone(), &common::test_config());
|
||||
let server = common::test_server(app);
|
||||
|
||||
let response = server
|
||||
.post("/auth/login")
|
||||
.json(&json!({ "email": format!("nobody-{}@example.com", Uuid::new_v4()), "password": "whatever-123" }))
|
||||
.await;
|
||||
response.assert_status(axum::http::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_is_case_insensitive_on_email() {
|
||||
let pool = common::test_pool().await;
|
||||
let app = accounts_service::build_app(pool.clone(), &common::test_config());
|
||||
let server = common::test_server(app);
|
||||
let tag = Uuid::new_v4();
|
||||
let registered = format!("CaseUser-{tag}@Example.com");
|
||||
|
||||
server
|
||||
.post("/auth/register")
|
||||
.json(&json!({ "email": registered, "password": "password123", "nick": "Rider" }))
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::CREATED);
|
||||
|
||||
server
|
||||
.post("/auth/login")
|
||||
.json(&json!({ "email": registered.to_lowercase(), "password": "password123" }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
|
||||
.bind(registered.to_lowercase())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_json_body_returns_400_with_json_error_shape() {
|
||||
let pool = common::test_pool().await;
|
||||
let app = accounts_service::build_app(pool.clone(), &common::test_config());
|
||||
let server = common::test_server(app);
|
||||
|
||||
let response = server
|
||||
.post("/auth/register")
|
||||
.content_type("application/json")
|
||||
.bytes(axum::body::Bytes::from_static(b"{ this is not valid json"))
|
||||
.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}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_rejects_oversized_password_with_400() {
|
||||
let pool = common::test_pool().await;
|
||||
let app = accounts_service::build_app(pool.clone(), &common::test_config());
|
||||
let server = common::test_server(app);
|
||||
|
||||
let response = server
|
||||
.post("/auth/register")
|
||||
.json(&json!({
|
||||
"email": format!("big-{}@example.com", Uuid::new_v4()),
|
||||
"password": "a".repeat(10_000),
|
||||
"nick": "Rider"
|
||||
}))
|
||||
.await;
|
||||
response.assert_status(axum::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
92
backend/accounts-service/tests/auth_flow/refresh.rs
Normal file
92
backend/accounts-service/tests/auth_flow/refresh.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use super::common;
|
||||
use axum::http::StatusCode;
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use serde_json::json;
|
||||
|
||||
async fn login(server: &axum_test::TestServer, email: &str) -> (String, String) {
|
||||
let res = server
|
||||
.post("/auth/login")
|
||||
.json(&json!({ "email": email, "password": "correct-horse-battery-staple" }))
|
||||
.await;
|
||||
res.assert_status_ok();
|
||||
let cookie = res.cookie("lv_refresh");
|
||||
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())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_the_cookie_and_issues_a_new_access_token() {
|
||||
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 (_, refresh) = login(&server, &email).await;
|
||||
|
||||
let res = server
|
||||
.post("/auth/refresh")
|
||||
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
|
||||
.await;
|
||||
res.assert_status_ok();
|
||||
assert!(res.json::<serde_json::Value>()["access_token"].is_string());
|
||||
assert_ne!(res.cookie("lv_refresh").value(), refresh);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reusing_a_rotated_refresh_token_revokes_all_sessions() {
|
||||
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;
|
||||
|
||||
let second = server
|
||||
.post("/auth/refresh")
|
||||
.add_cookie(Cookie::new("lv_refresh", first.clone()))
|
||||
.await
|
||||
.cookie("lv_refresh")
|
||||
.value()
|
||||
.to_owned();
|
||||
// Attacker replays the old token -> rejected, and the legit new one dies too.
|
||||
server
|
||||
.post("/auth/refresh")
|
||||
.add_cookie(Cookie::new("lv_refresh", first))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
server
|
||||
.post("/auth/refresh")
|
||||
.add_cookie(Cookie::new("lv_refresh", second))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_the_refresh_token() {
|
||||
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 (_, refresh) = login(&server, &email).await;
|
||||
|
||||
server
|
||||
.post("/auth/logout")
|
||||
.add_cookie(Cookie::new("lv_refresh", refresh.clone()))
|
||||
.await
|
||||
.assert_status(StatusCode::NO_CONTENT);
|
||||
server
|
||||
.post("/auth/refresh")
|
||||
.add_cookie(Cookie::new("lv_refresh", refresh))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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")
|
||||
.add_cookie(Cookie::new("lv_refresh", "lvr_garbage"))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
100
backend/accounts-service/tests/avatar_upload.rs
Normal file
100
backend/accounts-service/tests/avatar_upload.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum_test::multipart::{MultipartForm, Part};
|
||||
use common::ACCOUNT_ID_HEADER;
|
||||
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
let img = image::RgbaImage::new(64, 32);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
fn form(bytes: Vec<u8>) -> MultipartForm {
|
||||
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 (account_id, email) = common::register_account(&server).await;
|
||||
|
||||
let response = server
|
||||
.post("/avatars")
|
||||
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
|
||||
.multipart(form(png_bytes()))
|
||||
.await;
|
||||
response.assert_status_ok();
|
||||
let url = response.json::<serde_json::Value>()["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(
|
||||
"SELECT a.s3_key FROM avatars a JOIN accounts c ON c.id = a.account_id WHERE lower(c.email) = $1",
|
||||
)
|
||||
.bind(email.to_lowercase())
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("avatars row must exist");
|
||||
assert!(url.ends_with(&stored.0));
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
|
||||
.bind(email.to_lowercase())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[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 (account_id, email) = common::register_account(&server).await;
|
||||
|
||||
server
|
||||
.post("/avatars")
|
||||
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
|
||||
.multipart(form(b"definitely not an image".to_vec()))
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
|
||||
.bind(email.to_lowercase())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[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 (account_id, email) = common::register_account(&server).await;
|
||||
|
||||
server
|
||||
.post("/avatars")
|
||||
.add_header(ACCOUNT_ID_HEADER, account_id.to_string())
|
||||
.multipart(form(vec![0u8; 5 * 1024 * 1024 + 1]))
|
||||
.await
|
||||
.assert_status(StatusCode::BAD_REQUEST);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE lower(email) = $1")
|
||||
.bind(email.to_lowercase())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[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()));
|
||||
|
||||
server
|
||||
.post("/avatars")
|
||||
.multipart(form(png_bytes()))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
52
backend/accounts-service/tests/common/mod.rs
Normal file
52
backend/accounts-service/tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
#![allow(dead_code, unused_imports)] // each test binary uses a different subset
|
||||
|
||||
use accounts_service::config::Config;
|
||||
use axum_test::TestServer;
|
||||
use uuid::Uuid;
|
||||
|
||||
// `::` — the workspace crate, not this module (which every test binary
|
||||
// mounts as `mod common;`).
|
||||
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");
|
||||
pool
|
||||
}
|
||||
|
||||
pub fn test_config() -> Config {
|
||||
Config {
|
||||
database_url: String::new(),
|
||||
jwt_secret: "test-secret-test-secret-test-secret!".into(),
|
||||
internal_key: TEST_INTERNAL_KEY.into(),
|
||||
port: 0,
|
||||
s3_endpoint: "http://localhost:9000".into(),
|
||||
s3_bucket: "lovisual-avatars-test".into(),
|
||||
s3_access_key: "minioadmin".into(),
|
||||
s3_secret_key: "minioadmin".into(),
|
||||
cookie_secure: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const TEST_INTERNAL_KEY: &str = "internal-key-internal-key-internal!!";
|
||||
|
||||
/// A server that behaves like it sits behind the gateway.
|
||||
pub fn test_server(app: axum::Router) -> TestServer {
|
||||
let mut server = TestServer::new(app);
|
||||
server.add_header(INTERNAL_KEY_HEADER, TEST_INTERNAL_KEY);
|
||||
server
|
||||
}
|
||||
|
||||
/// Registers a fresh random account; returns its id and email.
|
||||
pub async fn register_account(server: &TestServer) -> (Uuid, String) {
|
||||
let email = format!("t-{}@example.com", Uuid::new_v4());
|
||||
let res = server
|
||||
.post("/auth/register")
|
||||
.json(&serde_json::json!({ "email": email, "password": "correct-horse-battery-staple", "nick": "Tester" }))
|
||||
.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)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
#[tokio::test]
|
||||
async fn migrations_create_accounts_table() {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://lovisual:lovisual@localhost:5432/accounts_db".into());
|
||||
let pool = sqlx::PgPool::connect(&database_url).await.expect("connect");
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await.expect("migrate");
|
||||
|
||||
let row: (i64,) = sqlx::query_as("SELECT count(*) FROM accounts")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("accounts table must exist");
|
||||
assert_eq!(row.0, 0);
|
||||
}
|
||||
87
backend/accounts-service/tests/device_flow.rs
Normal file
87
backend/accounts-service/tests/device_flow.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
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 (account_id, email) = common::register_account(&server).await;
|
||||
|
||||
let code_response: serde_json::Value = server.post("/device/code").await.json();
|
||||
let device_code = code_response["device_code"].as_str().unwrap();
|
||||
let user_code = code_response["user_code"].as_str().unwrap();
|
||||
|
||||
let poll_before = server
|
||||
.post("/device/token")
|
||||
.json(&json!({ "device_code": device_code }))
|
||||
.await;
|
||||
poll_before.assert_status(StatusCode::ACCEPTED);
|
||||
|
||||
server
|
||||
.post("/device/confirm")
|
||||
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
|
||||
.json(&json!({ "user_code": user_code }))
|
||||
.await
|
||||
.assert_status_ok();
|
||||
|
||||
let poll_after = server
|
||||
.post("/device/token")
|
||||
.json(&json!({ "device_code": device_code }))
|
||||
.await;
|
||||
poll_after.assert_status_ok();
|
||||
let token_body: serde_json::Value = poll_after.json();
|
||||
assert!(token_body["device_token"].is_string());
|
||||
|
||||
// Single use: the same device_code cannot be redeemed twice.
|
||||
server
|
||||
.post("/device/token")
|
||||
.json(&json!({ "device_code": device_code }))
|
||||
.await
|
||||
.assert_status(StatusCode::NOT_FOUND);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE email = $1")
|
||||
.bind(&email)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[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 code: serde_json::Value = server.post("/device/code").await.json();
|
||||
server
|
||||
.post("/device/confirm")
|
||||
.json(&json!({ "user_code": code["user_code"] }))
|
||||
.await
|
||||
.assert_status(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[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 (account_id, email) = common::register_account(&server).await;
|
||||
|
||||
server
|
||||
.post("/device/token")
|
||||
.json(&json!({ "device_code": "no-such-code" }))
|
||||
.await
|
||||
.assert_status(StatusCode::NOT_FOUND);
|
||||
|
||||
server
|
||||
.post("/device/confirm")
|
||||
.add_header(common::ACCOUNT_ID_HEADER, account_id.to_string())
|
||||
.json(&json!({ "user_code": "ZZZZ-ZZZZ" }))
|
||||
.await
|
||||
.assert_status(StatusCode::NOT_FOUND);
|
||||
|
||||
sqlx::query("DELETE FROM accounts WHERE email = $1")
|
||||
.bind(&email)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
use axum_test::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
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 app = accounts_service::build_app(pool);
|
||||
let server = TestServer::new(app);
|
||||
|
||||
let response = server.get("/health").await;
|
||||
response.assert_status_ok();
|
||||
response.assert_text("ok");
|
||||
}
|
||||
42
backend/accounts-service/tests/smoke.rs
Normal file
42
backend/accounts-service/tests/smoke.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
mod common;
|
||||
|
||||
use axum_test::TestServer;
|
||||
|
||||
#[tokio::test]
|
||||
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 app = accounts_service::build_app(pool, &common::test_config());
|
||||
let server = TestServer::new(app);
|
||||
|
||||
let response = server.get("/health").await;
|
||||
response.assert_status_ok();
|
||||
response.assert_text("ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrations_create_accounts_table() {
|
||||
let pool = common::test_pool().await;
|
||||
|
||||
let row: (Option<String>,) = sqlx::query_as("SELECT to_regclass('accounts')::text")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("query must succeed");
|
||||
assert!(row.0.is_some(), "accounts table must exist after migrations run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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()));
|
||||
server.get("/health").await.assert_status_ok();
|
||||
server
|
||||
.post("/device/code")
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
23
backend/common/Cargo.toml
Normal file
23
backend/common/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
subtle = "2"
|
||||
tonic = "0.14"
|
||||
tonic-prost = "0.14"
|
||||
prost = "0.14"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-prost-build = "0.14"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
axum-test = "21"
|
||||
4
backend/common/build.rs
Normal file
4
backend/common/build.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tonic_prost_build::compile_protos("proto/accounts.proto")?;
|
||||
Ok(())
|
||||
}
|
||||
13
backend/common/proto/accounts.proto
Normal file
13
backend/common/proto/accounts.proto
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
syntax = "proto3";
|
||||
package accounts.v1;
|
||||
|
||||
// Internal-only API of accounts-service. Never exposed publicly; every call
|
||||
// must carry the x-lovisual-internal-key metadata entry.
|
||||
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);
|
||||
}
|
||||
|
||||
message AuthenticateDeviceRequest { string device_token = 1; }
|
||||
message AuthenticateDeviceReply { string account_id = 1; }
|
||||
176
backend/common/src/internal.rs
Normal file
176
backend/common/src/internal.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
//! Contract between the gateway and internal services. Services trust the
|
||||
//! identity header ONLY because `require_internal_key` guarantees the request
|
||||
//! came through the gateway (which strips client-supplied copies of both).
|
||||
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Request, State},
|
||||
http::{request::Parts, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tonic::{
|
||||
metadata::{Ascii, MetadataValue},
|
||||
service::Interceptor,
|
||||
Status,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const INTERNAL_KEY_HEADER: &str = "x-lovisual-internal-key";
|
||||
pub const ACCOUNT_ID_HEADER: &str = "x-lovisual-account-id";
|
||||
pub const DEVICE_TOKEN_PREFIX: &str = "lvd_";
|
||||
|
||||
fn keys_match(given: &[u8], expected: &[u8]) -> bool {
|
||||
given.ct_eq(expected).into()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct InternalKey(Arc<str>);
|
||||
|
||||
impl InternalKey {
|
||||
pub fn new(key: String) -> Self {
|
||||
InternalKey(key.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_internal_key(State(key): State<InternalKey>, req: Request, next: Next) -> Response {
|
||||
let ok = req
|
||||
.headers()
|
||||
.get(INTERNAL_KEY_HEADER)
|
||||
.is_some_and(|v| keys_match(v.as_bytes(), key.0.as_bytes()));
|
||||
if !ok {
|
||||
return (StatusCode::FORBIDDEN, Json(json!({ "error": "forbidden" }))).into_response();
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// The authenticated account, as resolved by the gateway. Rejects with 401
|
||||
/// when the gateway forwarded the request anonymously.
|
||||
pub struct GatewayIdentity {
|
||||
pub account_id: Uuid,
|
||||
}
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for GatewayIdentity {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.headers
|
||||
.get(ACCOUNT_ID_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-side tonic interceptor: rejects calls without the internal key.
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcKeyCheck(Arc<str>);
|
||||
|
||||
impl GrpcKeyCheck {
|
||||
pub fn new(key: &str) -> Self {
|
||||
GrpcKeyCheck(key.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interceptor for GrpcKeyCheck {
|
||||
fn call(&mut self, req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
|
||||
match req.metadata().get(INTERNAL_KEY_HEADER) {
|
||||
Some(v) if keys_match(v.as_bytes(), self.0.as_bytes()) => Ok(req),
|
||||
_ => Err(Status::permission_denied("missing or invalid internal key")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-side tonic interceptor: attaches the internal key to every call.
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcKeyAttach(MetadataValue<Ascii>);
|
||||
|
||||
impl GrpcKeyAttach {
|
||||
pub fn new(key: &str) -> Result<Self, tonic::metadata::errors::InvalidMetadataValue> {
|
||||
Ok(GrpcKeyAttach(MetadataValue::try_from(key)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interceptor for GrpcKeyAttach {
|
||||
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
|
||||
req.metadata_mut().insert(INTERNAL_KEY_HEADER, self.0.clone());
|
||||
Ok(req)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{routing::get, Router};
|
||||
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("/open", get(|| async { "open" }))
|
||||
.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);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_with_wrong_internal_key_is_forbidden() {
|
||||
let server = TestServer::new(app());
|
||||
server
|
||||
.get("/open")
|
||||
.add_header(INTERNAL_KEY_HEADER, "nope")
|
||||
.await
|
||||
.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn correct_key_passes_and_identity_is_read() {
|
||||
let server = TestServer::new(app());
|
||||
let id = Uuid::new_v4();
|
||||
let res = server
|
||||
.get("/whoami")
|
||||
.add_header(INTERNAL_KEY_HEADER, KEY)
|
||||
.add_header(ACCOUNT_ID_HEADER, id.to_string())
|
||||
.await;
|
||||
res.assert_status_ok();
|
||||
assert_eq!(res.text(), id.to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_or_garbage_identity_is_401() {
|
||||
let server = TestServer::new(app());
|
||||
server
|
||||
.get("/whoami")
|
||||
.add_header(INTERNAL_KEY_HEADER, KEY)
|
||||
.await
|
||||
.assert_status_unauthorized();
|
||||
server
|
||||
.get("/whoami")
|
||||
.add_header(INTERNAL_KEY_HEADER, KEY)
|
||||
.add_header(ACCOUNT_ID_HEADER, "not-a-uuid")
|
||||
.await
|
||||
.assert_status_unauthorized();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grpc_key_check_accepts_only_the_right_key() {
|
||||
let mut check = GrpcKeyCheck::new(KEY);
|
||||
let mut attach = GrpcKeyAttach::new(KEY).unwrap();
|
||||
let ok = attach.call(tonic::Request::new(())).unwrap();
|
||||
assert!(check.call(ok).is_ok());
|
||||
assert!(check.call(tonic::Request::new(())).is_err());
|
||||
}
|
||||
}
|
||||
150
backend/common/src/jwt.rs
Normal file
150
backend/common/src/jwt.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
use axum::http::{header::AUTHORIZATION, HeaderMap};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
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).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TokenType {
|
||||
Access,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
pub token_type: TokenType,
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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<Claims> {
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.leeway = 0;
|
||||
validation.set_required_spec_claims(&["exp", "sub"]);
|
||||
let claims = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&validation,
|
||||
)
|
||||
.ok()?
|
||||
.claims;
|
||||
(claims.token_type == expected).then_some(claims)
|
||||
}
|
||||
|
||||
/// The raw token from `Authorization: Bearer <token>`, if the header is
|
||||
/// present, valid ASCII, uses the Bearer scheme and is non-empty.
|
||||
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
|
||||
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
|
||||
let token = value.strip_prefix("Bearer ")?.trim();
|
||||
(!token.is_empty()).then_some(token)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn access_token_round_trips() {
|
||||
let id = Uuid::new_v4();
|
||||
let token = issue_access_token(id, "test-secret");
|
||||
let claims = verify_token(&token, "test-secret", TokenType::Access).expect("should decode");
|
||||
assert_eq!(claims.sub, id.to_string());
|
||||
assert_eq!(claims.token_type, TokenType::Access);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_secret_fails_verify() {
|
||||
let token = issue_access_token(Uuid::new_v4(), "test-secret");
|
||||
assert!(verify_token(&token, "other-secret", TokenType::Access).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_token_fails_verify() {
|
||||
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);
|
||||
assert!(verify_token(&token, "test-secret", TokenType::Access).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_signed_with_other_algorithm_is_rejected() {
|
||||
let claims = Claims {
|
||||
sub: Uuid::new_v4().to_string(),
|
||||
exp: (chrono::Utc::now().timestamp() + 600) as usize,
|
||||
token_type: TokenType::Access,
|
||||
};
|
||||
let hs512 = encode(
|
||||
&Header::new(Algorithm::HS512),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(b"test-secret"),
|
||||
)
|
||||
.unwrap();
|
||||
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();
|
||||
h.insert(AUTHORIZATION, "Bearer abc.def".parse().unwrap());
|
||||
assert_eq!(bearer_token(&h), Some("abc.def"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_token_rejects_missing_wrong_scheme_and_empty() {
|
||||
let mut h = HeaderMap::new();
|
||||
assert_eq!(bearer_token(&h), None);
|
||||
h.insert(AUTHORIZATION, "Basic abc".parse().unwrap());
|
||||
assert_eq!(bearer_token(&h), None);
|
||||
h.insert(AUTHORIZATION, "Bearer ".parse().unwrap());
|
||||
assert_eq!(bearer_token(&h), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_token_rejects_garbage_headers() {
|
||||
assert_eq!(bearer_token(&headers_with("Bearer not.a.jwt")).unwrap(), "not.a.jwt");
|
||||
}
|
||||
}
|
||||
8
backend/common/src/lib.rs
Normal file
8
backend/common/src/lib.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
pub mod internal;
|
||||
pub mod jwt;
|
||||
|
||||
pub mod pb {
|
||||
pub mod accounts {
|
||||
tonic::include_proto!("accounts.v1");
|
||||
}
|
||||
}
|
||||
1389
backend/configs-service/PLAN.md
Normal file
1389
backend/configs-service/PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +0,0 @@
|
|||
# configs-service (planned)
|
||||
|
||||
4 cloud config slots per account, share codes, public showcase (Подсистема 1,
|
||||
часть 2). See `TODO.md` (Фаза 10) and `backend/STRUCTURE.md`. No
|
||||
implementation plan yet.
|
||||
2345
backend/gateway/PLAN.md
Normal file
2345
backend/gateway/PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +0,0 @@
|
|||
# gateway (planned)
|
||||
|
||||
Routing + single JWT check point + rate limits in front of `accounts-service`,
|
||||
`configs-service`, `chat-service`. See `TODO.md` (Фаза 10) and
|
||||
`backend/STRUCTURE.md`. No implementation plan yet — starts after
|
||||
`accounts-service` (`backend/PLAN.md`) is done.
|
||||
|
|
@ -67,9 +67,30 @@ frontend/src/
|
|||
из `backend/PLAN.md` (Task 6: `RegisterResponse`, `LoginResponse` и т.д.),
|
||||
не `any`/`unknown` без сужения
|
||||
|
||||
## Тестирование (когда дойдёт до реализации)
|
||||
Колокация тестов рядом с кодом (`Component.test.tsx` рядом с `Component.tsx`),
|
||||
не отдельное дерево `tests/` — так тест физически не потеряется при
|
||||
удалении/переносе фичи.
|
||||
## Тестирование
|
||||
Тесты живут в подпапке `tests/` внутри фичи (или `shared/*`), которую они
|
||||
проверяют — не рядом с файлом (`Component.test.tsx`) и не в отдельном дереве
|
||||
на верхнем уровне. Это всё ещё удаляется вместе с фичей при её удалении/
|
||||
переносе, но не съедает бюджет "≤4 файла на папку" — колокация `X.test.tsx`
|
||||
рядом с `X.tsx` вдвое сокращала бы вместимость каждой папки под этим правилом.
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
features/
|
||||
auth/
|
||||
components/
|
||||
hooks/
|
||||
api.ts
|
||||
types.ts
|
||||
tests/
|
||||
api.test.ts
|
||||
components.test.tsx
|
||||
shared/
|
||||
ui/
|
||||
Button.tsx
|
||||
TextField.tsx
|
||||
tests/
|
||||
ui.test.tsx
|
||||
```
|
||||
|
||||
Источники: [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).
|
||||
|
|
|
|||
1273
frontend/PLAN.md
Normal file
1273
frontend/PLAN.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,23 +5,67 @@
|
|||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/comfortaa": "^5.3.0",
|
||||
"@fontsource/iosevka": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.4.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@7.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.4.0", "@csstools/css-color-parser": "^4.2.3", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.1", "lru-cache": "^11.5.3" } }, "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@9.2.1", "", { "dependencies": { "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.3" } }, "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.4.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.3", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.4.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.14", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.1", "", {}, "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="],
|
||||
|
||||
"@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
|
||||
|
||||
"@fontsource/comfortaa": ["@fontsource/comfortaa@5.3.0", "", {}, "sha512-ab4DERH0q6ko8QCpEZzOV9ebK39LgQ2oM91AkLNqu0VIxxwT0nV2D3sOuI7iLirWWq4IMooaIN6/2llxrDN2JA=="],
|
||||
|
||||
"@fontsource/iosevka": ["@fontsource/iosevka@5.3.0", "", {}, "sha512-RcG0v/65e4PTuThR/d7n9jj6PLlex7YUGaLa4ZpL+NnqArWQmD8Z+6gYBTxPMVWxYMRcEw6OF4rdt8XisnC5zA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
|
@ -72,6 +116,8 @@
|
|||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.85.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA=="],
|
||||
|
||||
"@remix-run/route-pattern": ["@remix-run/route-pattern@0.22.1", "", {}, "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.10", "", { "os": "android", "cpu": "arm" }, "sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.10", "", { "os": "android", "cpu": "arm64" }, "sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA=="],
|
||||
|
|
@ -134,6 +180,26 @@
|
|||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@tanstack/query-core": ["@tanstack/query-core@5.103.2", "", {}, "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A=="],
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.103.2", "", { "dependencies": { "@tanstack/query-core": "5.103.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.2", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="],
|
||||
|
||||
"@testing-library/user-event": ["@testing-library/user-event@14.6.7", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
|
||||
|
||||
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||
|
|
@ -182,20 +248,68 @@
|
|||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@5.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@5.0.1", "", {}, "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.25.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w=="],
|
||||
|
||||
"entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsdom": ["jsdom@30.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^7.0.0", "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", "undici": "^8.10.2", "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
|
@ -220,42 +334,108 @@
|
|||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
"lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@1.4.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="],
|
||||
|
||||
"obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="],
|
||||
|
||||
"oxlint": ["oxlint@1.85.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.85.0", "@oxlint/binding-android-arm64": "1.85.0", "@oxlint/binding-darwin-arm64": "1.85.0", "@oxlint/binding-darwin-x64": "1.85.0", "@oxlint/binding-freebsd-x64": "1.85.0", "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", "@oxlint/binding-linux-arm-musleabihf": "1.85.0", "@oxlint/binding-linux-arm64-gnu": "1.85.0", "@oxlint/binding-linux-arm64-musl": "1.85.0", "@oxlint/binding-linux-ppc64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-gnu": "1.85.0", "@oxlint/binding-linux-riscv64-musl": "1.85.0", "@oxlint/binding-linux-s390x-gnu": "1.85.0", "@oxlint/binding-linux-x64-gnu": "1.85.0", "@oxlint/binding-linux-x64-musl": "1.85.0", "@oxlint/binding-openharmony-arm64": "1.85.0", "@oxlint/binding-win32-arm64-msvc": "1.85.0", "@oxlint/binding-win32-ia32-msvc": "1.85.0", "@oxlint/binding-win32-x64-msvc": "1.85.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||
|
||||
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
||||
|
||||
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.10", "", { "dependencies": { "@oxc-project/types": "=0.151.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.10", "@rolldown/binding-android-arm64": "1.2.10", "@rolldown/binding-darwin-arm64": "1.2.10", "@rolldown/binding-darwin-x64": "1.2.10", "@rolldown/binding-freebsd-x64": "1.2.10", "@rolldown/binding-linux-arm-gnueabihf": "1.2.10", "@rolldown/binding-linux-arm64-gnu": "1.2.10", "@rolldown/binding-linux-arm64-musl": "1.2.10", "@rolldown/binding-linux-ppc64-gnu": "1.2.10", "@rolldown/binding-linux-s390x-gnu": "1.2.10", "@rolldown/binding-linux-x64-gnu": "1.2.10", "@rolldown/binding-linux-x64-musl": "1.2.10", "@rolldown/binding-openharmony-arm64": "1.2.10", "@rolldown/binding-win32-arm64-msvc": "1.2.10", "@rolldown/binding-win32-x64-msvc": "1.2.10" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinybench": ["tinybench@6.1.4", "", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tldts": ["tldts@7.4.15", "", { "dependencies": { "tldts-core": "^7.4.15" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.4.15", "", {}, "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"undici": ["undici@8.11.2", "", {}, "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
|
||||
|
||||
"vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@6.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@17.1.2", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
|
@ -268,6 +448,12 @@
|
|||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<meta name="theme-color" content="#0d1416" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Облачные конфиги, коды для друзей и витрина настроек для мода LoVisual."
|
||||
/>
|
||||
<title>LoVisual — аккаунт и облачные конфиги</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -7,21 +7,33 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource/comfortaa": "^5.3.0",
|
||||
"@fontsource/iosevka": "^5.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.4.0",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.0"
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,184 +0,0 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import heroImg from './assets/hero.png'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import './App.css'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const router = createBrowserRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
export default function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
10
frontend/src/features/auth/RequireAuth.tsx
Normal file
10
frontend/src/features/auth/RequireAuth.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Navigate, Outlet, useLocation } from 'react-router'
|
||||
import { useSession } from './session'
|
||||
|
||||
export function RequireAuth() {
|
||||
const { status } = useSession()
|
||||
const location = useLocation()
|
||||
if (status === 'loading') return <p className="text-frost">Проверяем вход…</p>
|
||||
if (status === 'anonymous') return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
return <Outlet />
|
||||
}
|
||||
21
frontend/src/features/auth/api.ts
Normal file
21
frontend/src/features/auth/api.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { api } from '../../shared/api/client'
|
||||
import type { Me } from '../../shared/types'
|
||||
|
||||
export async function login(email: string, password: string): Promise<void> {
|
||||
const res = await api.request<{ access_token: string }>('/auth/login', { method: 'POST', json: { email, password } })
|
||||
api.setAccessToken(res.access_token)
|
||||
}
|
||||
|
||||
export async function register(email: string, password: string, nick: string): Promise<void> {
|
||||
await api.request('/auth/register', { method: 'POST', json: { email, password, nick } })
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.request('/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
api.setAccessToken(null)
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchMe = () => api.request<Me>('/me')
|
||||
51
frontend/src/features/auth/forms/LoginForm.tsx
Normal file
51
frontend/src/features/auth/forms/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { TextField } from '../../../shared/ui/TextField'
|
||||
import { useSession } from '../session'
|
||||
|
||||
export function LoginForm() {
|
||||
const { signIn } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const from = (useLocation().state as { from?: string } | null)?.from ?? '/configs'
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
await signIn(email.trim(), password)
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 401: 'Неверная почта или пароль.' }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField label="Почта" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Войти
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Нет аккаунта? <Link to="/register" className="text-ice underline-offset-4 hover:underline">Создать</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
81
frontend/src/features/auth/forms/RegisterForm.tsx
Normal file
81
frontend/src/features/auth/forms/RegisterForm.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { describeError } from '../../../shared/api/errors'
|
||||
import { Button } from '../../../shared/ui/Button'
|
||||
import { Notice } from '../../../shared/ui/Notice'
|
||||
import { TextField } from '../../../shared/ui/TextField'
|
||||
import { useSession } from '../session'
|
||||
import { validateEmail, validateNick, validatePassword } from '../validation'
|
||||
|
||||
export function RegisterForm() {
|
||||
const { signUp } = useSession()
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [nick, setNick] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [emailError, setEmailError] = useState<string>()
|
||||
const [nickError, setNickError] = useState<string>()
|
||||
const [passwordError, setPasswordError] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(undefined)
|
||||
|
||||
const emailErr = validateEmail(email)
|
||||
const nickErr = validateNick(nick)
|
||||
const passwordErr = validatePassword(password)
|
||||
setEmailError(emailErr)
|
||||
setNickError(nickErr)
|
||||
setPasswordError(passwordErr)
|
||||
if (emailErr || nickErr || passwordErr) return
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
await signUp(email.trim(), password, nick.trim())
|
||||
navigate('/configs', { replace: true })
|
||||
} catch (err) {
|
||||
setError(describeError(err, { 409: 'Эта почта уже занята. Войди или используй другую.' }))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex w-full max-w-sm flex-col gap-4" noValidate>
|
||||
<TextField
|
||||
label="Почта"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
error={emailError}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Ник на сайте"
|
||||
autoComplete="nickname"
|
||||
value={nick}
|
||||
error={nickError}
|
||||
hint={nickError ? undefined : 'Не обязательно совпадает с ником в Minecraft.'}
|
||||
onChange={(e) => setNick(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
error={passwordError}
|
||||
hint={passwordError ? undefined : 'Минимум 8 символов.'}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{error ? <Notice tone="error">{error}</Notice> : null}
|
||||
<Button type="submit" busy={busy}>
|
||||
Создать аккаунт
|
||||
</Button>
|
||||
<p className="text-sm text-frost">
|
||||
Уже есть аккаунт? <Link to="/login" className="text-ice underline-offset-4 hover:underline">Войти</Link>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
60
frontend/src/features/auth/session.tsx
Normal file
60
frontend/src/features/auth/session.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
|
||||
import { api } from '../../shared/api/client'
|
||||
import type { Me } from '../../shared/types'
|
||||
import { fetchMe, login, logout, register } from './api'
|
||||
|
||||
type Session = {
|
||||
status: 'loading' | 'anonymous' | 'authenticated'
|
||||
me: Me | null
|
||||
signIn(email: string, password: string): Promise<void>
|
||||
signUp(email: string, password: string, nick: string): Promise<void>
|
||||
signOut(): Promise<void>
|
||||
}
|
||||
|
||||
const SessionContext = createContext<Session | null>(null)
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const client = useQueryClient()
|
||||
// Restores the session after a reload: the refresh cookie survives, the
|
||||
// in-memory access token does not.
|
||||
const restored = useQuery({ queryKey: ['session-restore'], queryFn: () => api.refresh(), staleTime: Infinity })
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, enabled: restored.data === true })
|
||||
|
||||
const signIn = useCallback(
|
||||
async (email: string, password: string) => {
|
||||
await login(email, password)
|
||||
client.setQueryData(['session-restore'], true)
|
||||
await client.fetchQuery({ queryKey: ['me'], queryFn: fetchMe })
|
||||
},
|
||||
[client],
|
||||
)
|
||||
|
||||
const signUp = useCallback(
|
||||
async (email: string, password: string, nick: string) => {
|
||||
await register(email, password, nick)
|
||||
await signIn(email, password)
|
||||
},
|
||||
[signIn],
|
||||
)
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await logout()
|
||||
client.clear()
|
||||
client.setQueryData(['session-restore'], false)
|
||||
}, [client])
|
||||
|
||||
const value = useMemo<Session>(() => {
|
||||
const me = meQuery.data ?? null
|
||||
const status = restored.isPending || (restored.data && meQuery.isPending) ? 'loading' : me ? 'authenticated' : 'anonymous'
|
||||
return { status, me, signIn, signUp, signOut }
|
||||
}, [restored.isPending, restored.data, meQuery.isPending, meQuery.data, signIn, signUp, signOut])
|
||||
|
||||
return <SessionContext value={value}>{children}</SessionContext>
|
||||
}
|
||||
|
||||
export function useSession(): Session {
|
||||
const session = useContext(SessionContext)
|
||||
if (!session) throw new Error('useSession outside SessionProvider')
|
||||
return session
|
||||
}
|
||||
53
frontend/src/features/auth/tests/forms.test.tsx
Normal file
53
frontend/src/features/auth/tests/forms.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test } from 'vitest'
|
||||
import { LoginForm } from '../forms/LoginForm'
|
||||
import { RegisterForm } from '../forms/RegisterForm'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
|
||||
const anon = { 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) }
|
||||
|
||||
test('wrong password shows a clear error', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ ...anon, 'POST /auth/login': () => json({ error: 'unauthorized' }, 401) })
|
||||
renderApp([{ path: '/login', element: <LoginForm /> }], '/login')
|
||||
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
|
||||
await user.type(screen.getByLabelText('Пароль'), 'wrong-password')
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Неверная почта или пароль.')
|
||||
})
|
||||
|
||||
test('successful login goes to configs', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ ...anon, 'POST /auth/login': () => json({ access_token: 't' }), 'GET /me': () => json(me) })
|
||||
renderApp([{ path: '/login', element: <LoginForm /> }, { path: '/configs', element: <p>Мои конфиги</p> }], '/login')
|
||||
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
|
||||
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
expect(await screen.findByText('Мои конфиги')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('register validates before calling the server', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetch = mockFetch(anon)
|
||||
renderApp([{ path: '/register', element: <RegisterForm /> }], '/register')
|
||||
await user.type(await screen.findByLabelText('Почта'), 'not-an-email')
|
||||
await user.type(screen.getByLabelText('Пароль'), 'short')
|
||||
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
|
||||
expect(screen.getByLabelText('Почта')).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByLabelText('Пароль')).toHaveAccessibleDescription('Минимум 8 символов.')
|
||||
expect(fetch.mock.calls.some(([u]) => String(u).includes('/auth/register'))).toBe(false)
|
||||
})
|
||||
|
||||
test('taken email explains what to do', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockFetch({ ...anon, 'POST /auth/register': () => json({ error: 'email already registered' }, 409) })
|
||||
renderApp([{ path: '/register', element: <RegisterForm /> }], '/register')
|
||||
await user.type(await screen.findByLabelText('Почта'), 'a@b.c')
|
||||
await user.type(screen.getByLabelText('Ник на сайте'), 'Rider')
|
||||
await user.type(screen.getByLabelText('Пароль'), 'correct-horse')
|
||||
await user.click(screen.getByRole('button', { name: 'Создать аккаунт' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Эта почта уже занята')
|
||||
})
|
||||
30
frontend/src/features/auth/tests/session.test.tsx
Normal file
30
frontend/src/features/auth/tests/session.test.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { screen } from '@testing-library/react'
|
||||
import { expect, test } from 'vitest'
|
||||
import { RequireAuth } from '../RequireAuth'
|
||||
import { useSession } from '../session'
|
||||
import { renderApp } from '../../../test/render'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
const me = { id: '1', email: 'a@b.c', display_nick: 'Rider', role: 'user', avatar_url: null, created_at: '2026-09-24T00:00:00Z' }
|
||||
|
||||
function Whoami() {
|
||||
const { me } = useSession()
|
||||
return <p>Привет, {me?.display_nick}</p>
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', element: <p>Страница входа</p> },
|
||||
{ element: <RequireAuth />, children: [{ path: '/configs', element: <Whoami /> }] },
|
||||
]
|
||||
|
||||
test('restores the session from the refresh cookie', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({ access_token: 't' }), 'GET /me': () => json(me) })
|
||||
renderApp(routes, '/configs')
|
||||
expect(await screen.findByText('Привет, Rider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('anonymous visitor is sent to login', async () => {
|
||||
mockFetch({ 'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401) })
|
||||
renderApp(routes, '/configs')
|
||||
expect(await screen.findByText('Страница входа')).toBeInTheDocument()
|
||||
})
|
||||
16
frontend/src/features/auth/validation.ts
Normal file
16
frontend/src/features/auth/validation.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export function validateEmail(email: string): string | undefined {
|
||||
const v = email.trim()
|
||||
if (!v) return 'Введи почту.'
|
||||
if (v.length > 254 || !/^[^@\s]+@[^@\s]+$/.test(v)) return 'Похоже, в почте опечатка.'
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): string | undefined {
|
||||
if ([...password].length < 8) return 'Минимум 8 символов.'
|
||||
if (new TextEncoder().encode(password).length > 256) return 'Слишком длинный пароль.'
|
||||
}
|
||||
|
||||
export function validateNick(nick: string): string | undefined {
|
||||
const len = [...nick.trim()].length
|
||||
if (len === 0) return 'Придумай ник.'
|
||||
if (len > 32) return 'Не длиннее 32 символов.'
|
||||
}
|
||||
|
|
@ -1 +1,52 @@
|
|||
@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";
|
||||
|
||||
@theme {
|
||||
--color-abyss: #0d1416;
|
||||
--color-slate: #142226;
|
||||
--color-shoal: #1f3238;
|
||||
--color-frost: #9fb3b0;
|
||||
--color-snow: #eef5f3;
|
||||
--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;
|
||||
|
||||
--text-hero: 3.8125rem;
|
||||
--text-hero--line-height: 1.05;
|
||||
--radius-panel: 10px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
@apply bg-abyss text-snow font-sans antialiased;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
@apply font-display font-bold;
|
||||
}
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-ice);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
frontend/src/pages/public/LoginPage.tsx
Normal file
10
frontend/src/pages/public/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { LoginForm } from '../../features/auth/forms/LoginForm'
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Вход</h1>
|
||||
<LoginForm />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
13
frontend/src/pages/public/RegisterPage.tsx
Normal file
13
frontend/src/pages/public/RegisterPage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { RegisterForm } from '../../features/auth/forms/RegisterForm'
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<section className="flex flex-col gap-6 py-12">
|
||||
<h1 className="text-3xl">Новый аккаунт</h1>
|
||||
<p className="max-w-sm text-frost">
|
||||
Аккаунт нужен для облачных конфигов и привязки игры. Пароль в мод вводить не придётся.
|
||||
</p>
|
||||
<RegisterForm />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
101
frontend/src/shared/api/client.ts
Normal file
101
frontend/src/shared/api/client.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly retryAfter: number | null
|
||||
|
||||
constructor(status: number, message: string, retryAfter: number | null = null) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.retryAfter = retryAfter
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestOptions = { method?: string; json?: unknown; body?: BodyInit; signal?: AbortSignal }
|
||||
|
||||
export type ApiClient = {
|
||||
request<T>(path: string, options?: RequestOptions): Promise<T>
|
||||
refresh(): Promise<boolean>
|
||||
setAccessToken(token: string | null): void
|
||||
hasAccessToken(): boolean
|
||||
}
|
||||
|
||||
async function toError(res: Response): Promise<ApiError> {
|
||||
let message = `HTTP ${res.status}`
|
||||
try {
|
||||
const body: unknown = await res.json()
|
||||
if (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string') message = body.error
|
||||
} catch {
|
||||
// non-JSON error body: keep the status text
|
||||
}
|
||||
const retry = Number(res.headers.get('retry-after'))
|
||||
return new ApiError(res.status, message, Number.isFinite(retry) && retry > 0 ? retry : null)
|
||||
}
|
||||
|
||||
export function createApiClient(
|
||||
baseUrl = '/api',
|
||||
// Resolved at call time so tests can stub the global fetch.
|
||||
fetchImpl: typeof fetch = (input, init) => fetch(input, init),
|
||||
): ApiClient {
|
||||
let accessToken: string | null = null
|
||||
let refreshing: Promise<boolean> | null = null
|
||||
|
||||
async function refreshOnce(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetchImpl(`${baseUrl}/auth/refresh`, { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
accessToken = null
|
||||
return false
|
||||
}
|
||||
const body = (await res.json()) as { access_token: string }
|
||||
accessToken = body.access_token
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function refresh(): Promise<boolean> {
|
||||
refreshing ??= refreshOnce().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
function send(path: string, options: RequestOptions): Promise<Response> {
|
||||
const headers = new Headers()
|
||||
let body = options.body
|
||||
if (options.json !== undefined) {
|
||||
headers.set('content-type', 'application/json')
|
||||
body = JSON.stringify(options.json)
|
||||
}
|
||||
if (accessToken) headers.set('authorization', `Bearer ${accessToken}`)
|
||||
return fetchImpl(`${baseUrl}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body,
|
||||
credentials: 'include',
|
||||
signal: options.signal,
|
||||
})
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
let res = await send(path, options)
|
||||
if (res.status === 401 && !path.startsWith('/auth/') && (await refresh())) {
|
||||
res = await send(path, options)
|
||||
}
|
||||
if (!res.ok) throw await toError(res)
|
||||
const text = await res.text()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
refresh,
|
||||
setAccessToken: (token) => {
|
||||
accessToken = token
|
||||
},
|
||||
hasAccessToken: () => accessToken !== null,
|
||||
}
|
||||
}
|
||||
|
||||
export const api = createApiClient()
|
||||
22
frontend/src/shared/api/errors.ts
Normal file
22
frontend/src/shared/api/errors.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { ApiError } from './client'
|
||||
|
||||
const BY_STATUS: Record<number, string> = {
|
||||
400: 'Проверь введённые данные.',
|
||||
401: 'Нужно войти в аккаунт.',
|
||||
403: 'Недостаточно прав.',
|
||||
404: 'Не найдено.',
|
||||
409: 'Уже существует.',
|
||||
}
|
||||
|
||||
export function describeError(err: unknown, overrides: Partial<Record<number, string>> = {}): string {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 429) {
|
||||
return err.retryAfter
|
||||
? `Слишком много попыток. Подожди ${err.retryAfter} с.`
|
||||
: 'Слишком много попыток. Подожди немного.'
|
||||
}
|
||||
if (err.status >= 500) return 'Сервер не ответил. Попробуй через минуту.'
|
||||
return overrides[err.status] ?? BY_STATUS[err.status] ?? 'Проверь введённые данные.'
|
||||
}
|
||||
return 'Нет связи с сервером. Проверь интернет и попробуй ещё раз.'
|
||||
}
|
||||
82
frontend/src/shared/api/tests/client.test.ts
Normal file
82
frontend/src/shared/api/tests/client.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { expect, test } from 'vitest'
|
||||
import { ApiError, createApiClient } from '../client'
|
||||
import { describeError } from '../errors'
|
||||
import { json, mockFetch } from '../../../test/fetch'
|
||||
|
||||
test('sends json with bearer token and cookies', async () => {
|
||||
const fetch = mockFetch({ 'PUT /configs/1': () => json({ ok: true }) })
|
||||
const api = createApiClient()
|
||||
api.setAccessToken('tok')
|
||||
await api.request('/configs/1', { method: 'PUT', json: { a: 1 } })
|
||||
const [, init] = fetch.mock.calls[0]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('authorization')).toBe('Bearer tok')
|
||||
expect(headers.get('content-type')).toBe('application/json')
|
||||
expect(init?.credentials).toBe('include')
|
||||
expect(init?.body).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
test('401 triggers one refresh and one retry', async () => {
|
||||
let calls = 0
|
||||
const fetch = mockFetch({
|
||||
'GET /me': () => (++calls === 1 ? json({ error: 'unauthorized' }, 401) : json({ id: 'x' })),
|
||||
'POST /auth/refresh': () => json({ access_token: 'new' }),
|
||||
})
|
||||
const api = createApiClient()
|
||||
await expect(api.request('/me')).resolves.toEqual({ id: 'x' })
|
||||
expect(fetch.mock.calls.filter(([u]) => String(u).endsWith('/auth/refresh'))).toHaveLength(1)
|
||||
expect(new Headers(fetch.mock.calls[2][1]?.headers).get('authorization')).toBe('Bearer new')
|
||||
})
|
||||
|
||||
test('concurrent 401s share a single refresh', async () => {
|
||||
let refreshes = 0
|
||||
mockFetch({
|
||||
'GET /a': (init) => (new Headers(init.headers).get('authorization') ? json(1) : json({}, 401)),
|
||||
'GET /b': (init) => (new Headers(init.headers).get('authorization') ? json(2) : json({}, 401)),
|
||||
'POST /auth/refresh': () => {
|
||||
refreshes++
|
||||
return json({ access_token: 't' })
|
||||
},
|
||||
})
|
||||
const api = createApiClient()
|
||||
await Promise.all([api.request('/a'), api.request('/b')])
|
||||
expect(refreshes).toBe(1)
|
||||
})
|
||||
|
||||
test('failed refresh surfaces the original 401 and clears the token', async () => {
|
||||
mockFetch({
|
||||
'GET /me': () => json({ error: 'unauthorized' }, 401),
|
||||
'POST /auth/refresh': () => json({ error: 'unauthorized' }, 401),
|
||||
})
|
||||
const api = createApiClient()
|
||||
api.setAccessToken('stale')
|
||||
await expect(api.request('/me')).rejects.toMatchObject({ status: 401 })
|
||||
expect(api.hasAccessToken()).toBe(false)
|
||||
})
|
||||
|
||||
test('auth endpoints never trigger refresh', async () => {
|
||||
const fetch = mockFetch({ 'POST /auth/login': () => json({ error: 'unauthorized' }, 401) })
|
||||
await expect(createApiClient().request('/auth/login', { method: 'POST', json: {} })).rejects.toBeInstanceOf(
|
||||
ApiError,
|
||||
)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('429 carries retry-after; 204 resolves undefined', async () => {
|
||||
mockFetch({
|
||||
'POST /auth/login': () => json({ error: 'too many requests' }, 429, { 'retry-after': '42' }),
|
||||
'DELETE /device/links/1': () => new Response(null, { status: 204 }),
|
||||
})
|
||||
const api = createApiClient()
|
||||
await expect(api.request('/auth/login', { method: 'POST' })).rejects.toMatchObject({ status: 429, retryAfter: 42 })
|
||||
await expect(api.request('/device/links/1', { method: 'DELETE' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
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(new ApiError(404, 'x'))).toBe('Не найдено.')
|
||||
})
|
||||
8
frontend/src/shared/types.ts
Normal file
8
frontend/src/shared/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export type Me = {
|
||||
id: string
|
||||
email: string
|
||||
display_nick: string
|
||||
role: 'user' | 'admin'
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
28
frontend/src/shared/ui/Button.tsx
Normal file
28
frontend/src/shared/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
type Variant = 'primary' | 'quiet' | 'danger'
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
primary: 'bg-ice text-abyss hover:bg-ice/90',
|
||||
quiet: 'bg-transparent text-snow border border-shoal hover:border-frost',
|
||||
danger: 'bg-transparent text-ember border border-ember/60 hover:border-ember',
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
busy = false,
|
||||
disabled,
|
||||
className = '',
|
||||
type = 'button',
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; busy?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled || busy}
|
||||
aria-busy={busy || undefined}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${VARIANTS[variant]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
10
frontend/src/shared/ui/Notice.tsx
Normal file
10
frontend/src/shared/ui/Notice.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Notice({ tone, children }: { tone: 'info' | 'error'; children: ReactNode }) {
|
||||
const styles = tone === 'error' ? 'border-ember/60 text-ember' : 'border-shoal text-frost'
|
||||
return (
|
||||
<div role={tone === 'error' ? 'alert' : 'status'} className={`rounded-md border px-3 py-2 text-sm ${styles}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/shared/ui/ShareCode.tsx
Normal file
29
frontend/src/shared/ui/ShareCode.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Button } from './Button'
|
||||
|
||||
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const t = setTimeout(() => setCopied(false), 2000)
|
||||
return () => clearTimeout(t)
|
||||
}, [copied])
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{label ? <span className="text-sm text-frost">{label}</span> : null}
|
||||
<code className="font-code text-lg tracking-widest text-ice">{code}</code>
|
||||
<Button variant="quiet" onClick={copy}>
|
||||
Скопировать
|
||||
</Button>
|
||||
<span aria-live="polite" className="text-sm text-frost">
|
||||
{copied ? 'Скопировано' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
frontend/src/shared/ui/TextField.tsx
Normal file
35
frontend/src/shared/ui/TextField.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { useId, type InputHTMLAttributes } from 'react'
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className = '',
|
||||
...rest
|
||||
}: InputHTMLAttributes<HTMLInputElement> & { label: string; error?: string; hint?: string }) {
|
||||
const id = useId()
|
||||
const describedBy = error ? `${id}-error` : hint ? `${id}-hint` : undefined
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 ${className}`}>
|
||||
<label htmlFor={id} className="text-sm text-frost">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className="rounded-md border border-shoal bg-abyss px-3 py-2 text-snow placeholder:text-frost/60 focus:border-ice aria-invalid:border-ember"
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p id={`${id}-error`} className="text-sm text-ember">
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p id={`${id}-hint`} className="text-sm text-frost">
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
frontend/src/shared/ui/tests/ui.test.tsx
Normal file
36
frontend/src/shared/ui/tests/ui.test.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { Button } from '../Button'
|
||||
import { TextField } from '../TextField'
|
||||
import { ShareCode } from '../ShareCode'
|
||||
import { Notice } from '../Notice'
|
||||
|
||||
test('busy button is disabled and announces busy', () => {
|
||||
render(<Button busy>Сохранить</Button>)
|
||||
const b = screen.getByRole('button', { name: 'Сохранить' })
|
||||
expect(b).toBeDisabled()
|
||||
expect(b).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
|
||||
test('text field links label and error', () => {
|
||||
render(<TextField label="Почта" error="Введи почту" />)
|
||||
const input = screen.getByLabelText('Почта')
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(input).toHaveAccessibleDescription('Введи почту')
|
||||
})
|
||||
|
||||
test('share code copies to clipboard and confirms', async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
|
||||
render(<ShareCode code="7KQ3M9XA" />)
|
||||
expect(screen.getByText('7KQ3M9XA')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Скопировать' }))
|
||||
expect(writeText).toHaveBeenCalledWith('7KQ3M9XA')
|
||||
expect(await screen.findByText('Скопировано')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('error notice is an alert', () => {
|
||||
render(<Notice tone="error">Неверный код</Notice>)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Неверный код')
|
||||
})
|
||||
21
frontend/src/test/fetch.ts
Normal file
21
frontend/src/test/fetch.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { vi } from 'vitest'
|
||||
|
||||
type Handler = (init: RequestInit) => Response | Promise<Response>
|
||||
|
||||
export function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
})
|
||||
}
|
||||
|
||||
export function mockFetch(routes: Record<string, Handler>) {
|
||||
const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const url = new URL(String(input), 'http://localhost')
|
||||
const key = `${(init.method ?? 'GET').toUpperCase()} ${url.pathname.replace(/^\/api/, '')}`
|
||||
const handler = routes[key]
|
||||
return handler ? handler(init) : json({ error: `no mock for ${key}` }, 404)
|
||||
})
|
||||
vi.stubGlobal('fetch', fn)
|
||||
return fn
|
||||
}
|
||||
17
frontend/src/test/render.tsx
Normal file
17
frontend/src/test/render.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router'
|
||||
import { SessionProvider } from '../features/auth/session'
|
||||
|
||||
export function renderApp(routes: RouteObject[], initialPath = '/') {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const router = createMemoryRouter(routes, { initialEntries: [initialPath] })
|
||||
const result = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
return { ...result, router, client }
|
||||
}
|
||||
6
frontend/src/test/setup.ts
Normal file
6
frontend/src/test/setup.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
|
||||
afterEach(() => cleanup())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
9
frontend/src/test/smoke.test.tsx
Normal file
9
frontend/src/test/smoke.test.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test('router renders a route', () => {
|
||||
const router = createMemoryRouter([{ path: '/', element: <h1>LoVisual</h1> }])
|
||||
render(<RouterProvider router={router} />)
|
||||
expect(screen.getByRole('heading', { name: 'LoVisual' })).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -1,8 +1,24 @@
|
|||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
// Same-origin in dev: the browser talks to /api, Vite forwards to the gateway.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
// The refresh cookie is scoped to Path=/auth by the backend; in dev the
|
||||
// browser sees it under /api/auth, so rewrite the path accordingly.
|
||||
cookiePathRewrite: { '/auth': '/api/auth' },
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
17
mod/TODO.md
17
mod/TODO.md
|
|
@ -313,7 +313,9 @@
|
|||
(Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) —
|
||||
типы частиц в `visuals/effects/ambient/`
|
||||
- [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!)
|
||||
- [ ] LoVisualAddonManagerScreen 989 — как AltManager — как AltManager
|
||||
- [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/
|
||||
(Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183
|
||||
+ render/cards/ 79/88), все ≤200 — по образцу AltManager
|
||||
- [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий)
|
||||
- [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы
|
||||
- [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния
|
||||
|
|
@ -331,6 +333,12 @@
|
|||
CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663,
|
||||
DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617,
|
||||
ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.)
|
||||
- [x] Волна 2026-09-24 (HUD/settings): NameTags 453→223 (nametags/impl/ Keys+Collector+Projection) b1414d7a,
|
||||
MainSettingsComponent 448→291 (+Scroll+Model) 3f0d452f, LinuxMediaSession 441→148 3d11bb4e,
|
||||
CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы
|
||||
+bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7,
|
||||
CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435.
|
||||
bars/ закрыт: все файлы ≤200, impl/ = 3 файла.
|
||||
|
||||
### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить»
|
||||
- [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом
|
||||
|
|
@ -343,10 +351,15 @@
|
|||
- [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и
|
||||
ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов
|
||||
не трогают string-literals id — проверять grep'ом после moves.
|
||||
- [ ] Критерий закрытия для старта 9.2: ноль файлов >200 в путях, которые трогает Optimize
|
||||
- [x] Критерий закрытия для старта 9.2: ноль файлов >200 в путях, которые трогает Optimize
|
||||
(Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect,
|
||||
MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем
|
||||
244/86 — долгий хвост, 9.2 им не блокируется.
|
||||
→ 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓
|
||||
(glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >200:
|
||||
TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами
|
||||
(targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) →
|
||||
исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ.
|
||||
- [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики).
|
||||
|
||||
## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.features.command.impl;
|
||||
|
||||
import dev.loki.lovisual.features.command.ClientCommand;
|
||||
|
|
|
|||
247
mod/src/main/java/dev/loki/lovisual/HudRender.java
Normal file
247
mod/src/main/java/dev/loki/lovisual/HudRender.java
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
package dev.loki.lovisual;
|
||||
|
||||
import dev.loki.lovisual.addon.pipeline.AddonRenderPipelineManager;
|
||||
import dev.loki.lovisual.api.v0.render.LoVisualRenderStage;
|
||||
import dev.loki.lovisual.features.gui.hud.anchor.HudRenderSpace;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.buttons.SwapTooltip;
|
||||
import dev.loki.lovisual.features.module.core.ModuleManager;
|
||||
import dev.loki.lovisual.features.module.phase.HudPhase;
|
||||
import dev.loki.lovisual.features.module.phase.WorldPhase;
|
||||
import dev.loki.lovisual.mixininterface.render.IGuiGraphics;
|
||||
import dev.loki.lovisual.render.engine.core.frame.RenderPhase;
|
||||
import dev.loki.lovisual.render.engine.core.frame.RenderPhaseScope;
|
||||
import dev.loki.lovisual.render.engine.core.context.ViewportContext;
|
||||
import dev.loki.lovisual.render.engine.core.system.LoVisualRenderSystem;
|
||||
import dev.loki.lovisual.render.engine.profiler.config.ProfilerPhase;
|
||||
import dev.loki.lovisual.render.engine.profiler.render.RenderProfiler2D;
|
||||
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
||||
import dev.loki.lovisual.render.helpers.util.SystemCursor;
|
||||
import dev.loki.lovisual.render.helpers.util.TickDelta;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
|
||||
/**
|
||||
* HUD and world-phase rendering driven by the client entry point. Hosts the raw/scaled/logical
|
||||
* HUD pass orchestration that {@link LoVisual} registers against the Fabric HUD and level render
|
||||
* hooks, so behaviour stays identical to the inlined implementation.
|
||||
*/
|
||||
final class HudRender {
|
||||
private HudRender() {
|
||||
}
|
||||
|
||||
static void renderWorldPhase(WorldPhase phase,
|
||||
com.mojang.blaze3d.vertex.PoseStack matrices,
|
||||
net.minecraft.client.renderer.SubmitNodeCollector consumers) {
|
||||
if (!Lifecycle.canRunRender()) return;
|
||||
|
||||
ModuleManager.renderWorld(
|
||||
phase,
|
||||
matrices,
|
||||
consumers,
|
||||
TickDelta.tickProgress(false)
|
||||
);
|
||||
}
|
||||
|
||||
private static void refreshHudFrameTiming(DeltaTracker tickCounter) {
|
||||
LoVisualRenderSystem.updateFrameTiming(
|
||||
TickDelta.tickProgress(tickCounter, false),
|
||||
TickDelta.frameDeltaTicks(tickCounter),
|
||||
TickDelta.fixedDeltaTicks(tickCounter)
|
||||
);
|
||||
}
|
||||
|
||||
static void renderHudPhase(HudPhase phase, GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
|
||||
if (!Lifecycle.canRunHud()) return;
|
||||
float tickProgress = TickDelta.tickProgress(tickCounter, false);
|
||||
ModuleManager.renderHud(phase, ctx, tickProgress);
|
||||
Renderer2D.Deferred2DLayer layer = deferredLayerForHudPhase(phase);
|
||||
if (renderHudEngine(phase, layer, ctx, tickCounter)) {
|
||||
appendHudDeferredMarker(layer, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean renderHudEngine(HudPhase phase,
|
||||
Renderer2D.Deferred2DLayer layer,
|
||||
GuiGraphicsExtractor ctx,
|
||||
DeltaTracker tickCounter) {
|
||||
boolean rawMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW);
|
||||
boolean scaledMain = hasHudMainPassWork(phase, HudRenderSpace.SCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED);
|
||||
boolean logicalMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL);
|
||||
boolean rawForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW_FOREGROUND);
|
||||
boolean scaledForeground = hasHudForegroundPassWork(phase, HudRenderSpace.SCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED_FOREGROUND);
|
||||
boolean logicalForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND);
|
||||
if (!rawMain && !scaledMain && !logicalMain && !rawForeground && !scaledForeground && !logicalForeground) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float tickProgress = TickDelta.tickProgress(tickCounter, false);
|
||||
TextRenderer textRenderer = TextRenderer.get();
|
||||
|
||||
FullScreenRenderer.ensureInit();
|
||||
ViewportContext.beginUnscaled(ctx);
|
||||
refreshHudFrameTiming(tickCounter);
|
||||
|
||||
Renderer2D.withDeferredLayer(layer, () -> {
|
||||
SystemCursor.beginFrame(ctx);
|
||||
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_MAIN, "2d:phase:" + phase.name())) {
|
||||
// Raw HUD pass
|
||||
if (rawMain) {
|
||||
Renderer2D.COLOR.begin();
|
||||
ModuleManager.renderHudEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Scaled fixed HUD pass
|
||||
if (scaledMain) {
|
||||
ViewportContext.beginScaled(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Logical HUD pass
|
||||
if (logicalMain) {
|
||||
ViewportContext.beginUnscaledLogical(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
DraggableHudElementRegistry.renderAllEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Raw foreground
|
||||
if (rawForeground) {
|
||||
ViewportContext.beginUnscaled(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
ModuleManager.renderHudEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Scaled fixed HUD foreground
|
||||
if (scaledForeground) {
|
||||
ViewportContext.beginScaled(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Logical foreground
|
||||
if (logicalForeground) {
|
||||
ViewportContext.beginUnscaledLogical(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
DraggableHudElementRegistry.renderAllEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
} finally {
|
||||
SystemCursor.endFrame();
|
||||
ViewportContext.end(ctx);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
static void renderHudAfterHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
|
||||
if (!Lifecycle.canRunHud()) return;
|
||||
boolean swapTooltipWork = SwapTooltip.hasWork();
|
||||
boolean targetHudWork = DraggableHudElementRegistry.hasEngineWidgetWork("target_hud");
|
||||
if (!swapTooltipWork && !targetHudWork) return;
|
||||
|
||||
float tickProgress = TickDelta.tickProgress(tickCounter, false);
|
||||
TextRenderer textRenderer = TextRenderer.get();
|
||||
Renderer2D.Deferred2DLayer layer = Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
|
||||
|
||||
FullScreenRenderer.ensureInit();
|
||||
ViewportContext.beginUnscaled(ctx);
|
||||
refreshHudFrameTiming(tickCounter);
|
||||
|
||||
Renderer2D.withDeferredLayer(layer, () -> {
|
||||
SystemCursor.beginFrame(ctx);
|
||||
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_EFFECTS, "2d:after_hotbar")) {
|
||||
// Raw pass
|
||||
if (swapTooltipWork) {
|
||||
try (ProfilerPhase.Scope tooltipsScope = ProfilerPhase.scope("2d:after_hotbar:swap_tooltip");
|
||||
RenderProfiler2D.Section ignoredTooltips = RenderProfiler2D.section("swap_tooltip")) {
|
||||
Renderer2D.COLOR.begin();
|
||||
SwapTooltip.render();
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Logical pass
|
||||
if (targetHudWork) {
|
||||
ViewportContext.beginUnscaledLogical(ctx);
|
||||
try (ProfilerPhase.Scope targetHudScope = ProfilerPhase.scope("2d:after_hotbar:target_hud");
|
||||
RenderProfiler2D.Section ignoredTargetHud = RenderProfiler2D.section("target_hud")) {
|
||||
Renderer2D.COLOR.begin();
|
||||
DraggableHudElementRegistry.renderEngineWidget(
|
||||
"target_hud",
|
||||
Renderer2D.COLOR,
|
||||
textRenderer,
|
||||
ctx,
|
||||
tickProgress
|
||||
);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
SystemCursor.endFrame();
|
||||
ViewportContext.end(ctx);
|
||||
}
|
||||
});
|
||||
appendHudDeferredMarker(layer, ctx);
|
||||
}
|
||||
|
||||
private static void appendHudDeferredMarker(Renderer2D.Deferred2DLayer layer, GuiGraphicsExtractor ctx) {
|
||||
if (!(ctx instanceof IGuiGraphics graphics)) return;
|
||||
ctx.nextStratum();
|
||||
graphics.lovisual$addDeferredHudMarker(layer);
|
||||
ctx.nextStratum();
|
||||
}
|
||||
|
||||
private static Renderer2D.Deferred2DLayer deferredLayerForHudPhase(HudPhase phase) {
|
||||
if (phase == null) return Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
|
||||
return switch (phase) {
|
||||
case NONE -> Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
|
||||
case FIRST -> Renderer2D.Deferred2DLayer.HUD_FIRST;
|
||||
case BEFORE_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_BEFORE_MISC_OVERLAYS;
|
||||
case AFTER_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
|
||||
case AFTER_BOSS_BAR -> Renderer2D.Deferred2DLayer.HUD_AFTER_BOSS_BAR;
|
||||
case BEFORE_DEMO_TIMER -> Renderer2D.Deferred2DLayer.HUD_BEFORE_DEMO_TIMER;
|
||||
case BEFORE_CHAT -> Renderer2D.Deferred2DLayer.HUD_BEFORE_CHAT;
|
||||
case AFTER_SUBTITLES -> Renderer2D.Deferred2DLayer.HUD_AFTER_SUBTITLES;
|
||||
case LAST -> Renderer2D.Deferred2DLayer.HUD_LAST;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean hasHudMainPassWork(HudPhase phase, HudRenderSpace space) {
|
||||
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineWork(phase);
|
||||
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, false);
|
||||
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, false);
|
||||
return moduleWork || staticWork || draggableWork;
|
||||
}
|
||||
|
||||
private static boolean hasHudForegroundPassWork(HudPhase phase, HudRenderSpace space) {
|
||||
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineForegroundWork(phase);
|
||||
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, true);
|
||||
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, true);
|
||||
return moduleWork || staticWork || draggableWork;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual;
|
||||
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual;
|
||||
|
||||
import dev.loki.lovisual.events.impl.render.GameTickEvent;
|
||||
|
|
@ -14,12 +7,8 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
|
|||
import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements;
|
||||
import net.fabricmc.fabric.api.client.rendering.v1.level.LevelRenderEvents;
|
||||
import net.minecraft.client.DeltaTracker;
|
||||
import net.minecraft.client.gui.GuiGraphicsExtractor;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import dev.loki.lovisual.addon.core.AddonManager;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonRenderPipelineManager;
|
||||
import dev.loki.lovisual.api.v0.render.LoVisualRenderStage;
|
||||
import dev.loki.lovisual.config.i18n.MainConfig;
|
||||
import dev.loki.lovisual.events.Events;
|
||||
import dev.loki.lovisual.features.account.AccountConfig;
|
||||
|
|
@ -27,33 +16,19 @@ import dev.loki.lovisual.features.command.core.CommandManager;
|
|||
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
|
||||
import dev.loki.lovisual.features.gui.clickgui.settings.i18n.I18nPreflightContributors;
|
||||
import dev.loki.lovisual.features.gui.hud.registry.HudElements;
|
||||
import dev.loki.lovisual.features.gui.hud.anchor.HudRenderSpace;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementBootstrap;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.impl.buttons.SwapTooltip;
|
||||
import dev.loki.lovisual.features.module.phase.HudPhase;
|
||||
import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader;
|
||||
import dev.loki.lovisual.features.module.core.ModuleManager;
|
||||
import dev.loki.lovisual.features.module.phase.WorldPhase;
|
||||
import dev.loki.lovisual.mixininterface.render.IGuiGraphics;
|
||||
import dev.loki.lovisual.render.effects.NetherPortalRiftPass;
|
||||
import dev.loki.lovisual.render.effects.SleepOverlayPass;
|
||||
import dev.loki.lovisual.render.engine.LoVisualRenderEngineBootstrap;
|
||||
import dev.loki.lovisual.render.engine.core.system.LoVisualRenderSystem;
|
||||
import dev.loki.lovisual.render.engine.core.frame.RenderPhase;
|
||||
import dev.loki.lovisual.render.engine.core.frame.RenderPhaseScope;
|
||||
import dev.loki.lovisual.render.engine.core.context.ViewportContext;
|
||||
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessManager;
|
||||
import dev.loki.lovisual.render.engine.profiler.config.ProfilerPhase;
|
||||
import dev.loki.lovisual.render.engine.profiler.render.RenderProfiler2D;
|
||||
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
|
||||
import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmContributors;
|
||||
import dev.loki.lovisual.render.engine.prewarm.RenderPrewarmManager;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
|
||||
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
|
||||
import dev.loki.lovisual.render.helpers.util.SystemCursor;
|
||||
import dev.loki.lovisual.render.helpers.util.TickDelta;
|
||||
import dev.loki.lovisual.util.aiming.rotation.RotationManager;
|
||||
import dev.loki.lovisual.util.block.bed.SelfBedTracker;
|
||||
import dev.loki.lovisual.util.click.AttackPressing;
|
||||
|
|
@ -71,223 +46,9 @@ import dev.loki.lovisual.util.pvp.tracker.PvpTargetTracker;
|
|||
import dev.loki.lovisual.util.pvp.tracker.PvpTracker;
|
||||
import dev.loki.lovisual.util.target.TargetManager;
|
||||
import dev.loki.lovisual.util.time.TimerController;
|
||||
import dev.loki.lovisual.features.gui.clickgui.settings.bind.KeyBindSetting;
|
||||
import dev.loki.lovisual.features.module.core.Module;
|
||||
|
||||
public class LoVisual implements ClientModInitializer {
|
||||
|
||||
private static void renderWorldPhase(WorldPhase phase,
|
||||
com.mojang.blaze3d.vertex.PoseStack matrices,
|
||||
net.minecraft.client.renderer.SubmitNodeCollector consumers) {
|
||||
if (!Lifecycle.canRunRender()) return;
|
||||
|
||||
ModuleManager.renderWorld(
|
||||
phase,
|
||||
matrices,
|
||||
consumers,
|
||||
TickDelta.tickProgress(false)
|
||||
);
|
||||
}
|
||||
|
||||
private static void refreshHudFrameTiming(DeltaTracker tickCounter) {
|
||||
LoVisualRenderSystem.updateFrameTiming(
|
||||
TickDelta.tickProgress(tickCounter, false),
|
||||
TickDelta.frameDeltaTicks(tickCounter),
|
||||
TickDelta.fixedDeltaTicks(tickCounter)
|
||||
);
|
||||
}
|
||||
|
||||
private static void renderHudPhase(HudPhase phase, GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
|
||||
if (!Lifecycle.canRunHud()) return;
|
||||
float tickProgress = TickDelta.tickProgress(tickCounter, false);
|
||||
ModuleManager.renderHud(phase, ctx, tickProgress);
|
||||
Renderer2D.Deferred2DLayer layer = deferredLayerForHudPhase(phase);
|
||||
if (renderHudEngine(phase, layer, ctx, tickCounter)) {
|
||||
appendHudDeferredMarker(layer, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean renderHudEngine(HudPhase phase,
|
||||
Renderer2D.Deferred2DLayer layer,
|
||||
GuiGraphicsExtractor ctx,
|
||||
DeltaTracker tickCounter) {
|
||||
boolean rawMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW);
|
||||
boolean scaledMain = hasHudMainPassWork(phase, HudRenderSpace.SCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED);
|
||||
boolean logicalMain = hasHudMainPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL);
|
||||
boolean rawForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_RAW_FOREGROUND);
|
||||
boolean scaledForeground = hasHudForegroundPassWork(phase, HudRenderSpace.SCALED)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_SCALED_FOREGROUND);
|
||||
boolean logicalForeground = hasHudForegroundPassWork(phase, HudRenderSpace.UNSCALED_LOGICAL)
|
||||
|| AddonRenderPipelineManager.hasActiveCallbacks(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND);
|
||||
if (!rawMain && !scaledMain && !logicalMain && !rawForeground && !scaledForeground && !logicalForeground) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float tickProgress = TickDelta.tickProgress(tickCounter, false);
|
||||
TextRenderer textRenderer = TextRenderer.get();
|
||||
|
||||
FullScreenRenderer.ensureInit();
|
||||
ViewportContext.beginUnscaled(ctx);
|
||||
refreshHudFrameTiming(tickCounter);
|
||||
|
||||
Renderer2D.withDeferredLayer(layer, () -> {
|
||||
SystemCursor.beginFrame(ctx);
|
||||
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_MAIN, "2d:phase:" + phase.name())) {
|
||||
// Raw HUD pass
|
||||
if (rawMain) {
|
||||
Renderer2D.COLOR.begin();
|
||||
ModuleManager.renderHudEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Scaled fixed HUD pass
|
||||
if (scaledMain) {
|
||||
ViewportContext.beginScaled(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Logical HUD pass
|
||||
if (logicalMain) {
|
||||
ViewportContext.beginUnscaledLogical(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngine(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
DraggableHudElementRegistry.renderAllEngine(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Raw foreground
|
||||
if (rawForeground) {
|
||||
ViewportContext.beginUnscaled(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
ModuleManager.renderHudEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_RAW_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Scaled fixed HUD foreground
|
||||
if (scaledForeground) {
|
||||
ViewportContext.beginScaled(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.SCALED, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_SCALED_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
|
||||
// Logical foreground
|
||||
if (logicalForeground) {
|
||||
ViewportContext.beginUnscaledLogical(ctx);
|
||||
Renderer2D.COLOR.begin();
|
||||
StaticHudElementRegistry.renderAllEngineForeground(phase, HudRenderSpace.UNSCALED_LOGICAL, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
DraggableHudElementRegistry.renderAllEngineForeground(phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
AddonRenderPipelineManager.render2D(LoVisualRenderStage.HUD_LOGICAL_FOREGROUND, phase, Renderer2D.COLOR, textRenderer, ctx, tickProgress);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
} finally {
|
||||
SystemCursor.endFrame();
|
||||
ViewportContext.end(ctx);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void renderHudAfterHotbar(GuiGraphicsExtractor ctx, DeltaTracker tickCounter) {
|
||||
if (!Lifecycle.canRunHud()) return;
|
||||
boolean swapTooltipWork = SwapTooltip.hasWork();
|
||||
boolean targetHudWork = DraggableHudElementRegistry.hasEngineWidgetWork("target_hud");
|
||||
if (!swapTooltipWork && !targetHudWork) return;
|
||||
|
||||
float tickProgress = TickDelta.tickProgress(tickCounter, false);
|
||||
TextRenderer textRenderer = TextRenderer.get();
|
||||
Renderer2D.Deferred2DLayer layer = Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
|
||||
|
||||
FullScreenRenderer.ensureInit();
|
||||
ViewportContext.beginUnscaled(ctx);
|
||||
refreshHudFrameTiming(tickCounter);
|
||||
|
||||
Renderer2D.withDeferredLayer(layer, () -> {
|
||||
SystemCursor.beginFrame(ctx);
|
||||
try (RenderPhaseScope hudPhase = LoVisualRenderSystem.phase(RenderPhase.HUD_EFFECTS, "2d:after_hotbar")) {
|
||||
// Raw pass
|
||||
if (swapTooltipWork) {
|
||||
try (ProfilerPhase.Scope tooltipsScope = ProfilerPhase.scope("2d:after_hotbar:swap_tooltip");
|
||||
RenderProfiler2D.Section ignoredTooltips = RenderProfiler2D.section("swap_tooltip")) {
|
||||
Renderer2D.COLOR.begin();
|
||||
SwapTooltip.render();
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Logical pass
|
||||
if (targetHudWork) {
|
||||
ViewportContext.beginUnscaledLogical(ctx);
|
||||
try (ProfilerPhase.Scope targetHudScope = ProfilerPhase.scope("2d:after_hotbar:target_hud");
|
||||
RenderProfiler2D.Section ignoredTargetHud = RenderProfiler2D.section("target_hud")) {
|
||||
Renderer2D.COLOR.begin();
|
||||
DraggableHudElementRegistry.renderEngineWidget(
|
||||
"target_hud",
|
||||
Renderer2D.COLOR,
|
||||
textRenderer,
|
||||
ctx,
|
||||
tickProgress
|
||||
);
|
||||
Renderer2D.COLOR.render();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
SystemCursor.endFrame();
|
||||
ViewportContext.end(ctx);
|
||||
}
|
||||
});
|
||||
appendHudDeferredMarker(layer, ctx);
|
||||
}
|
||||
|
||||
private static void appendHudDeferredMarker(Renderer2D.Deferred2DLayer layer, GuiGraphicsExtractor ctx) {
|
||||
if (!(ctx instanceof IGuiGraphics graphics)) return;
|
||||
ctx.nextStratum();
|
||||
graphics.lovisual$addDeferredHudMarker(layer);
|
||||
ctx.nextStratum();
|
||||
}
|
||||
|
||||
private static Renderer2D.Deferred2DLayer deferredLayerForHudPhase(HudPhase phase) {
|
||||
if (phase == null) return Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
|
||||
return switch (phase) {
|
||||
case NONE -> Renderer2D.Deferred2DLayer.BEFORE_VANILLA_GUI;
|
||||
case FIRST -> Renderer2D.Deferred2DLayer.HUD_FIRST;
|
||||
case BEFORE_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_BEFORE_MISC_OVERLAYS;
|
||||
case AFTER_MISC_OVERLAYS -> Renderer2D.Deferred2DLayer.HUD_AFTER_MISC_OVERLAYS;
|
||||
case AFTER_BOSS_BAR -> Renderer2D.Deferred2DLayer.HUD_AFTER_BOSS_BAR;
|
||||
case BEFORE_DEMO_TIMER -> Renderer2D.Deferred2DLayer.HUD_BEFORE_DEMO_TIMER;
|
||||
case BEFORE_CHAT -> Renderer2D.Deferred2DLayer.HUD_BEFORE_CHAT;
|
||||
case AFTER_SUBTITLES -> Renderer2D.Deferred2DLayer.HUD_AFTER_SUBTITLES;
|
||||
case LAST -> Renderer2D.Deferred2DLayer.HUD_LAST;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean hasHudMainPassWork(HudPhase phase, HudRenderSpace space) {
|
||||
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineWork(phase);
|
||||
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, false);
|
||||
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, false);
|
||||
return moduleWork || staticWork || draggableWork;
|
||||
}
|
||||
|
||||
private static boolean hasHudForegroundPassWork(HudPhase phase, HudRenderSpace space) {
|
||||
boolean moduleWork = space == HudRenderSpace.UNSCALED && ModuleManager.hasHudEngineForegroundWork(phase);
|
||||
boolean staticWork = StaticHudElementRegistry.hasEngineWork(phase, space, true);
|
||||
boolean draggableWork = space == HudRenderSpace.UNSCALED_LOGICAL && DraggableHudElementRegistry.hasEngineWork(phase, true);
|
||||
return moduleWork || staticWork || draggableWork;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInitializeClient() {
|
||||
LoVisualRenderEngineBootstrap.init();
|
||||
|
|
@ -352,25 +113,25 @@ public class LoVisual implements ClientModInitializer {
|
|||
|
||||
/* ====================== WORLD RENDER ====================== */
|
||||
|
||||
LevelRenderEvents.COLLECT_SUBMITS.register(ctx -> renderWorldPhase(
|
||||
LevelRenderEvents.COLLECT_SUBMITS.register(ctx -> HudRender.renderWorldPhase(
|
||||
WorldPhase.BEFORE_ENTITIES,
|
||||
ctx.poseStack(),
|
||||
ctx.submitNodeCollector()
|
||||
));
|
||||
|
||||
LevelRenderEvents.AFTER_SOLID_FEATURES.register(ctx -> renderWorldPhase(
|
||||
LevelRenderEvents.AFTER_SOLID_FEATURES.register(ctx -> HudRender.renderWorldPhase(
|
||||
WorldPhase.AFTER_ENTITIES,
|
||||
ctx.poseStack(),
|
||||
ctx.submitNodeCollector()
|
||||
));
|
||||
|
||||
LevelRenderEvents.BEFORE_TRANSLUCENT_TERRAIN.register(ctx -> renderWorldPhase(
|
||||
LevelRenderEvents.BEFORE_TRANSLUCENT_TERRAIN.register(ctx -> HudRender.renderWorldPhase(
|
||||
WorldPhase.BEFORE_TRANSLUCENT,
|
||||
ctx.poseStack(),
|
||||
ctx.submitNodeCollector()
|
||||
));
|
||||
|
||||
LevelRenderEvents.END_MAIN.register(ctx -> renderWorldPhase(
|
||||
LevelRenderEvents.END_MAIN.register(ctx -> HudRender.renderWorldPhase(
|
||||
WorldPhase.END_MAIN,
|
||||
ctx.poseStack(),
|
||||
ctx.submitNodeCollector()
|
||||
|
|
@ -380,55 +141,55 @@ public class LoVisual implements ClientModInitializer {
|
|||
|
||||
HudElementRegistry.addFirst(
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_first"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.FIRST, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.FIRST, ctx, tc)
|
||||
);
|
||||
|
||||
HudElementRegistry.addLast(
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_last"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.LAST, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.LAST, ctx, tc)
|
||||
);
|
||||
|
||||
// остальные позиции HUD:
|
||||
HudElementRegistry.attachElementBefore(
|
||||
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS,
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_before_misc"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.BEFORE_MISC_OVERLAYS, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.BEFORE_MISC_OVERLAYS, ctx, tc)
|
||||
);
|
||||
|
||||
HudElementRegistry.attachElementAfter(
|
||||
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.MISC_OVERLAYS,
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_after_misc"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.AFTER_MISC_OVERLAYS, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.AFTER_MISC_OVERLAYS, ctx, tc)
|
||||
);
|
||||
|
||||
HudElementRegistry.attachElementAfter(
|
||||
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SLEEP,
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_after_boss"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.AFTER_BOSS_BAR, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.AFTER_BOSS_BAR, ctx, tc)
|
||||
);
|
||||
|
||||
HudElementRegistry.attachElementBefore(
|
||||
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.DEMO_TIMER,
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_before_demo"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.BEFORE_DEMO_TIMER, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.BEFORE_DEMO_TIMER, ctx, tc)
|
||||
);
|
||||
|
||||
HudElementRegistry.attachElementBefore(
|
||||
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.CHAT,
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_before_chat"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.BEFORE_CHAT, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.BEFORE_CHAT, ctx, tc)
|
||||
);
|
||||
|
||||
HudElementRegistry.attachElementAfter(
|
||||
net.fabricmc.fabric.api.client.rendering.v1.hud.VanillaHudElements.SUBTITLES,
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_after_subtitles"),
|
||||
(ctx, tc) -> renderHudPhase(HudPhase.AFTER_SUBTITLES, ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudPhase(HudPhase.AFTER_SUBTITLES, ctx, tc)
|
||||
);
|
||||
|
||||
HudElementRegistry.attachElementAfter(
|
||||
VanillaHudElements.HOTBAR,
|
||||
Identifier.fromNamespaceAndPath("lovisual", "hud_after_hotbar"),
|
||||
(ctx, tc) -> renderHudAfterHotbar(ctx, tc)
|
||||
(ctx, tc) -> HudRender.renderHudAfterHotbar(ctx, tc)
|
||||
);
|
||||
|
||||
Lifecycle.activate();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.context;
|
||||
|
||||
import dev.loki.lovisual.api.v0.addon.LoVisualAddonContext;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.context;
|
||||
|
||||
import dev.loki.lovisual.api.v0.addon.LoVisualAddonRuntimeContext;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.context;
|
||||
|
||||
import dev.loki.lovisual.api.v0.addon.LoVisualModuleLoadContext;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.context;
|
||||
|
||||
import dev.loki.lovisual.api.v0.module.ModuleExtensionContext;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
import java.util.List;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
public record AddonIssue(
|
||||
|
|
|
|||
|
|
@ -1,24 +1,9 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
import dev.loki.lovisual.api.v0.addon.LoVisualAddon;
|
||||
import dev.loki.lovisual.features.gui.hud.base.AbstractHudElement;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
|
||||
import dev.loki.lovisual.features.module.core.Module;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonSnapshot;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonStateStore;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonStatus;
|
||||
import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader;
|
||||
import dev.loki.lovisual.features.module.core.ModuleManager;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.fabricmc.loader.api.metadata.CustomValue;
|
||||
import net.fabricmc.loader.api.metadata.ModMetadata;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
|
|
@ -26,30 +11,28 @@ import java.util.LinkedHashMap;
|
|||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import dev.loki.lovisual.addon.context.LoVisualAddonContextImpl;
|
||||
import dev.loki.lovisual.addon.context.LoVisualAddonRuntimeContextImpl;
|
||||
import dev.loki.lovisual.addon.context.LoVisualModuleLoadContextImpl;
|
||||
import dev.loki.lovisual.addon.core.AddonDescriptor;
|
||||
import dev.loki.lovisual.addon.core.AddonIssue;
|
||||
import dev.loki.lovisual.addon.core.AddonRegistration;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonSnapshot;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonStateStore;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonStatus;
|
||||
|
||||
/**
|
||||
* Singleton entry point for LoVisual add-on discovery and lifecycle orchestration. Keeps the
|
||||
* addon registry ({@code ADDONS}), the persisted enabled-overrides and the client-ready flags, and
|
||||
* drives discovery, init and the public lookup/enable API consumed by the ClickGUI and commands.
|
||||
*
|
||||
* <p>Delegates the heavy work within the same package: {@link AddonLifecycle} (per-registration
|
||||
* callbacks), {@link AddonRuntime} (enable/disable + contribution suspend/resume),
|
||||
* {@link AddonDescriptors} (metadata parsing + validation) and {@link AddonProfiles} (config-profile
|
||||
* owner keys), keeping the public static API stable while each file stays under the size caps.</p>
|
||||
*/
|
||||
public enum AddonManager {
|
||||
;
|
||||
|
||||
public static final String ADDON_METADATA_KEY = "lovisual:addon";
|
||||
|
||||
private static final Map<String, AddonRegistration> ADDONS = new LinkedHashMap<>();
|
||||
private static final Map<String, Boolean> ENABLED_OVERRIDES = new LinkedHashMap<>();
|
||||
private static final Map<String, String> MODULE_OWNERS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> DRAGGABLE_HUD_OWNERS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> STATIC_HUD_OWNERS = new LinkedHashMap<>();
|
||||
static final Map<String, AddonRegistration> ADDONS = new LinkedHashMap<>();
|
||||
static final Map<String, Boolean> ENABLED_OVERRIDES = new LinkedHashMap<>();
|
||||
private static boolean discovered;
|
||||
private static boolean moduleLoadingPrepared;
|
||||
private static boolean initialized;
|
||||
private static boolean clientReady;
|
||||
static boolean clientReady;
|
||||
|
||||
/**
|
||||
* Discovers enabled addon entrypoints and lets them configure built-in module
|
||||
|
|
@ -59,7 +42,7 @@ public enum AddonManager {
|
|||
ensureDiscovered();
|
||||
if (moduleLoadingPrepared) return;
|
||||
moduleLoadingPrepared = true;
|
||||
configureEnabledAddons();
|
||||
AddonLifecycle.configureEnabled();
|
||||
}
|
||||
|
||||
public static void init() {
|
||||
|
|
@ -68,9 +51,9 @@ public enum AddonManager {
|
|||
if (!ModuleAutoLoader.isDiscoveryFinished()) {
|
||||
return;
|
||||
}
|
||||
reconcileModuleExclusions();
|
||||
AddonLifecycle.reconcileExclusions();
|
||||
initialized = true;
|
||||
initializeEnabledAddons();
|
||||
AddonLifecycle.initializeEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,7 +64,7 @@ public enum AddonManager {
|
|||
if (!initialized) return;
|
||||
clientReady = true;
|
||||
for (AddonRegistration registration : ADDONS.values()) {
|
||||
notifyClientReady(registration);
|
||||
AddonLifecycle.notifyClientReady(registration);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +73,7 @@ public enum AddonManager {
|
|||
discovered = true;
|
||||
ENABLED_OVERRIDES.clear();
|
||||
ENABLED_OVERRIDES.putAll(AddonStateStore.loadEnabledOverrides());
|
||||
discoverEntrypoints();
|
||||
AddonLifecycle.discover();
|
||||
}
|
||||
|
||||
public static List<AddonSnapshot> snapshots() {
|
||||
|
|
@ -117,7 +100,7 @@ public enum AddonManager {
|
|||
init();
|
||||
List<AddonIssue> out = new ArrayList<>();
|
||||
for (AddonRegistration registration : ADDONS.values()) {
|
||||
validateRegistration(registration);
|
||||
AddonDescriptors.validate(registration);
|
||||
out.addAll(registration.issues);
|
||||
}
|
||||
if (out.isEmpty()) {
|
||||
|
|
@ -127,22 +110,19 @@ public enum AddonManager {
|
|||
}
|
||||
|
||||
public static String moduleProfileOwnerId(String moduleId) {
|
||||
String addonId = MODULE_OWNERS.get(normalizeId(moduleId));
|
||||
return addonId == null ? "module:" + moduleId : "addon:" + addonId + ":module:" + moduleId;
|
||||
return AddonProfiles.moduleProfileOwnerId(moduleId);
|
||||
}
|
||||
|
||||
public static String draggableHudProfileOwnerId(String elementId) {
|
||||
String addonId = DRAGGABLE_HUD_OWNERS.get(normalizeId(elementId));
|
||||
return addonId == null ? "hud.draggable:" + elementId : "addon:" + addonId + ":hud.draggable:" + elementId;
|
||||
return AddonProfiles.draggableHudProfileOwnerId(elementId);
|
||||
}
|
||||
|
||||
public static String staticHudProfileOwnerId(String elementId) {
|
||||
String addonId = STATIC_HUD_OWNERS.get(normalizeId(elementId));
|
||||
return addonId == null ? "hud.static:" + elementId : "addon:" + addonId + ":hud.static:" + elementId;
|
||||
return AddonProfiles.staticHudProfileOwnerId(elementId);
|
||||
}
|
||||
|
||||
public static String profileDisplayName(String ownerId, String displayName) {
|
||||
String addonId = addonIdFromProfileOwner(ownerId);
|
||||
String addonId = AddonProfiles.addonIdFromProfileOwner(ownerId);
|
||||
if (addonId == null) return displayName;
|
||||
AddonRegistration registration = ADDONS.get(addonId);
|
||||
String addonName = registration == null ? addonId : registration.descriptor.name();
|
||||
|
|
@ -150,11 +130,7 @@ public enum AddonManager {
|
|||
}
|
||||
|
||||
public static String addonIdFromProfileOwner(String ownerId) {
|
||||
if (ownerId == null || !ownerId.startsWith("addon:")) return null;
|
||||
int start = "addon:".length();
|
||||
int end = ownerId.indexOf(':', start);
|
||||
if (end <= start) return null;
|
||||
return normalizeId(ownerId.substring(start, end));
|
||||
return AddonProfiles.addonIdFromProfileOwner(ownerId);
|
||||
}
|
||||
|
||||
public static boolean setEnabled(String id, boolean enabled) {
|
||||
|
|
@ -170,33 +146,10 @@ public enum AddonManager {
|
|||
AddonStateStore.saveEnabledOverrides(ENABLED_OVERRIDES);
|
||||
|
||||
if (enabled) {
|
||||
if (!registration.moduleConfigurationApplied) {
|
||||
configureAddonModules(registration);
|
||||
}
|
||||
if (!registration.initialized) {
|
||||
initializeAddon(registration);
|
||||
} else {
|
||||
resumeRuntimeContributions(registration);
|
||||
resumeAddon(registration, "enabled by user");
|
||||
if (registration.status != AddonStatus.ERROR) {
|
||||
registration.status = AddonStatus.ACTIVE;
|
||||
}
|
||||
}
|
||||
if (clientReady) {
|
||||
notifyClientReady(registration);
|
||||
}
|
||||
return true;
|
||||
AddonRuntime.enable(registration, clientReady);
|
||||
} else {
|
||||
AddonRuntime.disable(registration);
|
||||
}
|
||||
|
||||
registration.restartRequired = !registration.disabledBuiltInModules.isEmpty();
|
||||
if (!registration.initialized) {
|
||||
registration.status = AddonStatus.DISABLED;
|
||||
return true;
|
||||
}
|
||||
|
||||
suspendRuntimeContributions(registration);
|
||||
suspendAddon(registration, "disabled by user");
|
||||
registration.status = AddonStatus.DISABLED;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -208,279 +161,20 @@ public enum AddonManager {
|
|||
return count;
|
||||
}
|
||||
|
||||
private static void discoverEntrypoints() {
|
||||
for (var entrypoint : FabricLoader.getInstance()
|
||||
.getEntrypointContainers(LoVisualAddon.ENTRYPOINT_KEY, LoVisualAddon.class)) {
|
||||
ModMetadata metadata = entrypoint.getProvider().getMetadata();
|
||||
String id = normalizeId(metadata.getId());
|
||||
AddonDescriptor descriptor = descriptor(metadata, entrypoint.getDefinition());
|
||||
boolean enabled = ENABLED_OVERRIDES.getOrDefault(id, true);
|
||||
AddonStatus status = enabled ? AddonStatus.DISCOVERED : AddonStatus.DISABLED;
|
||||
AddonRegistration duplicate = ADDONS.get(id);
|
||||
if (duplicate != null) {
|
||||
duplicate.issue(AddonIssue.Severity.ERROR, "Duplicate addon id", metadata.getId());
|
||||
continue;
|
||||
}
|
||||
AddonRegistration registration = new AddonRegistration(descriptor, entrypoint, enabled, status);
|
||||
validateRegistration(registration);
|
||||
ADDONS.put(id, registration);
|
||||
}
|
||||
}
|
||||
|
||||
private static void configureEnabledAddons() {
|
||||
for (AddonRegistration registration : ADDONS.values()) {
|
||||
if (registration.enabled) {
|
||||
configureAddonModules(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void configureAddonModules(AddonRegistration registration) {
|
||||
if (registration.moduleConfigurationApplied || !registration.enabled || registration.status == AddonStatus.ERROR) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
LoVisualAddon addon = addonInstance(registration);
|
||||
LoVisualModuleLoadContextImpl context = new LoVisualModuleLoadContextImpl(registration);
|
||||
addon.onConfigureModules(context);
|
||||
context.commit();
|
||||
registration.moduleConfigurationApplied = true;
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Module configuration failed", t.toString());
|
||||
DebugLog.error("Addon module configuration failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
private static void reconcileModuleExclusions() {
|
||||
for (AddonRegistration registration : ADDONS.values()) {
|
||||
if (registration.disabledBuiltInModules.isEmpty()) continue;
|
||||
registration.disabledBuiltInModules.removeIf(reference -> {
|
||||
if (ModuleAutoLoader.wasDisableRequestMatched(reference, registration.descriptor.id())) {
|
||||
return false;
|
||||
}
|
||||
registration.issue(
|
||||
AddonIssue.Severity.WARNING,
|
||||
"Built-in module exclusion target was not found",
|
||||
reference
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void initializeEnabledAddons() {
|
||||
for (AddonRegistration registration : ADDONS.values()) {
|
||||
if (registration.enabled && registration.status != AddonStatus.ERROR) {
|
||||
initializeAddon(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void initializeAddon(AddonRegistration registration) {
|
||||
if (registration.initialized || !registration.enabled || registration.status == AddonStatus.ERROR) return;
|
||||
if (!registration.moduleConfigurationApplied) {
|
||||
configureAddonModules(registration);
|
||||
if (registration.status == AddonStatus.ERROR) return;
|
||||
}
|
||||
try {
|
||||
LoVisualAddon addon = addonInstance(registration);
|
||||
addon.onInitialize(new LoVisualAddonContextImpl(registration));
|
||||
registration.initialized = true;
|
||||
registration.status = AddonStatus.ACTIVE;
|
||||
DebugLog.info("Addon initialized: %s", registration.descriptor.id());
|
||||
if (clientReady) {
|
||||
notifyClientReady(registration);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Initialization failed", t.toString());
|
||||
DebugLog.error("Addon initialization failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
private static void notifyClientReady(AddonRegistration registration) {
|
||||
if (registration == null
|
||||
|| registration.clientReadyNotified
|
||||
|| !registration.initialized
|
||||
|| !registration.enabled
|
||||
|| registration.status != AddonStatus.ACTIVE) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
registration.addon.onClientReady(
|
||||
new LoVisualAddonRuntimeContextImpl(registration.descriptor.id())
|
||||
);
|
||||
registration.clientReadyNotified = true;
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Client ready callback failed", t.toString());
|
||||
DebugLog.error("Addon client ready callback failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
private static LoVisualAddon addonInstance(AddonRegistration registration) {
|
||||
LoVisualAddon addon = registration.addon;
|
||||
if (addon == null) {
|
||||
addon = registration.entrypoint.getEntrypoint();
|
||||
registration.addon = addon;
|
||||
}
|
||||
return addon;
|
||||
}
|
||||
|
||||
private static void suspendAddon(AddonRegistration registration, String reason) {
|
||||
if (registration.addon == null) return;
|
||||
try {
|
||||
registration.addon.onRuntimeSuspended(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Runtime suspend failed", t.toString());
|
||||
DebugLog.error("Addon runtime suspend failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
private static void resumeAddon(AddonRegistration registration, String reason) {
|
||||
if (registration.addon == null) return;
|
||||
try {
|
||||
registration.addon.onRuntimeResumed(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Runtime resume failed", t.toString());
|
||||
DebugLog.error("Addon runtime resume failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
private static void shutdownAddon(AddonRegistration registration, String reason) {
|
||||
if (registration.addon == null) return;
|
||||
try {
|
||||
registration.addon.onShutdown(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
|
||||
} catch (Throwable t) {
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Shutdown failed", t.toString());
|
||||
DebugLog.error("Addon shutdown failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
private static void suspendRuntimeContributions(AddonRegistration registration) {
|
||||
registration.suspendedModules.clear();
|
||||
registration.suspendedDraggableHudElements.clear();
|
||||
registration.suspendedStaticHudElements.clear();
|
||||
for (String id : registration.modules) {
|
||||
Module module = ModuleManager.get(id);
|
||||
if (module != null) {
|
||||
registration.suspendedModules.put(id, module.isEnabled());
|
||||
ModuleManager.setRuntimeEnabledTransient(id, false);
|
||||
}
|
||||
}
|
||||
for (String id : registration.draggableHudElements) {
|
||||
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
|
||||
if (element != null) {
|
||||
registration.suspendedDraggableHudElements.put(id, element.isEnabled());
|
||||
element.setRuntimeEnabledTransient(false);
|
||||
}
|
||||
}
|
||||
for (String id : registration.staticHudElements) {
|
||||
AbstractHudElement element = StaticHudElementRegistry.getById(id);
|
||||
if (element != null) {
|
||||
registration.suspendedStaticHudElements.put(id, element.isEnabled());
|
||||
element.setRuntimeEnabledTransient(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void resumeRuntimeContributions(AddonRegistration registration) {
|
||||
for (String id : registration.modules) {
|
||||
Boolean enabled = registration.suspendedModules.get(id);
|
||||
if (enabled != null) {
|
||||
ModuleManager.setRuntimeEnabledTransient(id, enabled);
|
||||
}
|
||||
}
|
||||
for (String id : registration.draggableHudElements) {
|
||||
Boolean enabled = registration.suspendedDraggableHudElements.get(id);
|
||||
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
|
||||
if (enabled != null && element != null) {
|
||||
element.setRuntimeEnabledTransient(enabled);
|
||||
}
|
||||
}
|
||||
for (String id : registration.staticHudElements) {
|
||||
Boolean enabled = registration.suspendedStaticHudElements.get(id);
|
||||
AbstractHudElement element = StaticHudElementRegistry.getById(id);
|
||||
if (enabled != null && element != null) {
|
||||
element.setRuntimeEnabledTransient(enabled);
|
||||
}
|
||||
}
|
||||
registration.suspendedModules.clear();
|
||||
registration.suspendedDraggableHudElements.clear();
|
||||
registration.suspendedStaticHudElements.clear();
|
||||
}
|
||||
|
||||
public static void noteModuleOwner(String addonId, String moduleId) {
|
||||
|
||||
if (moduleId != null && !moduleId.isBlank()) {
|
||||
MODULE_OWNERS.put(normalizeId(moduleId), normalizeId(addonId));
|
||||
}
|
||||
AddonProfiles.noteModuleOwner(addonId, moduleId);
|
||||
}
|
||||
|
||||
public static void noteDraggableHudOwner(String addonId, String elementId) {
|
||||
|
||||
if (elementId != null && !elementId.isBlank()) {
|
||||
DRAGGABLE_HUD_OWNERS.put(normalizeId(elementId), normalizeId(addonId));
|
||||
}
|
||||
AddonProfiles.noteDraggableHudOwner(addonId, elementId);
|
||||
}
|
||||
|
||||
public static void noteStaticHudOwner(String addonId, String elementId) {
|
||||
|
||||
if (elementId != null && !elementId.isBlank()) {
|
||||
STATIC_HUD_OWNERS.put(normalizeId(elementId), normalizeId(addonId));
|
||||
}
|
||||
AddonProfiles.noteStaticHudOwner(addonId, elementId);
|
||||
}
|
||||
|
||||
private static void validateRegistration(AddonRegistration registration) {
|
||||
if (registration.descriptor.apiVersion() != LoVisualAddon.API_VERSION) {
|
||||
registration.issue(
|
||||
AddonIssue.Severity.WARNING,
|
||||
"Addon API version differs from client API",
|
||||
"addon=" + registration.descriptor.apiVersion() + ", client=" + LoVisualAddon.API_VERSION
|
||||
);
|
||||
}
|
||||
if (registration.descriptor.iconPath() == null || registration.descriptor.iconPath().isBlank()) {
|
||||
registration.issue(AddonIssue.Severity.WARNING, "Addon icon is missing", "Add icon in fabric.mod.json");
|
||||
}
|
||||
if (registration.descriptor.entrypointDefinition() == null
|
||||
|| registration.descriptor.entrypointDefinition().isBlank()) {
|
||||
registration.issue(AddonIssue.Severity.WARNING, "Entrypoint definition is unavailable", "");
|
||||
}
|
||||
}
|
||||
|
||||
private static AddonDescriptor descriptor(ModMetadata metadata, String entrypointDefinition) {
|
||||
List<String> authors = metadata.getAuthors().stream()
|
||||
.map(person -> person.getName() == null ? "" : person.getName())
|
||||
.filter(name -> !name.isBlank())
|
||||
.toList();
|
||||
return new AddonDescriptor(
|
||||
normalizeId(metadata.getId()),
|
||||
blankFallback(metadata.getName(), metadata.getId()),
|
||||
String.valueOf(metadata.getVersion()),
|
||||
metadata.getDescription() == null ? "" : metadata.getDescription(),
|
||||
authors,
|
||||
metadata.getIconPath(64).or(() -> metadata.getIconPath(32)).orElse(""),
|
||||
apiVersion(metadata),
|
||||
entrypointDefinition
|
||||
);
|
||||
}
|
||||
|
||||
private static int apiVersion(ModMetadata metadata) {
|
||||
CustomValue value = metadata.getCustomValue(ADDON_METADATA_KEY);
|
||||
if (value == null || value.getType() != CustomValue.CvType.OBJECT) return LoVisualAddon.API_VERSION;
|
||||
CustomValue api = value.getAsObject().get("api");
|
||||
if (api == null || api.getType() != CustomValue.CvType.NUMBER) return LoVisualAddon.API_VERSION;
|
||||
return api.getAsNumber().intValue();
|
||||
}
|
||||
|
||||
private static String blankFallback(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
private static String normalizeId(String id) {
|
||||
/** Locale-independent lowercase key used for every addon / module / HUD id lookup. */
|
||||
static String normalizeId(String id) {
|
||||
return id == null ? "" : id.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
import dev.loki.lovisual.api.v0.addon.LoVisualAddon;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
import dev.loki.lovisual.api.v0.addon.LoVisualAddon;
|
||||
import net.fabricmc.loader.api.metadata.CustomValue;
|
||||
import net.fabricmc.loader.api.metadata.ModMetadata;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Builds an {@link AddonDescriptor} from Fabric mod metadata and validates a registration's
|
||||
* sanity (API-version match, icon and entrypoint presence), recording findings as
|
||||
* {@link AddonIssue}s on the registration. Pure helpers over data the caller already holds,
|
||||
* extracted from {@link AddonManager} to keep it under the file-size cap. Shares
|
||||
* {@code addon.core}'s package so {@link AddonManager#normalizeId} stays reachable.
|
||||
*/
|
||||
final class AddonDescriptors {
|
||||
|
||||
private AddonDescriptors() { }
|
||||
|
||||
/** Parse a descriptor from the mod's metadata plus its resolved entrypoint definition. */
|
||||
static AddonDescriptor descriptor(ModMetadata metadata, String entrypointDefinition) {
|
||||
List<String> authors = metadata.getAuthors().stream()
|
||||
.map(person -> person.getName() == null ? "" : person.getName())
|
||||
.filter(name -> !name.isBlank())
|
||||
.toList();
|
||||
return new AddonDescriptor(
|
||||
AddonManager.normalizeId(metadata.getId()),
|
||||
blankFallback(metadata.getName(), metadata.getId()),
|
||||
String.valueOf(metadata.getVersion()),
|
||||
metadata.getDescription() == null ? "" : metadata.getDescription(),
|
||||
authors,
|
||||
metadata.getIconPath(64).or(() -> metadata.getIconPath(32)).orElse(""),
|
||||
apiVersion(metadata),
|
||||
entrypointDefinition
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the declared LoVisual API version from the {@code lovisual:addon} custom block. */
|
||||
static int apiVersion(ModMetadata metadata) {
|
||||
CustomValue value = metadata.getCustomValue(AddonManager.ADDON_METADATA_KEY);
|
||||
if (value == null || value.getType() != CustomValue.CvType.OBJECT) return LoVisualAddon.API_VERSION;
|
||||
CustomValue api = value.getAsObject().get("api");
|
||||
if (api == null || api.getType() != CustomValue.CvType.NUMBER) return LoVisualAddon.API_VERSION;
|
||||
return api.getAsNumber().intValue();
|
||||
}
|
||||
|
||||
/** Flag API-version drift, a missing icon and an unavailable entrypoint definition. */
|
||||
static void validate(AddonRegistration registration) {
|
||||
if (registration.descriptor.apiVersion() != LoVisualAddon.API_VERSION) {
|
||||
registration.issue(
|
||||
AddonIssue.Severity.WARNING,
|
||||
"Addon API version differs from client API",
|
||||
"addon=" + registration.descriptor.apiVersion() + ", client=" + LoVisualAddon.API_VERSION
|
||||
);
|
||||
}
|
||||
if (registration.descriptor.iconPath() == null || registration.descriptor.iconPath().isBlank()) {
|
||||
registration.issue(AddonIssue.Severity.WARNING, "Addon icon is missing", "Add icon in fabric.mod.json");
|
||||
}
|
||||
if (registration.descriptor.entrypointDefinition() == null
|
||||
|| registration.descriptor.entrypointDefinition().isBlank()) {
|
||||
registration.issue(AddonIssue.Severity.WARNING, "Entrypoint definition is unavailable", "");
|
||||
}
|
||||
}
|
||||
|
||||
private static String blankFallback(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
import dev.loki.lovisual.addon.context.LoVisualAddonContextImpl;
|
||||
import dev.loki.lovisual.addon.context.LoVisualAddonRuntimeContextImpl;
|
||||
import dev.loki.lovisual.addon.context.LoVisualModuleLoadContextImpl;
|
||||
import dev.loki.lovisual.addon.pipeline.AddonStatus;
|
||||
import dev.loki.lovisual.api.v0.addon.LoVisualAddon;
|
||||
import dev.loki.lovisual.features.module.lifecycle.ModuleAutoLoader;
|
||||
import dev.loki.lovisual.util.logging.DebugLog;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
import net.fabricmc.loader.api.metadata.ModMetadata;
|
||||
|
||||
/**
|
||||
* Per-registration lifecycle callbacks backing {@link AddonManager}: lazy entrypoint
|
||||
* materialization plus the {@code onConfigureModules} / {@code onInitialize} /
|
||||
* {@code onClientReady} / {@code onRuntimeSuspended} / {@code onRuntimeResumed} /
|
||||
* {@code onShutdown} invocations, each guarded by the registration's flags and turning any
|
||||
* thrown error into an {@link AddonIssue} + {@link AddonStatus#ERROR}. Shares {@code addon.core}'s
|
||||
* package so the registration's package-private state stays reachable; lives in a subfolder to
|
||||
* keep {@link AddonManager} under the file-size cap.
|
||||
*/
|
||||
final class AddonLifecycle {
|
||||
|
||||
private AddonLifecycle() { }
|
||||
|
||||
/** Discover addon entrypoints from the Fabric loader and register each as a new addon. */
|
||||
static void discover() {
|
||||
for (var entrypoint : FabricLoader.getInstance()
|
||||
.getEntrypointContainers(LoVisualAddon.ENTRYPOINT_KEY, LoVisualAddon.class)) {
|
||||
ModMetadata metadata = entrypoint.getProvider().getMetadata();
|
||||
String id = AddonManager.normalizeId(metadata.getId());
|
||||
AddonDescriptor descriptor = AddonDescriptors.descriptor(metadata, entrypoint.getDefinition());
|
||||
boolean enabled = AddonManager.ENABLED_OVERRIDES.getOrDefault(id, true);
|
||||
AddonStatus status = enabled ? AddonStatus.DISCOVERED : AddonStatus.DISABLED;
|
||||
AddonRegistration duplicate = AddonManager.ADDONS.get(id);
|
||||
if (duplicate != null) {
|
||||
duplicate.issue(AddonIssue.Severity.ERROR, "Duplicate addon id", metadata.getId());
|
||||
continue;
|
||||
}
|
||||
AddonRegistration registration = new AddonRegistration(descriptor, entrypoint, enabled, status);
|
||||
AddonDescriptors.validate(registration);
|
||||
AddonManager.ADDONS.put(id, registration);
|
||||
}
|
||||
}
|
||||
|
||||
/** Configure modules for every currently-enabled addon (pre-instantiation pass). */
|
||||
static void configureEnabled() {
|
||||
for (AddonRegistration registration : AddonManager.ADDONS.values()) {
|
||||
if (registration.enabled) {
|
||||
configure(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop built-in module exclusions whose target was never found, warning about each. */
|
||||
static void reconcileExclusions() {
|
||||
for (AddonRegistration registration : AddonManager.ADDONS.values()) {
|
||||
if (registration.disabledBuiltInModules.isEmpty()) continue;
|
||||
registration.disabledBuiltInModules.removeIf(reference -> {
|
||||
if (ModuleAutoLoader.wasDisableRequestMatched(reference, registration.descriptor.id())) {
|
||||
return false;
|
||||
}
|
||||
registration.issue(
|
||||
AddonIssue.Severity.WARNING,
|
||||
"Built-in module exclusion target was not found",
|
||||
reference
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Initialize every enabled, non-errored addon. */
|
||||
static void initializeEnabled() {
|
||||
for (AddonRegistration registration : AddonManager.ADDONS.values()) {
|
||||
if (registration.enabled && registration.status != AddonStatus.ERROR) {
|
||||
initialize(registration, AddonManager.clientReady);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Let the addon configure built-in module exclusions once, before modules are instantiated. */
|
||||
static void configure(AddonRegistration registration) {
|
||||
if (registration.moduleConfigurationApplied || !registration.enabled || registration.status == AddonStatus.ERROR) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
LoVisualAddon addon = addonInstance(registration);
|
||||
LoVisualModuleLoadContextImpl context = new LoVisualModuleLoadContextImpl(registration);
|
||||
addon.onConfigureModules(context);
|
||||
context.commit();
|
||||
registration.moduleConfigurationApplied = true;
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Module configuration failed", t.toString());
|
||||
DebugLog.error("Addon module configuration failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
/** Initialize the addon (configuring modules first if needed); notifies client-ready if up. */
|
||||
static void initialize(AddonRegistration registration, boolean clientReady) {
|
||||
if (registration.initialized || !registration.enabled || registration.status == AddonStatus.ERROR) return;
|
||||
if (!registration.moduleConfigurationApplied) {
|
||||
configure(registration);
|
||||
if (registration.status == AddonStatus.ERROR) return;
|
||||
}
|
||||
try {
|
||||
LoVisualAddon addon = addonInstance(registration);
|
||||
addon.onInitialize(new LoVisualAddonContextImpl(registration));
|
||||
registration.initialized = true;
|
||||
registration.status = AddonStatus.ACTIVE;
|
||||
DebugLog.info("Addon initialized: %s", registration.descriptor.id());
|
||||
if (clientReady) {
|
||||
notifyClientReady(registration);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Initialization failed", t.toString());
|
||||
DebugLog.error("Addon initialization failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire {@code onClientReady} once, after module configs have been applied. */
|
||||
static void notifyClientReady(AddonRegistration registration) {
|
||||
if (registration == null
|
||||
|| registration.clientReadyNotified
|
||||
|| !registration.initialized
|
||||
|| !registration.enabled
|
||||
|| registration.status != AddonStatus.ACTIVE) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
registration.addon.onClientReady(
|
||||
new LoVisualAddonRuntimeContextImpl(registration.descriptor.id())
|
||||
);
|
||||
registration.clientReadyNotified = true;
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Client ready callback failed", t.toString());
|
||||
DebugLog.error("Addon client ready callback failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire {@code onRuntimeSuspended} for an addon being toggled off at runtime. */
|
||||
static void suspend(AddonRegistration registration, String reason) {
|
||||
if (registration.addon == null) return;
|
||||
try {
|
||||
registration.addon.onRuntimeSuspended(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Runtime suspend failed", t.toString());
|
||||
DebugLog.error("Addon runtime suspend failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire {@code onRuntimeResumed} for an addon being toggled back on at runtime. */
|
||||
static void resume(AddonRegistration registration, String reason) {
|
||||
if (registration.addon == null) return;
|
||||
try {
|
||||
registration.addon.onRuntimeResumed(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
|
||||
} catch (Throwable t) {
|
||||
registration.status = AddonStatus.ERROR;
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Runtime resume failed", t.toString());
|
||||
DebugLog.error("Addon runtime resume failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire {@code onShutdown} on full addon teardown. */
|
||||
static void shutdown(AddonRegistration registration, String reason) {
|
||||
if (registration.addon == null) return;
|
||||
try {
|
||||
registration.addon.onShutdown(new LoVisualAddonRuntimeContextImpl(registration.descriptor.id()));
|
||||
} catch (Throwable t) {
|
||||
registration.issue(AddonIssue.Severity.ERROR, "Shutdown failed", t.toString());
|
||||
DebugLog.error("Addon shutdown failed: %s", t, registration.descriptor.id());
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize (and memoize) the addon instance from its entrypoint container. */
|
||||
static LoVisualAddon addonInstance(AddonRegistration registration) {
|
||||
LoVisualAddon addon = registration.addon;
|
||||
if (addon == null) {
|
||||
addon = registration.entrypoint.getEntrypoint();
|
||||
registration.addon = addon;
|
||||
}
|
||||
return addon;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Owns the id → addon-id reverse indexes for contributed modules, draggable-HUD and static-HUD
|
||||
* elements, and turns them into the namespaced config-profile owner keys the profile system uses
|
||||
* ({@code addon:<addon>:module:<id>} when an addon owns the entry, plain otherwise). Extracted
|
||||
* from {@link AddonManager} to keep it under the file-size cap; shares {@code addon.core}'s
|
||||
* package so {@link AddonManager#normalizeId} stays reachable.
|
||||
*/
|
||||
final class AddonProfiles {
|
||||
|
||||
private static final Map<String, String> MODULE_OWNERS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> DRAGGABLE_HUD_OWNERS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> STATIC_HUD_OWNERS = new LinkedHashMap<>();
|
||||
|
||||
private AddonProfiles() { }
|
||||
|
||||
/** Record that {@code addonId} owns the given module / draggable-HUD / static-HUD element. */
|
||||
static void noteModuleOwner(String addonId, String moduleId) {
|
||||
if (moduleId != null && !moduleId.isBlank()) {
|
||||
MODULE_OWNERS.put(AddonManager.normalizeId(moduleId), AddonManager.normalizeId(addonId));
|
||||
}
|
||||
}
|
||||
|
||||
static void noteDraggableHudOwner(String addonId, String elementId) {
|
||||
if (elementId != null && !elementId.isBlank()) {
|
||||
DRAGGABLE_HUD_OWNERS.put(AddonManager.normalizeId(elementId), AddonManager.normalizeId(addonId));
|
||||
}
|
||||
}
|
||||
|
||||
static void noteStaticHudOwner(String addonId, String elementId) {
|
||||
if (elementId != null && !elementId.isBlank()) {
|
||||
STATIC_HUD_OWNERS.put(AddonManager.normalizeId(elementId), AddonManager.normalizeId(addonId));
|
||||
}
|
||||
}
|
||||
|
||||
/** Namespaced config-profile owner key for a module. */
|
||||
static String moduleProfileOwnerId(String moduleId) {
|
||||
String addonId = MODULE_OWNERS.get(AddonManager.normalizeId(moduleId));
|
||||
return addonId == null ? "module:" + moduleId : "addon:" + addonId + ":module:" + moduleId;
|
||||
}
|
||||
|
||||
/** Namespaced config-profile owner key for a draggable HUD element. */
|
||||
static String draggableHudProfileOwnerId(String elementId) {
|
||||
String addonId = DRAGGABLE_HUD_OWNERS.get(AddonManager.normalizeId(elementId));
|
||||
return addonId == null ? "hud.draggable:" + elementId : "addon:" + addonId + ":hud.draggable:" + elementId;
|
||||
}
|
||||
|
||||
/** Namespaced config-profile owner key for a static HUD element. */
|
||||
static String staticHudProfileOwnerId(String elementId) {
|
||||
String addonId = STATIC_HUD_OWNERS.get(AddonManager.normalizeId(elementId));
|
||||
return addonId == null ? "hud.static:" + elementId : "addon:" + addonId + ":hud.static:" + elementId;
|
||||
}
|
||||
|
||||
/** Extract the owning addon id from an {@code addon:<id>:...} owner key, or {@code null}. */
|
||||
static String addonIdFromProfileOwner(String ownerId) {
|
||||
if (ownerId == null || !ownerId.startsWith("addon:")) return null;
|
||||
int start = "addon:".length();
|
||||
int end = ownerId.indexOf(':', start);
|
||||
if (end <= start) return null;
|
||||
return AddonManager.normalizeId(ownerId.substring(start, end));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package dev.loki.lovisual.addon.core;
|
||||
|
||||
import dev.loki.lovisual.addon.pipeline.AddonStatus;
|
||||
import dev.loki.lovisual.features.gui.hud.base.AbstractHudElement;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
|
||||
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
|
||||
import dev.loki.lovisual.features.gui.hud.nondraggable.StaticHudElementRegistry;
|
||||
import dev.loki.lovisual.features.module.core.Module;
|
||||
import dev.loki.lovisual.features.module.core.ModuleManager;
|
||||
|
||||
/**
|
||||
* Runtime enable/disable transitions for a single addon, backing {@link AddonManager#setEnabled}.
|
||||
* Applies the contributed modules / draggable-HUD / static-HUD elements transiently (recording
|
||||
* their prior enabled state so it can be restored) and drives the {@code onRuntimeResumed} /
|
||||
* {@code onRuntimeSuspended} callbacks via {@link AddonLifecycle}. Shares {@code addon.core}'s
|
||||
* package for package-private registration access; lives in a subfolder to keep the manager small.
|
||||
*/
|
||||
final class AddonRuntime {
|
||||
|
||||
private AddonRuntime() { }
|
||||
|
||||
/** Bring a just-enabled addon up: configure, initialize-or-resume, and notify if client-ready. */
|
||||
static void enable(AddonRegistration registration, boolean clientReady) {
|
||||
if (!registration.moduleConfigurationApplied) {
|
||||
AddonLifecycle.configure(registration);
|
||||
}
|
||||
if (!registration.initialized) {
|
||||
AddonLifecycle.initialize(registration, clientReady);
|
||||
} else {
|
||||
resumeContributions(registration);
|
||||
AddonLifecycle.resume(registration, "enabled by user");
|
||||
if (registration.status != AddonStatus.ERROR) {
|
||||
registration.status = AddonStatus.ACTIVE;
|
||||
}
|
||||
}
|
||||
if (clientReady) {
|
||||
AddonLifecycle.notifyClientReady(registration);
|
||||
}
|
||||
}
|
||||
|
||||
/** Take a just-disabled addon down: flag a restart if it excluded built-ins, then suspend. */
|
||||
static void disable(AddonRegistration registration) {
|
||||
registration.restartRequired = !registration.disabledBuiltInModules.isEmpty();
|
||||
if (!registration.initialized) {
|
||||
registration.status = AddonStatus.DISABLED;
|
||||
return;
|
||||
}
|
||||
suspendContributions(registration);
|
||||
AddonLifecycle.suspend(registration, "disabled by user");
|
||||
registration.status = AddonStatus.DISABLED;
|
||||
}
|
||||
|
||||
/** Record and transiently disable every module / HUD element the addon contributes. */
|
||||
static void suspendContributions(AddonRegistration registration) {
|
||||
registration.suspendedModules.clear();
|
||||
registration.suspendedDraggableHudElements.clear();
|
||||
registration.suspendedStaticHudElements.clear();
|
||||
for (String id : registration.modules) {
|
||||
Module module = ModuleManager.get(id);
|
||||
if (module != null) {
|
||||
registration.suspendedModules.put(id, module.isEnabled());
|
||||
ModuleManager.setRuntimeEnabledTransient(id, false);
|
||||
}
|
||||
}
|
||||
for (String id : registration.draggableHudElements) {
|
||||
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
|
||||
if (element != null) {
|
||||
registration.suspendedDraggableHudElements.put(id, element.isEnabled());
|
||||
element.setRuntimeEnabledTransient(false);
|
||||
}
|
||||
}
|
||||
for (String id : registration.staticHudElements) {
|
||||
AbstractHudElement element = StaticHudElementRegistry.getById(id);
|
||||
if (element != null) {
|
||||
registration.suspendedStaticHudElements.put(id, element.isEnabled());
|
||||
element.setRuntimeEnabledTransient(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore the pre-suspend enabled state of every contributed module / HUD element. */
|
||||
static void resumeContributions(AddonRegistration registration) {
|
||||
for (String id : registration.modules) {
|
||||
Boolean enabled = registration.suspendedModules.get(id);
|
||||
if (enabled != null) {
|
||||
ModuleManager.setRuntimeEnabledTransient(id, enabled);
|
||||
}
|
||||
}
|
||||
for (String id : registration.draggableHudElements) {
|
||||
Boolean enabled = registration.suspendedDraggableHudElements.get(id);
|
||||
DraggableHudElement element = DraggableHudElementRegistry.getById(id);
|
||||
if (enabled != null && element != null) {
|
||||
element.setRuntimeEnabledTransient(enabled);
|
||||
}
|
||||
}
|
||||
for (String id : registration.staticHudElements) {
|
||||
Boolean enabled = registration.suspendedStaticHudElements.get(id);
|
||||
AbstractHudElement element = StaticHudElementRegistry.getById(id);
|
||||
if (enabled != null && element != null) {
|
||||
element.setRuntimeEnabledTransient(enabled);
|
||||
}
|
||||
}
|
||||
registration.suspendedModules.clear();
|
||||
registration.suspendedDraggableHudElements.clear();
|
||||
registration.suspendedStaticHudElements.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.manager;
|
||||
|
||||
import dev.loki.lovisual.api.v0.clickgui.LoVisualClickGuiSection;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.manager;
|
||||
|
||||
import dev.loki.lovisual.api.v0.module.LoVisualModuleExtension;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.pipeline;
|
||||
|
||||
import com.mojang.blaze3d.textures.GpuTextureView;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/*
|
||||
* This file is part of the LoVisual Client distribution.
|
||||
* Copyright (c) 2026 loki5512344.
|
||||
*
|
||||
* Licensed under the GNU General Public License v3.0.
|
||||
*/
|
||||
|
||||
package dev.loki.lovisual.addon.pipeline;
|
||||
|
||||
import java.util.List;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue