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:
parent
72bc4c7148
commit
7f4b532f99
257 changed files with 13085 additions and 6582 deletions
|
|
@ -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
337
backend/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/` —
|
||||
|
|
|
|||
3931
backend/accounts-service/Cargo.lock
generated
3931
backend/accounts-service/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
88
backend/accounts-service/src/accounts/handlers.rs
Normal file
88
backend/accounts-service/src/accounts/handlers.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod handlers;
|
||||
pub mod model;
|
||||
pub mod repo;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
pub mod handlers;
|
||||
pub mod password;
|
||||
pub mod tokens;
|
||||
pub mod handlers;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
backend/accounts-service/src/device/links.rs
Normal file
54
backend/accounts-service/src/device/links.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod handlers;
|
||||
pub mod links;
|
||||
pub mod store;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
215
backend/accounts-service/src/grpc/mod.rs
Normal file
215
backend/accounts-service/src/grpc/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
backend/accounts-service/tests/device_flow/links.rs
Normal file
116
backend/accounts-service/tests/device_flow/links.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
backend/configs-service/Cargo.toml
Normal file
27
backend/configs-service/Cargo.toml
Normal 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"
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
31
backend/configs-service/migrations/0001_init.sql
Normal file
31
backend/configs-service/migrations/0001_init.sql
Normal 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;
|
||||
56
backend/configs-service/src/config.rs
Normal file
56
backend/configs-service/src/config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
83
backend/configs-service/src/error.rs
Normal file
83
backend/configs-service/src/error.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
55
backend/configs-service/src/lib.rs
Normal file
55
backend/configs-service/src/lib.rs
Normal 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)
|
||||
}
|
||||
20
backend/configs-service/src/main.rs
Normal file
20
backend/configs-service/src/main.rs
Normal 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(())
|
||||
}
|
||||
35
backend/configs-service/src/sharing/codes.rs
Normal file
35
backend/configs-service/src/sharing/codes.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
52
backend/configs-service/src/sharing/handlers.rs
Normal file
52
backend/configs-service/src/sharing/handlers.rs
Normal 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()))
|
||||
}
|
||||
2
backend/configs-service/src/sharing/mod.rs
Normal file
2
backend/configs-service/src/sharing/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod codes;
|
||||
pub mod handlers;
|
||||
174
backend/configs-service/src/showcase/handlers.rs
Normal file
174
backend/configs-service/src/showcase/handlers.rs
Normal 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
3
backend/configs-service/src/showcase/mod.rs
Normal file
3
backend/configs-service/src/showcase/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod handlers;
|
||||
pub mod profiles;
|
||||
pub mod repo;
|
||||
74
backend/configs-service/src/showcase/profiles.rs
Normal file
74
backend/configs-service/src/showcase/profiles.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
213
backend/configs-service/src/showcase/repo.rs
Normal file
213
backend/configs-service/src/showcase/repo.rs
Normal 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)
|
||||
}
|
||||
89
backend/configs-service/src/slots/handlers.rs
Normal file
89
backend/configs-service/src/slots/handlers.rs
Normal 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?,
|
||||
))
|
||||
}
|
||||
2
backend/configs-service/src/slots/mod.rs
Normal file
2
backend/configs-service/src/slots/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod handlers;
|
||||
pub mod repo;
|
||||
119
backend/configs-service/src/slots/repo.rs
Normal file
119
backend/configs-service/src/slots/repo.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
72
backend/configs-service/tests/common/mod.rs
Normal file
72
backend/configs-service/tests/common/mod.rs
Normal 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)
|
||||
}
|
||||
93
backend/configs-service/tests/sharing.rs
Normal file
93
backend/configs-service/tests/sharing.rs
Normal 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);
|
||||
}
|
||||
261
backend/configs-service/tests/showcase.rs
Normal file
261
backend/configs-service/tests/showcase.rs
Normal 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);
|
||||
}
|
||||
128
backend/configs-service/tests/slots.rs
Normal file
128
backend/configs-service/tests/slots.rs
Normal 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();
|
||||
}
|
||||
29
backend/gateway/Cargo.toml
Normal file
29
backend/gateway/Cargo.toml
Normal 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"] }
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
89
backend/gateway/src/config.rs
Normal file
89
backend/gateway/src/config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
60
backend/gateway/src/guard/honeypot.rs
Normal file
60
backend/gateway/src/guard/honeypot.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
111
backend/gateway/src/guard/mod.rs
Normal file
111
backend/gateway/src/guard/mod.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
66
backend/gateway/src/identity/device.rs
Normal file
66
backend/gateway/src/identity/device.rs
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
114
backend/gateway/src/identity/mod.rs
Normal file
114
backend/gateway/src/identity/mod.rs
Normal 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
|
||||
}
|
||||
76
backend/gateway/src/lib.rs
Normal file
76
backend/gateway/src/lib.rs
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
23
backend/gateway/src/main.rs
Normal file
23
backend/gateway/src/main.rs
Normal 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(())
|
||||
}
|
||||
124
backend/gateway/src/proxy/forward.rs
Normal file
124
backend/gateway/src/proxy/forward.rs
Normal 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"))
|
||||
}
|
||||
2
backend/gateway/src/proxy/mod.rs
Normal file
2
backend/gateway/src/proxy/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod forward;
|
||||
pub mod routes;
|
||||
29
backend/gateway/src/proxy/routes.rs
Normal file
29
backend/gateway/src/proxy/routes.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
111
backend/gateway/src/rate_limit/mod.rs
Normal file
111
backend/gateway/src/rate_limit/mod.rs
Normal 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
|
||||
}
|
||||
85
backend/gateway/src/rate_limit/rules.rs
Normal file
85
backend/gateway/src/rate_limit/rules.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
141
backend/gateway/tests/common/mod.rs
Normal file
141
backend/gateway/tests/common/mod.rs
Normal 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(),
|
||||
)
|
||||
}
|
||||
111
backend/gateway/tests/identity.rs
Normal file
111
backend/gateway/tests/identity.rs
Normal 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);
|
||||
}
|
||||
89
backend/gateway/tests/path_guard.rs
Normal file
89
backend/gateway/tests/path_guard.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
110
backend/gateway/tests/proxy.rs
Normal file
110
backend/gateway/tests/proxy.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
78
backend/gateway/tests/rate_limit.rs
Normal file
78
backend/gateway/tests/rate_limit.rs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue