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

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

View file

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

337
backend/Cargo.lock generated
View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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