Microservice to bring 2FA to self hosted PDSes

Admin RBAC without searchAccount #17

open opened by torrho.blacksky.team targeting main from feature/admin-rbac

Problem: Bluesky's reference PDS using a PDS_ADMIN_PASSWORD with basic auth as an admin api authz. If the PDS is managed by a team, then this becomes a shared password.

Solution: have pds-gateway manage the PDS_ADMIN_PASSWORD and have the PDS administration team login to pds-gateway via OAuth with their corresponding PDS, and then they'll be given a portal scoped to their capabilities specified by the RBAC scpecification, see ADMIN.md. Admin portal is inspired by https://github.com/betamax/pds-admin. When an admin team member wants to perform an operation (e.g. send a new users an invite code), the team member can click through the portal to do so and the pds-gateway will perform the operations with the PDS_ADMIN_PASSWORD on their behalf. This prevents the shared password problem whilst giving admins a tool to manage their PDS easier!

Labels

None yet.

Participants 1
AT URI
at://did:plc:n6jx25m5pr3bndqtmjot62xw/sh.tangled.repo.pull/3mglvydejmz22
+5869 -28
Diff #0
+2 -1
.gitignore
··· 1 /target 2 .idea 3 - pds.env
··· 1 /target 2 .idea 3 + pds.env 4 + dev_admin_rbac.yaml
+201
ADMIN.md
···
··· 1 + # PDS Admin Portal 2 + 3 + ## Overview 4 + 5 + Bluesky's PDS admin API relies on `PDS_ADMIN_PASSWORD` — a single shared secret that grants unrestricted access to every administrative endpoint. This is workable for solo operators but becomes a liability when multiple team members need admin access. There is no way to limit what any individual can do, no audit trail of who performed an action, and credential rotation affects everyone simultaneously. 6 + 7 + The pds-gatekeeper admin portal solves this by introducing role-based access control (RBAC). Team members authenticate with ATProto OAuth using their own identity, and gatekeeper enforces per-user permissions based on a YAML configuration file. Authorized requests are proxied to the PDS using the admin password on behalf of the authenticated user — the password itself is never exposed to browsers or end users. 8 + 9 + ## Prerequisites 10 + 11 + - A PDS instance running behind pds-gatekeeper 12 + - HTTPS with a valid TLS certificate (required for ATProto OAuth flows) 13 + - SMTP configured on the PDS for email functionality (used by the PDS itself, not strictly by the admin portal) 14 + 15 + ## Quick Start 16 + 17 + ### 1. Create an RBAC configuration file 18 + 19 + Copy the example configuration as a starting point: 20 + 21 + ```sh 22 + cp examples/admin_rbac.yaml /path/to/your/admin_rbac.yaml 23 + ``` 24 + 25 + ### 2. Find your team members' DIDs 26 + 27 + Use [`goat`](https://github.com/bluesky-social/indigo/tree/main/cmd/goat) to resolve a handle to its DID: 28 + 29 + ```sh 30 + goat resolve alice.example.com 31 + ``` 32 + 33 + The DID is the `id` field in the output (e.g. `did:plc:abcdef1234567890`). 34 + 35 + ### 3. Set environment variables 36 + 37 + ```sh 38 + # Required 39 + GATEKEEPER_ADMIN_RBAC_CONFIG=/path/to/your/admin_rbac.yaml 40 + PDS_ADMIN_PASSWORD=your-pds-admin-password 41 + 42 + # Optional 43 + GATEKEEPER_ADMIN_COOKIE_SECRET=<64-character-hex-string> 44 + GATEKEEPER_ADMIN_SESSION_TTL_HOURS=24 45 + ``` 46 + 47 + ### 4. Restart pds-gatekeeper 48 + 49 + ```sh 50 + # If running with systemd: 51 + sudo systemctl restart pds-gatekeeper 52 + 53 + # If running with Docker: 54 + docker restart pds-gatekeeper 55 + ``` 56 + 57 + ### 5. Navigate to the admin portal 58 + 59 + Open your browser and go to: 60 + 61 + ``` 62 + https://your-pds.example.com/admin/login 63 + ``` 64 + 65 + ## RBAC Configuration 66 + 67 + The RBAC configuration is a YAML file with two top-level sections: `roles` and `members`. 68 + 69 + - **Roles** define named sets of endpoint patterns that grant access to specific admin operations. 70 + - **Members** map an ATProto DID to one or more roles. 71 + 72 + A member's effective permissions are the **union** of all endpoints from all of their assigned roles. 73 + 74 + Endpoint patterns support wildcard matching: `com.atproto.admin.*` matches all endpoints under the `com.atproto.admin` namespace. 75 + 76 + Example: 77 + 78 + ```yaml 79 + roles: 80 + pds-admin: 81 + endpoints: 82 + - "com.atproto.admin.*" 83 + - "com.atproto.server.createInviteCode" 84 + - "com.atproto.server.createAccount" 85 + 86 + moderator: 87 + endpoints: 88 + - "com.atproto.admin.getAccountInfo" 89 + - "com.atproto.admin.getAccountInfos" 90 + - "com.atproto.admin.getSubjectStatus" 91 + - "com.atproto.admin.updateSubjectStatus" 92 + - "com.atproto.admin.sendEmail" 93 + - "com.atproto.admin.getInviteCodes" 94 + 95 + invite-manager: 96 + endpoints: 97 + - "com.atproto.server.createInviteCode" 98 + - "com.atproto.admin.getInviteCodes" 99 + - "com.atproto.admin.disableInviteCodes" 100 + - "com.atproto.admin.enableAccountInvites" 101 + - "com.atproto.admin.disableAccountInvites" 102 + 103 + members: 104 + - did: "did:plc:abcdef1234567890" 105 + roles: 106 + - pds-admin 107 + 108 + - did: "did:plc:bbbbbbbbbbbbbbbb" 109 + roles: 110 + - moderator 111 + - invite-manager 112 + ``` 113 + 114 + ## Available Roles (Reference) 115 + 116 + ### Suggested role templates 117 + 118 + You can make your own roles and teams 119 + 120 + | Role | Description | Endpoints | 121 + |---|---|---| 122 + | `pds-admin` | Full administrative access | `com.atproto.admin.*`, `createInviteCode`, `createAccount` | 123 + | `moderator` | View accounts, manage takedowns, send email, view invite codes | `getAccountInfo`, `getAccountInfos`, `getSubjectStatus`, `updateSubjectStatus`, `sendEmail`, `getInviteCodes` | 124 + | `invite-manager` | Manage invite codes and per-account invite permissions | `createInviteCode`, `getInviteCodes`, `disableInviteCodes`, `enableAccountInvites`, `disableAccountInvites` | 125 + 126 + ### All admin XRPC endpoints 127 + 128 + | Endpoint | Description | 129 + |---|---| 130 + | `com.atproto.admin.getAccountInfo` | View single account details | 131 + | `com.atproto.admin.getAccountInfos` | View multiple accounts | 132 + | `com.atproto.admin.getSubjectStatus` | Get takedown status | 133 + | `com.atproto.admin.updateSubjectStatus` | Apply or remove takedowns | 134 + | `com.atproto.admin.deleteAccount` | Permanently delete an account | 135 + | `com.atproto.admin.updateAccountPassword` | Reset account password | 136 + | `com.atproto.admin.enableAccountInvites` | Enable invites for an account | 137 + | `com.atproto.admin.disableAccountInvites` | Disable invites for an account | 138 + | `com.atproto.admin.getInviteCodes` | List invite codes | 139 + | `com.atproto.admin.disableInviteCodes` | Disable specific invite codes | 140 + | `com.atproto.admin.sendEmail` | Send email to an account | 141 + | `com.atproto.server.createInviteCode` | Create a new invite code | 142 + | `com.atproto.server.createAccount` | Create a new account | 143 + 144 + ## How It Works 145 + 146 + ### 1. OAuth Login 147 + 148 + The user navigates to `/admin/login` and enters their ATProto handle. Gatekeeper initiates an OAuth authorization flow, redirecting the user to their identity's authorization server. The user authenticates there and is redirected back to gatekeeper with an authorization code. 149 + 150 + ### 2. Session Creation 151 + 152 + Gatekeeper exchanges the authorization code for tokens, extracts the user's DID from the OAuth session, and checks it against the RBAC configuration. If the DID is found in the members list, a signed session cookie is created and set in the browser. 153 + 154 + ### 3. Request Flow 155 + 156 + When the user performs an admin action, gatekeeper: 157 + 158 + 1. Validates the session cookie signature and expiration 159 + 2. Looks up the user's DID in the RBAC configuration 160 + 3. Checks whether the user's roles grant access to the target XRPC endpoint 161 + 4. If authorized, proxies the request to the PDS with `Authorization: Basic` using `PDS_ADMIN_PASSWORD` 162 + 5. Returns the PDS response to the user 163 + 164 + ### 4. UI Rendering 165 + 166 + The admin portal uses server-rendered pages that show or hide actions based on the authenticated user's permissions. However, RBAC is always enforced server-side in route handlers regardless of what the UI displays — hiding a button in the template is a convenience, not a security boundary. 167 + 168 + ## Environment Variables 169 + 170 + | Variable | Required | Default | Description | 171 + |---|---|---|---| 172 + | `GATEKEEPER_ADMIN_RBAC_CONFIG` | Yes | — | Path to the RBAC YAML configuration file | 173 + | `PDS_ADMIN_PASSWORD` | Yes | — | PDS admin password used for proxied requests | 174 + | `GATEKEEPER_ADMIN_COOKIE_SECRET` | No | Derived from `GATEKEEPER_JWE_KEY` | 32-byte hex key for signing session cookies | 175 + | `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` | No | `24` | Admin session lifetime in hours | 176 + 177 + ## Security Considerations 178 + 179 + - **Password isolation**: `PDS_ADMIN_PASSWORD` is never sent to or accessible from browsers. It is only used server-side when proxying authorized requests to the PDS. 180 + - **OAuth security**: The OAuth flow uses DPoP binding and PKCE to prevent token interception and replay attacks. 181 + - **Cookie protections**: Session cookies are signed (tamper-proof) and set with `HttpOnly`, `Secure`, and `SameSite=Lax` attributes. 182 + - **Server-side enforcement**: RBAC is enforced in route handlers, not just in template rendering. Manipulating the UI cannot bypass access controls. 183 + - **Session lifecycle**: Sessions expire after a configurable TTL. Expired sessions are cleaned up automatically. 184 + - **Opt-in activation**: The admin portal is completely opt-in. If `GATEKEEPER_ADMIN_RBAC_CONFIG` is not set, no admin routes are mounted and the portal is entirely inactive. 185 + 186 + ## Troubleshooting 187 + 188 + **OAuth callback failures** 189 + Ensure HTTPS is properly configured with a valid certificate, DNS resolves correctly for your PDS hostname, and the hostname the user accesses matches the PDS configuration. 190 + 191 + **"Access Denied" after login** 192 + Verify that the DID in your RBAC configuration exactly matches the DID of the authenticating identity. Use `goat resolve {handle}` to confirm the correct DID. 193 + 194 + **Session expired** 195 + Sessions expire after the configured TTL (default 24 hours). Either increase `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` or log in again. 196 + 197 + **403 on admin action** 198 + The authenticated user's roles do not include the endpoint being accessed. Check the `members` and `roles` sections of your RBAC config to ensure the required endpoint pattern is granted. 199 + 200 + **Admin portal not appearing** 201 + Confirm that `GATEKEEPER_ADMIN_RBAC_CONFIG` is set in the environment, the file path is correct, and the file exists and is readable by the gatekeeper process.
+1142 -25
Cargo.lock
··· 28 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 29 30 [[package]] 31 name = "ahash" 32 version = "0.8.12" 33 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 55 checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 56 57 [[package]] 58 name = "allocator-api2" 59 version = "0.2.21" 60 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 83 dependencies = [ 84 "object", 85 ] 86 87 [[package]] 88 name = "async-channel" ··· 365 ] 366 367 [[package]] 368 name = "axum-macros" 369 version = "0.5.0" 370 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 411 412 [[package]] 413 name = "base64" 414 version = "0.22.1" 415 source = "registry+https://github.com/rust-lang/crates.io-index" 416 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" ··· 487 ] 488 489 [[package]] 490 name = "bstr" 491 version = "1.12.1" 492 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 518 "cc-traits", 519 "slab", 520 "smallvec", 521 ] 522 523 [[package]] ··· 572 ] 573 574 [[package]] 575 name = "cfg-if" 576 version = "1.0.4" 577 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 606 "hashbrown 0.14.5", 607 "stacker", 608 ] 609 610 [[package]] 611 name = "ciborium" ··· 677 ] 678 679 [[package]] 680 name = "compression-codecs" 681 version = "0.4.35" 682 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 717 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 718 719 [[package]] 720 name = "cordyceps" 721 version = "0.3.4" 722 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 731 version = "0.9.4" 732 source = "registry+https://github.com/rust-lang/crates.io-index" 733 checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 734 dependencies = [ 735 "core-foundation-sys", 736 "libc", ··· 789 version = "1.2.0" 790 source = "registry+https://github.com/rust-lang/crates.io-index" 791 checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 792 793 [[package]] 794 name = "crossbeam-queue" ··· 944 ] 945 946 [[package]] 947 name = "der" 948 version = "0.7.10" 949 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1093 checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 1094 dependencies = [ 1095 "base16ct", 1096 "crypto-bigint", 1097 "digest", 1098 "ff", ··· 1102 "pkcs8", 1103 "rand_core 0.6.4", 1104 "sec1", 1105 "subtle", 1106 "zeroize", 1107 ] ··· 1112 source = "registry+https://github.com/rust-lang/crates.io-index" 1113 checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" 1114 dependencies = [ 1115 - "base64", 1116 "memchr", 1117 ] 1118 ··· 1144 ] 1145 1146 [[package]] 1147 name = "equivalent" 1148 version = "1.0.2" 1149 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1156 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 1157 dependencies = [ 1158 "libc", 1159 - "windows-sys 0.59.0", 1160 ] 1161 1162 [[package]] ··· 1214 ] 1215 1216 [[package]] 1217 name = "find-msvc-tools" 1218 version = "0.1.6" 1219 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1297 version = "1.3.0" 1298 source = "registry+https://github.com/rust-lang/crates.io-index" 1299 checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 1300 1301 [[package]] 1302 name = "futures-buffered" ··· 1468 ] 1469 1470 [[package]] 1471 name = "globset" 1472 version = "0.4.18" 1473 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1481 ] 1482 1483 [[package]] 1484 name = "gloo-timers" 1485 version = "0.3.0" 1486 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1490 "futures-core", 1491 "js-sys", 1492 "wasm-bindgen", 1493 ] 1494 1495 [[package]] ··· 1524 "ff", 1525 "rand_core 0.6.4", 1526 "subtle", 1527 ] 1528 1529 [[package]] ··· 1674 checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" 1675 1676 [[package]] 1677 name = "hkdf" 1678 version = "0.12.4" 1679 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1710 ] 1711 1712 [[package]] 1713 name = "http" 1714 version = "1.4.0" 1715 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1813 source = "registry+https://github.com/rust-lang/crates.io-index" 1814 checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" 1815 dependencies = [ 1816 - "base64", 1817 "bytes", 1818 "futures-channel", 1819 "futures-core", ··· 1825 "libc", 1826 "percent-encoding", 1827 "pin-project-lite", 1828 - "socket2", 1829 "system-configuration", 1830 "tokio", 1831 "tower-service", ··· 1939 ] 1940 1941 [[package]] 1942 name = "ident_case" 1943 version = "1.0.1" 1944 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2016 ] 2017 2018 [[package]] 2019 name = "ipld-core" 2020 version = "0.4.2" 2021 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2049 checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 2050 2051 [[package]] 2052 name = "jacquard-api" 2053 version = "0.9.5" 2054 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2074 source = "registry+https://github.com/rust-lang/crates.io-index" 2075 checksum = "1751921e0bdae5e0077afade6161545e9ef7698306c868f800916e99ecbcaae9" 2076 dependencies = [ 2077 - "base64", 2078 "bon", 2079 "bytes", 2080 "chrono", ··· 2130 dependencies = [ 2131 "bon", 2132 "bytes", 2133 "http", 2134 "jacquard-api", 2135 "jacquard-common", 2136 "jacquard-lexicon", 2137 "miette", 2138 "n0-future", 2139 "percent-encoding", 2140 "reqwest", ··· 2176 ] 2177 2178 [[package]] 2179 name = "jobserver" 2180 version = "0.1.34" 2181 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2186 ] 2187 2188 [[package]] 2189 name = "josekit" 2190 version = "0.10.3" 2191 source = "registry+https://github.com/rust-lang/crates.io-index" 2192 checksum = "a808e078330e6af222eb0044b71d4b1ff981bfef43e7bc8133a88234e0c86a0c" 2193 dependencies = [ 2194 "anyhow", 2195 - "base64", 2196 "flate2", 2197 "openssl", 2198 "regex", ··· 2276 ] 2277 2278 [[package]] 2279 name = "lettre" 2280 version = "0.11.19" 2281 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2283 dependencies = [ 2284 "async-std", 2285 "async-trait", 2286 - "base64", 2287 "chumsky", 2288 "email-encoding", 2289 "email_address", ··· 2297 "percent-encoding", 2298 "quoted_printable", 2299 "rustls", 2300 - "socket2", 2301 "tokio", 2302 "tokio-rustls", 2303 "url", ··· 2339 ] 2340 2341 [[package]] 2342 name = "linux-raw-sys" 2343 version = "0.11.0" 2344 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2382 ] 2383 2384 [[package]] 2385 name = "lru-slab" 2386 version = "0.1.2" 2387 source = "registry+https://github.com/rust-lang/crates.io-index" 2388 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 2389 2390 [[package]] 2391 name = "match-lookup" ··· 2458 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 2459 2460 [[package]] 2461 name = "minimal-lexical" 2462 version = "0.2.1" 2463 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2508 ] 2509 2510 [[package]] 2511 name = "n0-future" 2512 version = "0.1.3" 2513 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2527 "wasm-bindgen-futures", 2528 "web-time", 2529 ] 2530 2531 [[package]] 2532 name = "nom" ··· 2565 source = "registry+https://github.com/rust-lang/crates.io-index" 2566 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2567 dependencies = [ 2568 - "windows-sys 0.59.0", 2569 ] 2570 2571 [[package]] ··· 2636 ] 2637 2638 [[package]] 2639 name = "object" 2640 version = "0.32.2" 2641 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2725 ] 2726 2727 [[package]] 2728 name = "parking" 2729 version = "2.2.1" 2730 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2781 "anyhow", 2782 "aws-lc-rs", 2783 "axum", 2784 "axum-template", 2785 "chrono", 2786 "dashmap", 2787 "dotenvy", ··· 2789 "hex", 2790 "html-escape", 2791 "hyper-util", 2792 "jacquard-common", 2793 "jacquard-identity", 2794 "josekit", 2795 "jwt-compact", 2796 "lettre", 2797 "multibase", 2798 "rand 0.9.2", 2799 "reqwest", 2800 "rust-embed", ··· 2802 "scrypt", 2803 "serde", 2804 "serde_json", 2805 "sha2", 2806 "sqlx", 2807 "tokio", ··· 2812 "tracing-subscriber", 2813 "url", 2814 "urlencoding", 2815 - "valuable", 2816 ] 2817 2818 [[package]] ··· 2874 ] 2875 2876 [[package]] 2877 name = "pin-project" 2878 version = "1.1.10" 2879 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3001 ] 3002 3003 [[package]] 3004 name = "prettyplease" 3005 version = "0.2.37" 3006 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3091 ] 3092 3093 [[package]] 3094 name = "quinn" 3095 version = "0.11.9" 3096 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3103 "quinn-udp", 3104 "rustc-hash", 3105 "rustls", 3106 - "socket2", 3107 "thiserror 2.0.17", 3108 "tokio", 3109 "tracing", ··· 3140 "cfg_aliases", 3141 "libc", 3142 "once_cell", 3143 - "socket2", 3144 "tracing", 3145 - "windows-sys 0.59.0", 3146 ] 3147 3148 [[package]] ··· 3319 source = "registry+https://github.com/rust-lang/crates.io-index" 3320 checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 3321 dependencies = [ 3322 - "base64", 3323 "bytes", 3324 "encoding_rs", 3325 "futures-core", ··· 3353 "web-sys", 3354 "webpki-roots 1.0.5", 3355 ] 3356 3357 [[package]] 3358 name = "rfc6979" ··· 3379 ] 3380 3381 [[package]] 3382 name = "rsa" 3383 version = "0.9.9" 3384 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3458 "errno", 3459 "libc", 3460 "linux-raw-sys", 3461 - "windows-sys 0.52.0", 3462 ] 3463 3464 [[package]] ··· 3512 checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" 3513 3514 [[package]] 3515 name = "salsa20" 3516 version = "0.10.2" 3517 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3587 "der", 3588 "generic-array", 3589 "pkcs8", 3590 "subtle", 3591 "zeroize", 3592 ] ··· 3740 source = "registry+https://github.com/rust-lang/crates.io-index" 3741 checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" 3742 dependencies = [ 3743 - "base64", 3744 "chrono", 3745 "hex", 3746 "indexmap 1.9.3", ··· 3766 ] 3767 3768 [[package]] 3769 name = "sha1" 3770 version = "0.10.6" 3771 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3775 "cpufeatures", 3776 "digest", 3777 ] 3778 3779 [[package]] 3780 name = "sha2" ··· 3829 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 3830 3831 [[package]] 3832 name = "slab" 3833 version = "0.4.11" 3834 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3855 3856 [[package]] 3857 name = "socket2" 3858 version = "0.6.1" 3859 source = "registry+https://github.com/rust-lang/crates.io-index" 3860 checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" ··· 3916 source = "registry+https://github.com/rust-lang/crates.io-index" 3917 checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" 3918 dependencies = [ 3919 - "base64", 3920 "bytes", 3921 "chrono", 3922 "crc", ··· 3992 checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" 3993 dependencies = [ 3994 "atoi", 3995 - "base64", 3996 "bitflags", 3997 "byteorder", 3998 "bytes", ··· 4035 checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" 4036 dependencies = [ 4037 "atoi", 4038 - "base64", 4039 "bitflags", 4040 "byteorder", 4041 "chrono", ··· 4137 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 4138 4139 [[package]] 4140 name = "stringprep" 4141 version = "0.1.5" 4142 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4208 checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 4209 dependencies = [ 4210 "bitflags", 4211 - "core-foundation", 4212 "system-configuration-sys", 4213 ] 4214 ··· 4223 ] 4224 4225 [[package]] 4226 name = "thiserror" 4227 version = "1.0.69" 4228 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4272 ] 4273 4274 [[package]] 4275 name = "time" 4276 version = "0.3.44" 4277 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4279 dependencies = [ 4280 "deranged", 4281 "itoa", 4282 "num-conv", 4283 "powerfmt", 4284 "serde", 4285 "time-core", ··· 4303 ] 4304 4305 [[package]] 4306 name = "tinystr" 4307 version = "0.8.2" 4308 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4338 "mio", 4339 "pin-project-lite", 4340 "signal-hook-registry", 4341 - "socket2", 4342 "tokio-macros", 4343 "windows-sys 0.61.2", 4344 ] ··· 4397 dependencies = [ 4398 "async-trait", 4399 "axum", 4400 - "base64", 4401 "bytes", 4402 "h2", 4403 "http", ··· 4408 "hyper-util", 4409 "percent-encoding", 4410 "pin-project", 4411 - "socket2", 4412 "sync_wrapper", 4413 "tokio", 4414 "tokio-stream", ··· 4577 ] 4578 4579 [[package]] 4580 name = "try-lock" 4581 version = "0.2.5" 4582 source = "registry+https://github.com/rust-lang/crates.io-index" 4583 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 4584 4585 [[package]] 4586 name = "typenum" 4587 version = "1.19.0" 4588 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4595 checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" 4596 4597 [[package]] 4598 name = "unicode-bidi" 4599 version = "0.3.18" 4600 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4640 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 4641 4642 [[package]] 4643 name = "unsigned-varint" 4644 version = "0.8.0" 4645 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4676 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 4677 4678 [[package]] 4679 name = "utf8-width" 4680 version = "0.1.8" 4681 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4686 version = "1.0.4" 4687 source = "registry+https://github.com/rust-lang/crates.io-index" 4688 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 4689 4690 [[package]] 4691 name = "valuable" ··· 4742 source = "registry+https://github.com/rust-lang/crates.io-index" 4743 checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 4744 dependencies = [ 4745 - "wit-bindgen", 4746 ] 4747 4748 [[package]] ··· 4810 ] 4811 4812 [[package]] 4813 name = "web-sys" 4814 version = "0.3.83" 4815 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4830 ] 4831 4832 [[package]] 4833 name = "webpki-roots" 4834 version = "0.26.11" 4835 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4858 ] 4859 4860 [[package]] 4861 name = "winapi" 4862 version = "0.3.9" 4863 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4879 source = "registry+https://github.com/rust-lang/crates.io-index" 4880 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 4881 dependencies = [ 4882 - "windows-sys 0.48.0", 4883 ] 4884 4885 [[package]] ··· 4960 4961 [[package]] 4962 name = "windows-sys" 4963 version = "0.48.0" 4964 source = "registry+https://github.com/rust-lang/crates.io-index" 4965 checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" ··· 5005 5006 [[package]] 5007 name = "windows-targets" 5008 version = "0.48.5" 5009 source = "registry+https://github.com/rust-lang/crates.io-index" 5010 checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" ··· 5053 5054 [[package]] 5055 name = "windows_aarch64_gnullvm" 5056 version = "0.48.5" 5057 source = "registry+https://github.com/rust-lang/crates.io-index" 5058 checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" ··· 5071 5072 [[package]] 5073 name = "windows_aarch64_msvc" 5074 version = "0.48.5" 5075 source = "registry+https://github.com/rust-lang/crates.io-index" 5076 checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" ··· 5089 5090 [[package]] 5091 name = "windows_i686_gnu" 5092 version = "0.48.5" 5093 source = "registry+https://github.com/rust-lang/crates.io-index" 5094 checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" ··· 5119 5120 [[package]] 5121 name = "windows_i686_msvc" 5122 version = "0.48.5" 5123 source = "registry+https://github.com/rust-lang/crates.io-index" 5124 checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" ··· 5137 5138 [[package]] 5139 name = "windows_x86_64_gnu" 5140 version = "0.48.5" 5141 source = "registry+https://github.com/rust-lang/crates.io-index" 5142 checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" ··· 5155 5156 [[package]] 5157 name = "windows_x86_64_gnullvm" 5158 version = "0.48.5" 5159 source = "registry+https://github.com/rust-lang/crates.io-index" 5160 checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" ··· 5173 5174 [[package]] 5175 name = "windows_x86_64_msvc" 5176 version = "0.48.5" 5177 source = "registry+https://github.com/rust-lang/crates.io-index" 5178 checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" ··· 5190 checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 5191 5192 [[package]] 5193 name = "wit-bindgen" 5194 version = "0.46.0" 5195 source = "registry+https://github.com/rust-lang/crates.io-index" 5196 checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 5197 5198 [[package]] 5199 name = "writeable" 5200 version = "0.6.2" 5201 source = "registry+https://github.com/rust-lang/crates.io-index" 5202 checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 5203 5204 [[package]] 5205 name = "yansi" ··· 5277 source = "registry+https://github.com/rust-lang/crates.io-index" 5278 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 5279 dependencies = [ 5280 "zeroize_derive", 5281 ] 5282
··· 28 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 29 30 [[package]] 31 + name = "adler32" 32 + version = "1.2.0" 33 + source = "registry+https://github.com/rust-lang/crates.io-index" 34 + checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" 35 + 36 + [[package]] 37 name = "ahash" 38 version = "0.8.12" 39 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 61 checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 62 63 [[package]] 64 + name = "alloc-no-stdlib" 65 + version = "2.0.4" 66 + source = "registry+https://github.com/rust-lang/crates.io-index" 67 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 68 + 69 + [[package]] 70 + name = "alloc-stdlib" 71 + version = "0.2.2" 72 + source = "registry+https://github.com/rust-lang/crates.io-index" 73 + checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 74 + dependencies = [ 75 + "alloc-no-stdlib", 76 + ] 77 + 78 + [[package]] 79 name = "allocator-api2" 80 version = "0.2.21" 81 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 104 dependencies = [ 105 "object", 106 ] 107 + 108 + [[package]] 109 + name = "ascii" 110 + version = "1.1.0" 111 + source = "registry+https://github.com/rust-lang/crates.io-index" 112 + checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" 113 114 [[package]] 115 name = "async-channel" ··· 392 ] 393 394 [[package]] 395 + name = "axum-extra" 396 + version = "0.10.3" 397 + source = "registry+https://github.com/rust-lang/crates.io-index" 398 + checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" 399 + dependencies = [ 400 + "axum", 401 + "axum-core", 402 + "bytes", 403 + "cookie", 404 + "futures-util", 405 + "http", 406 + "http-body", 407 + "http-body-util", 408 + "mime", 409 + "pin-project-lite", 410 + "rustversion", 411 + "serde_core", 412 + "tower-layer", 413 + "tower-service", 414 + "tracing", 415 + ] 416 + 417 + [[package]] 418 name = "axum-macros" 419 version = "0.5.0" 420 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 461 462 [[package]] 463 name = "base64" 464 + version = "0.13.1" 465 + source = "registry+https://github.com/rust-lang/crates.io-index" 466 + checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 467 + 468 + [[package]] 469 + name = "base64" 470 version = "0.22.1" 471 source = "registry+https://github.com/rust-lang/crates.io-index" 472 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" ··· 543 ] 544 545 [[package]] 546 + name = "brotli" 547 + version = "3.5.0" 548 + source = "registry+https://github.com/rust-lang/crates.io-index" 549 + checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" 550 + dependencies = [ 551 + "alloc-no-stdlib", 552 + "alloc-stdlib", 553 + "brotli-decompressor", 554 + ] 555 + 556 + [[package]] 557 + name = "brotli-decompressor" 558 + version = "2.5.1" 559 + source = "registry+https://github.com/rust-lang/crates.io-index" 560 + checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" 561 + dependencies = [ 562 + "alloc-no-stdlib", 563 + "alloc-stdlib", 564 + ] 565 + 566 + [[package]] 567 name = "bstr" 568 version = "1.12.1" 569 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 595 "cc-traits", 596 "slab", 597 "smallvec", 598 + ] 599 + 600 + [[package]] 601 + name = "buf_redux" 602 + version = "0.8.4" 603 + source = "registry+https://github.com/rust-lang/crates.io-index" 604 + checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" 605 + dependencies = [ 606 + "memchr", 607 + "safemem", 608 ] 609 610 [[package]] ··· 659 ] 660 661 [[package]] 662 + name = "cesu8" 663 + version = "1.1.0" 664 + source = "registry+https://github.com/rust-lang/crates.io-index" 665 + checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 666 + 667 + [[package]] 668 name = "cfg-if" 669 version = "1.0.4" 670 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 699 "hashbrown 0.14.5", 700 "stacker", 701 ] 702 + 703 + [[package]] 704 + name = "chunked_transfer" 705 + version = "1.5.0" 706 + source = "registry+https://github.com/rust-lang/crates.io-index" 707 + checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" 708 709 [[package]] 710 name = "ciborium" ··· 776 ] 777 778 [[package]] 779 + name = "combine" 780 + version = "4.6.7" 781 + source = "registry+https://github.com/rust-lang/crates.io-index" 782 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 783 + dependencies = [ 784 + "bytes", 785 + "memchr", 786 + ] 787 + 788 + [[package]] 789 name = "compression-codecs" 790 version = "0.4.35" 791 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 826 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 827 828 [[package]] 829 + name = "cookie" 830 + version = "0.18.1" 831 + source = "registry+https://github.com/rust-lang/crates.io-index" 832 + checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" 833 + dependencies = [ 834 + "base64 0.22.1", 835 + "hmac", 836 + "percent-encoding", 837 + "rand 0.8.5", 838 + "sha2", 839 + "subtle", 840 + "time", 841 + "version_check", 842 + ] 843 + 844 + [[package]] 845 name = "cordyceps" 846 version = "0.3.4" 847 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 856 version = "0.9.4" 857 source = "registry+https://github.com/rust-lang/crates.io-index" 858 checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 859 + dependencies = [ 860 + "core-foundation-sys", 861 + "libc", 862 + ] 863 + 864 + [[package]] 865 + name = "core-foundation" 866 + version = "0.10.1" 867 + source = "registry+https://github.com/rust-lang/crates.io-index" 868 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 869 dependencies = [ 870 "core-foundation-sys", 871 "libc", ··· 924 version = "1.2.0" 925 source = "registry+https://github.com/rust-lang/crates.io-index" 926 checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 927 + 928 + [[package]] 929 + name = "crossbeam-channel" 930 + version = "0.5.15" 931 + source = "registry+https://github.com/rust-lang/crates.io-index" 932 + checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" 933 + dependencies = [ 934 + "crossbeam-utils", 935 + ] 936 937 [[package]] 938 name = "crossbeam-queue" ··· 1088 ] 1089 1090 [[package]] 1091 + name = "deflate" 1092 + version = "1.0.0" 1093 + source = "registry+https://github.com/rust-lang/crates.io-index" 1094 + checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" 1095 + dependencies = [ 1096 + "adler32", 1097 + "gzip-header", 1098 + ] 1099 + 1100 + [[package]] 1101 name = "der" 1102 version = "0.7.10" 1103 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1247 checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 1248 dependencies = [ 1249 "base16ct", 1250 + "base64ct", 1251 "crypto-bigint", 1252 "digest", 1253 "ff", ··· 1257 "pkcs8", 1258 "rand_core 0.6.4", 1259 "sec1", 1260 + "serde_json", 1261 + "serdect", 1262 "subtle", 1263 "zeroize", 1264 ] ··· 1269 source = "registry+https://github.com/rust-lang/crates.io-index" 1270 checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" 1271 dependencies = [ 1272 + "base64 0.22.1", 1273 "memchr", 1274 ] 1275 ··· 1301 ] 1302 1303 [[package]] 1304 + name = "enum-as-inner" 1305 + version = "0.6.1" 1306 + source = "registry+https://github.com/rust-lang/crates.io-index" 1307 + checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" 1308 + dependencies = [ 1309 + "heck 0.5.0", 1310 + "proc-macro2", 1311 + "quote", 1312 + "syn 2.0.112", 1313 + ] 1314 + 1315 + [[package]] 1316 name = "equivalent" 1317 version = "1.0.2" 1318 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1325 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 1326 dependencies = [ 1327 "libc", 1328 + "windows-sys 0.61.2", 1329 ] 1330 1331 [[package]] ··· 1383 ] 1384 1385 [[package]] 1386 + name = "filetime" 1387 + version = "0.2.27" 1388 + source = "registry+https://github.com/rust-lang/crates.io-index" 1389 + checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" 1390 + dependencies = [ 1391 + "cfg-if", 1392 + "libc", 1393 + "libredox", 1394 + ] 1395 + 1396 + [[package]] 1397 name = "find-msvc-tools" 1398 version = "0.1.6" 1399 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1477 version = "1.3.0" 1478 source = "registry+https://github.com/rust-lang/crates.io-index" 1479 checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 1480 + 1481 + [[package]] 1482 + name = "futf" 1483 + version = "0.1.5" 1484 + source = "registry+https://github.com/rust-lang/crates.io-index" 1485 + checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" 1486 + dependencies = [ 1487 + "mac", 1488 + "new_debug_unreachable", 1489 + ] 1490 1491 [[package]] 1492 name = "futures-buffered" ··· 1658 ] 1659 1660 [[package]] 1661 + name = "getrandom" 1662 + version = "0.4.1" 1663 + source = "registry+https://github.com/rust-lang/crates.io-index" 1664 + checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" 1665 + dependencies = [ 1666 + "cfg-if", 1667 + "libc", 1668 + "r-efi", 1669 + "wasip2", 1670 + "wasip3", 1671 + ] 1672 + 1673 + [[package]] 1674 name = "globset" 1675 version = "0.4.18" 1676 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1684 ] 1685 1686 [[package]] 1687 + name = "gloo-storage" 1688 + version = "0.3.0" 1689 + source = "registry+https://github.com/rust-lang/crates.io-index" 1690 + checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" 1691 + dependencies = [ 1692 + "gloo-utils", 1693 + "js-sys", 1694 + "serde", 1695 + "serde_json", 1696 + "thiserror 1.0.69", 1697 + "wasm-bindgen", 1698 + "web-sys", 1699 + ] 1700 + 1701 + [[package]] 1702 name = "gloo-timers" 1703 version = "0.3.0" 1704 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1708 "futures-core", 1709 "js-sys", 1710 "wasm-bindgen", 1711 + ] 1712 + 1713 + [[package]] 1714 + name = "gloo-utils" 1715 + version = "0.2.0" 1716 + source = "registry+https://github.com/rust-lang/crates.io-index" 1717 + checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" 1718 + dependencies = [ 1719 + "js-sys", 1720 + "serde", 1721 + "serde_json", 1722 + "wasm-bindgen", 1723 + "web-sys", 1724 ] 1725 1726 [[package]] ··· 1755 "ff", 1756 "rand_core 0.6.4", 1757 "subtle", 1758 + ] 1759 + 1760 + [[package]] 1761 + name = "gzip-header" 1762 + version = "1.0.0" 1763 + source = "registry+https://github.com/rust-lang/crates.io-index" 1764 + checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" 1765 + dependencies = [ 1766 + "crc32fast", 1767 ] 1768 1769 [[package]] ··· 1914 checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" 1915 1916 [[package]] 1917 + name = "hickory-proto" 1918 + version = "0.24.4" 1919 + source = "registry+https://github.com/rust-lang/crates.io-index" 1920 + checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" 1921 + dependencies = [ 1922 + "async-trait", 1923 + "cfg-if", 1924 + "data-encoding", 1925 + "enum-as-inner", 1926 + "futures-channel", 1927 + "futures-io", 1928 + "futures-util", 1929 + "idna", 1930 + "ipnet", 1931 + "once_cell", 1932 + "rand 0.8.5", 1933 + "thiserror 1.0.69", 1934 + "tinyvec", 1935 + "tokio", 1936 + "tracing", 1937 + "url", 1938 + ] 1939 + 1940 + [[package]] 1941 + name = "hickory-resolver" 1942 + version = "0.24.4" 1943 + source = "registry+https://github.com/rust-lang/crates.io-index" 1944 + checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" 1945 + dependencies = [ 1946 + "cfg-if", 1947 + "futures-util", 1948 + "hickory-proto", 1949 + "ipconfig", 1950 + "lru-cache", 1951 + "once_cell", 1952 + "parking_lot", 1953 + "rand 0.8.5", 1954 + "resolv-conf", 1955 + "smallvec", 1956 + "thiserror 1.0.69", 1957 + "tokio", 1958 + "tracing", 1959 + ] 1960 + 1961 + [[package]] 1962 name = "hkdf" 1963 version = "0.12.4" 1964 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1995 ] 1996 1997 [[package]] 1998 + name = "html5ever" 1999 + version = "0.27.0" 2000 + source = "registry+https://github.com/rust-lang/crates.io-index" 2001 + checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" 2002 + dependencies = [ 2003 + "log", 2004 + "mac", 2005 + "markup5ever", 2006 + "proc-macro2", 2007 + "quote", 2008 + "syn 2.0.112", 2009 + ] 2010 + 2011 + [[package]] 2012 name = "http" 2013 version = "1.4.0" 2014 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2112 source = "registry+https://github.com/rust-lang/crates.io-index" 2113 checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" 2114 dependencies = [ 2115 + "base64 0.22.1", 2116 "bytes", 2117 "futures-channel", 2118 "futures-core", ··· 2124 "libc", 2125 "percent-encoding", 2126 "pin-project-lite", 2127 + "socket2 0.6.1", 2128 "system-configuration", 2129 "tokio", 2130 "tower-service", ··· 2238 ] 2239 2240 [[package]] 2241 + name = "id-arena" 2242 + version = "2.3.0" 2243 + source = "registry+https://github.com/rust-lang/crates.io-index" 2244 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 2245 + 2246 + [[package]] 2247 name = "ident_case" 2248 version = "1.0.1" 2249 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2321 ] 2322 2323 [[package]] 2324 + name = "ipconfig" 2325 + version = "0.3.2" 2326 + source = "registry+https://github.com/rust-lang/crates.io-index" 2327 + checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" 2328 + dependencies = [ 2329 + "socket2 0.5.10", 2330 + "widestring", 2331 + "windows-sys 0.48.0", 2332 + "winreg", 2333 + ] 2334 + 2335 + [[package]] 2336 name = "ipld-core" 2337 version = "0.4.2" 2338 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2366 checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" 2367 2368 [[package]] 2369 + name = "jacquard" 2370 + version = "0.9.5" 2371 + source = "registry+https://github.com/rust-lang/crates.io-index" 2372 + checksum = "f7c1fdbcf1153e6e6b87fde20036c1ffe7473c4852f1c6369bc4ef1fe47ccb9f" 2373 + dependencies = [ 2374 + "bytes", 2375 + "getrandom 0.2.16", 2376 + "gloo-storage", 2377 + "http", 2378 + "jacquard-api", 2379 + "jacquard-common", 2380 + "jacquard-derive", 2381 + "jacquard-identity", 2382 + "jacquard-oauth", 2383 + "jose-jwk", 2384 + "miette", 2385 + "regex", 2386 + "regex-lite", 2387 + "reqwest", 2388 + "serde", 2389 + "serde_html_form", 2390 + "serde_json", 2391 + "smol_str", 2392 + "thiserror 2.0.17", 2393 + "tokio", 2394 + "trait-variant", 2395 + "url", 2396 + "webpage", 2397 + ] 2398 + 2399 + [[package]] 2400 name = "jacquard-api" 2401 version = "0.9.5" 2402 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2422 source = "registry+https://github.com/rust-lang/crates.io-index" 2423 checksum = "1751921e0bdae5e0077afade6161545e9ef7698306c868f800916e99ecbcaae9" 2424 dependencies = [ 2425 + "base64 0.22.1", 2426 "bon", 2427 "bytes", 2428 "chrono", ··· 2478 dependencies = [ 2479 "bon", 2480 "bytes", 2481 + "hickory-resolver", 2482 "http", 2483 "jacquard-api", 2484 "jacquard-common", 2485 "jacquard-lexicon", 2486 "miette", 2487 + "mini-moka-wasm", 2488 "n0-future", 2489 "percent-encoding", 2490 "reqwest", ··· 2526 ] 2527 2528 [[package]] 2529 + name = "jacquard-oauth" 2530 + version = "0.9.6" 2531 + source = "registry+https://github.com/rust-lang/crates.io-index" 2532 + checksum = "68bf0b0e061d85b09cfa78588dc098918d5b62f539a719165c6a806a1d2c0ef2" 2533 + dependencies = [ 2534 + "base64 0.22.1", 2535 + "bytes", 2536 + "chrono", 2537 + "dashmap", 2538 + "elliptic-curve", 2539 + "http", 2540 + "jacquard-common", 2541 + "jacquard-identity", 2542 + "jose-jwa", 2543 + "jose-jwk", 2544 + "miette", 2545 + "p256", 2546 + "rand 0.8.5", 2547 + "rouille", 2548 + "serde", 2549 + "serde_html_form", 2550 + "serde_json", 2551 + "sha2", 2552 + "smol_str", 2553 + "thiserror 2.0.17", 2554 + "tokio", 2555 + "trait-variant", 2556 + "url", 2557 + "webbrowser", 2558 + ] 2559 + 2560 + [[package]] 2561 + name = "jni" 2562 + version = "0.21.1" 2563 + source = "registry+https://github.com/rust-lang/crates.io-index" 2564 + checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 2565 + dependencies = [ 2566 + "cesu8", 2567 + "cfg-if", 2568 + "combine", 2569 + "jni-sys", 2570 + "log", 2571 + "thiserror 1.0.69", 2572 + "walkdir", 2573 + "windows-sys 0.45.0", 2574 + ] 2575 + 2576 + [[package]] 2577 + name = "jni-sys" 2578 + version = "0.3.0" 2579 + source = "registry+https://github.com/rust-lang/crates.io-index" 2580 + checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 2581 + 2582 + [[package]] 2583 name = "jobserver" 2584 version = "0.1.34" 2585 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2590 ] 2591 2592 [[package]] 2593 + name = "jose-b64" 2594 + version = "0.1.2" 2595 + source = "registry+https://github.com/rust-lang/crates.io-index" 2596 + checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 2597 + dependencies = [ 2598 + "base64ct", 2599 + "serde", 2600 + "subtle", 2601 + "zeroize", 2602 + ] 2603 + 2604 + [[package]] 2605 + name = "jose-jwa" 2606 + version = "0.1.2" 2607 + source = "registry+https://github.com/rust-lang/crates.io-index" 2608 + checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 2609 + dependencies = [ 2610 + "serde", 2611 + ] 2612 + 2613 + [[package]] 2614 + name = "jose-jwk" 2615 + version = "0.1.2" 2616 + source = "registry+https://github.com/rust-lang/crates.io-index" 2617 + checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 2618 + dependencies = [ 2619 + "jose-b64", 2620 + "jose-jwa", 2621 + "p256", 2622 + "p384", 2623 + "rsa", 2624 + "serde", 2625 + "zeroize", 2626 + ] 2627 + 2628 + [[package]] 2629 name = "josekit" 2630 version = "0.10.3" 2631 source = "registry+https://github.com/rust-lang/crates.io-index" 2632 checksum = "a808e078330e6af222eb0044b71d4b1ff981bfef43e7bc8133a88234e0c86a0c" 2633 dependencies = [ 2634 "anyhow", 2635 + "base64 0.22.1", 2636 "flate2", 2637 "openssl", 2638 "regex", ··· 2716 ] 2717 2718 [[package]] 2719 + name = "leb128fmt" 2720 + version = "0.1.0" 2721 + source = "registry+https://github.com/rust-lang/crates.io-index" 2722 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 2723 + 2724 + [[package]] 2725 name = "lettre" 2726 version = "0.11.19" 2727 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2729 dependencies = [ 2730 "async-std", 2731 "async-trait", 2732 + "base64 0.22.1", 2733 "chumsky", 2734 "email-encoding", 2735 "email_address", ··· 2743 "percent-encoding", 2744 "quoted_printable", 2745 "rustls", 2746 + "socket2 0.6.1", 2747 "tokio", 2748 "tokio-rustls", 2749 "url", ··· 2785 ] 2786 2787 [[package]] 2788 + name = "linked-hash-map" 2789 + version = "0.5.6" 2790 + source = "registry+https://github.com/rust-lang/crates.io-index" 2791 + checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 2792 + 2793 + [[package]] 2794 name = "linux-raw-sys" 2795 version = "0.11.0" 2796 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2834 ] 2835 2836 [[package]] 2837 + name = "lru-cache" 2838 + version = "0.1.2" 2839 + source = "registry+https://github.com/rust-lang/crates.io-index" 2840 + checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" 2841 + dependencies = [ 2842 + "linked-hash-map", 2843 + ] 2844 + 2845 + [[package]] 2846 name = "lru-slab" 2847 version = "0.1.2" 2848 source = "registry+https://github.com/rust-lang/crates.io-index" 2849 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 2850 + 2851 + [[package]] 2852 + name = "mac" 2853 + version = "0.1.1" 2854 + source = "registry+https://github.com/rust-lang/crates.io-index" 2855 + checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 2856 + 2857 + [[package]] 2858 + name = "markup5ever" 2859 + version = "0.12.1" 2860 + source = "registry+https://github.com/rust-lang/crates.io-index" 2861 + checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" 2862 + dependencies = [ 2863 + "log", 2864 + "phf", 2865 + "phf_codegen", 2866 + "string_cache", 2867 + "string_cache_codegen", 2868 + "tendril", 2869 + ] 2870 + 2871 + [[package]] 2872 + name = "markup5ever_rcdom" 2873 + version = "0.3.0" 2874 + source = "registry+https://github.com/rust-lang/crates.io-index" 2875 + checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" 2876 + dependencies = [ 2877 + "html5ever", 2878 + "markup5ever", 2879 + "tendril", 2880 + "xml5ever", 2881 + ] 2882 2883 [[package]] 2884 name = "match-lookup" ··· 2951 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 2952 2953 [[package]] 2954 + name = "mime_guess" 2955 + version = "2.0.5" 2956 + source = "registry+https://github.com/rust-lang/crates.io-index" 2957 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 2958 + dependencies = [ 2959 + "mime", 2960 + "unicase", 2961 + ] 2962 + 2963 + [[package]] 2964 + name = "mini-moka-wasm" 2965 + version = "0.10.99" 2966 + source = "registry+https://github.com/rust-lang/crates.io-index" 2967 + checksum = "0102b9a2ad50fa47ca89eead2316c8222285ecfbd3f69ce99564fbe4253866e8" 2968 + dependencies = [ 2969 + "crossbeam-channel", 2970 + "crossbeam-utils", 2971 + "dashmap", 2972 + "smallvec", 2973 + "tagptr", 2974 + "triomphe", 2975 + "web-time", 2976 + ] 2977 + 2978 + [[package]] 2979 name = "minimal-lexical" 2980 version = "0.2.1" 2981 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3026 ] 3027 3028 [[package]] 3029 + name = "multipart" 3030 + version = "0.18.0" 3031 + source = "registry+https://github.com/rust-lang/crates.io-index" 3032 + checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" 3033 + dependencies = [ 3034 + "buf_redux", 3035 + "httparse", 3036 + "log", 3037 + "mime", 3038 + "mime_guess", 3039 + "quick-error", 3040 + "rand 0.8.5", 3041 + "safemem", 3042 + "tempfile", 3043 + "twoway", 3044 + ] 3045 + 3046 + [[package]] 3047 name = "n0-future" 3048 version = "0.1.3" 3049 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3063 "wasm-bindgen-futures", 3064 "web-time", 3065 ] 3066 + 3067 + [[package]] 3068 + name = "ndk-context" 3069 + version = "0.1.1" 3070 + source = "registry+https://github.com/rust-lang/crates.io-index" 3071 + checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" 3072 + 3073 + [[package]] 3074 + name = "new_debug_unreachable" 3075 + version = "1.0.6" 3076 + source = "registry+https://github.com/rust-lang/crates.io-index" 3077 + checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 3078 3079 [[package]] 3080 name = "nom" ··· 3113 source = "registry+https://github.com/rust-lang/crates.io-index" 3114 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 3115 dependencies = [ 3116 + "windows-sys 0.61.2", 3117 ] 3118 3119 [[package]] ··· 3184 ] 3185 3186 [[package]] 3187 + name = "num_cpus" 3188 + version = "1.17.0" 3189 + source = "registry+https://github.com/rust-lang/crates.io-index" 3190 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 3191 + dependencies = [ 3192 + "hermit-abi", 3193 + "libc", 3194 + ] 3195 + 3196 + [[package]] 3197 + name = "num_threads" 3198 + version = "0.1.7" 3199 + source = "registry+https://github.com/rust-lang/crates.io-index" 3200 + checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 3201 + dependencies = [ 3202 + "libc", 3203 + ] 3204 + 3205 + [[package]] 3206 + name = "objc2" 3207 + version = "0.6.4" 3208 + source = "registry+https://github.com/rust-lang/crates.io-index" 3209 + checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" 3210 + dependencies = [ 3211 + "objc2-encode", 3212 + ] 3213 + 3214 + [[package]] 3215 + name = "objc2-encode" 3216 + version = "4.1.0" 3217 + source = "registry+https://github.com/rust-lang/crates.io-index" 3218 + checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" 3219 + 3220 + [[package]] 3221 + name = "objc2-foundation" 3222 + version = "0.3.2" 3223 + source = "registry+https://github.com/rust-lang/crates.io-index" 3224 + checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" 3225 + dependencies = [ 3226 + "bitflags", 3227 + "objc2", 3228 + ] 3229 + 3230 + [[package]] 3231 name = "object" 3232 version = "0.32.2" 3233 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3317 ] 3318 3319 [[package]] 3320 + name = "p384" 3321 + version = "0.13.1" 3322 + source = "registry+https://github.com/rust-lang/crates.io-index" 3323 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 3324 + dependencies = [ 3325 + "elliptic-curve", 3326 + "primeorder", 3327 + ] 3328 + 3329 + [[package]] 3330 name = "parking" 3331 version = "2.2.1" 3332 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3383 "anyhow", 3384 "aws-lc-rs", 3385 "axum", 3386 + "axum-extra", 3387 "axum-template", 3388 + "base64 0.22.1", 3389 "chrono", 3390 "dashmap", 3391 "dotenvy", ··· 3393 "hex", 3394 "html-escape", 3395 "hyper-util", 3396 + "jacquard", 3397 + "jacquard-api", 3398 "jacquard-common", 3399 "jacquard-identity", 3400 + "jacquard-oauth", 3401 + "jose-jwk", 3402 "josekit", 3403 "jwt-compact", 3404 "lettre", 3405 "multibase", 3406 + "p256", 3407 "rand 0.9.2", 3408 "reqwest", 3409 "rust-embed", ··· 3411 "scrypt", 3412 "serde", 3413 "serde_json", 3414 + "serde_yaml", 3415 "sha2", 3416 "sqlx", 3417 "tokio", ··· 3422 "tracing-subscriber", 3423 "url", 3424 "urlencoding", 3425 + "uuid", 3426 ] 3427 3428 [[package]] ··· 3484 ] 3485 3486 [[package]] 3487 + name = "phf" 3488 + version = "0.11.3" 3489 + source = "registry+https://github.com/rust-lang/crates.io-index" 3490 + checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" 3491 + dependencies = [ 3492 + "phf_shared", 3493 + ] 3494 + 3495 + [[package]] 3496 + name = "phf_codegen" 3497 + version = "0.11.3" 3498 + source = "registry+https://github.com/rust-lang/crates.io-index" 3499 + checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" 3500 + dependencies = [ 3501 + "phf_generator", 3502 + "phf_shared", 3503 + ] 3504 + 3505 + [[package]] 3506 + name = "phf_generator" 3507 + version = "0.11.3" 3508 + source = "registry+https://github.com/rust-lang/crates.io-index" 3509 + checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 3510 + dependencies = [ 3511 + "phf_shared", 3512 + "rand 0.8.5", 3513 + ] 3514 + 3515 + [[package]] 3516 + name = "phf_shared" 3517 + version = "0.11.3" 3518 + source = "registry+https://github.com/rust-lang/crates.io-index" 3519 + checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 3520 + dependencies = [ 3521 + "siphasher", 3522 + ] 3523 + 3524 + [[package]] 3525 name = "pin-project" 3526 version = "1.1.10" 3527 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3649 ] 3650 3651 [[package]] 3652 + name = "precomputed-hash" 3653 + version = "0.1.1" 3654 + source = "registry+https://github.com/rust-lang/crates.io-index" 3655 + checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3656 + 3657 + [[package]] 3658 name = "prettyplease" 3659 version = "0.2.37" 3660 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3745 ] 3746 3747 [[package]] 3748 + name = "quick-error" 3749 + version = "1.2.3" 3750 + source = "registry+https://github.com/rust-lang/crates.io-index" 3751 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 3752 + 3753 + [[package]] 3754 name = "quinn" 3755 version = "0.11.9" 3756 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3763 "quinn-udp", 3764 "rustc-hash", 3765 "rustls", 3766 + "socket2 0.6.1", 3767 "thiserror 2.0.17", 3768 "tokio", 3769 "tracing", ··· 3800 "cfg_aliases", 3801 "libc", 3802 "once_cell", 3803 + "socket2 0.6.1", 3804 "tracing", 3805 + "windows-sys 0.60.2", 3806 ] 3807 3808 [[package]] ··· 3979 source = "registry+https://github.com/rust-lang/crates.io-index" 3980 checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 3981 dependencies = [ 3982 + "base64 0.22.1", 3983 "bytes", 3984 "encoding_rs", 3985 "futures-core", ··· 4013 "web-sys", 4014 "webpki-roots 1.0.5", 4015 ] 4016 + 4017 + [[package]] 4018 + name = "resolv-conf" 4019 + version = "0.7.6" 4020 + source = "registry+https://github.com/rust-lang/crates.io-index" 4021 + checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" 4022 4023 [[package]] 4024 name = "rfc6979" ··· 4045 ] 4046 4047 [[package]] 4048 + name = "rouille" 4049 + version = "3.6.2" 4050 + source = "registry+https://github.com/rust-lang/crates.io-index" 4051 + checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" 4052 + dependencies = [ 4053 + "base64 0.13.1", 4054 + "brotli", 4055 + "chrono", 4056 + "deflate", 4057 + "filetime", 4058 + "multipart", 4059 + "percent-encoding", 4060 + "rand 0.8.5", 4061 + "serde", 4062 + "serde_derive", 4063 + "serde_json", 4064 + "sha1_smol", 4065 + "threadpool", 4066 + "time", 4067 + "tiny_http", 4068 + "url", 4069 + ] 4070 + 4071 + [[package]] 4072 name = "rsa" 4073 version = "0.9.9" 4074 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4148 "errno", 4149 "libc", 4150 "linux-raw-sys", 4151 + "windows-sys 0.61.2", 4152 ] 4153 4154 [[package]] ··· 4202 checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" 4203 4204 [[package]] 4205 + name = "safemem" 4206 + version = "0.3.3" 4207 + source = "registry+https://github.com/rust-lang/crates.io-index" 4208 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 4209 + 4210 + [[package]] 4211 name = "salsa20" 4212 version = "0.10.2" 4213 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4283 "der", 4284 "generic-array", 4285 "pkcs8", 4286 + "serdect", 4287 "subtle", 4288 "zeroize", 4289 ] ··· 4437 source = "registry+https://github.com/rust-lang/crates.io-index" 4438 checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" 4439 dependencies = [ 4440 + "base64 0.22.1", 4441 "chrono", 4442 "hex", 4443 "indexmap 1.9.3", ··· 4463 ] 4464 4465 [[package]] 4466 + name = "serde_yaml" 4467 + version = "0.9.34+deprecated" 4468 + source = "registry+https://github.com/rust-lang/crates.io-index" 4469 + checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 4470 + dependencies = [ 4471 + "indexmap 2.12.1", 4472 + "itoa", 4473 + "ryu", 4474 + "serde", 4475 + "unsafe-libyaml", 4476 + ] 4477 + 4478 + [[package]] 4479 + name = "serdect" 4480 + version = "0.2.0" 4481 + source = "registry+https://github.com/rust-lang/crates.io-index" 4482 + checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" 4483 + dependencies = [ 4484 + "base16ct", 4485 + "serde", 4486 + ] 4487 + 4488 + [[package]] 4489 name = "sha1" 4490 version = "0.10.6" 4491 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4495 "cpufeatures", 4496 "digest", 4497 ] 4498 + 4499 + [[package]] 4500 + name = "sha1_smol" 4501 + version = "1.0.1" 4502 + source = "registry+https://github.com/rust-lang/crates.io-index" 4503 + checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 4504 4505 [[package]] 4506 name = "sha2" ··· 4555 checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 4556 4557 [[package]] 4558 + name = "siphasher" 4559 + version = "1.0.2" 4560 + source = "registry+https://github.com/rust-lang/crates.io-index" 4561 + checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" 4562 + 4563 + [[package]] 4564 name = "slab" 4565 version = "0.4.11" 4566 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4587 4588 [[package]] 4589 name = "socket2" 4590 + version = "0.5.10" 4591 + source = "registry+https://github.com/rust-lang/crates.io-index" 4592 + checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" 4593 + dependencies = [ 4594 + "libc", 4595 + "windows-sys 0.52.0", 4596 + ] 4597 + 4598 + [[package]] 4599 + name = "socket2" 4600 version = "0.6.1" 4601 source = "registry+https://github.com/rust-lang/crates.io-index" 4602 checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" ··· 4658 source = "registry+https://github.com/rust-lang/crates.io-index" 4659 checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" 4660 dependencies = [ 4661 + "base64 0.22.1", 4662 "bytes", 4663 "chrono", 4664 "crc", ··· 4734 checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" 4735 dependencies = [ 4736 "atoi", 4737 + "base64 0.22.1", 4738 "bitflags", 4739 "byteorder", 4740 "bytes", ··· 4777 checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" 4778 dependencies = [ 4779 "atoi", 4780 + "base64 0.22.1", 4781 "bitflags", 4782 "byteorder", 4783 "chrono", ··· 4879 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 4880 4881 [[package]] 4882 + name = "string_cache" 4883 + version = "0.8.9" 4884 + source = "registry+https://github.com/rust-lang/crates.io-index" 4885 + checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" 4886 + dependencies = [ 4887 + "new_debug_unreachable", 4888 + "parking_lot", 4889 + "phf_shared", 4890 + "precomputed-hash", 4891 + "serde", 4892 + ] 4893 + 4894 + [[package]] 4895 + name = "string_cache_codegen" 4896 + version = "0.5.4" 4897 + source = "registry+https://github.com/rust-lang/crates.io-index" 4898 + checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" 4899 + dependencies = [ 4900 + "phf_generator", 4901 + "phf_shared", 4902 + "proc-macro2", 4903 + "quote", 4904 + ] 4905 + 4906 + [[package]] 4907 name = "stringprep" 4908 version = "0.1.5" 4909 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4975 checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" 4976 dependencies = [ 4977 "bitflags", 4978 + "core-foundation 0.9.4", 4979 "system-configuration-sys", 4980 ] 4981 ··· 4990 ] 4991 4992 [[package]] 4993 + name = "tagptr" 4994 + version = "0.2.0" 4995 + source = "registry+https://github.com/rust-lang/crates.io-index" 4996 + checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" 4997 + 4998 + [[package]] 4999 + name = "tempfile" 5000 + version = "3.25.0" 5001 + source = "registry+https://github.com/rust-lang/crates.io-index" 5002 + checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" 5003 + dependencies = [ 5004 + "fastrand", 5005 + "getrandom 0.4.1", 5006 + "once_cell", 5007 + "rustix", 5008 + "windows-sys 0.61.2", 5009 + ] 5010 + 5011 + [[package]] 5012 + name = "tendril" 5013 + version = "0.4.3" 5014 + source = "registry+https://github.com/rust-lang/crates.io-index" 5015 + checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" 5016 + dependencies = [ 5017 + "futf", 5018 + "mac", 5019 + "utf-8", 5020 + ] 5021 + 5022 + [[package]] 5023 name = "thiserror" 5024 version = "1.0.69" 5025 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5069 ] 5070 5071 [[package]] 5072 + name = "threadpool" 5073 + version = "1.8.1" 5074 + source = "registry+https://github.com/rust-lang/crates.io-index" 5075 + checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" 5076 + dependencies = [ 5077 + "num_cpus", 5078 + ] 5079 + 5080 + [[package]] 5081 name = "time" 5082 version = "0.3.44" 5083 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5085 dependencies = [ 5086 "deranged", 5087 "itoa", 5088 + "libc", 5089 "num-conv", 5090 + "num_threads", 5091 "powerfmt", 5092 "serde", 5093 "time-core", ··· 5111 ] 5112 5113 [[package]] 5114 + name = "tiny_http" 5115 + version = "0.12.0" 5116 + source = "registry+https://github.com/rust-lang/crates.io-index" 5117 + checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" 5118 + dependencies = [ 5119 + "ascii", 5120 + "chunked_transfer", 5121 + "httpdate", 5122 + "log", 5123 + ] 5124 + 5125 + [[package]] 5126 name = "tinystr" 5127 version = "0.8.2" 5128 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5158 "mio", 5159 "pin-project-lite", 5160 "signal-hook-registry", 5161 + "socket2 0.6.1", 5162 "tokio-macros", 5163 "windows-sys 0.61.2", 5164 ] ··· 5217 dependencies = [ 5218 "async-trait", 5219 "axum", 5220 + "base64 0.22.1", 5221 "bytes", 5222 "h2", 5223 "http", ··· 5228 "hyper-util", 5229 "percent-encoding", 5230 "pin-project", 5231 + "socket2 0.6.1", 5232 "sync_wrapper", 5233 "tokio", 5234 "tokio-stream", ··· 5397 ] 5398 5399 [[package]] 5400 + name = "triomphe" 5401 + version = "0.1.15" 5402 + source = "registry+https://github.com/rust-lang/crates.io-index" 5403 + checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" 5404 + 5405 + [[package]] 5406 name = "try-lock" 5407 version = "0.2.5" 5408 source = "registry+https://github.com/rust-lang/crates.io-index" 5409 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 5410 5411 [[package]] 5412 + name = "twoway" 5413 + version = "0.1.8" 5414 + source = "registry+https://github.com/rust-lang/crates.io-index" 5415 + checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" 5416 + dependencies = [ 5417 + "memchr", 5418 + ] 5419 + 5420 + [[package]] 5421 name = "typenum" 5422 version = "1.19.0" 5423 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5430 checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" 5431 5432 [[package]] 5433 + name = "unicase" 5434 + version = "2.9.0" 5435 + source = "registry+https://github.com/rust-lang/crates.io-index" 5436 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 5437 + 5438 + [[package]] 5439 name = "unicode-bidi" 5440 version = "0.3.18" 5441 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5481 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 5482 5483 [[package]] 5484 + name = "unsafe-libyaml" 5485 + version = "0.2.11" 5486 + source = "registry+https://github.com/rust-lang/crates.io-index" 5487 + checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 5488 + 5489 + [[package]] 5490 name = "unsigned-varint" 5491 version = "0.8.0" 5492 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5523 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 5524 5525 [[package]] 5526 + name = "utf-8" 5527 + version = "0.7.6" 5528 + source = "registry+https://github.com/rust-lang/crates.io-index" 5529 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 5530 + 5531 + [[package]] 5532 name = "utf8-width" 5533 version = "0.1.8" 5534 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5539 version = "1.0.4" 5540 source = "registry+https://github.com/rust-lang/crates.io-index" 5541 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 5542 + 5543 + [[package]] 5544 + name = "uuid" 5545 + version = "1.21.0" 5546 + source = "registry+https://github.com/rust-lang/crates.io-index" 5547 + checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" 5548 + dependencies = [ 5549 + "getrandom 0.4.1", 5550 + "js-sys", 5551 + "wasm-bindgen", 5552 + ] 5553 5554 [[package]] 5555 name = "valuable" ··· 5606 source = "registry+https://github.com/rust-lang/crates.io-index" 5607 checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 5608 dependencies = [ 5609 + "wit-bindgen 0.46.0", 5610 + ] 5611 + 5612 + [[package]] 5613 + name = "wasip3" 5614 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 5615 + source = "registry+https://github.com/rust-lang/crates.io-index" 5616 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 5617 + dependencies = [ 5618 + "wit-bindgen 0.51.0", 5619 ] 5620 5621 [[package]] ··· 5683 ] 5684 5685 [[package]] 5686 + name = "wasm-encoder" 5687 + version = "0.244.0" 5688 + source = "registry+https://github.com/rust-lang/crates.io-index" 5689 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 5690 + dependencies = [ 5691 + "leb128fmt", 5692 + "wasmparser", 5693 + ] 5694 + 5695 + [[package]] 5696 + name = "wasm-metadata" 5697 + version = "0.244.0" 5698 + source = "registry+https://github.com/rust-lang/crates.io-index" 5699 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 5700 + dependencies = [ 5701 + "anyhow", 5702 + "indexmap 2.12.1", 5703 + "wasm-encoder", 5704 + "wasmparser", 5705 + ] 5706 + 5707 + [[package]] 5708 + name = "wasmparser" 5709 + version = "0.244.0" 5710 + source = "registry+https://github.com/rust-lang/crates.io-index" 5711 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 5712 + dependencies = [ 5713 + "bitflags", 5714 + "hashbrown 0.15.5", 5715 + "indexmap 2.12.1", 5716 + "semver", 5717 + ] 5718 + 5719 + [[package]] 5720 name = "web-sys" 5721 version = "0.3.83" 5722 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5737 ] 5738 5739 [[package]] 5740 + name = "webbrowser" 5741 + version = "1.1.0" 5742 + source = "registry+https://github.com/rust-lang/crates.io-index" 5743 + checksum = "3f00bb839c1cf1e3036066614cbdcd035ecf215206691ea646aa3c60a24f68f2" 5744 + dependencies = [ 5745 + "core-foundation 0.10.1", 5746 + "jni", 5747 + "log", 5748 + "ndk-context", 5749 + "objc2", 5750 + "objc2-foundation", 5751 + "url", 5752 + "web-sys", 5753 + ] 5754 + 5755 + [[package]] 5756 + name = "webpage" 5757 + version = "2.0.1" 5758 + source = "registry+https://github.com/rust-lang/crates.io-index" 5759 + checksum = "70862efc041d46e6bbaa82bb9c34ae0596d090e86cbd14bd9e93b36ee6802eac" 5760 + dependencies = [ 5761 + "html5ever", 5762 + "markup5ever_rcdom", 5763 + "serde_json", 5764 + "url", 5765 + ] 5766 + 5767 + [[package]] 5768 name = "webpki-roots" 5769 version = "0.26.11" 5770 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5793 ] 5794 5795 [[package]] 5796 + name = "widestring" 5797 + version = "1.2.1" 5798 + source = "registry+https://github.com/rust-lang/crates.io-index" 5799 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 5800 + 5801 + [[package]] 5802 name = "winapi" 5803 version = "0.3.9" 5804 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5820 source = "registry+https://github.com/rust-lang/crates.io-index" 5821 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 5822 dependencies = [ 5823 + "windows-sys 0.61.2", 5824 ] 5825 5826 [[package]] ··· 5901 5902 [[package]] 5903 name = "windows-sys" 5904 + version = "0.45.0" 5905 + source = "registry+https://github.com/rust-lang/crates.io-index" 5906 + checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 5907 + dependencies = [ 5908 + "windows-targets 0.42.2", 5909 + ] 5910 + 5911 + [[package]] 5912 + name = "windows-sys" 5913 version = "0.48.0" 5914 source = "registry+https://github.com/rust-lang/crates.io-index" 5915 checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" ··· 5955 5956 [[package]] 5957 name = "windows-targets" 5958 + version = "0.42.2" 5959 + source = "registry+https://github.com/rust-lang/crates.io-index" 5960 + checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 5961 + dependencies = [ 5962 + "windows_aarch64_gnullvm 0.42.2", 5963 + "windows_aarch64_msvc 0.42.2", 5964 + "windows_i686_gnu 0.42.2", 5965 + "windows_i686_msvc 0.42.2", 5966 + "windows_x86_64_gnu 0.42.2", 5967 + "windows_x86_64_gnullvm 0.42.2", 5968 + "windows_x86_64_msvc 0.42.2", 5969 + ] 5970 + 5971 + [[package]] 5972 + name = "windows-targets" 5973 version = "0.48.5" 5974 source = "registry+https://github.com/rust-lang/crates.io-index" 5975 checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" ··· 6018 6019 [[package]] 6020 name = "windows_aarch64_gnullvm" 6021 + version = "0.42.2" 6022 + source = "registry+https://github.com/rust-lang/crates.io-index" 6023 + checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 6024 + 6025 + [[package]] 6026 + name = "windows_aarch64_gnullvm" 6027 version = "0.48.5" 6028 source = "registry+https://github.com/rust-lang/crates.io-index" 6029 checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" ··· 6042 6043 [[package]] 6044 name = "windows_aarch64_msvc" 6045 + version = "0.42.2" 6046 + source = "registry+https://github.com/rust-lang/crates.io-index" 6047 + checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 6048 + 6049 + [[package]] 6050 + name = "windows_aarch64_msvc" 6051 version = "0.48.5" 6052 source = "registry+https://github.com/rust-lang/crates.io-index" 6053 checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" ··· 6066 6067 [[package]] 6068 name = "windows_i686_gnu" 6069 + version = "0.42.2" 6070 + source = "registry+https://github.com/rust-lang/crates.io-index" 6071 + checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 6072 + 6073 + [[package]] 6074 + name = "windows_i686_gnu" 6075 version = "0.48.5" 6076 source = "registry+https://github.com/rust-lang/crates.io-index" 6077 checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" ··· 6102 6103 [[package]] 6104 name = "windows_i686_msvc" 6105 + version = "0.42.2" 6106 + source = "registry+https://github.com/rust-lang/crates.io-index" 6107 + checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 6108 + 6109 + [[package]] 6110 + name = "windows_i686_msvc" 6111 version = "0.48.5" 6112 source = "registry+https://github.com/rust-lang/crates.io-index" 6113 checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" ··· 6126 6127 [[package]] 6128 name = "windows_x86_64_gnu" 6129 + version = "0.42.2" 6130 + source = "registry+https://github.com/rust-lang/crates.io-index" 6131 + checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 6132 + 6133 + [[package]] 6134 + name = "windows_x86_64_gnu" 6135 version = "0.48.5" 6136 source = "registry+https://github.com/rust-lang/crates.io-index" 6137 checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" ··· 6150 6151 [[package]] 6152 name = "windows_x86_64_gnullvm" 6153 + version = "0.42.2" 6154 + source = "registry+https://github.com/rust-lang/crates.io-index" 6155 + checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 6156 + 6157 + [[package]] 6158 + name = "windows_x86_64_gnullvm" 6159 version = "0.48.5" 6160 source = "registry+https://github.com/rust-lang/crates.io-index" 6161 checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" ··· 6174 6175 [[package]] 6176 name = "windows_x86_64_msvc" 6177 + version = "0.42.2" 6178 + source = "registry+https://github.com/rust-lang/crates.io-index" 6179 + checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 6180 + 6181 + [[package]] 6182 + name = "windows_x86_64_msvc" 6183 version = "0.48.5" 6184 source = "registry+https://github.com/rust-lang/crates.io-index" 6185 checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" ··· 6197 checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 6198 6199 [[package]] 6200 + name = "winreg" 6201 + version = "0.50.0" 6202 + source = "registry+https://github.com/rust-lang/crates.io-index" 6203 + checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 6204 + dependencies = [ 6205 + "cfg-if", 6206 + "windows-sys 0.48.0", 6207 + ] 6208 + 6209 + [[package]] 6210 name = "wit-bindgen" 6211 version = "0.46.0" 6212 source = "registry+https://github.com/rust-lang/crates.io-index" 6213 checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 6214 6215 [[package]] 6216 + name = "wit-bindgen" 6217 + version = "0.51.0" 6218 + source = "registry+https://github.com/rust-lang/crates.io-index" 6219 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 6220 + dependencies = [ 6221 + "wit-bindgen-rust-macro", 6222 + ] 6223 + 6224 + [[package]] 6225 + name = "wit-bindgen-core" 6226 + version = "0.51.0" 6227 + source = "registry+https://github.com/rust-lang/crates.io-index" 6228 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 6229 + dependencies = [ 6230 + "anyhow", 6231 + "heck 0.5.0", 6232 + "wit-parser", 6233 + ] 6234 + 6235 + [[package]] 6236 + name = "wit-bindgen-rust" 6237 + version = "0.51.0" 6238 + source = "registry+https://github.com/rust-lang/crates.io-index" 6239 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 6240 + dependencies = [ 6241 + "anyhow", 6242 + "heck 0.5.0", 6243 + "indexmap 2.12.1", 6244 + "prettyplease", 6245 + "syn 2.0.112", 6246 + "wasm-metadata", 6247 + "wit-bindgen-core", 6248 + "wit-component", 6249 + ] 6250 + 6251 + [[package]] 6252 + name = "wit-bindgen-rust-macro" 6253 + version = "0.51.0" 6254 + source = "registry+https://github.com/rust-lang/crates.io-index" 6255 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 6256 + dependencies = [ 6257 + "anyhow", 6258 + "prettyplease", 6259 + "proc-macro2", 6260 + "quote", 6261 + "syn 2.0.112", 6262 + "wit-bindgen-core", 6263 + "wit-bindgen-rust", 6264 + ] 6265 + 6266 + [[package]] 6267 + name = "wit-component" 6268 + version = "0.244.0" 6269 + source = "registry+https://github.com/rust-lang/crates.io-index" 6270 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 6271 + dependencies = [ 6272 + "anyhow", 6273 + "bitflags", 6274 + "indexmap 2.12.1", 6275 + "log", 6276 + "serde", 6277 + "serde_derive", 6278 + "serde_json", 6279 + "wasm-encoder", 6280 + "wasm-metadata", 6281 + "wasmparser", 6282 + "wit-parser", 6283 + ] 6284 + 6285 + [[package]] 6286 + name = "wit-parser" 6287 + version = "0.244.0" 6288 + source = "registry+https://github.com/rust-lang/crates.io-index" 6289 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 6290 + dependencies = [ 6291 + "anyhow", 6292 + "id-arena", 6293 + "indexmap 2.12.1", 6294 + "log", 6295 + "semver", 6296 + "serde", 6297 + "serde_derive", 6298 + "serde_json", 6299 + "unicode-xid", 6300 + "wasmparser", 6301 + ] 6302 + 6303 + [[package]] 6304 name = "writeable" 6305 version = "0.6.2" 6306 source = "registry+https://github.com/rust-lang/crates.io-index" 6307 checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 6308 + 6309 + [[package]] 6310 + name = "xml5ever" 6311 + version = "0.18.1" 6312 + source = "registry+https://github.com/rust-lang/crates.io-index" 6313 + checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" 6314 + dependencies = [ 6315 + "log", 6316 + "mac", 6317 + "markup5ever", 6318 + ] 6319 6320 [[package]] 6321 name = "yansi" ··· 6393 source = "registry+https://github.com/rust-lang/crates.io-index" 6394 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 6395 dependencies = [ 6396 + "serde", 6397 "zeroize_derive", 6398 ] 6399
+10 -1
Cargo.toml
··· 30 anyhow = "1.0.100" 31 chrono = { version = "0.4.42", features = ["default", "serde"] } 32 sha2 = "0.10" 33 jacquard-common = "0.9.5" 34 jacquard-identity = "0.9.5" 35 multibase = "0.9.2" ··· 40 josekit = "0.10.3" 41 dashmap = "6.1" 42 tower = "0.5" 43 - valuable = "0.1.1"
··· 30 anyhow = "1.0.100" 31 chrono = { version = "0.4.42", features = ["default", "serde"] } 32 sha2 = "0.10" 33 + jacquard-api = { version = "0.9.5", features = ["com_atproto"] } 34 jacquard-common = "0.9.5" 35 jacquard-identity = "0.9.5" 36 multibase = "0.9.2" ··· 41 josekit = "0.10.3" 42 dashmap = "6.1" 43 tower = "0.5" 44 + serde_yaml = "0.9" 45 + jacquard-oauth = "0.9.6" 46 + axum-extra = { version = "0.10", features = ["cookie-signed"] } 47 + uuid = { version = "1", features = ["v4"] } 48 + p256 = { version = "0.13", features = ["ecdsa", "jwk"] } 49 + jose-jwk = { version = "0.1", features = ["p256"] } 50 + base64 = "0.22" 51 + jacquard = "0.9.5" 52 +
+67
examples/admin_rbac.yaml
···
··· 1 + # PDS Admin Team — Role-Based Access Control 2 + # 3 + # This file defines which ATProto identities can perform admin operations 4 + # through pds-gatekeeper's admin portal. Each member authenticates via 5 + # ATProto OAuth (using their Bluesky/AT Protocol identity) and is granted 6 + # access only to the endpoints their roles permit. 7 + # 8 + # Endpoint patterns: 9 + # - Exact match: "com.atproto.admin.getAccountInfo" 10 + # - Wildcard: "com.atproto.admin.*" (matches all admin endpoints) 11 + # 12 + # Usage: 13 + # 1. Copy this file and customize for your team 14 + # 2. Set GATEKEEPER_ADMIN_RBAC_CONFIG=/path/to/your/admin_rbac.yaml 15 + # 3. Set PDS_ADMIN_PASSWORD=your-pds-admin-password 16 + # 4. Restart pds-gatekeeper 17 + # 5. Navigate to https://your-pds.example.com/admin/login 18 + 19 + roles: 20 + pds-admin: 21 + description: "Full PDS administrator — all admin endpoints + account/invite creation" 22 + endpoints: 23 + - "com.atproto.admin.*" 24 + - "com.atproto.server.createInviteCode" 25 + - "com.atproto.server.createInviteCodes" 26 + - "com.atproto.server.createAccount" 27 + - "com.atproto.sync.requestCrawl" 28 + 29 + moderator: 30 + description: "Content moderation — view accounts, manage takedowns and subject status" 31 + endpoints: 32 + - "com.atproto.admin.getAccountInfo" 33 + - "com.atproto.admin.getAccountInfos" 34 + - "com.atproto.admin.getSubjectStatus" 35 + - "com.atproto.admin.updateSubjectStatus" 36 + - "com.atproto.admin.sendEmail" 37 + - "com.atproto.admin.getInviteCodes" 38 + 39 + invite-manager: 40 + description: "Invite code management — create and manage invite codes" 41 + endpoints: 42 + - "com.atproto.admin.getInviteCodes" 43 + - "com.atproto.admin.disableInviteCodes" 44 + - "com.atproto.admin.enableAccountInvites" 45 + - "com.atproto.admin.disableAccountInvites" 46 + - "com.atproto.server.createInviteCode" 47 + - "com.atproto.server.createInviteCodes" 48 + 49 + members: 50 + # Replace these with your team members' DIDs. 51 + # Resolve a handle to its DID with: goat resolve {handle} 52 + 53 + # Example: Full admin 54 + - did: "did:plc:your-admin-did-here" 55 + roles: 56 + - pds-admin 57 + 58 + # Example: Moderator only 59 + - did: "did:plc:your-moderator-did-here" 60 + roles: 61 + - moderator 62 + 63 + # Example: Someone with both moderator and invite manager roles 64 + - did: "did:plc:your-team-member-did-here" 65 + roles: 66 + - moderator 67 + - invite-manager
+223
html_templates/admin/account_detail.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>{{account.handle}} - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + </head> 10 + <body> 11 + <div class="layout"> 12 + {{> admin/partials/sidebar.hbs}} 13 + 14 + <main class="main"> 15 + {{> admin/partials/flash.hbs}} 16 + 17 + <a href="/admin/accounts" class="back-link">&larr; Back to Accounts</a> 18 + 19 + <h1 class="page-title">{{account.handle}}</h1> 20 + <div class="page-subtitle">{{account.did}}</div> 21 + 22 + {{#if new_password}} 23 + <div class="password-box"> 24 + <div class="pw-label">New Password (copy now -- it will not be shown again)</div> 25 + <div class="pw-value">{{new_password}}</div> 26 + </div> 27 + {{/if}} 28 + 29 + <div class="detail-section"> 30 + <h3>Account Information</h3> 31 + <div class="detail-row"> 32 + <span class="label">Handle</span> 33 + <span class="value">{{account.handle}}</span> 34 + </div> 35 + <div class="detail-row"> 36 + <span class="label">DID</span> 37 + <span class="value">{{account.did}}</span> 38 + </div> 39 + <div class="detail-row"> 40 + <span class="label">Email</span> 41 + <span class="value">{{account.email}}</span> 42 + </div> 43 + {{#if account.indexedAt}} 44 + <div class="detail-row"> 45 + <span class="label">Indexed At</span> 46 + <span class="value">{{account.indexedAt}}</span> 47 + </div> 48 + {{/if}} 49 + {{#if handle_resolution_checked}} 50 + <div class="detail-row"> 51 + <span class="label">Handle Resolution</span> 52 + <span class="value"> 53 + {{#if handle_is_correct}} 54 + <span class="badge badge-success">Valid</span> 55 + {{else}} 56 + <span class="badge badge-danger">Failed</span> 57 + {{/if}} 58 + </span> 59 + </div> 60 + {{/if}} 61 + </div> 62 + 63 + <div class="detail-section"> 64 + <h3>Status</h3> 65 + <div class="detail-row"> 66 + <span class="label">Takedown</span> 67 + <span class="value"> 68 + {{#if is_taken_down}} 69 + <span class="badge badge-danger">Taken Down</span> 70 + {{else}} 71 + <span class="badge badge-success">Active</span> 72 + {{/if}} 73 + </span> 74 + </div> 75 + {{#if takedown_ref}} 76 + <div class="detail-row"> 77 + <span class="label">Takedown Reference</span> 78 + <span class="value">{{takedown_ref}}</span> 79 + </div> 80 + {{/if}} 81 + <div class="detail-row"> 82 + <span class="label">Email Confirmed</span> 83 + <span class="value"> 84 + {{#if account.emailConfirmedAt}} 85 + <span class="badge badge-success">Confirmed</span> 86 + {{else}} 87 + <span class="badge badge-warning">Unconfirmed</span> 88 + {{/if}} 89 + </span> 90 + </div> 91 + <div class="detail-row"> 92 + <span class="label">Deactivated</span> 93 + <span class="value"> 94 + {{#if account.deactivatedAt}} 95 + <span class="badge badge-warning">{{account.deactivatedAt}}</span> 96 + {{else}} 97 + <span class="badge badge-success">No</span> 98 + {{/if}} 99 + </span> 100 + </div> 101 + </div> 102 + 103 + {{#if repo_status_checked}} 104 + <div class="detail-section"> 105 + <h3>Repo Status</h3> 106 + <div class="detail-row"> 107 + <span class="label">Active</span> 108 + <span class="value"> 109 + {{#if repo_active}} 110 + <span class="badge badge-success">Active</span> 111 + {{else}} 112 + <span class="badge badge-danger">Inactive</span> 113 + {{/if}} 114 + </span> 115 + </div> 116 + {{#if repo_status_reason}} 117 + <div class="detail-row"> 118 + <span class="label">Status Reason</span> 119 + <span class="value">{{repo_status_reason}}</span> 120 + </div> 121 + {{/if}} 122 + {{#if repo_rev}} 123 + <div class="detail-row"> 124 + <span class="label">Revision</span> 125 + <span class="value mono-text">{{repo_rev}}</span> 126 + </div> 127 + {{/if}} 128 + </div> 129 + {{/if}} 130 + 131 + <div class="detail-section"> 132 + <h3>Invite Information</h3> 133 + {{#if account.invitedBy}} 134 + <div class="detail-row"> 135 + <span class="label">Invited By</span> 136 + <span class="value">{{account.invitedBy}}</span> 137 + </div> 138 + {{/if}} 139 + <div class="detail-row"> 140 + <span class="label">Invites Disabled</span> 141 + <span class="value"> 142 + {{#if account.invitesDisabled}} 143 + <span class="badge badge-danger">Yes</span> 144 + {{else}} 145 + <span class="badge badge-success">No</span> 146 + {{/if}} 147 + </span> 148 + </div> 149 + {{#if account.inviteNote}} 150 + <div class="detail-row"> 151 + <span class="label">Invite Note</span> 152 + <span class="value">{{account.inviteNote}}</span> 153 + </div> 154 + {{/if}} 155 + </div> 156 + 157 + {{#if collections}} 158 + <div class="detail-section"> 159 + <h3>Collections</h3> 160 + <div class="collection-list"> 161 + {{#each collections}} 162 + <div class="collection-item">{{this}}</div> 163 + {{/each}} 164 + </div> 165 + </div> 166 + {{/if}} 167 + 168 + {{#if threat_signatures}} 169 + <div class="detail-section"> 170 + <h3>Threat Signatures</h3> 171 + {{#each threat_signatures}} 172 + <div class="threat-sig">{{this.property}}: {{this.value}}</div> 173 + {{/each}} 174 + </div> 175 + {{/if}} 176 + 177 + <div class="detail-section"> 178 + <h3>Actions</h3> 179 + <div class="actions"> 180 + {{#if can_manage_takedowns}} 181 + {{#if is_taken_down}} 182 + <form method="POST" action="/admin/accounts/{{account.did}}/untakedown"> 183 + <button type="submit" class="btn btn-primary">Remove Takedown</button> 184 + </form> 185 + {{else}} 186 + <form method="POST" action="/admin/accounts/{{account.did}}/takedown"> 187 + <button type="submit" class="btn btn-warning">Takedown Account</button> 188 + </form> 189 + {{/if}} 190 + {{/if}} 191 + 192 + {{#if can_reset_password}} 193 + <form method="POST" action="/admin/accounts/{{account.did}}/reset-password" 194 + onsubmit="return confirm('Are you sure you want to reset this account password? The current password will be invalidated.');"> 195 + <button type="submit" class="btn">Reset Password</button> 196 + </form> 197 + {{/if}} 198 + 199 + {{#if can_manage_invites}} 200 + {{#if account.invitesDisabled}} 201 + <form method="POST" action="/admin/accounts/{{account.did}}/enable-invites"> 202 + <button type="submit" class="btn">Enable Invites</button> 203 + </form> 204 + {{else}} 205 + <form method="POST" action="/admin/accounts/{{account.did}}/disable-invites"> 206 + <button type="submit" class="btn">Disable Invites</button> 207 + </form> 208 + {{/if}} 209 + {{/if}} 210 + 211 + {{#if can_delete_account}} 212 + <form method="POST" action="/admin/accounts/{{account.did}}/delete" 213 + onsubmit="return confirm('PERMANENTLY DELETE this account? This action cannot be undone.');"> 214 + <button type="submit" class="btn btn-danger">Delete Account</button> 215 + </form> 216 + {{/if}} 217 + </div> 218 + </div> 219 + </main> 220 + </div> 221 + 222 + </body> 223 + </html>
+78
html_templates/admin/accounts.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Accounts - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + </head> 10 + <body> 11 + <div class="layout"> 12 + {{> admin/partials/sidebar.hbs}} 13 + 14 + <main class="main"> 15 + {{> admin/partials/flash.hbs}} 16 + 17 + <h1 class="page-title">Accounts</h1> 18 + 19 + <form class="search-form" method="GET" action="/admin/accounts/lookup"> 20 + <input type="text" name="direct_lookup" placeholder="Direct lookup by did or handle"/> 21 + <button type="submit" class="btn btn-primary">Lookup</button> 22 + </form> 23 + 24 + 25 + {{#if accounts}} 26 + <div class="table-container"> 27 + <table> 28 + <thead> 29 + <tr> 30 + <th>Handle</th> 31 + <th>DID</th> 32 + <th>Email</th> 33 + <th>Status</th> 34 + </tr> 35 + </thead> 36 + <tbody> 37 + {{#each accounts}} 38 + <tr> 39 + <td><a href="/admin/accounts/{{this.did}}">{{this.handle}}</a></td> 40 + <td class="did-cell">{{this.did}}</td> 41 + <td>{{this.email}}</td> 42 + <td> 43 + {{#if (or this.is_taken_down (eq this.status "takedown"))}} 44 + <span class="badge badge-danger">Taken Down</span> 45 + {{/if}} 46 + {{#if (or this.deactivatedAt (eq this.status "deactivated"))}} 47 + <span class="badge badge-warning">Deactivated</span> 48 + {{/if}} 49 + {{#if (and (not this.is_taken_down) (not (eq this.status "takedown")) (not this.deactivatedAt) (not (eq this.status "deactivated")))}} 50 + <span class="badge badge-success">Active</span> 51 + {{/if}} 52 + </td> 53 + </tr> 54 + {{/each}} 55 + </tbody> 56 + </table> 57 + </div> 58 + 59 + {{#if (or has_prev has_next)}} 60 + <div class="pagination"> 61 + {{#if has_prev}} 62 + <a href="{{prev_url}}" class="btn btn-small">&larr; Previous</a> 63 + {{/if}} 64 + <span class="pagination-info">Showing {{account_count}} accounts</span> 65 + {{#if has_next}} 66 + <a href="{{next_url}}" class="btn btn-small">Next &rarr;</a> 67 + {{/if}} 68 + </div> 69 + {{/if}} 70 + {{else}} 71 + <div class="empty-state"> 72 + No accounts found 73 + </div> 74 + {{/if}} 75 + </main> 76 + </div> 77 + </body> 78 + </html>
+101
html_templates/admin/create_account.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Create Account - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + </head> 10 + <body> 11 + <div class="layout"> 12 + {{> admin/partials/sidebar.hbs}} 13 + 14 + <main class="main"> 15 + {{> admin/partials/flash.hbs}} 16 + 17 + <h1 class="page-title">Create Account</h1> 18 + 19 + {{#if created}} 20 + <div class="success-card"> 21 + <h3>Account Created Successfully</h3> 22 + <div class="detail-row"> 23 + <span class="label">DID</span> 24 + <span class="value">{{created.did}} 25 + <button class="copy-btn" onclick="copyToClipboard('{{created.did}}', this)">Copy</button></span> 26 + </div> 27 + <div class="detail-row"> 28 + <span class="label">Handle</span> 29 + <span class="value">{{created.handle}} 30 + <button class="copy-btn" 31 + onclick="copyToClipboard('{{created.handle}}', this)">Copy</button></span> 32 + </div> 33 + <div class="detail-row"> 34 + <span class="label">Email</span> 35 + <span class="value">{{created.email}} 36 + <button class="copy-btn" 37 + onclick="copyToClipboard('{{created.email}}', this)">Copy</button></span> 38 + </div> 39 + <div class="detail-row"> 40 + <span class="label">Password</span> 41 + <span class="value"><span class="password-highlight">{{created.password}}</span> <button 42 + class="copy-btn" 43 + onclick="copyToClipboard('{{created.password}}', this)">Copy</button></span> 44 + </div> 45 + {{#if created.inviteCode}} 46 + <div class="detail-row"> 47 + <span class="label">Invite Code Used</span> 48 + <span class="value">{{created.inviteCode}} 49 + <button class="copy-btn" 50 + onclick="copyToClipboard('{{created.inviteCode}}', this)">Copy</button></span> 51 + </div> 52 + {{/if}} 53 + </div> 54 + {{else}} 55 + <div class="form-card"> 56 + <form method="POST" action="/admin/create-account"> 57 + <div class="form-group"> 58 + <label for="email">Email</label> 59 + <input type="email" id="email" name="email" placeholder="user@example.com" required/> 60 + </div> 61 + <div class="form-group"> 62 + <label for="handle">Handle</label> 63 + <input type="text" id="handle" name="handle" placeholder="user.example.com" required/> 64 + <div class="hint">Must be a valid handle for this PDS</div> 65 + </div> 66 + <button type="submit" class="btn btn-primary">Create Account</button> 67 + </form> 68 + </div> 69 + {{/if}} 70 + </main> 71 + </div> 72 + 73 + <script> 74 + function copyToClipboard(text, btn) { 75 + if (navigator.clipboard && navigator.clipboard.writeText) { 76 + navigator.clipboard.writeText(text).then(function () { 77 + var orig = btn.textContent; 78 + btn.textContent = 'Copied'; 79 + setTimeout(function () { 80 + btn.textContent = orig; 81 + }, 1500); 82 + }); 83 + } else { 84 + var el = document.createElement('textarea'); 85 + el.value = text; 86 + el.style.position = 'fixed'; 87 + el.style.opacity = '0'; 88 + document.body.appendChild(el); 89 + el.select(); 90 + document.execCommand('copy'); 91 + document.body.removeChild(el); 92 + var orig = btn.textContent; 93 + btn.textContent = 'Copied'; 94 + setTimeout(function () { 95 + btn.textContent = orig; 96 + }, 1500); 97 + } 98 + } 99 + </script> 100 + </body> 101 + </html>
+91
html_templates/admin/dashboard.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Dashboard - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + </head> 10 + <body> 11 + <div class="layout"> 12 + {{> admin/partials/sidebar.hbs}} 13 + 14 + <main class="main"> 15 + {{> admin/partials/flash.hbs}} 16 + 17 + <h1 class="page-title">Dashboard</h1> 18 + 19 + <div class="cards"> 20 + <div class="card"> 21 + <div class="card-label">PDS Version</div> 22 + <div class="card-value">{{version}}</div> 23 + </div> 24 + {{#if can_view_accounts}} 25 + <div class="card"> 26 + <div class="card-label">Total Accounts</div> 27 + <div class="card-value">{{account_count}}</div> 28 + </div> 29 + {{/if}} 30 + <div class="card"> 31 + <div class="card-label">Invite Code Required</div> 32 + <div class="card-value">{{#if invite_code_required}}Yes{{else}}No{{/if}}</div> 33 + </div> 34 + <div class="card"> 35 + <div class="card-label">Phone Verification</div> 36 + <div class="card-value">{{#if phone_verification_required}}Required{{else}}Not Required{{/if}}</div> 37 + </div> 38 + </div> 39 + 40 + <div class="detail-section"> 41 + <h3>Server Information</h3> 42 + <div class="detail-row"> 43 + <span class="label">Server DID</span> 44 + <span class="value">{{server_did}}</span> 45 + </div> 46 + <div class="detail-row"> 47 + <span class="label">Available Domains</span> 48 + <span class="value">{{available_domains}}</span> 49 + </div> 50 + {{#if contact_email}} 51 + <div class="detail-row"> 52 + <span class="label">Contact Email</span> 53 + <span class="value">{{contact_email}}</span> 54 + </div> 55 + {{/if}} 56 + </div> 57 + 58 + {{#if privacy_policy}} 59 + <div class="detail-section"> 60 + <h3>Server Links</h3> 61 + {{#if terms_of_service}} 62 + <div class="detail-row"> 63 + <span class="label">Terms of Service</span> 64 + <span class="value"><a href="{{terms_of_service}}" target="_blank" 65 + rel="noopener">{{terms_of_service}}</a></span> 66 + </div> 67 + {{/if}} 68 + {{#if privacy_policy}} 69 + <div class="detail-row"> 70 + <span class="label">Privacy Policy</span> 71 + <span class="value"><a href="{{privacy_policy}}" target="_blank" 72 + rel="noopener">{{privacy_policy}}</a></span> 73 + </div> 74 + {{/if}} 75 + </div> 76 + {{else}} 77 + {{#if terms_of_service}} 78 + <div class="detail-section"> 79 + <h3>Server Links</h3> 80 + <div class="detail-row"> 81 + <span class="label">Terms of Service</span> 82 + <span class="value"><a href="{{terms_of_service}}" target="_blank" 83 + rel="noopener">{{terms_of_service}}</a></span> 84 + </div> 85 + </div> 86 + {{/if}} 87 + {{/if}} 88 + </main> 89 + </div> 90 + </body> 91 + </html>
+37
html_templates/admin/error.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Error - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + </head> 10 + <body> 11 + {{#if handle}} 12 + {{!-- Logged-in user: show sidebar layout --}} 13 + <div class="layout"> 14 + {{> admin/partials/sidebar.hbs}} 15 + 16 + <main class="main"> 17 + <div class="error-card error-card--inset"> 18 + <div class="error-icon">!</div> 19 + <div class="error-title">{{error_title}}</div> 20 + <div class="error-message">{{error_message}}</div> 21 + <a href="/admin/dashboard" class="error-link">Back to Dashboard</a> 22 + </div> 23 + </main> 24 + </div> 25 + {{else}} 26 + {{!-- Not logged in: standalone centered layout --}} 27 + <div class="centered"> 28 + <div class="error-card"> 29 + <div class="error-icon">!</div> 30 + <div class="error-title">{{error_title}}</div> 31 + <div class="error-message">{{error_message}}</div> 32 + <a href="/admin/login" class="error-link">Go to Login</a> 33 + </div> 34 + </div> 35 + {{/if}} 36 + </body> 37 + </html>
+93
html_templates/admin/invite_codes.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Invite Codes - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + </head> 10 + <body> 11 + <div class="layout"> 12 + {{> admin/partials/sidebar.hbs}} 13 + 14 + <main class="main"> 15 + {{> admin/partials/flash.hbs}} 16 + 17 + <h1 class="page-title">Invite Codes</h1> 18 + 19 + {{#if can_create_invite}} 20 + <form class="create-form" method="POST" action="/admin/invite-codes/create"> 21 + <div class="form-group"> 22 + <label for="use_count">Max Uses</label> 23 + <input type="number" id="use_count" name="use_count" value="1" min="1" max="100"/> 24 + </div> 25 + <button type="submit" class="btn btn-primary">Create Invite Code</button> 26 + </form> 27 + {{/if}} 28 + 29 + {{#if new_code}} 30 + <div class="code-box"> 31 + <div class="code-label">New Invite Code Created</div> 32 + <div class="code-value">{{new_code}}</div> 33 + </div> 34 + {{/if}} 35 + 36 + {{#if codes}} 37 + <div class="table-container"> 38 + <table> 39 + <thead> 40 + <tr> 41 + <th>Code</th> 42 + <th>Remaining / Total</th> 43 + <th>Status</th> 44 + <th>Created By</th> 45 + <th>Created At</th> 46 + {{#if can_manage_invites}} 47 + <th></th> 48 + {{/if}} 49 + </tr> 50 + </thead> 51 + <tbody> 52 + {{#each codes}} 53 + <tr> 54 + <td class="code-cell">{{this.code}}</td> 55 + <td>{{this.remaining}} / {{this.available}}</td> 56 + <td> 57 + {{#if this.disabled}} 58 + <span class="badge badge-danger">Disabled</span> 59 + {{else}} 60 + <span class="badge badge-success">Active</span> 61 + {{/if}} 62 + </td> 63 + <td>{{this.createdBy}}</td> 64 + <td>{{this.createdAt}}</td> 65 + {{#if ../can_manage_invites}} 66 + <td> 67 + {{#unless this.disabled}} 68 + <form method="POST" action="/admin/invite-codes/disable" 69 + class="inline-form"> 70 + <input type="hidden" name="codes" value="{{this.code}}"/> 71 + <button type="submit" class="btn btn-small btn-outline-danger">Disable 72 + </button> 73 + </form> 74 + {{/unless}} 75 + </td> 76 + {{/if}} 77 + </tr> 78 + {{/each}} 79 + </tbody> 80 + </table> 81 + </div> 82 + {{#if has_more}} 83 + <div class="load-more"> 84 + <a href="/admin/invite-codes?cursor={{next_cursor}}">Load More</a> 85 + </div> 86 + {{/if}} 87 + {{else}} 88 + <div class="empty-state">No invite codes found</div> 89 + {{/if}} 90 + </main> 91 + </div> 92 + </body> 93 + </html>
+36
html_templates/admin/login.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Admin Login - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + <style> 10 + body { 11 + min-height: 100vh; 12 + display: flex; 13 + align-items: center; 14 + justify-content: center; 15 + } 16 + </style> 17 + </head> 18 + <body> 19 + <div class="login-card"> 20 + <div class="login-title">{{pds_hostname}}</div> 21 + <div class="login-subtitle">Admin Portal</div> 22 + 23 + {{#if error}} 24 + <div class="error-msg">{{error}}</div> 25 + {{/if}} 26 + 27 + <form method="POST" action="/admin/login"> 28 + <div class="form-group"> 29 + <label for="handle">Handle</label> 30 + <input type="text" id="handle" name="handle" placeholder="you.bsky.social" required autofocus /> 31 + </div> 32 + <button type="submit" class="btn btn-primary">Login with OAuth</button> 33 + </form> 34 + </div> 35 + </body> 36 + </html>
+6
html_templates/admin/partials/flash.hbs
···
··· 1 + {{#if flash_success}} 2 + <div class="flash-success">{{flash_success}}</div> 3 + {{/if}} 4 + {{#if flash_error}} 5 + <div class="flash-error">{{flash_error}}</div> 6 + {{/if}}
+38
html_templates/admin/partials/sidebar.hbs
···
··· 1 + <div class="topbar"> 2 + <button class="hamburger" id="hamburger" aria-label="Toggle menu"> 3 + <span></span> 4 + <span></span> 5 + <span></span> 6 + </button> 7 + <span class="topbar-title">{{pds_hostname}} — Admin</span> 8 + </div> 9 + 10 + <div class="sidebar-overlay" id="sidebar-overlay"></div> 11 + 12 + <aside class="sidebar"> 13 + <div class="sidebar-title">{{pds_hostname}}</div> 14 + <div class="sidebar-subtitle">Admin Portal</div> 15 + <nav> 16 + <a href="/admin/dashboard" {{#if (eq active_page "dashboard")}}class="active"{{/if}}>Dashboard</a> 17 + {{#if can_view_accounts}} 18 + <a href="/admin/accounts" {{#if (eq active_page "accounts")}}class="active"{{/if}}>Accounts</a> 19 + {{/if}} 20 + {{#if can_manage_invites}} 21 + <a href="/admin/invite-codes" {{#if (eq active_page "invite_codes")}}class="active"{{/if}}>Invite Codes</a> 22 + {{/if}} 23 + {{#if can_create_account}} 24 + <a href="/admin/create-account" {{#if (eq active_page "create_account")}}class="active"{{/if}}>Create Account</a> 25 + {{/if}} 26 + {{#if can_request_crawl}} 27 + <a href="/admin/request-crawl" {{#if (eq active_page "request_crawl")}}class="active"{{/if}}>Request Crawl</a> 28 + {{/if}} 29 + </nav> 30 + <div class="sidebar-footer"> 31 + <div class="session-info">Signed in as {{handle}}</div> 32 + <form method="POST" action="/admin/logout"> 33 + <button type="submit">Sign out</button> 34 + </form> 35 + </div> 36 + </aside> 37 + 38 + <script src="/admin/static/js/admin.js"></script>
+34
html_templates/admin/request_crawl.hbs
···
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Request Crawl - {{pds_hostname}}</title> 8 + <link rel="stylesheet" href="/admin/static/css/admin.css"> 9 + </head> 10 + <body> 11 + <div class="layout"> 12 + {{> admin/partials/sidebar.hbs}} 13 + 14 + <main class="main"> 15 + {{> admin/partials/flash.hbs}} 16 + 17 + <h1 class="page-title">Request Crawl</h1> 18 + <p class="page-description">Request a relay to crawl this PDS. This sends your PDS hostname to the relay so it 19 + can discover and index your content.</p> 20 + 21 + <div class="form-card"> 22 + <form method="POST" action="/admin/request-crawl"> 23 + <div class="form-group"> 24 + <label for="relay_host">Relay Host</label> 25 + <input type="text" id="relay_host" name="relay_host" value="{{default_relay}}" required/> 26 + <div class="hint">The relay hostname to send the crawl request to (e.g., bsky.network)</div> 27 + </div> 28 + <button type="submit" class="btn btn-primary">Request Crawl</button> 29 + </form> 30 + </div> 31 + </main> 32 + </div> 33 + </body> 34 + </html>
+8
migrations/20260228000000_admin_sessions.sql
···
··· 1 + CREATE TABLE admin_sessions ( 2 + session_id VARCHAR(36) PRIMARY KEY, 3 + did VARCHAR NOT NULL, 4 + handle VARCHAR NOT NULL, 5 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 + expires_at TIMESTAMP NOT NULL 7 + ); 8 + CREATE INDEX idx_admin_sessions_expires_at ON admin_sessions(expires_at);
+18
migrations/20260303000000_oauth_auth_store.sql
···
··· 1 + -- OAuth client session storage (replaces in-memory MemoryAuthStore) 2 + CREATE TABLE IF NOT EXISTS oauth_client_sessions ( 3 + session_key VARCHAR NOT NULL PRIMARY KEY, 4 + did VARCHAR NOT NULL, 5 + session_id VARCHAR NOT NULL, 6 + data TEXT NOT NULL, 7 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 9 + ); 10 + 11 + CREATE INDEX IF NOT EXISTS idx_oauth_client_sessions_did ON oauth_client_sessions(did); 12 + 13 + -- OAuth authorization request storage (transient, keyed by state token) 14 + CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 15 + state VARCHAR NOT NULL PRIMARY KEY, 16 + data TEXT NOT NULL, 17 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 18 + );
+109
src/admin/middleware.rs
···
··· 1 + use axum::{ 2 + extract::{Request, State}, 3 + http::StatusCode, 4 + middleware::Next, 5 + response::{IntoResponse, Redirect, Response}, 6 + }; 7 + use axum_extra::extract::cookie::SignedCookieJar; 8 + 9 + use crate::AppState; 10 + 11 + use super::rbac::RbacConfig; 12 + use super::session; 13 + 14 + /// Admin session data injected into request extensions. 15 + #[derive(Debug, Clone)] 16 + pub struct AdminSession { 17 + pub did: String, 18 + pub handle: String, 19 + pub roles: Vec<String>, 20 + } 21 + 22 + /// Pre-computed permission flags for template rendering and quick checks. 23 + #[derive(Debug, Clone)] 24 + pub struct AdminPermissions { 25 + pub can_view_accounts: bool, 26 + pub can_manage_takedowns: bool, 27 + pub can_delete_account: bool, 28 + pub can_reset_password: bool, 29 + pub can_create_account: bool, 30 + pub can_manage_invites: bool, 31 + pub can_create_invite: bool, 32 + pub can_send_email: bool, 33 + pub can_request_crawl: bool, 34 + } 35 + 36 + impl AdminPermissions { 37 + pub fn compute(rbac: &RbacConfig, did: &str) -> Self { 38 + Self { 39 + can_view_accounts: rbac 40 + .can_access_endpoint(did, "com.atproto.admin.getAccountInfo") 41 + || rbac.can_access_endpoint(did, "com.atproto.admin.getAccountInfos"), 42 + can_manage_takedowns: rbac 43 + .can_access_endpoint(did, "com.atproto.admin.updateSubjectStatus"), 44 + can_delete_account: rbac 45 + .can_access_endpoint(did, "com.atproto.admin.deleteAccount"), 46 + can_reset_password: rbac 47 + .can_access_endpoint(did, "com.atproto.admin.updateAccountPassword"), 48 + can_create_account: rbac 49 + .can_access_endpoint(did, "com.atproto.server.createAccount"), 50 + can_manage_invites: rbac 51 + .can_access_endpoint(did, "com.atproto.admin.getInviteCodes"), 52 + can_create_invite: rbac 53 + .can_access_endpoint(did, "com.atproto.server.createInviteCode"), 54 + can_send_email: rbac.can_access_endpoint(did, "com.atproto.admin.sendEmail"), 55 + can_request_crawl: rbac 56 + .can_access_endpoint(did, "com.atproto.sync.requestCrawl"), 57 + } 58 + } 59 + } 60 + 61 + /// Middleware that checks for a valid admin session cookie. 62 + /// If valid, injects AdminSession and AdminPermissions into request extensions. 63 + /// If invalid or missing, redirects to /admin/login. 64 + pub async fn admin_auth_middleware( 65 + State(state): State<AppState>, 66 + jar: SignedCookieJar, 67 + mut req: Request, 68 + next: Next, 69 + ) -> Response { 70 + let rbac = match &state.admin_rbac_config { 71 + Some(rbac) => rbac, 72 + None => return StatusCode::NOT_FOUND.into_response(), 73 + }; 74 + 75 + // Extract session ID from signed cookie 76 + let session_id = match jar.get("__gatekeeper_admin_session") { 77 + Some(cookie) => cookie.value().to_string(), 78 + None => return Redirect::to("/admin/login").into_response(), 79 + }; 80 + 81 + // Look up session in database 82 + let session_row = match session::get_session(&state.pds_gatekeeper_pool, &session_id).await { 83 + Ok(Some(row)) => row, 84 + Ok(None) => return Redirect::to("/admin/login").into_response(), 85 + Err(e) => { 86 + tracing::error!("Failed to look up admin session: {}", e); 87 + return Redirect::to("/admin/login").into_response(); 88 + } 89 + }; 90 + 91 + // Verify the DID is still a valid member 92 + if !rbac.is_member(&session_row.did) { 93 + return Redirect::to("/admin/login").into_response(); 94 + } 95 + 96 + let roles = rbac.get_member_roles(&session_row.did); 97 + let permissions = AdminPermissions::compute(rbac, &session_row.did); 98 + 99 + let admin_session = AdminSession { 100 + did: session_row.did, 101 + handle: session_row.handle, 102 + roles, 103 + }; 104 + 105 + req.extensions_mut().insert(admin_session); 106 + req.extensions_mut().insert(permissions); 107 + 108 + next.run(req).await 109 + }
+68
src/admin/mod.rs
···
··· 1 + pub mod middleware; 2 + pub mod oauth; 3 + pub mod pds_proxy; 4 + pub mod rbac; 5 + pub mod routes; 6 + pub mod session; 7 + pub mod static_files; 8 + pub mod store; 9 + 10 + use axum::{Router, middleware as ax_middleware, routing::get, routing::post}; 11 + 12 + use crate::AppState; 13 + 14 + /// Build the admin sub-router. 15 + /// Public routes (login, OAuth callback, client-metadata) are not behind auth middleware. 16 + /// All other admin routes require a valid session. 17 + pub fn router(state: AppState) -> Router<AppState> { 18 + // Routes that do NOT require authentication 19 + let public_routes = Router::new() 20 + .route("/", get(routes::dashboard)) 21 + .route("/login", get(oauth::get_login).post(oauth::post_login)) 22 + .route("/oauth/callback", get(oauth::oauth_callback)) 23 + .route("/client-metadata.json", get(oauth::client_metadata_json)) 24 + .route("/static/{*path}", get(static_files::serve_static)); 25 + 26 + // Routes that DO require authentication (via admin_auth middleware) 27 + let protected_routes = Router::new() 28 + .route("/dashboard", get(routes::dashboard)) 29 + .route("/accounts", get(routes::accounts_list)) 30 + .route("/accounts/{did}", get(routes::account_detail)) 31 + .route("/accounts/{did}/takedown", post(routes::takedown_account)) 32 + .route( 33 + "/accounts/{did}/untakedown", 34 + post(routes::untakedown_account), 35 + ) 36 + .route("/accounts/{did}/delete", post(routes::delete_account)) 37 + .route( 38 + "/accounts/{did}/reset-password", 39 + post(routes::reset_password), 40 + ) 41 + .route( 42 + "/accounts/{did}/disable-invites", 43 + post(routes::disable_account_invites), 44 + ) 45 + .route( 46 + "/accounts/{did}/enable-invites", 47 + post(routes::enable_account_invites), 48 + ) 49 + .route("/invite-codes", get(routes::invite_codes_list)) 50 + .route("/invite-codes/create", post(routes::create_invite_code)) 51 + .route("/invite-codes/disable", post(routes::disable_invite_codes)) 52 + .route( 53 + "/create-account", 54 + get(routes::get_create_account).post(routes::post_create_account), 55 + ) 56 + .route( 57 + "/request-crawl", 58 + get(routes::get_request_crawl).post(routes::post_request_crawl), 59 + ) 60 + .route("/logout", post(routes::logout)) 61 + .fallback(get(routes::dashboard)) 62 + .layer(ax_middleware::from_fn_with_state( 63 + state.clone(), 64 + middleware::admin_auth_middleware, 65 + )); 66 + 67 + Router::new().merge(public_routes).merge(protected_routes) 68 + }
+254
src/admin/oauth.rs
···
··· 1 + use super::session; 2 + use crate::AppState; 3 + use crate::admin::store::SqlAuthStore; 4 + use axum::{ 5 + extract::{Query, State}, 6 + http::StatusCode, 7 + response::{Html, IntoResponse, Redirect, Response}, 8 + }; 9 + use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; 10 + use jacquard_identity::JacquardResolver; 11 + use jacquard_oauth::session::ClientSessionData; 12 + use jacquard_oauth::{ 13 + atproto::{AtprotoClientMetadata, GrantType}, 14 + client::OAuthClient, 15 + session::ClientData, 16 + types::{AuthorizeOptions, CallbackParams}, 17 + }; 18 + use serde::Deserialize; 19 + use sqlx::SqlitePool; 20 + use tracing::log; 21 + 22 + /// Type alias for the concrete OAuthClient we use. 23 + pub type AdminOAuthClient = OAuthClient<JacquardResolver, SqlAuthStore>; 24 + 25 + /// Initialize the OAuth client for admin portal authentication. 26 + pub fn init_oauth_client( 27 + pds_hostname: &str, 28 + pool: SqlitePool, 29 + ) -> Result<AdminOAuthClient, anyhow::Error> { 30 + // Build client metadata 31 + let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname) 32 + .parse() 33 + .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_id URL"))?; 34 + let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname) 35 + .parse() 36 + .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid redirect_uri URL"))?; 37 + let client_uri = format!("https://{}/admin/", pds_hostname) 38 + .parse() 39 + .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_uri URL"))?; 40 + 41 + let config = AtprotoClientMetadata::new( 42 + client_id, 43 + Some(client_uri), 44 + vec![redirect_uri], 45 + vec![GrantType::AuthorizationCode], 46 + vec![jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope")], 47 + None, 48 + ); 49 + 50 + let client_data = ClientData::new(None, config); 51 + let store = SqlAuthStore::new(pool); 52 + let client = OAuthClient::new(store, client_data); 53 + 54 + Ok(client) 55 + } 56 + 57 + /// GET /admin/client-metadata.json — Serves the OAuth client metadata. 58 + pub async fn client_metadata_json(State(state): State<AppState>) -> Response { 59 + let pds_hostname = &state.app_config.pds_hostname; 60 + let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname); 61 + let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname); 62 + let client_uri = format!("https://{}/admin/", pds_hostname); 63 + 64 + let metadata = serde_json::json!({ 65 + "client_id": client_id, 66 + "client_uri": client_uri, 67 + "redirect_uris": [redirect_uri], 68 + "grant_types": ["authorization_code"], 69 + "response_types": ["code"], 70 + "scope": "atproto", 71 + "token_endpoint_auth_method": "none", 72 + "application_type": "web", 73 + "dpop_bound_access_tokens": true, 74 + 75 + }); 76 + 77 + ( 78 + StatusCode::OK, 79 + [(axum::http::header::CONTENT_TYPE, "application/json")], 80 + serde_json::to_string_pretty(&metadata).unwrap_or_default(), 81 + ) 82 + .into_response() 83 + } 84 + 85 + /// GET /admin/login — Renders the login page. 86 + pub async fn get_login( 87 + State(state): State<AppState>, 88 + Query(params): Query<LoginQueryParams>, 89 + ) -> Response { 90 + let mut data = serde_json::json!({ 91 + "pds_hostname": state.app_config.pds_hostname, 92 + }); 93 + 94 + if let Some(error) = params.error { 95 + data["error"] = serde_json::Value::String(error); 96 + } 97 + 98 + use axum_template::TemplateEngine; 99 + match state.template_engine.render("admin/login.hbs", data) { 100 + Ok(html) => Html(html).into_response(), 101 + Err(e) => { 102 + tracing::error!("Failed to render login template: {}", e); 103 + StatusCode::INTERNAL_SERVER_ERROR.into_response() 104 + } 105 + } 106 + } 107 + 108 + #[derive(Debug, Deserialize)] 109 + pub struct LoginQueryParams { 110 + pub error: Option<String>, 111 + } 112 + 113 + #[derive(Debug, Deserialize)] 114 + pub struct LoginForm { 115 + pub handle: String, 116 + } 117 + 118 + /// POST /admin/login — Initiates the OAuth flow. 119 + pub async fn post_login( 120 + State(state): State<AppState>, 121 + axum::extract::Form(form): axum::extract::Form<LoginForm>, 122 + ) -> Response { 123 + let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 124 + Some(client) => client, 125 + None => return StatusCode::NOT_FOUND.into_response(), 126 + }; 127 + 128 + let pds_hostname = &state.app_config.pds_hostname; 129 + let redirect_uri: url::Url = match format!("https://{}/admin/oauth/callback", pds_hostname) 130 + .parse() 131 + { 132 + Ok(u) => u, 133 + Err(_) => { 134 + return Redirect::to("/admin/login?error=Invalid+server+configuration").into_response(); 135 + } 136 + }; 137 + 138 + let options = AuthorizeOptions { 139 + redirect_uri: Some(redirect_uri), 140 + scopes: vec![ 141 + jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope"), 142 + jacquard_oauth::scopes::Scope::parse("transition:generic").expect("valid scope"), 143 + ], 144 + prompt: None, 145 + state: None, 146 + }; 147 + 148 + match oauth_client.start_auth(&form.handle, options).await { 149 + Ok(auth_url) => Redirect::to(&auth_url).into_response(), 150 + Err(e) => { 151 + tracing::error!("OAuth start_auth failed: {}", e); 152 + let msg = format!("Login failed: {}", e); 153 + let error_msg = urlencoding::encode(&msg); 154 + Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response() 155 + } 156 + } 157 + } 158 + 159 + #[derive(Debug, Deserialize)] 160 + pub struct OAuthCallbackParams { 161 + pub code: String, 162 + pub state: Option<String>, 163 + pub iss: Option<String>, 164 + } 165 + 166 + /// GET /admin/oauth/callback — Handles the OAuth callback. 167 + pub async fn oauth_callback( 168 + State(state): State<AppState>, 169 + Query(params): Query<OAuthCallbackParams>, 170 + jar: SignedCookieJar, 171 + ) -> Response { 172 + let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 173 + Some(client) => client, 174 + None => return StatusCode::NOT_FOUND.into_response(), 175 + }; 176 + 177 + let rbac = match &state.admin_rbac_config { 178 + Some(rbac) => rbac, 179 + None => return StatusCode::NOT_FOUND.into_response(), 180 + }; 181 + 182 + let callback_params = CallbackParams { 183 + code: params.code.as_str().into(), 184 + state: params.state.as_deref().map(Into::into), 185 + iss: params.iss.as_deref().map(Into::into), 186 + }; 187 + 188 + // Exchange authorization code for session 189 + let oauth_session = match oauth_client.callback(callback_params).await { 190 + Ok(session) => session, 191 + Err(e) => { 192 + tracing::error!("OAuth callback failed: {}", e); 193 + let msg = format!("Authentication failed: {}", e); 194 + let error_msg = urlencoding::encode(&msg); 195 + return Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response(); 196 + } 197 + }; 198 + 199 + // Extract DID and handle from the OAuth session 200 + let (did, handle) = oauth_session.session_info().await; 201 + let did_str = did.to_string(); 202 + let handle_str = handle.to_string(); 203 + log::info!("Authenticated as DID {} ({})", did_str, handle_str); 204 + // Check if this DID is a member in the RBAC config 205 + if !rbac.is_member(&did_str) { 206 + tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str); 207 + return render_error( 208 + &state, 209 + "Access Denied", 210 + &format!( 211 + "Your identity ({}) is not authorized to access the admin portal. Contact your PDS administrator.", 212 + handle_str 213 + ), 214 + ); 215 + } 216 + 217 + // Create admin session 218 + let ttl_hours = state.app_config.admin_session_ttl_hours; 219 + let session_id = 220 + match session::create_session(&state.pds_gatekeeper_pool, &did_str, &handle_str, ttl_hours) 221 + .await 222 + { 223 + Ok(id) => id, 224 + Err(e) => { 225 + tracing::error!("Failed to create admin session: {}", e); 226 + return Redirect::to("/admin/login?error=Session+creation+failed").into_response(); 227 + } 228 + }; 229 + 230 + // Set signed cookie 231 + let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id); 232 + cookie.set_http_only(true); 233 + cookie.set_secure(true); 234 + cookie.set_same_site(SameSite::Lax); 235 + cookie.set_path("/admin/"); 236 + 237 + let updated_jar = jar.add(cookie); 238 + 239 + (updated_jar, Redirect::to("/admin/dashboard")).into_response() 240 + } 241 + 242 + fn render_error(state: &AppState, title: &str, message: &str) -> Response { 243 + let data = serde_json::json!({ 244 + "error_title": title, 245 + "error_message": message, 246 + "pds_hostname": state.app_config.pds_hostname, 247 + }); 248 + 249 + use axum_template::TemplateEngine; 250 + match state.template_engine.render("admin/error.hbs", data) { 251 + Ok(html) => Html(html).into_response(), 252 + Err(_) => (StatusCode::FORBIDDEN, format!("{}: {}", title, message)).into_response(), 253 + } 254 + }
+297
src/admin/pds_proxy.rs
···
··· 1 + use serde::de::DeserializeOwned; 2 + use serde::Serialize; 3 + use std::fmt; 4 + 5 + #[derive(Debug)] 6 + pub enum AdminProxyError { 7 + RequestFailed(String), 8 + PdsError { 9 + status: u16, 10 + error: String, 11 + message: String, 12 + }, 13 + ParseError(String), 14 + } 15 + 16 + impl fmt::Display for AdminProxyError { 17 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 18 + match self { 19 + AdminProxyError::RequestFailed(msg) => write!(f, "Request failed: {msg}"), 20 + AdminProxyError::PdsError { 21 + status, 22 + error, 23 + message, 24 + } => write!(f, "PDS error ({status}): {error} - {message}"), 25 + AdminProxyError::ParseError(msg) => write!(f, "Parse error: {msg}"), 26 + } 27 + } 28 + } 29 + 30 + /// Make an authenticated GET request to a PDS XRPC endpoint. 31 + pub async fn admin_xrpc_get<R: DeserializeOwned>( 32 + pds_base_url: &str, 33 + admin_password: &str, 34 + endpoint: &str, 35 + query_params: &[(&str, &str)], 36 + ) -> Result<R, AdminProxyError> { 37 + let url = format!( 38 + "{}/xrpc/{}", 39 + pds_base_url.trim_end_matches('/'), 40 + endpoint 41 + ); 42 + let client = reqwest::Client::new(); 43 + let resp = client 44 + .get(&url) 45 + .query(query_params) 46 + .basic_auth("admin", Some(admin_password)) 47 + .send() 48 + .await 49 + .map_err(|e| AdminProxyError::RequestFailed(e.to_string()))?; 50 + 51 + if !resp.status().is_success() { 52 + let status = resp.status().as_u16(); 53 + let body = resp.text().await.unwrap_or_default(); 54 + if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&body) { 55 + return Err(AdminProxyError::PdsError { 56 + status, 57 + error: err_json["error"] 58 + .as_str() 59 + .unwrap_or("Unknown") 60 + .to_string(), 61 + message: err_json["message"] 62 + .as_str() 63 + .unwrap_or(&body) 64 + .to_string(), 65 + }); 66 + } 67 + return Err(AdminProxyError::PdsError { 68 + status, 69 + error: "Unknown".to_string(), 70 + message: body, 71 + }); 72 + } 73 + 74 + resp.json::<R>() 75 + .await 76 + .map_err(|e| AdminProxyError::ParseError(e.to_string())) 77 + } 78 + 79 + /// Make an authenticated POST request to a PDS XRPC endpoint. 80 + pub async fn admin_xrpc_post<T: Serialize, R: DeserializeOwned>( 81 + pds_base_url: &str, 82 + admin_password: &str, 83 + endpoint: &str, 84 + body: &T, 85 + ) -> Result<R, AdminProxyError> { 86 + let url = format!( 87 + "{}/xrpc/{}", 88 + pds_base_url.trim_end_matches('/'), 89 + endpoint 90 + ); 91 + let client = reqwest::Client::new(); 92 + let resp = client 93 + .post(&url) 94 + .json(body) 95 + .basic_auth("admin", Some(admin_password)) 96 + .send() 97 + .await 98 + .map_err(|e| AdminProxyError::RequestFailed(e.to_string()))?; 99 + 100 + if !resp.status().is_success() { 101 + let status = resp.status().as_u16(); 102 + let body = resp.text().await.unwrap_or_default(); 103 + if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&body) { 104 + return Err(AdminProxyError::PdsError { 105 + status, 106 + error: err_json["error"] 107 + .as_str() 108 + .unwrap_or("Unknown") 109 + .to_string(), 110 + message: err_json["message"] 111 + .as_str() 112 + .unwrap_or(&body) 113 + .to_string(), 114 + }); 115 + } 116 + return Err(AdminProxyError::PdsError { 117 + status, 118 + error: "Unknown".to_string(), 119 + message: body, 120 + }); 121 + } 122 + 123 + resp.json::<R>() 124 + .await 125 + .map_err(|e| AdminProxyError::ParseError(e.to_string())) 126 + } 127 + 128 + /// Make an authenticated POST request that returns no meaningful body (just success/failure). 129 + pub async fn admin_xrpc_post_no_response<T: Serialize>( 130 + pds_base_url: &str, 131 + admin_password: &str, 132 + endpoint: &str, 133 + body: &T, 134 + ) -> Result<(), AdminProxyError> { 135 + let url = format!( 136 + "{}/xrpc/{}", 137 + pds_base_url.trim_end_matches('/'), 138 + endpoint 139 + ); 140 + let client = reqwest::Client::new(); 141 + let resp = client 142 + .post(&url) 143 + .json(body) 144 + .basic_auth("admin", Some(admin_password)) 145 + .send() 146 + .await 147 + .map_err(|e| AdminProxyError::RequestFailed(e.to_string()))?; 148 + 149 + if !resp.status().is_success() { 150 + let status = resp.status().as_u16(); 151 + let body = resp.text().await.unwrap_or_default(); 152 + if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&body) { 153 + return Err(AdminProxyError::PdsError { 154 + status, 155 + error: err_json["error"] 156 + .as_str() 157 + .unwrap_or("Unknown") 158 + .to_string(), 159 + message: err_json["message"] 160 + .as_str() 161 + .unwrap_or(&body) 162 + .to_string(), 163 + }); 164 + } 165 + return Err(AdminProxyError::PdsError { 166 + status, 167 + error: "Unknown".to_string(), 168 + message: body, 169 + }); 170 + } 171 + 172 + Ok(()) 173 + } 174 + 175 + /// Make an unauthenticated GET request that returns text (e.g., _health). 176 + pub async fn get_text( 177 + pds_base_url: &str, 178 + endpoint: &str, 179 + ) -> Result<String, AdminProxyError> { 180 + let url = format!( 181 + "{}/{}", 182 + pds_base_url.trim_end_matches('/'), 183 + endpoint 184 + ); 185 + let client = reqwest::Client::new(); 186 + let resp = client 187 + .get(&url) 188 + .send() 189 + .await 190 + .map_err(|e| AdminProxyError::RequestFailed(e.to_string()))?; 191 + 192 + if !resp.status().is_success() { 193 + let status = resp.status().as_u16(); 194 + let body = resp.text().await.unwrap_or_default(); 195 + return Err(AdminProxyError::PdsError { 196 + status, 197 + error: "Unknown".to_string(), 198 + message: body, 199 + }); 200 + } 201 + 202 + resp.text() 203 + .await 204 + .map_err(|e| AdminProxyError::ParseError(e.to_string())) 205 + } 206 + 207 + /// Make an unauthenticated POST request to an arbitrary base URL (e.g., relay). 208 + pub async fn public_xrpc_post<T: Serialize>( 209 + base_url: &str, 210 + endpoint: &str, 211 + body: &T, 212 + ) -> Result<(), AdminProxyError> { 213 + let url = format!( 214 + "{}/xrpc/{}", 215 + base_url.trim_end_matches('/'), 216 + endpoint 217 + ); 218 + let client = reqwest::Client::new(); 219 + let resp = client 220 + .post(&url) 221 + .json(body) 222 + .send() 223 + .await 224 + .map_err(|e| AdminProxyError::RequestFailed(e.to_string()))?; 225 + 226 + if !resp.status().is_success() { 227 + let status = resp.status().as_u16(); 228 + let body = resp.text().await.unwrap_or_default(); 229 + if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&body) { 230 + return Err(AdminProxyError::PdsError { 231 + status, 232 + error: err_json["error"] 233 + .as_str() 234 + .unwrap_or("Unknown") 235 + .to_string(), 236 + message: err_json["message"] 237 + .as_str() 238 + .unwrap_or(&body) 239 + .to_string(), 240 + }); 241 + } 242 + return Err(AdminProxyError::PdsError { 243 + status, 244 + error: "Unknown".to_string(), 245 + message: body, 246 + }); 247 + } 248 + 249 + Ok(()) 250 + } 251 + 252 + /// Make an unauthenticated GET request with JSON response parsing. 253 + pub async fn public_xrpc_get<R: DeserializeOwned>( 254 + pds_base_url: &str, 255 + endpoint: &str, 256 + query_params: &[(&str, &str)], 257 + ) -> Result<R, AdminProxyError> { 258 + let url = format!( 259 + "{}/xrpc/{}", 260 + pds_base_url.trim_end_matches('/'), 261 + endpoint 262 + ); 263 + let client = reqwest::Client::new(); 264 + let resp = client 265 + .get(&url) 266 + .query(query_params) 267 + .send() 268 + .await 269 + .map_err(|e| AdminProxyError::RequestFailed(e.to_string()))?; 270 + 271 + if !resp.status().is_success() { 272 + let status = resp.status().as_u16(); 273 + let body = resp.text().await.unwrap_or_default(); 274 + if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&body) { 275 + return Err(AdminProxyError::PdsError { 276 + status, 277 + error: err_json["error"] 278 + .as_str() 279 + .unwrap_or("Unknown") 280 + .to_string(), 281 + message: err_json["message"] 282 + .as_str() 283 + .unwrap_or(&body) 284 + .to_string(), 285 + }); 286 + } 287 + return Err(AdminProxyError::PdsError { 288 + status, 289 + error: "Unknown".to_string(), 290 + message: body, 291 + }); 292 + } 293 + 294 + resp.json::<R>() 295 + .await 296 + .map_err(|e| AdminProxyError::ParseError(e.to_string())) 297 + }
+262
src/admin/rbac.rs
···
··· 1 + use serde::Deserialize; 2 + use std::collections::HashMap; 3 + use std::path::Path; 4 + 5 + #[derive(Debug, Clone, Deserialize)] 6 + pub struct RbacConfig { 7 + pub roles: HashMap<String, RoleDefinition>, 8 + pub members: Vec<MemberDefinition>, 9 + } 10 + 11 + #[derive(Debug, Clone, Deserialize)] 12 + pub struct RoleDefinition { 13 + pub description: String, 14 + pub endpoints: Vec<String>, 15 + } 16 + 17 + #[derive(Debug, Clone, Deserialize)] 18 + pub struct MemberDefinition { 19 + pub did: String, 20 + pub roles: Vec<String>, 21 + } 22 + 23 + impl RbacConfig { 24 + /// Load RBAC configuration from a YAML file. 25 + pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self, anyhow::Error> { 26 + let contents = std::fs::read_to_string(path)?; 27 + let config: RbacConfig = serde_yaml::from_str(&contents)?; 28 + config.validate()?; 29 + Ok(config) 30 + } 31 + 32 + /// Validate that all member roles reference defined roles. 33 + fn validate(&self) -> Result<(), anyhow::Error> { 34 + for member in &self.members { 35 + for role in &member.roles { 36 + if !self.roles.contains_key(role) { 37 + return Err(anyhow::anyhow!( 38 + "Member {} references undefined role: {}", 39 + member.did, 40 + role 41 + )); 42 + } 43 + } 44 + } 45 + Ok(()) 46 + } 47 + 48 + /// Check whether a DID is a configured member. 49 + pub fn is_member(&self, did: &str) -> bool { 50 + self.members.iter().any(|m| m.did == did) 51 + } 52 + 53 + /// Get the role names assigned to a DID. 54 + pub fn get_member_roles(&self, did: &str) -> Vec<String> { 55 + self.members 56 + .iter() 57 + .find(|m| m.did == did) 58 + .map(|m| m.roles.clone()) 59 + .unwrap_or_default() 60 + } 61 + 62 + /// Get all endpoint patterns that a DID is allowed to access (aggregated from all roles). 63 + pub fn get_allowed_endpoints(&self, did: &str) -> Vec<String> { 64 + let roles = self.get_member_roles(did); 65 + let mut endpoints = Vec::new(); 66 + for role_name in &roles { 67 + if let Some(role) = self.roles.get(role_name) { 68 + endpoints.extend(role.endpoints.clone()); 69 + } 70 + } 71 + endpoints 72 + } 73 + 74 + /// Check whether a DID can access a specific endpoint. 75 + /// Supports wildcard matching: `com.atproto.admin.*` matches `com.atproto.admin.getAccountInfo`. 76 + pub fn can_access_endpoint(&self, did: &str, endpoint: &str) -> bool { 77 + let allowed = self.get_allowed_endpoints(did); 78 + for pattern in &allowed { 79 + if matches_endpoint_pattern(pattern, endpoint) { 80 + return true; 81 + } 82 + } 83 + false 84 + } 85 + } 86 + 87 + /// Match an endpoint against a pattern. Supports trailing `*` wildcard. 88 + /// e.g. `com.atproto.admin.*` matches `com.atproto.admin.getAccountInfo` 89 + fn matches_endpoint_pattern(pattern: &str, endpoint: &str) -> bool { 90 + if pattern == endpoint { 91 + return true; 92 + } 93 + if let Some(prefix) = pattern.strip_suffix('*') { 94 + return endpoint.starts_with(prefix); 95 + } 96 + false 97 + } 98 + 99 + #[cfg(test)] 100 + mod tests { 101 + use super::*; 102 + 103 + fn test_config() -> RbacConfig { 104 + let yaml = r#" 105 + roles: 106 + pds-admin: 107 + description: "Full admin" 108 + endpoints: 109 + - "com.atproto.admin.*" 110 + - "com.atproto.server.createInviteCode" 111 + - "com.atproto.server.createAccount" 112 + moderator: 113 + description: "Content moderation" 114 + endpoints: 115 + - "com.atproto.admin.getAccountInfo" 116 + - "com.atproto.admin.getAccountInfos" 117 + - "com.atproto.admin.getSubjectStatus" 118 + - "com.atproto.admin.updateSubjectStatus" 119 + - "com.atproto.admin.getInviteCodes" 120 + invite-manager: 121 + description: "Invite management" 122 + endpoints: 123 + - "com.atproto.admin.getInviteCodes" 124 + - "com.atproto.admin.disableInviteCodes" 125 + - "com.atproto.server.createInviteCode" 126 + 127 + members: 128 + - did: "did:plc:admin123" 129 + roles: 130 + - pds-admin 131 + - did: "did:plc:mod456" 132 + roles: 133 + - moderator 134 + - did: "did:plc:both789" 135 + roles: 136 + - moderator 137 + - invite-manager 138 + "#; 139 + serde_yaml::from_str(yaml).unwrap() 140 + } 141 + 142 + #[test] 143 + fn test_is_member() { 144 + let config = test_config(); 145 + assert!(config.is_member("did:plc:admin123")); 146 + assert!(config.is_member("did:plc:mod456")); 147 + assert!(!config.is_member("did:plc:unknown")); 148 + } 149 + 150 + #[test] 151 + fn test_get_member_roles() { 152 + let config = test_config(); 153 + assert_eq!(config.get_member_roles("did:plc:admin123"), vec!["pds-admin"]); 154 + assert_eq!( 155 + config.get_member_roles("did:plc:both789"), 156 + vec!["moderator", "invite-manager"] 157 + ); 158 + assert!(config.get_member_roles("did:plc:unknown").is_empty()); 159 + } 160 + 161 + #[test] 162 + fn test_wildcard_matching() { 163 + assert!(matches_endpoint_pattern( 164 + "com.atproto.admin.*", 165 + "com.atproto.admin.getAccountInfo" 166 + )); 167 + assert!(matches_endpoint_pattern( 168 + "com.atproto.admin.*", 169 + "com.atproto.admin.deleteAccount" 170 + )); 171 + assert!(!matches_endpoint_pattern( 172 + "com.atproto.admin.*", 173 + "com.atproto.server.createAccount" 174 + )); 175 + } 176 + 177 + #[test] 178 + fn test_exact_matching() { 179 + assert!(matches_endpoint_pattern( 180 + "com.atproto.server.createInviteCode", 181 + "com.atproto.server.createInviteCode" 182 + )); 183 + assert!(!matches_endpoint_pattern( 184 + "com.atproto.server.createInviteCode", 185 + "com.atproto.server.createAccount" 186 + )); 187 + } 188 + 189 + #[test] 190 + fn test_admin_can_access_all_admin_endpoints() { 191 + let config = test_config(); 192 + assert!(config.can_access_endpoint("did:plc:admin123", "com.atproto.admin.getAccountInfo")); 193 + assert!(config.can_access_endpoint("did:plc:admin123", "com.atproto.admin.deleteAccount")); 194 + assert!(config.can_access_endpoint( 195 + "did:plc:admin123", 196 + "com.atproto.server.createInviteCode" 197 + )); 198 + assert!(config.can_access_endpoint("did:plc:admin123", "com.atproto.server.createAccount")); 199 + } 200 + 201 + #[test] 202 + fn test_moderator_limited_access() { 203 + let config = test_config(); 204 + assert!(config.can_access_endpoint("did:plc:mod456", "com.atproto.admin.getAccountInfo")); 205 + assert!(config.can_access_endpoint( 206 + "did:plc:mod456", 207 + "com.atproto.admin.updateSubjectStatus" 208 + )); 209 + assert!(!config.can_access_endpoint("did:plc:mod456", "com.atproto.admin.deleteAccount")); 210 + assert!(!config.can_access_endpoint( 211 + "did:plc:mod456", 212 + "com.atproto.server.createInviteCode" 213 + )); 214 + } 215 + 216 + #[test] 217 + fn test_combined_roles() { 218 + let config = test_config(); 219 + // Has moderator + invite-manager 220 + assert!(config.can_access_endpoint("did:plc:both789", "com.atproto.admin.getAccountInfo")); 221 + assert!(config.can_access_endpoint("did:plc:both789", "com.atproto.admin.getInviteCodes")); 222 + assert!(config.can_access_endpoint( 223 + "did:plc:both789", 224 + "com.atproto.server.createInviteCode" 225 + )); 226 + assert!(config.can_access_endpoint( 227 + "did:plc:both789", 228 + "com.atproto.admin.disableInviteCodes" 229 + )); 230 + // But not delete or create account 231 + assert!(!config.can_access_endpoint("did:plc:both789", "com.atproto.admin.deleteAccount")); 232 + assert!(!config.can_access_endpoint( 233 + "did:plc:both789", 234 + "com.atproto.server.createAccount" 235 + )); 236 + } 237 + 238 + #[test] 239 + fn test_non_member_no_access() { 240 + let config = test_config(); 241 + assert!(!config.can_access_endpoint( 242 + "did:plc:unknown", 243 + "com.atproto.admin.getAccountInfo" 244 + )); 245 + } 246 + 247 + #[test] 248 + fn test_validate_rejects_undefined_role() { 249 + let yaml = r#" 250 + roles: 251 + admin: 252 + description: "Admin" 253 + endpoints: ["com.atproto.admin.*"] 254 + members: 255 + - did: "did:plc:test" 256 + roles: 257 + - nonexistent 258 + "#; 259 + let config: RbacConfig = serde_yaml::from_str(yaml).unwrap(); 260 + assert!(config.validate().is_err()); 261 + } 262 + }
+1280
src/admin/routes.rs
···
··· 1 + use super::middleware::{AdminPermissions, AdminSession}; 2 + use super::pds_proxy; 3 + use super::session; 4 + use crate::AppState; 5 + use axum::{ 6 + extract::{Extension, Path, Query, State}, 7 + http::StatusCode, 8 + response::{Html, IntoResponse, Redirect, Response}, 9 + }; 10 + use axum_extra::extract::cookie::{Cookie, SignedCookieJar}; 11 + use jacquard::client::BasicClient; 12 + use jacquard_common::types::handle::Handle; 13 + use jacquard_identity::resolver::IdentityResolver; 14 + use serde::Deserialize; 15 + use tracing::log; 16 + 17 + // ─── Query parameter types ─────────────────────────────────────────────────── 18 + 19 + #[derive(Debug, Deserialize)] 20 + pub struct FlashParams { 21 + pub flash_success: Option<String>, 22 + pub flash_error: Option<String>, 23 + } 24 + 25 + #[derive(Debug, Deserialize)] 26 + pub struct AccountDetailParams { 27 + pub flash_success: Option<String>, 28 + pub flash_error: Option<String>, 29 + pub new_password: Option<String>, 30 + pub direct_lookup: Option<String>, 31 + } 32 + 33 + #[derive(Debug, Deserialize)] 34 + pub struct AccountsParams { 35 + pub cursor: Option<String>, 36 + pub prev_cursor: Option<String>, 37 + pub flash_success: Option<String>, 38 + pub flash_error: Option<String>, 39 + } 40 + 41 + #[derive(Debug, Deserialize)] 42 + pub struct InviteCodesParams { 43 + pub cursor: Option<String>, 44 + pub flash_success: Option<String>, 45 + pub flash_error: Option<String>, 46 + } 47 + 48 + // ─── Form types ────────────────────────────────────────────────────────────── 49 + 50 + #[derive(Debug, Deserialize)] 51 + pub struct CreateInviteForm { 52 + pub use_count: Option<i64>, 53 + } 54 + 55 + #[derive(Debug, Deserialize)] 56 + pub struct DisableInviteCodesForm { 57 + pub codes: String, // comma-separated 58 + } 59 + 60 + #[derive(Debug, Deserialize)] 61 + pub struct CreateAccountForm { 62 + pub email: String, 63 + pub handle: String, 64 + } 65 + 66 + #[derive(Debug, Deserialize)] 67 + pub struct RequestCrawlForm { 68 + pub relay_host: String, 69 + } 70 + 71 + // ─── Helper functions ──────────────────────────────────────────────────────── 72 + 73 + fn admin_password(state: &AppState) -> &str { 74 + state.app_config.pds_admin_password.as_deref().unwrap_or("") 75 + } 76 + 77 + fn pds_url(state: &AppState) -> &str { 78 + &state.app_config.pds_base_url 79 + } 80 + 81 + fn render_template(state: &AppState, template: &str, data: serde_json::Value) -> Response { 82 + use axum_template::TemplateEngine; 83 + match state.template_engine.render(template, data) { 84 + Ok(html) => Html(html).into_response(), 85 + Err(e) => { 86 + tracing::error!("Template render error for {}: {}", template, e); 87 + StatusCode::INTERNAL_SERVER_ERROR.into_response() 88 + } 89 + } 90 + } 91 + 92 + fn inject_nav_data( 93 + data: &mut serde_json::Value, 94 + session: &AdminSession, 95 + permissions: &AdminPermissions, 96 + ) { 97 + let obj = data.as_object_mut().unwrap(); 98 + // Bug 1 fix: templates reference {{handle}}, not {{admin_handle}} 99 + obj.insert("handle".into(), session.handle.clone().into()); 100 + obj.insert("admin_did".into(), session.did.clone().into()); 101 + obj.insert( 102 + "can_view_accounts".into(), 103 + permissions.can_view_accounts.into(), 104 + ); 105 + obj.insert( 106 + "can_manage_takedowns".into(), 107 + permissions.can_manage_takedowns.into(), 108 + ); 109 + obj.insert( 110 + "can_delete_account".into(), 111 + permissions.can_delete_account.into(), 112 + ); 113 + obj.insert( 114 + "can_reset_password".into(), 115 + permissions.can_reset_password.into(), 116 + ); 117 + obj.insert( 118 + "can_create_account".into(), 119 + permissions.can_create_account.into(), 120 + ); 121 + obj.insert( 122 + "can_manage_invites".into(), 123 + permissions.can_manage_invites.into(), 124 + ); 125 + obj.insert( 126 + "can_create_invite".into(), 127 + permissions.can_create_invite.into(), 128 + ); 129 + obj.insert("can_send_email".into(), permissions.can_send_email.into()); 130 + obj.insert( 131 + "can_request_crawl".into(), 132 + permissions.can_request_crawl.into(), 133 + ); 134 + } 135 + 136 + fn flash_redirect(base_path: &str, success: Option<&str>, error: Option<&str>) -> Response { 137 + let mut url = base_path.to_string(); 138 + let mut sep = '?'; 139 + if let Some(msg) = success { 140 + url.push_str(&format!( 141 + "{}flash_success={}", 142 + sep, 143 + urlencoding::encode(msg) 144 + )); 145 + sep = '&'; 146 + } 147 + if let Some(msg) = error { 148 + url.push_str(&format!("{}flash_error={}", sep, urlencoding::encode(msg))); 149 + } 150 + Redirect::to(&url).into_response() 151 + } 152 + 153 + fn generate_random_password() -> String { 154 + use rand::Rng; 155 + let chars = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; 156 + let mut rng = rand::rng(); 157 + (0..24) 158 + .map(|_| chars[rng.random_range(0..chars.len())] as char) 159 + .collect() 160 + } 161 + 162 + // ─── Route handlers ────────────────────────────────────────────────────────── 163 + 164 + /// GET /admin/dashboard — Dashboard 165 + pub async fn dashboard( 166 + State(state): State<AppState>, 167 + Extension(session): Extension<AdminSession>, 168 + Extension(permissions): Extension<AdminPermissions>, 169 + Query(flash): Query<FlashParams>, 170 + ) -> Response { 171 + let pds = pds_url(&state); 172 + 173 + // Fetch health and server description in parallel 174 + let health_fut = pds_proxy::get_text(pds, "xrpc/_health"); 175 + let desc_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 176 + pds, 177 + "com.atproto.server.describeServer", 178 + &[], 179 + ); 180 + 181 + let (health_res, desc_res) = tokio::join!(health_fut, desc_fut); 182 + 183 + let version = health_res 184 + .ok() 185 + .and_then(|t| { 186 + serde_json::from_str::<serde_json::Value>(&t) 187 + .ok() 188 + .and_then(|h| h["version"].as_str().map(|s| s.to_string())) 189 + }) 190 + .unwrap_or_else(|| "Unknown".to_string()); 191 + 192 + let desc = desc_res.ok(); 193 + 194 + // Conditionally fetch account count only if admin can view accounts 195 + let account_count = if permissions.can_view_accounts { 196 + pds_proxy::public_xrpc_get::<serde_json::Value>( 197 + pds, 198 + "com.atproto.sync.listRepos", 199 + &[("limit", "1000")], 200 + ) 201 + .await 202 + .ok() 203 + .and_then(|r| r["repos"].as_array().map(|a| a.len())) 204 + .unwrap_or(0) 205 + } else { 206 + 0 207 + }; 208 + 209 + // Extract describeServer fields (Gap 1) 210 + let server_did = desc 211 + .as_ref() 212 + .and_then(|d| d["did"].as_str()) 213 + .unwrap_or_default() 214 + .to_string(); 215 + let available_domains: Vec<String> = desc 216 + .as_ref() 217 + .and_then(|d| d["availableUserDomains"].as_array()) 218 + .map(|arr| { 219 + arr.iter() 220 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 221 + .collect() 222 + }) 223 + .unwrap_or_default(); 224 + let invite_code_required = desc 225 + .as_ref() 226 + .and_then(|d| d["inviteCodeRequired"].as_bool()) 227 + .unwrap_or(false); 228 + let phone_verification_required = desc 229 + .as_ref() 230 + .and_then(|d| d["phoneVerificationRequired"].as_bool()) 231 + .unwrap_or(false); 232 + 233 + // Gap 1: server links 234 + let privacy_policy = desc 235 + .as_ref() 236 + .and_then(|d| d["links"]["privacyPolicy"].as_str()) 237 + .map(|s| s.to_string()); 238 + let terms_of_service = desc 239 + .as_ref() 240 + .and_then(|d| d["links"]["termsOfService"].as_str()) 241 + .map(|s| s.to_string()); 242 + 243 + // Gap 1: contact email 244 + let contact_email = desc 245 + .as_ref() 246 + .and_then(|d| d["contact"]["email"].as_str()) 247 + .map(|s| s.to_string()); 248 + 249 + let mut data = serde_json::json!({ 250 + "version": version, 251 + "account_count": account_count, 252 + "server_did": server_did, 253 + "available_domains": available_domains, 254 + "invite_code_required": invite_code_required, 255 + "phone_verification_required": phone_verification_required, 256 + "pds_hostname": state.app_config.pds_hostname, 257 + "active_page": "dashboard", 258 + }); 259 + 260 + if let Some(url) = privacy_policy { 261 + data["privacy_policy"] = url.into(); 262 + } 263 + if let Some(url) = terms_of_service { 264 + data["terms_of_service"] = url.into(); 265 + } 266 + if let Some(email) = contact_email { 267 + data["contact_email"] = email.into(); 268 + } 269 + 270 + if let Some(msg) = flash.flash_success { 271 + data["flash_success"] = msg.into(); 272 + } 273 + if let Some(msg) = flash.flash_error { 274 + data["flash_error"] = msg.into(); 275 + } 276 + 277 + inject_nav_data(&mut data, &session, &permissions); 278 + 279 + render_template(&state, "admin/dashboard.hbs", data) 280 + } 281 + 282 + /// GET /admin/accounts — Account list (paginated) 283 + pub async fn accounts_list( 284 + State(state): State<AppState>, 285 + Extension(session): Extension<AdminSession>, 286 + Extension(permissions): Extension<AdminPermissions>, 287 + Query(params): Query<AccountsParams>, 288 + ) -> Response { 289 + if !permissions.can_view_accounts { 290 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 291 + } 292 + 293 + let pds = pds_url(&state); 294 + let password = admin_password(&state); 295 + 296 + let limit = 100; 297 + let limit_as_string = limit.to_string(); 298 + 299 + let mut repo_infos: std::collections::HashMap<String, (bool, Option<String>)> = 300 + std::collections::HashMap::new(); 301 + 302 + // Regular list 303 + let mut query_params: Vec<(&str, &str)> = vec![("limit", &limit_as_string)]; 304 + let cursor_val; 305 + if let Some(ref c) = params.cursor { 306 + cursor_val = c.clone(); 307 + query_params.push(("cursor", &cursor_val)); 308 + } 309 + 310 + let (accounts_raw, next_cursor) = match pds_proxy::public_xrpc_get::<serde_json::Value>( 311 + pds, 312 + "com.atproto.sync.listRepos", 313 + &query_params, 314 + ) 315 + .await 316 + { 317 + Ok(repos) => { 318 + let next_cursor = repos["cursor"].as_str().map(|s| s.to_string()); 319 + if let Some(arr) = repos["repos"].as_array() { 320 + for r in arr { 321 + if let Some(did) = r["did"].as_str() { 322 + let active = r["active"].as_bool().unwrap_or(true); 323 + let status = r["status"].as_str().map(|s| s.to_string()); 324 + repo_infos.insert(did.to_string(), (active, status)); 325 + } 326 + } 327 + } 328 + 329 + let dids: Vec<String> = repo_infos.keys().cloned().collect(); 330 + 331 + if !dids.is_empty() { 332 + let did_params: Vec<(&str, &str)> = 333 + dids.iter().map(|d| ("dids", d.as_str())).collect(); 334 + match pds_proxy::admin_xrpc_get::<serde_json::Value>( 335 + pds, 336 + password, 337 + "com.atproto.admin.getAccountInfos", 338 + &did_params, 339 + ) 340 + .await 341 + { 342 + Ok(res) => (res["infos"].clone(), next_cursor), 343 + Err(e) => { 344 + tracing::error!("Failed to get account infos: {}", e); 345 + (serde_json::json!([]), next_cursor) 346 + } 347 + } 348 + } else { 349 + (serde_json::json!([]), next_cursor) 350 + } 351 + } 352 + Err(e) => { 353 + tracing::error!("Failed to list repos: {}", e); 354 + return flash_redirect( 355 + "/admin/dashboard", 356 + None, 357 + Some(&format!("Failed to list accounts: {}", e)), 358 + ); 359 + } 360 + }; 361 + 362 + let accounts: Vec<serde_json::Value> = if let Some(arr) = accounts_raw.as_array() { 363 + let mut processed = Vec::new(); 364 + for account in arr { 365 + let mut a = account.clone(); 366 + let did = a["did"].as_str().unwrap_or_default(); 367 + 368 + // Enrichment from listRepos if available 369 + if let Some((active, status)) = repo_infos.get(did) { 370 + let is_taken_down = !*active && status.as_deref() != Some("deactivated"); 371 + a["is_taken_down"] = is_taken_down.into(); 372 + 373 + if a["status"].is_null() { 374 + if let Some(s) = status { 375 + a["status"] = s.clone().into(); 376 + } 377 + } 378 + } else { 379 + // If we don't have repo info for this DID (shouldn't happen with our logic) 380 + a["is_taken_down"] = false.into(); 381 + } 382 + processed.push(a); 383 + } 384 + processed 385 + } else { 386 + Vec::new() 387 + }; 388 + 389 + let account_count = accounts.len(); 390 + 391 + let mut data = serde_json::json!({ 392 + "accounts": accounts, 393 + "account_count": account_count, 394 + "pds_hostname": state.app_config.pds_hostname, 395 + "active_page": "accounts", 396 + }); 397 + 398 + // Pagination: "Next" link 399 + if let Some(ref cursor) = next_cursor { 400 + if accounts.len() >= limit { 401 + data["has_next"] = true.into(); 402 + let mut next_url = format!("/admin/accounts?cursor={}", urlencoding::encode(cursor)); 403 + if let Some(ref current_cursor) = params.cursor { 404 + next_url.push_str(&format!( 405 + "&prev_cursor={}", 406 + urlencoding::encode(current_cursor) 407 + )); 408 + } 409 + data["next_url"] = next_url.into(); 410 + } 411 + } 412 + 413 + // Pagination: "Previous" link 414 + if params.cursor.is_some() { 415 + data["has_prev"] = true.into(); 416 + let mut prev_url = "/admin/accounts".to_string(); 417 + if let Some(ref prev_cursor) = params.prev_cursor { 418 + prev_url.push_str(&format!("?cursor={}", urlencoding::encode(prev_cursor))); 419 + } 420 + data["prev_url"] = prev_url.into(); 421 + } 422 + 423 + if let Some(msg) = &params.flash_success { 424 + data["flash_success"] = msg.clone().into(); 425 + } 426 + if let Some(msg) = &params.flash_error { 427 + data["flash_error"] = msg.clone().into(); 428 + } 429 + 430 + inject_nav_data(&mut data, &session, &permissions); 431 + 432 + render_template(&state, "admin/accounts.hbs", data) 433 + } 434 + 435 + /// GET /admin/accounts/:did — Account detail 436 + pub async fn account_detail( 437 + State(state): State<AppState>, 438 + Extension(session): Extension<AdminSession>, 439 + Extension(permissions): Extension<AdminPermissions>, 440 + Path(did): Path<String>, 441 + Query(params): Query<AccountDetailParams>, 442 + ) -> Response { 443 + if !permissions.can_view_accounts { 444 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 445 + } 446 + 447 + let pds = pds_url(&state); 448 + let password = admin_password(&state); 449 + 450 + let mut look_up_did: String = params.direct_lookup.unwrap_or_else(|| did); 451 + 452 + if !look_up_did.starts_with("did") { 453 + let handle = match Handle::new(&look_up_did) { 454 + Ok(h) => h, 455 + Err(e) => { 456 + tracing::warn!("Invalid handle '{}': {}", look_up_did, e); 457 + return flash_redirect( 458 + "/admin/accounts", 459 + None, 460 + Some(&format!("Invalid handle '{}': {}", look_up_did, e)), 461 + ); 462 + } 463 + }; 464 + let client = BasicClient::unauthenticated(); 465 + 466 + tracing::debug!("Resolving handle: {}", handle); 467 + look_up_did = match client.resolve_handle(&handle).await { 468 + Ok(did) => did.to_string(), 469 + Err(e) => { 470 + tracing::warn!("Failed to resolve handle '{}': {}", handle, e); 471 + return flash_redirect( 472 + "/admin/accounts", 473 + None, 474 + Some(&format!("Could not resolve handle '{}': {}", handle, e)), 475 + ); 476 + } 477 + }; 478 + } 479 + 480 + // Fetch account info, subject status, repo description, and repo status in parallel 481 + let did_param: Vec<(&str, &str)> = vec![("did", look_up_did.as_str())]; 482 + let account_fut = pds_proxy::admin_xrpc_get::<serde_json::Value>( 483 + pds, 484 + password, 485 + "com.atproto.admin.getAccountInfo", 486 + &did_param, 487 + ); 488 + 489 + let did_param2: Vec<(&str, &str)> = vec![("did", look_up_did.as_str())]; 490 + let status_fut = pds_proxy::admin_xrpc_get::<serde_json::Value>( 491 + pds, 492 + password, 493 + "com.atproto.admin.getSubjectStatus", 494 + &did_param2, 495 + ); 496 + 497 + // Gap 2: fetch repo description (public, no auth needed) 498 + let repo_param: Vec<(&str, &str)> = vec![("repo", look_up_did.as_str())]; 499 + let describe_repo_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 500 + pds, 501 + "com.atproto.repo.describeRepo", 502 + &repo_param, 503 + ); 504 + 505 + // Gap 2: fetch repo status (public, no auth needed) 506 + let did_param3: Vec<(&str, &str)> = vec![("did", look_up_did.as_str())]; 507 + let repo_status_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 508 + pds, 509 + "com.atproto.sync.getRepoStatus", 510 + &did_param3, 511 + ); 512 + 513 + let (account_res, status_res, describe_repo_res, repo_status_res) = 514 + tokio::join!(account_fut, status_fut, describe_repo_fut, repo_status_fut); 515 + 516 + let account = match account_res { 517 + Ok(a) => a, 518 + Err(e) => { 519 + tracing::error!("Failed to get account info for {}: {}", look_up_did, e); 520 + return flash_redirect( 521 + "/admin/accounts", 522 + None, 523 + Some(&format!("Failed to get account: {}", e)), 524 + ); 525 + } 526 + }; 527 + 528 + let status_val = status_res.ok(); 529 + let takedown_applied = status_val 530 + .as_ref() 531 + .and_then(|s| s["takedown"]["applied"].as_bool()) 532 + .unwrap_or(false); 533 + let takedown_ref = status_val 534 + .as_ref() 535 + .and_then(|s| s["takedown"]["ref"].as_str()) 536 + .map(|s| s.to_string()); 537 + 538 + // Gap 2: extract repo description data 539 + let describe_repo = describe_repo_res.ok(); 540 + let handle_is_correct = describe_repo 541 + .as_ref() 542 + .and_then(|d| d["handleIsCorrect"].as_bool()); 543 + let collections: Vec<String> = describe_repo 544 + .as_ref() 545 + .and_then(|d| d["collections"].as_array()) 546 + .map(|arr| { 547 + arr.iter() 548 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 549 + .collect() 550 + }) 551 + .unwrap_or_default(); 552 + 553 + // Gap 2: extract repo status data 554 + let repo_status = repo_status_res.ok(); 555 + let repo_active = repo_status.as_ref().and_then(|r| r["active"].as_bool()); 556 + let repo_status_reason = repo_status 557 + .as_ref() 558 + .and_then(|r| r["status"].as_str()) 559 + .map(|s| s.to_string()); 560 + let repo_rev = repo_status 561 + .as_ref() 562 + .and_then(|r| r["rev"].as_str()) 563 + .map(|s| s.to_string()); 564 + 565 + // Extract threat signatures from account view 566 + let threat_signatures = account["threatSignatures"].clone(); 567 + 568 + let mut data = serde_json::json!({ 569 + "account": account, 570 + "is_taken_down": takedown_applied, 571 + "pds_hostname": state.app_config.pds_hostname, 572 + "active_page": "accounts", 573 + }); 574 + 575 + // Gap 2: add repo description data 576 + if let Some(correct) = handle_is_correct { 577 + data["handle_is_correct"] = correct.into(); 578 + data["handle_resolution_checked"] = true.into(); 579 + } 580 + if !collections.is_empty() { 581 + data["collections"] = serde_json::json!(collections); 582 + } 583 + 584 + // Gap 2: add repo status data 585 + if let Some(active) = repo_active { 586 + data["repo_active"] = active.into(); 587 + data["repo_status_checked"] = true.into(); 588 + } 589 + if let Some(reason) = repo_status_reason { 590 + data["repo_status_reason"] = reason.into(); 591 + } 592 + if let Some(rev) = repo_rev { 593 + data["repo_rev"] = rev.into(); 594 + } 595 + 596 + // Takedown reference 597 + if let Some(tref) = takedown_ref { 598 + data["takedown_ref"] = tref.into(); 599 + } 600 + 601 + // Threat signatures 602 + if threat_signatures.is_array() && !threat_signatures.as_array().unwrap().is_empty() { 603 + data["threat_signatures"] = threat_signatures; 604 + } 605 + 606 + // Bug 3 fix: pass new_password from query params into template data 607 + if let Some(pw) = params.new_password { 608 + data["new_password"] = pw.into(); 609 + } 610 + 611 + if let Some(msg) = params.flash_success { 612 + data["flash_success"] = msg.into(); 613 + } 614 + if let Some(msg) = params.flash_error { 615 + data["flash_error"] = msg.into(); 616 + } 617 + 618 + inject_nav_data(&mut data, &session, &permissions); 619 + 620 + render_template(&state, "admin/account_detail.hbs", data) 621 + } 622 + 623 + /// POST /admin/accounts/:did/takedown 624 + pub async fn takedown_account( 625 + State(state): State<AppState>, 626 + Extension(session): Extension<AdminSession>, 627 + Extension(_permissions): Extension<AdminPermissions>, 628 + Path(did): Path<String>, 629 + ) -> Response { 630 + let rbac = match &state.admin_rbac_config { 631 + Some(r) => r, 632 + None => return StatusCode::NOT_FOUND.into_response(), 633 + }; 634 + 635 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.updateSubjectStatus") { 636 + return flash_redirect( 637 + &format!("/admin/accounts/{}", did), 638 + None, 639 + Some("Access denied: cannot manage takedowns"), 640 + ); 641 + } 642 + 643 + let body = serde_json::json!({ 644 + "subject": { 645 + "$type": "com.atproto.admin.defs#repoRef", 646 + "did": did, 647 + }, 648 + "takedown": { 649 + "applied": true, 650 + "ref": chrono::Utc::now().timestamp().to_string(), 651 + }, 652 + }); 653 + 654 + match pds_proxy::admin_xrpc_post_no_response( 655 + pds_url(&state), 656 + admin_password(&state), 657 + "com.atproto.admin.updateSubjectStatus", 658 + &body, 659 + ) 660 + .await 661 + { 662 + Ok(()) => flash_redirect( 663 + &format!("/admin/accounts/{}", did), 664 + Some("Account taken down successfully"), 665 + None, 666 + ), 667 + Err(e) => flash_redirect( 668 + &format!("/admin/accounts/{}", did), 669 + None, 670 + Some(&format!("Takedown failed: {}", e)), 671 + ), 672 + } 673 + } 674 + 675 + /// POST /admin/accounts/:did/untakedown 676 + pub async fn untakedown_account( 677 + State(state): State<AppState>, 678 + Extension(session): Extension<AdminSession>, 679 + Extension(_permissions): Extension<AdminPermissions>, 680 + Path(did): Path<String>, 681 + ) -> Response { 682 + let rbac = match &state.admin_rbac_config { 683 + Some(r) => r, 684 + None => return StatusCode::NOT_FOUND.into_response(), 685 + }; 686 + 687 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.updateSubjectStatus") { 688 + return flash_redirect( 689 + &format!("/admin/accounts/{}", did), 690 + None, 691 + Some("Access denied"), 692 + ); 693 + } 694 + 695 + let body = serde_json::json!({ 696 + "subject": { 697 + "$type": "com.atproto.admin.defs#repoRef", 698 + "did": did, 699 + }, 700 + "takedown": { 701 + "applied": false, 702 + }, 703 + }); 704 + 705 + match pds_proxy::admin_xrpc_post_no_response( 706 + pds_url(&state), 707 + admin_password(&state), 708 + "com.atproto.admin.updateSubjectStatus", 709 + &body, 710 + ) 711 + .await 712 + { 713 + Ok(()) => flash_redirect( 714 + &format!("/admin/accounts/{}", did), 715 + Some("Takedown removed successfully"), 716 + None, 717 + ), 718 + Err(e) => flash_redirect( 719 + &format!("/admin/accounts/{}", did), 720 + None, 721 + Some(&format!("Failed to remove takedown: {}", e)), 722 + ), 723 + } 724 + } 725 + 726 + /// POST /admin/accounts/:did/delete 727 + pub async fn delete_account( 728 + State(state): State<AppState>, 729 + Extension(session): Extension<AdminSession>, 730 + Extension(_permissions): Extension<AdminPermissions>, 731 + Path(did): Path<String>, 732 + ) -> Response { 733 + let rbac = match &state.admin_rbac_config { 734 + Some(r) => r, 735 + None => return StatusCode::NOT_FOUND.into_response(), 736 + }; 737 + 738 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.deleteAccount") { 739 + return flash_redirect( 740 + &format!("/admin/accounts/{}", did), 741 + None, 742 + Some("Access denied: cannot delete accounts"), 743 + ); 744 + } 745 + 746 + let body = serde_json::json!({ "did": did }); 747 + 748 + match pds_proxy::admin_xrpc_post_no_response( 749 + pds_url(&state), 750 + admin_password(&state), 751 + "com.atproto.admin.deleteAccount", 752 + &body, 753 + ) 754 + .await 755 + { 756 + Ok(()) => flash_redirect( 757 + "/admin/accounts", 758 + Some(&format!("Account {} deleted", did)), 759 + None, 760 + ), 761 + Err(e) => flash_redirect( 762 + &format!("/admin/accounts/{}", did), 763 + None, 764 + Some(&format!("Delete failed: {}", e)), 765 + ), 766 + } 767 + } 768 + 769 + /// POST /admin/accounts/:did/reset-password 770 + pub async fn reset_password( 771 + State(state): State<AppState>, 772 + Extension(session): Extension<AdminSession>, 773 + Extension(_permissions): Extension<AdminPermissions>, 774 + Path(did): Path<String>, 775 + Query(_flash): Query<FlashParams>, 776 + ) -> Response { 777 + let rbac = match &state.admin_rbac_config { 778 + Some(r) => r, 779 + None => return StatusCode::NOT_FOUND.into_response(), 780 + }; 781 + 782 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.updateAccountPassword") { 783 + return flash_redirect( 784 + &format!("/admin/accounts/{}", did), 785 + None, 786 + Some("Access denied: cannot reset passwords"), 787 + ); 788 + } 789 + 790 + let new_password = generate_random_password(); 791 + let body = serde_json::json!({ 792 + "did": did, 793 + "password": new_password, 794 + }); 795 + 796 + match pds_proxy::admin_xrpc_post_no_response( 797 + pds_url(&state), 798 + admin_password(&state), 799 + "com.atproto.admin.updateAccountPassword", 800 + &body, 801 + ) 802 + .await 803 + { 804 + Ok(()) => { 805 + // Bug 3 fix: redirect with new_password param so account_detail can display it 806 + let encoded = urlencoding::encode(&new_password); 807 + Redirect::to(&format!( 808 + "/admin/accounts/{}?flash_success=Password+reset+successfully&new_password={}", 809 + did, encoded 810 + )) 811 + .into_response() 812 + } 813 + Err(e) => flash_redirect( 814 + &format!("/admin/accounts/{}", did), 815 + None, 816 + Some(&format!("Password reset failed: {}", e)), 817 + ), 818 + } 819 + } 820 + 821 + /// POST /admin/accounts/:did/disable-invites 822 + pub async fn disable_account_invites( 823 + State(state): State<AppState>, 824 + Extension(session): Extension<AdminSession>, 825 + Extension(_permissions): Extension<AdminPermissions>, 826 + Path(did): Path<String>, 827 + ) -> Response { 828 + let rbac = match &state.admin_rbac_config { 829 + Some(r) => r, 830 + None => return StatusCode::NOT_FOUND.into_response(), 831 + }; 832 + 833 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.disableAccountInvites") { 834 + return flash_redirect( 835 + &format!("/admin/accounts/{}", did), 836 + None, 837 + Some("Access denied"), 838 + ); 839 + } 840 + 841 + let body = serde_json::json!({ "account": did }); 842 + 843 + match pds_proxy::admin_xrpc_post_no_response( 844 + pds_url(&state), 845 + admin_password(&state), 846 + "com.atproto.admin.disableAccountInvites", 847 + &body, 848 + ) 849 + .await 850 + { 851 + Ok(()) => flash_redirect( 852 + &format!("/admin/accounts/{}", did), 853 + Some("Invites disabled"), 854 + None, 855 + ), 856 + Err(e) => flash_redirect( 857 + &format!("/admin/accounts/{}", did), 858 + None, 859 + Some(&format!("Failed: {}", e)), 860 + ), 861 + } 862 + } 863 + 864 + /// POST /admin/accounts/:did/enable-invites 865 + pub async fn enable_account_invites( 866 + State(state): State<AppState>, 867 + Extension(session): Extension<AdminSession>, 868 + Extension(_permissions): Extension<AdminPermissions>, 869 + Path(did): Path<String>, 870 + ) -> Response { 871 + let rbac = match &state.admin_rbac_config { 872 + Some(r) => r, 873 + None => return StatusCode::NOT_FOUND.into_response(), 874 + }; 875 + 876 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.enableAccountInvites") { 877 + return flash_redirect( 878 + &format!("/admin/accounts/{}", did), 879 + None, 880 + Some("Access denied"), 881 + ); 882 + } 883 + 884 + let body = serde_json::json!({ "account": did }); 885 + 886 + match pds_proxy::admin_xrpc_post_no_response( 887 + pds_url(&state), 888 + admin_password(&state), 889 + "com.atproto.admin.enableAccountInvites", 890 + &body, 891 + ) 892 + .await 893 + { 894 + Ok(()) => flash_redirect( 895 + &format!("/admin/accounts/{}", did), 896 + Some("Invites enabled"), 897 + None, 898 + ), 899 + Err(e) => flash_redirect( 900 + &format!("/admin/accounts/{}", did), 901 + None, 902 + Some(&format!("Failed: {}", e)), 903 + ), 904 + } 905 + } 906 + 907 + /// GET /admin/invite-codes — List invite codes (with pagination) 908 + pub async fn invite_codes_list( 909 + State(state): State<AppState>, 910 + Extension(session): Extension<AdminSession>, 911 + Extension(permissions): Extension<AdminPermissions>, 912 + Query(params): Query<InviteCodesParams>, 913 + ) -> Response { 914 + let rbac = match &state.admin_rbac_config { 915 + Some(r) => r, 916 + None => return StatusCode::NOT_FOUND.into_response(), 917 + }; 918 + 919 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.getInviteCodes") { 920 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 921 + } 922 + 923 + // Phase 4: pagination support with cursor 924 + let mut query_params = vec![("limit", "100"), ("sort", "recent")]; 925 + let cursor_val; 926 + if let Some(ref c) = params.cursor { 927 + cursor_val = c.clone(); 928 + query_params.push(("cursor", &cursor_val)); 929 + } 930 + 931 + let response = pds_proxy::admin_xrpc_get::<serde_json::Value>( 932 + pds_url(&state), 933 + admin_password(&state), 934 + "com.atproto.admin.getInviteCodes", 935 + &query_params, 936 + ) 937 + .await; 938 + 939 + let (codes_raw, next_cursor) = match response { 940 + Ok(res) => { 941 + let codes = res["codes"].clone(); 942 + let cursor = res["cursor"].as_str().map(|s| s.to_string()); 943 + (codes, cursor) 944 + } 945 + Err(e) => { 946 + tracing::error!("Failed to get invite codes: {}", e); 947 + (serde_json::json!([]), None) 948 + } 949 + }; 950 + 951 + // Bug 5 fix: post-process codes to compute used_count and remaining 952 + let codes = if let Some(arr) = codes_raw.as_array() { 953 + let processed: Vec<serde_json::Value> = arr 954 + .iter() 955 + .map(|code| { 956 + let mut c = code.clone(); 957 + let available = c["available"].as_i64().unwrap_or(0); 958 + let used_count = c["uses"].as_array().map(|u| u.len() as i64).unwrap_or(0); 959 + let remaining = (available - used_count).max(0); 960 + c["used_count"] = used_count.into(); 961 + c["remaining"] = remaining.into(); 962 + c 963 + }) 964 + .collect(); 965 + serde_json::json!(processed) 966 + } else { 967 + serde_json::json!([]) 968 + }; 969 + 970 + let mut data = serde_json::json!({ 971 + "codes": codes, 972 + "pds_hostname": state.app_config.pds_hostname, 973 + "active_page": "invite_codes", 974 + }); 975 + 976 + // Phase 4: pagination 977 + if let Some(cursor) = next_cursor { 978 + data["next_cursor"] = cursor.into(); 979 + data["has_more"] = true.into(); 980 + } 981 + 982 + if let Some(msg) = params.flash_success { 983 + data["flash_success"] = msg.into(); 984 + } 985 + if let Some(msg) = params.flash_error { 986 + data["flash_error"] = msg.into(); 987 + } 988 + 989 + inject_nav_data(&mut data, &session, &permissions); 990 + 991 + render_template(&state, "admin/invite_codes.hbs", data) 992 + } 993 + 994 + /// POST /admin/invite-codes/create 995 + pub async fn create_invite_code( 996 + State(state): State<AppState>, 997 + Extension(session): Extension<AdminSession>, 998 + Extension(_permissions): Extension<AdminPermissions>, 999 + axum::extract::Form(form): axum::extract::Form<CreateInviteForm>, 1000 + ) -> Response { 1001 + let rbac = match &state.admin_rbac_config { 1002 + Some(r) => r, 1003 + None => return StatusCode::NOT_FOUND.into_response(), 1004 + }; 1005 + 1006 + if !rbac.can_access_endpoint(&session.did, "com.atproto.server.createInviteCode") { 1007 + return flash_redirect("/admin/invite-codes", None, Some("Access denied")); 1008 + } 1009 + 1010 + let use_count = form.use_count.unwrap_or(1).max(1); 1011 + let body = serde_json::json!({ "useCount": use_count }); 1012 + 1013 + match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 1014 + pds_url(&state), 1015 + admin_password(&state), 1016 + "com.atproto.server.createInviteCode", 1017 + &body, 1018 + ) 1019 + .await 1020 + { 1021 + Ok(res) => { 1022 + let code = res["code"].as_str().unwrap_or("unknown"); 1023 + flash_redirect( 1024 + "/admin/invite-codes", 1025 + Some(&format!("Invite code created: {}", code)), 1026 + None, 1027 + ) 1028 + } 1029 + Err(e) => flash_redirect( 1030 + "/admin/invite-codes", 1031 + None, 1032 + Some(&format!("Failed to create invite code: {}", e)), 1033 + ), 1034 + } 1035 + } 1036 + 1037 + /// POST /admin/invite-codes/disable 1038 + pub async fn disable_invite_codes( 1039 + State(state): State<AppState>, 1040 + Extension(session): Extension<AdminSession>, 1041 + Extension(_permissions): Extension<AdminPermissions>, 1042 + axum::extract::Form(form): axum::extract::Form<DisableInviteCodesForm>, 1043 + ) -> Response { 1044 + let rbac = match &state.admin_rbac_config { 1045 + Some(r) => r, 1046 + None => return StatusCode::NOT_FOUND.into_response(), 1047 + }; 1048 + 1049 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.disableInviteCodes") { 1050 + return flash_redirect("/admin/invite-codes", None, Some("Access denied")); 1051 + } 1052 + 1053 + let codes: Vec<String> = form 1054 + .codes 1055 + .split(',') 1056 + .map(|s| s.trim().to_string()) 1057 + .filter(|s| !s.is_empty()) 1058 + .collect(); 1059 + 1060 + let body = serde_json::json!({ 1061 + "codes": codes, 1062 + "accounts": [], 1063 + }); 1064 + 1065 + match pds_proxy::admin_xrpc_post_no_response( 1066 + pds_url(&state), 1067 + admin_password(&state), 1068 + "com.atproto.admin.disableInviteCodes", 1069 + &body, 1070 + ) 1071 + .await 1072 + { 1073 + Ok(()) => flash_redirect("/admin/invite-codes", Some("Invite codes disabled"), None), 1074 + Err(e) => flash_redirect("/admin/invite-codes", None, Some(&format!("Failed: {}", e))), 1075 + } 1076 + } 1077 + 1078 + /// GET /admin/create-account — Form 1079 + pub async fn get_create_account( 1080 + State(state): State<AppState>, 1081 + Extension(session): Extension<AdminSession>, 1082 + Extension(permissions): Extension<AdminPermissions>, 1083 + Query(flash): Query<FlashParams>, 1084 + ) -> Response { 1085 + let rbac = match &state.admin_rbac_config { 1086 + Some(r) => r, 1087 + None => return StatusCode::NOT_FOUND.into_response(), 1088 + }; 1089 + 1090 + if !rbac.can_access_endpoint(&session.did, "com.atproto.server.createAccount") { 1091 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 1092 + } 1093 + 1094 + let mut data = serde_json::json!({ 1095 + "pds_hostname": state.app_config.pds_hostname, 1096 + "active_page": "create_account", 1097 + }); 1098 + 1099 + if let Some(msg) = flash.flash_success { 1100 + data["flash_success"] = msg.into(); 1101 + } 1102 + if let Some(msg) = flash.flash_error { 1103 + data["flash_error"] = msg.into(); 1104 + } 1105 + 1106 + inject_nav_data(&mut data, &session, &permissions); 1107 + 1108 + render_template(&state, "admin/create_account.hbs", data) 1109 + } 1110 + 1111 + /// POST /admin/create-account — Create account 1112 + pub async fn post_create_account( 1113 + State(state): State<AppState>, 1114 + Extension(session): Extension<AdminSession>, 1115 + Extension(permissions): Extension<AdminPermissions>, 1116 + axum::extract::Form(form): axum::extract::Form<CreateAccountForm>, 1117 + ) -> Response { 1118 + let rbac = match &state.admin_rbac_config { 1119 + Some(r) => r, 1120 + None => return StatusCode::NOT_FOUND.into_response(), 1121 + }; 1122 + 1123 + if !rbac.can_access_endpoint(&session.did, "com.atproto.server.createAccount") { 1124 + return flash_redirect("/admin/create-account", None, Some("Access denied")); 1125 + } 1126 + 1127 + let pds = pds_url(&state); 1128 + let password_str = admin_password(&state); 1129 + 1130 + // Step 1: Create invite code 1131 + let invite_body = serde_json::json!({ "useCount": 1 }); 1132 + let invite_res = match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 1133 + pds, 1134 + password_str, 1135 + "com.atproto.server.createInviteCode", 1136 + &invite_body, 1137 + ) 1138 + .await 1139 + { 1140 + Ok(res) => res, 1141 + Err(e) => { 1142 + return flash_redirect( 1143 + "/admin/create-account", 1144 + None, 1145 + Some(&format!("Failed to create invite code: {}", e)), 1146 + ); 1147 + } 1148 + }; 1149 + 1150 + let invite_code = invite_res["code"].as_str().unwrap_or("").to_string(); 1151 + 1152 + // Step 2: Create account 1153 + let account_password = generate_random_password(); 1154 + let account_body = serde_json::json!({ 1155 + "email": form.email, 1156 + "handle": form.handle, 1157 + "password": account_password, 1158 + "inviteCode": invite_code, 1159 + }); 1160 + 1161 + match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 1162 + pds, 1163 + password_str, 1164 + "com.atproto.server.createAccount", 1165 + &account_body, 1166 + ) 1167 + .await 1168 + { 1169 + Ok(res) => { 1170 + // Bug 4 fix: nest data under "created" object so template can use 1171 + // {{created.did}}, {{created.handle}}, etc. 1172 + let mut data = serde_json::json!({ 1173 + "pds_hostname": state.app_config.pds_hostname, 1174 + "active_page": "create_account", 1175 + "created": { 1176 + "did": res["did"].as_str().unwrap_or(""), 1177 + "handle": res["handle"].as_str().unwrap_or(""), 1178 + "email": form.email, 1179 + "password": account_password, 1180 + "inviteCode": invite_code, 1181 + }, 1182 + }); 1183 + 1184 + inject_nav_data(&mut data, &session, &permissions); 1185 + 1186 + render_template(&state, "admin/create_account.hbs", data) 1187 + } 1188 + Err(e) => flash_redirect( 1189 + "/admin/create-account", 1190 + None, 1191 + Some(&format!("Failed to create account: {}", e)), 1192 + ), 1193 + } 1194 + } 1195 + 1196 + /// GET /admin/request-crawl — Request Crawl form (Gap 3) 1197 + pub async fn get_request_crawl( 1198 + State(state): State<AppState>, 1199 + Extension(session): Extension<AdminSession>, 1200 + Extension(permissions): Extension<AdminPermissions>, 1201 + Query(flash): Query<FlashParams>, 1202 + ) -> Response { 1203 + if !permissions.can_request_crawl { 1204 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 1205 + } 1206 + 1207 + let mut data = serde_json::json!({ 1208 + "pds_hostname": state.app_config.pds_hostname, 1209 + "active_page": "request_crawl", 1210 + "default_relay": "bsky.network", 1211 + }); 1212 + 1213 + if let Some(msg) = flash.flash_success { 1214 + data["flash_success"] = msg.into(); 1215 + } 1216 + if let Some(msg) = flash.flash_error { 1217 + data["flash_error"] = msg.into(); 1218 + } 1219 + 1220 + inject_nav_data(&mut data, &session, &permissions); 1221 + 1222 + render_template(&state, "admin/request_crawl.hbs", data) 1223 + } 1224 + 1225 + /// POST /admin/request-crawl — Submit crawl request to relay (Gap 3) 1226 + pub async fn post_request_crawl( 1227 + State(state): State<AppState>, 1228 + Extension(session): Extension<AdminSession>, 1229 + Extension(_permissions): Extension<AdminPermissions>, 1230 + axum::extract::Form(form): axum::extract::Form<RequestCrawlForm>, 1231 + ) -> Response { 1232 + let rbac = match &state.admin_rbac_config { 1233 + Some(r) => r, 1234 + None => return StatusCode::NOT_FOUND.into_response(), 1235 + }; 1236 + 1237 + if !rbac.can_access_endpoint(&session.did, "com.atproto.sync.requestCrawl") { 1238 + return flash_redirect("/admin/request-crawl", None, Some("Access denied")); 1239 + } 1240 + 1241 + let relay_base = format!("https://{}", form.relay_host.trim_end_matches('/')); 1242 + let pds_hostname = &state.app_config.pds_hostname; 1243 + 1244 + let body = serde_json::json!({ 1245 + "hostname": pds_hostname, 1246 + }); 1247 + 1248 + match pds_proxy::public_xrpc_post(&relay_base, "com.atproto.sync.requestCrawl", &body).await { 1249 + Ok(()) => flash_redirect( 1250 + "/admin/request-crawl", 1251 + Some(&format!( 1252 + "Crawl requested from {} for {}", 1253 + form.relay_host, pds_hostname 1254 + )), 1255 + None, 1256 + ), 1257 + Err(e) => flash_redirect( 1258 + "/admin/request-crawl", 1259 + None, 1260 + Some(&format!("Request crawl failed: {}", e)), 1261 + ), 1262 + } 1263 + } 1264 + 1265 + /// POST /admin/logout — Clear session and redirect to login 1266 + pub async fn logout(State(state): State<AppState>, jar: SignedCookieJar) -> Response { 1267 + if let Some(cookie) = jar.get("__gatekeeper_admin_session") { 1268 + let session_id = cookie.value().to_string(); 1269 + let _ = session::delete_session(&state.pds_gatekeeper_pool, &session_id).await; 1270 + } 1271 + 1272 + let mut removal = Cookie::build("__gatekeeper_admin_session") 1273 + .path("/admin/dashboard") 1274 + .build(); 1275 + removal.make_removal(); 1276 + 1277 + let updated_jar = jar.remove(removal); 1278 + 1279 + (updated_jar, Redirect::to("/admin/login")).into_response() 1280 + }
+79
src/admin/session.rs
···
··· 1 + use anyhow::Result; 2 + use chrono::Utc; 3 + use sqlx::SqlitePool; 4 + use uuid::Uuid; 5 + 6 + #[derive(Debug, Clone, sqlx::FromRow)] 7 + pub struct AdminSessionRow { 8 + pub session_id: String, 9 + pub did: String, 10 + pub handle: String, 11 + pub created_at: String, 12 + pub expires_at: String, 13 + } 14 + 15 + /// Creates a new admin session and returns the generated session_id. 16 + pub async fn create_session( 17 + pool: &SqlitePool, 18 + did: &str, 19 + handle: &str, 20 + ttl_hours: u64, 21 + ) -> Result<String> { 22 + let session_id = Uuid::new_v4().to_string(); 23 + let now = Utc::now(); 24 + let created_at = now.to_rfc3339(); 25 + let expires_at = (now + chrono::Duration::hours(ttl_hours as i64)).to_rfc3339(); 26 + 27 + sqlx::query( 28 + "INSERT INTO admin_sessions (session_id, did, handle, created_at, expires_at) VALUES (?, ?, ?, ?, ?)", 29 + ) 30 + .bind(&session_id) 31 + .bind(did) 32 + .bind(handle) 33 + .bind(&created_at) 34 + .bind(&expires_at) 35 + .execute(pool) 36 + .await?; 37 + 38 + Ok(session_id) 39 + } 40 + 41 + /// Looks up a session by ID. Returns None if the session does not exist or has expired. 42 + pub async fn get_session( 43 + pool: &SqlitePool, 44 + session_id: &str, 45 + ) -> Result<Option<AdminSessionRow>> { 46 + let now = Utc::now().to_rfc3339(); 47 + 48 + let row = sqlx::query_as::<_, AdminSessionRow>( 49 + "SELECT session_id, did, handle, created_at, expires_at FROM admin_sessions WHERE session_id = ? AND expires_at > ?", 50 + ) 51 + .bind(session_id) 52 + .bind(&now) 53 + .fetch_optional(pool) 54 + .await?; 55 + 56 + Ok(row) 57 + } 58 + 59 + /// Deletes a session by ID. 60 + pub async fn delete_session(pool: &SqlitePool, session_id: &str) -> Result<()> { 61 + sqlx::query("DELETE FROM admin_sessions WHERE session_id = ?") 62 + .bind(session_id) 63 + .execute(pool) 64 + .await?; 65 + 66 + Ok(()) 67 + } 68 + 69 + /// Deletes all expired sessions and returns the number of rows removed. 70 + pub async fn cleanup_expired_sessions(pool: &SqlitePool) -> Result<u64> { 71 + let now = Utc::now().to_rfc3339(); 72 + 73 + let result = sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= ?") 74 + .bind(&now) 75 + .execute(pool) 76 + .await?; 77 + 78 + Ok(result.rows_affected()) 79 + }
+45
src/admin/static_files.rs
···
··· 1 + use axum::{ 2 + extract::Path, 3 + http::{StatusCode, header}, 4 + response::{IntoResponse, Response}, 5 + }; 6 + 7 + use crate::StaticFiles; 8 + 9 + /// Serve embedded static files at /admin/static/*path 10 + pub async fn serve_static(Path(path): Path<String>) -> Response { 11 + let file = match StaticFiles::get(&path) { 12 + Some(file) => file, 13 + None => return StatusCode::NOT_FOUND.into_response(), 14 + }; 15 + 16 + let mime_type = mime_from_path(&path); 17 + 18 + ( 19 + [ 20 + (header::CONTENT_TYPE, mime_type), 21 + (header::CACHE_CONTROL, cache_control_value()), 22 + ], 23 + file.data.to_vec(), 24 + ) 25 + .into_response() 26 + } 27 + 28 + fn mime_from_path(path: &str) -> &'static str { 29 + match path.rsplit('.').next() { 30 + Some("css") => "text/css; charset=utf-8", 31 + Some("js") => "application/javascript; charset=utf-8", 32 + Some("svg") => "image/svg+xml", 33 + Some("png") => "image/png", 34 + Some("jpg") | Some("jpeg") => "image/jpeg", 35 + _ => "application/octet-stream", 36 + } 37 + } 38 + 39 + fn cache_control_value() -> &'static str { 40 + if cfg!(debug_assertions) { 41 + "no-cache" 42 + } else { 43 + "public, max-age=3600, stale-while-revalidate=86400" 44 + } 45 + }
+154
src/admin/store.rs
···
··· 1 + use jacquard_common::IntoStatic; 2 + use jacquard_common::session::SessionStoreError; 3 + use jacquard_common::types::did::Did; 4 + use jacquard_oauth::authstore::ClientAuthStore; 5 + use jacquard_oauth::session::{AuthRequestData, ClientSessionData}; 6 + use sqlx::SqlitePool; 7 + 8 + fn sqlx_to_session_err(e: sqlx::Error) -> SessionStoreError { 9 + SessionStoreError::Other(Box::new(e)) 10 + } 11 + 12 + #[derive(Clone)] 13 + pub struct SqlAuthStore { 14 + pool: SqlitePool, 15 + } 16 + 17 + impl SqlAuthStore { 18 + pub fn new(pool: SqlitePool) -> Self { 19 + Self { pool } 20 + } 21 + } 22 + 23 + impl ClientAuthStore for SqlAuthStore { 24 + async fn get_session( 25 + &self, 26 + did: &Did<'_>, 27 + session_id: &str, 28 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 29 + let key = format!("{}_{}", did, session_id); 30 + 31 + let row: Option<(String,)> = 32 + sqlx::query_as("SELECT data FROM oauth_client_sessions WHERE session_key = ?") 33 + .bind(&key) 34 + .fetch_optional(&self.pool) 35 + .await 36 + .map_err(sqlx_to_session_err)?; 37 + 38 + match row { 39 + Some((json_data,)) => { 40 + let session: ClientSessionData<'_> = serde_json::from_str(&json_data)?; 41 + Ok(Some(session.into_static())) 42 + } 43 + None => Ok(None), 44 + } 45 + } 46 + 47 + async fn upsert_session( 48 + &self, 49 + session: ClientSessionData<'_>, 50 + ) -> Result<(), SessionStoreError> { 51 + let static_session = session.into_static(); 52 + let did = static_session.account_did.to_string(); 53 + let session_id = static_session.session_id.to_string(); 54 + let key = format!("{}_{}", did, session_id); 55 + let json_data = serde_json::to_string(&static_session)?; 56 + let now = chrono::Utc::now().to_rfc3339(); 57 + 58 + sqlx::query( 59 + "INSERT INTO oauth_client_sessions (session_key, did, session_id, data, created_at, updated_at) 60 + VALUES (?, ?, ?, ?, ?, ?) 61 + ON CONFLICT(session_key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at", 62 + ) 63 + .bind(&key) 64 + .bind(&did) 65 + .bind(&session_id) 66 + .bind(&json_data) 67 + .bind(&now) 68 + .bind(&now) 69 + .execute(&self.pool) 70 + .await 71 + .map_err(sqlx_to_session_err)?; 72 + 73 + Ok(()) 74 + } 75 + 76 + async fn delete_session( 77 + &self, 78 + did: &Did<'_>, 79 + session_id: &str, 80 + ) -> Result<(), SessionStoreError> { 81 + let key = format!("{}_{}", did, session_id); 82 + 83 + sqlx::query("DELETE FROM oauth_client_sessions WHERE session_key = ?") 84 + .bind(&key) 85 + .execute(&self.pool) 86 + .await 87 + .map_err(sqlx_to_session_err)?; 88 + 89 + Ok(()) 90 + } 91 + 92 + async fn get_auth_req_info( 93 + &self, 94 + state: &str, 95 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 96 + let row: Option<(String,)> = 97 + sqlx::query_as("SELECT data FROM oauth_auth_requests WHERE state = ?") 98 + .bind(state) 99 + .fetch_optional(&self.pool) 100 + .await 101 + .map_err(sqlx_to_session_err)?; 102 + 103 + match row { 104 + Some((json_data,)) => { 105 + let auth_req: AuthRequestData<'_> = serde_json::from_str(&json_data)?; 106 + Ok(Some(auth_req.into_static())) 107 + } 108 + None => Ok(None), 109 + } 110 + } 111 + 112 + async fn save_auth_req_info( 113 + &self, 114 + auth_req_info: &AuthRequestData<'_>, 115 + ) -> Result<(), SessionStoreError> { 116 + let static_info = auth_req_info.clone().into_static(); 117 + let state = static_info.state.to_string(); 118 + let json_data = serde_json::to_string(&static_info)?; 119 + let now = chrono::Utc::now().to_rfc3339(); 120 + 121 + sqlx::query("INSERT INTO oauth_auth_requests (state, data, created_at) VALUES (?, ?, ?)") 122 + .bind(&state) 123 + .bind(&json_data) 124 + .bind(&now) 125 + .execute(&self.pool) 126 + .await 127 + .map_err(sqlx_to_session_err)?; 128 + 129 + Ok(()) 130 + } 131 + 132 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 133 + sqlx::query("DELETE FROM oauth_auth_requests WHERE state = ?") 134 + .bind(state) 135 + .execute(&self.pool) 136 + .await 137 + .map_err(sqlx_to_session_err)?; 138 + 139 + Ok(()) 140 + } 141 + } 142 + 143 + /// Delete auth requests older than the given number of minutes. 144 + pub async fn cleanup_stale_auth_requests( 145 + pool: &SqlitePool, 146 + max_age_minutes: i64, 147 + ) -> Result<u64, sqlx::Error> { 148 + let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(max_age_minutes)).to_rfc3339(); 149 + let result = sqlx::query("DELETE FROM oauth_auth_requests WHERE created_at < ?") 150 + .bind(&cutoff) 151 + .execute(pool) 152 + .await?; 153 + Ok(result.rows_affected()) 154 + }
+118 -1
src/main.rs
··· 41 use tracing::{Span, log}; 42 use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 43 44 mod auth; 45 mod gate; 46 pub mod helpers; ··· 60 #[folder = "html_templates"] 61 #[include = "*.hbs"] 62 struct HtmlTemplates; 63 64 /// Mostly the env variables that are used in the app 65 #[derive(Clone, Debug)] ··· 75 pds_service_did: Did<'static>, 76 gate_jwe_key: Vec<u8>, 77 captcha_success_redirects: Vec<String>, 78 } 79 80 impl AppConfig { ··· 134 } 135 }; 136 137 AppConfig { 138 pds_base_url, 139 mailer_from, ··· 147 .expect("PDS_SERVICE_DID is not a valid did or could not infer from PDS_HOSTNAME"), 148 gate_jwe_key, 149 captcha_success_redirects, 150 } 151 } 152 } ··· 161 resolver: Arc<PublicResolver>, 162 handle_cache: auth::HandleCache, 163 app_config: AppConfig, 164 } 165 166 async fn root_handler() -> impl axum::response::IntoResponse { ··· 277 let mut resolver = PublicResolver::default(); 278 resolver = resolver.with_plc_source(plc_source.clone()); 279 280 let state = AppState { 281 account_pool, 282 pds_gatekeeper_pool, ··· 285 template_engine: Engine::from(hbs), 286 resolver: Arc::new(resolver), 287 handle_cache: auth::HandleCache::new(), 288 - app_config: AppConfig::new(), 289 }; 290 291 // Rate limiting ··· 387 "/gate/signup", 388 get(get_gate).post(post_gate.layer(GovernorLayer::new(captcha_governor_conf))), 389 ); 390 } 391 392 let request_logging = env::var("GATEKEEPER_REQUEST_LOGGING")
··· 41 use tracing::{Span, log}; 42 use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 43 44 + mod admin; 45 mod auth; 46 mod gate; 47 pub mod helpers; ··· 61 #[folder = "html_templates"] 62 #[include = "*.hbs"] 63 struct HtmlTemplates; 64 + 65 + #[derive(RustEmbed)] 66 + #[folder = "static"] 67 + pub struct StaticFiles; 68 69 /// Mostly the env variables that are used in the app 70 #[derive(Clone, Debug)] ··· 80 pds_service_did: Did<'static>, 81 gate_jwe_key: Vec<u8>, 82 captcha_success_redirects: Vec<String>, 83 + // Admin portal config 84 + pub pds_admin_password: Option<String>, 85 + pub pds_hostname: String, 86 + pub admin_session_ttl_hours: u64, 87 } 88 89 impl AppConfig { ··· 143 } 144 }; 145 146 + let pds_hostname = env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 147 + 148 + let pds_admin_password = env::var("PDS_ADMIN_PASSWORD").ok(); 149 + 150 + let admin_session_ttl_hours = env::var("GATEKEEPER_ADMIN_SESSION_TTL_HOURS") 151 + .ok() 152 + .and_then(|v| v.parse().ok()) 153 + .unwrap_or(24u64); 154 + 155 AppConfig { 156 pds_base_url, 157 mailer_from, ··· 165 .expect("PDS_SERVICE_DID is not a valid did or could not infer from PDS_HOSTNAME"), 166 gate_jwe_key, 167 captcha_success_redirects, 168 + pds_admin_password, 169 + pds_hostname, 170 + admin_session_ttl_hours, 171 } 172 } 173 } ··· 182 resolver: Arc<PublicResolver>, 183 handle_cache: auth::HandleCache, 184 app_config: AppConfig, 185 + // Admin portal 186 + admin_rbac_config: Option<Arc<admin::rbac::RbacConfig>>, 187 + admin_oauth_client: Option<Arc<admin::oauth::AdminOAuthClient>>, 188 + cookie_key: axum_extra::extract::cookie::Key, 189 + } 190 + 191 + impl axum::extract::FromRef<AppState> for axum_extra::extract::cookie::Key { 192 + fn from_ref(state: &AppState) -> Self { 193 + state.cookie_key.clone() 194 + } 195 } 196 197 async fn root_handler() -> impl axum::response::IntoResponse { ··· 308 let mut resolver = PublicResolver::default(); 309 resolver = resolver.with_plc_source(plc_source.clone()); 310 311 + let app_config = AppConfig::new(); 312 + 313 + // Admin portal setup (opt-in via GATEKEEPER_ADMIN_RBAC_CONFIG) 314 + let admin_rbac_config = env::var("GATEKEEPER_ADMIN_RBAC_CONFIG").ok().map(|path| { 315 + let config = admin::rbac::RbacConfig::load_from_file(&path) 316 + .unwrap_or_else(|e| panic!("Failed to load RBAC config from {}: {}", path, e)); 317 + log::info!( 318 + "Loaded admin RBAC config from {} ({} members)", 319 + path, 320 + config.members.len() 321 + ); 322 + Arc::new(config) 323 + }); 324 + 325 + let admin_oauth_client = if admin_rbac_config.is_some() { 326 + match admin::oauth::init_oauth_client(&app_config.pds_hostname, pds_gatekeeper_pool.clone()) 327 + { 328 + Ok(client) => { 329 + log::info!( 330 + "Admin OAuth client initialized for {}", 331 + app_config.pds_hostname 332 + ); 333 + Some(Arc::new(client)) 334 + } 335 + Err(e) => { 336 + log::error!( 337 + "Failed to initialize admin OAuth client: {}. Admin portal will be disabled.", 338 + e 339 + ); 340 + None 341 + } 342 + } 343 + } else { 344 + None 345 + }; 346 + 347 + // Cookie signing key for admin sessions 348 + let cookie_key = env::var("GATEKEEPER_ADMIN_COOKIE_SECRET") 349 + .ok() 350 + .and_then(|hex_str| hex::decode(hex_str).ok()) 351 + .unwrap_or_else(|| app_config.gate_jwe_key.clone()); 352 + let cookie_key = { 353 + // Key::from requires at least 64 bytes; derive by repeating if needed 354 + let mut key_bytes = cookie_key.clone(); 355 + while key_bytes.len() < 64 { 356 + key_bytes.extend_from_slice(&cookie_key); 357 + } 358 + axum_extra::extract::cookie::Key::from(&key_bytes[..64]) 359 + }; 360 + 361 let state = AppState { 362 account_pool, 363 pds_gatekeeper_pool, ··· 366 template_engine: Engine::from(hbs), 367 resolver: Arc::new(resolver), 368 handle_cache: auth::HandleCache::new(), 369 + app_config, 370 + admin_rbac_config, 371 + admin_oauth_client, 372 + cookie_key, 373 }; 374 375 // Rate limiting ··· 471 "/gate/signup", 472 get(get_gate).post(post_gate.layer(GovernorLayer::new(captcha_governor_conf))), 473 ); 474 + } 475 + 476 + // Mount admin portal if RBAC config is loaded 477 + if state.admin_rbac_config.is_some() { 478 + let admin_router = admin::router(state.clone()); 479 + app = app.nest("/admin", admin_router); 480 + log::info!("Admin portal mounted at /admin/"); 481 + } 482 + 483 + // Background cleanup for admin sessions 484 + let admin_enabled = state.admin_rbac_config.is_some(); 485 + if admin_enabled { 486 + let admin_session_ttl_in_mins = state.app_config.admin_session_ttl_hours * 60; 487 + let cleanup_pool = state.pds_gatekeeper_pool.clone(); 488 + tokio::spawn(async move { 489 + let mut interval = tokio::time::interval(Duration::from_secs(300)); 490 + loop { 491 + interval.tick().await; 492 + if admin_enabled { 493 + if let Err(e) = admin::session::cleanup_expired_sessions(&cleanup_pool).await { 494 + tracing::error!("Failed to cleanup expired admin sessions: {}", e); 495 + } 496 + if let Err(e) = admin::store::cleanup_stale_auth_requests( 497 + &cleanup_pool, 498 + admin_session_ttl_in_mins as i64, 499 + ) 500 + .await 501 + { 502 + tracing::error!("Failed to cleanup stale OAuth auth requests: {}", e); 503 + } 504 + } 505 + } 506 + }); 507 } 508 509 let request_logging = env::var("GATEKEEPER_REQUEST_LOGGING")
+991
static/css/admin.css
···
··· 1 + :root, 2 + :root.light-mode { 3 + --brand-color: rgb(16, 131, 254); 4 + --primary-color: rgb(7, 10, 13); 5 + --secondary-color: rgb(66, 86, 108); 6 + --bg-primary-color: rgb(255, 255, 255); 7 + --bg-secondary-color: rgb(240, 242, 245); 8 + --border-color: rgb(220, 225, 230); 9 + --danger-color: rgb(220, 38, 38); 10 + --success-color: rgb(22, 163, 74); 11 + --warning-color: rgb(234, 179, 8); 12 + --table-stripe: rgba(0, 0, 0, 0.02); 13 + } 14 + 15 + @media (prefers-color-scheme: dark) { 16 + :root { 17 + --brand-color: rgb(16, 131, 254); 18 + --primary-color: rgb(255, 255, 255); 19 + --secondary-color: rgb(133, 152, 173); 20 + --bg-primary-color: rgb(7, 10, 13); 21 + --bg-secondary-color: rgb(13, 18, 23); 22 + --border-color: rgb(40, 45, 55); 23 + --table-stripe: rgba(255, 255, 255, 0.02); 24 + } 25 + } 26 + 27 + :root.dark-mode { 28 + --brand-color: rgb(16, 131, 254); 29 + --primary-color: rgb(255, 255, 255); 30 + --secondary-color: rgb(133, 152, 173); 31 + --bg-primary-color: rgb(7, 10, 13); 32 + --bg-secondary-color: rgb(13, 18, 23); 33 + --border-color: rgb(40, 45, 55); 34 + --table-stripe: rgba(255, 255, 255, 0.02); 35 + } 36 + 37 + /* --- Reset & Base ------------------------------------------- */ 38 + 39 + * { 40 + margin: 0; 41 + padding: 0; 42 + box-sizing: border-box; 43 + } 44 + 45 + body { 46 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 47 + background: var(--bg-secondary-color); 48 + color: var(--primary-color); 49 + text-rendering: optimizeLegibility; 50 + -webkit-font-smoothing: antialiased; 51 + } 52 + 53 + /* --- Layout: Sidebar + Main --------------------------------- */ 54 + 55 + .layout { 56 + display: flex; 57 + min-height: 100vh; 58 + } 59 + 60 + .sidebar { 61 + width: 220px; 62 + background: var(--bg-primary-color); 63 + border-right: 1px solid var(--border-color); 64 + padding: 20px 0; 65 + position: fixed; 66 + top: 0; 67 + left: 0; 68 + bottom: 0; 69 + overflow-y: auto; 70 + display: flex; 71 + flex-direction: column; 72 + } 73 + 74 + .sidebar-title { 75 + font-size: 0.8125rem; 76 + font-weight: 700; 77 + padding: 0 20px; 78 + margin-bottom: 4px; 79 + white-space: nowrap; 80 + overflow: hidden; 81 + text-overflow: ellipsis; 82 + } 83 + 84 + .sidebar-subtitle { 85 + font-size: 0.6875rem; 86 + color: var(--secondary-color); 87 + padding: 0 20px; 88 + margin-bottom: 20px; 89 + } 90 + 91 + .sidebar nav { 92 + flex: 1; 93 + } 94 + 95 + .sidebar nav a { 96 + display: block; 97 + padding: 8px 20px; 98 + font-size: 0.8125rem; 99 + color: var(--secondary-color); 100 + text-decoration: none; 101 + transition: background 0.1s, color 0.1s; 102 + } 103 + 104 + .sidebar nav a:hover { 105 + background: var(--bg-secondary-color); 106 + color: var(--primary-color); 107 + } 108 + 109 + .sidebar nav a.active { 110 + color: var(--brand-color); 111 + font-weight: 500; 112 + } 113 + 114 + .sidebar-footer { 115 + padding: 16px 20px 0; 116 + border-top: 1px solid var(--border-color); 117 + margin-top: 16px; 118 + } 119 + 120 + .sidebar-footer .session-info { 121 + font-size: 0.75rem; 122 + color: var(--secondary-color); 123 + margin-bottom: 8px; 124 + } 125 + 126 + .sidebar-footer form { 127 + display: inline; 128 + } 129 + 130 + .sidebar-footer button { 131 + background: none; 132 + border: none; 133 + font-size: 0.75rem; 134 + color: var(--secondary-color); 135 + cursor: pointer; 136 + padding: 0; 137 + text-decoration: underline; 138 + } 139 + 140 + .sidebar-footer button:hover { 141 + color: var(--primary-color); 142 + } 143 + 144 + .main { 145 + margin-left: 220px; 146 + flex: 1; 147 + padding: 32px; 148 + max-width: 960px; 149 + } 150 + 151 + /* --- Common Components -------------------------------------- */ 152 + 153 + .page-title { 154 + font-size: 1.5rem; 155 + font-weight: 700; 156 + margin-bottom: 24px; 157 + } 158 + 159 + .page-subtitle { 160 + font-size: 0.8125rem; 161 + color: var(--secondary-color); 162 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 163 + margin-top: -20px; 164 + margin-bottom: 24px; 165 + word-break: break-all; 166 + } 167 + 168 + .page-description { 169 + font-size: 0.875rem; 170 + color: var(--secondary-color); 171 + margin-top: -16px; 172 + margin-bottom: 24px; 173 + } 174 + 175 + .flash-success { 176 + background: rgba(22, 163, 74, 0.1); 177 + color: var(--success-color); 178 + border: 1px solid rgba(22, 163, 74, 0.2); 179 + border-radius: 8px; 180 + padding: 10px 14px; 181 + font-size: 0.875rem; 182 + margin-bottom: 20px; 183 + } 184 + 185 + .flash-error { 186 + background: rgba(220, 38, 38, 0.1); 187 + color: var(--danger-color); 188 + border: 1px solid rgba(220, 38, 38, 0.2); 189 + border-radius: 8px; 190 + padding: 10px 14px; 191 + font-size: 0.875rem; 192 + margin-bottom: 20px; 193 + } 194 + 195 + /* --- Buttons ------------------------------------------------ */ 196 + 197 + .btn { 198 + display: inline-flex; 199 + align-items: center; 200 + justify-content: center; 201 + padding: 10px 20px; 202 + font-size: 0.875rem; 203 + font-weight: 500; 204 + border: none; 205 + border-radius: 8px; 206 + cursor: pointer; 207 + transition: opacity 0.15s; 208 + text-decoration: none; 209 + } 210 + 211 + .btn:hover { 212 + opacity: 0.85; 213 + } 214 + 215 + .btn-primary { 216 + background: var(--brand-color); 217 + color: #fff; 218 + } 219 + 220 + .btn-danger { 221 + background: var(--danger-color); 222 + color: #fff; 223 + border-color: var(--danger-color); 224 + } 225 + 226 + .btn-warning { 227 + background: var(--warning-color); 228 + color: #000; 229 + border-color: var(--warning-color); 230 + } 231 + 232 + .btn-small { 233 + padding: 6px 12px; 234 + font-size: 0.75rem; 235 + } 236 + 237 + .btn-outline-danger { 238 + background: transparent; 239 + color: var(--danger-color); 240 + border: 1px solid var(--danger-color); 241 + } 242 + 243 + /* --- Detail Sections ---------------------------------------- */ 244 + 245 + .detail-section { 246 + background: var(--bg-primary-color); 247 + border: 1px solid var(--border-color); 248 + border-radius: 10px; 249 + padding: 20px; 250 + margin-bottom: 16px; 251 + } 252 + 253 + .detail-section h3 { 254 + font-size: 0.875rem; 255 + font-weight: 600; 256 + margin-bottom: 12px; 257 + } 258 + 259 + .detail-row { 260 + display: flex; 261 + justify-content: space-between; 262 + align-items: center; 263 + padding: 8px 0; 264 + font-size: 0.8125rem; 265 + border-bottom: 1px solid var(--border-color); 266 + } 267 + 268 + .detail-row:last-child { 269 + border-bottom: none; 270 + } 271 + 272 + .detail-row .label { 273 + color: var(--secondary-color); 274 + flex-shrink: 0; 275 + } 276 + 277 + .detail-row .value { 278 + font-weight: 500; 279 + word-break: break-all; 280 + text-align: right; 281 + max-width: 65%; 282 + } 283 + 284 + .detail-row .value a { 285 + color: var(--brand-color); 286 + text-decoration: none; 287 + } 288 + 289 + .detail-row .value a:hover { 290 + text-decoration: underline; 291 + } 292 + 293 + /* --- Tables ------------------------------------------------- */ 294 + 295 + .table-container { 296 + background: var(--bg-primary-color); 297 + border: 1px solid var(--border-color); 298 + border-radius: 10px; 299 + overflow: hidden; 300 + } 301 + 302 + table { 303 + width: 100%; 304 + border-collapse: collapse; 305 + } 306 + 307 + thead th { 308 + text-align: left; 309 + padding: 12px 16px; 310 + font-size: 0.75rem; 311 + font-weight: 600; 312 + color: var(--secondary-color); 313 + text-transform: uppercase; 314 + letter-spacing: 0.5px; 315 + border-bottom: 1px solid var(--border-color); 316 + } 317 + 318 + tbody tr { 319 + border-bottom: 1px solid var(--border-color); 320 + } 321 + 322 + tbody tr:last-child { 323 + border-bottom: none; 324 + } 325 + 326 + tbody tr:nth-child(even) { 327 + background: var(--table-stripe); 328 + } 329 + 330 + tbody td { 331 + padding: 10px 16px; 332 + font-size: 0.8125rem; 333 + } 334 + 335 + tbody td a { 336 + color: var(--brand-color); 337 + text-decoration: none; 338 + } 339 + 340 + tbody td a:hover { 341 + text-decoration: underline; 342 + } 343 + 344 + /* --- Badges ------------------------------------------------- */ 345 + 346 + .badge { 347 + display: inline-block; 348 + padding: 2px 8px; 349 + border-radius: 4px; 350 + font-size: 0.75rem; 351 + font-weight: 500; 352 + } 353 + 354 + .badge-success { 355 + background: rgba(22, 163, 74, 0.1); 356 + color: var(--success-color); 357 + } 358 + 359 + .badge-danger { 360 + background: rgba(220, 38, 38, 0.1); 361 + color: var(--danger-color); 362 + } 363 + 364 + .badge-warning { 365 + background: rgba(234, 179, 8, 0.1); 366 + color: var(--warning-color); 367 + } 368 + 369 + /* --- Forms -------------------------------------------------- */ 370 + 371 + .form-card { 372 + background: var(--bg-primary-color); 373 + border: 1px solid var(--border-color); 374 + border-radius: 10px; 375 + padding: 24px; 376 + max-width: 480px; 377 + } 378 + 379 + .form-group { 380 + margin-bottom: 16px; 381 + } 382 + 383 + .form-group label { 384 + display: block; 385 + font-size: 0.8125rem; 386 + font-weight: 500; 387 + margin-bottom: 6px; 388 + color: var(--primary-color); 389 + } 390 + 391 + .form-group input { 392 + width: 100%; 393 + padding: 10px 12px; 394 + font-size: 0.875rem; 395 + border: 1px solid var(--border-color); 396 + border-radius: 8px; 397 + background: var(--bg-primary-color); 398 + color: var(--primary-color); 399 + outline: none; 400 + transition: border-color 0.15s; 401 + } 402 + 403 + .form-group input:focus { 404 + border-color: var(--brand-color); 405 + } 406 + 407 + .form-group .hint { 408 + font-size: 0.75rem; 409 + color: var(--secondary-color); 410 + margin-top: 4px; 411 + } 412 + 413 + .search-form { 414 + display: flex; 415 + gap: 8px; 416 + margin-bottom: 24px; 417 + } 418 + 419 + .search-form input { 420 + flex: 1; 421 + padding: 10px 12px; 422 + font-size: 0.875rem; 423 + border: 1px solid var(--border-color); 424 + border-radius: 8px; 425 + background: var(--bg-primary-color); 426 + color: var(--primary-color); 427 + outline: none; 428 + } 429 + 430 + .search-form input:focus { 431 + border-color: var(--brand-color); 432 + } 433 + 434 + .create-form { 435 + display: flex; 436 + gap: 8px; 437 + align-items: flex-end; 438 + margin-bottom: 24px; 439 + } 440 + 441 + .create-form .form-group { 442 + display: flex; 443 + flex-direction: column; 444 + gap: 4px; 445 + } 446 + 447 + .create-form label { 448 + font-size: 0.75rem; 449 + font-weight: 500; 450 + color: var(--secondary-color); 451 + } 452 + 453 + .create-form input[type="number"] { 454 + padding: 10px 12px; 455 + font-size: 0.875rem; 456 + border: 1px solid var(--border-color); 457 + border-radius: 8px; 458 + background: var(--bg-primary-color); 459 + color: var(--primary-color); 460 + outline: none; 461 + width: 120px; 462 + } 463 + 464 + .create-form input[type="number"]:focus { 465 + border-color: var(--brand-color); 466 + } 467 + 468 + /* --- Utility ------------------------------------------------ */ 469 + 470 + .mono-text { 471 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 472 + font-size: 0.75rem; 473 + } 474 + 475 + .inline-form { 476 + display: inline; 477 + } 478 + 479 + .empty-state { 480 + text-align: center; 481 + padding: 40px 20px; 482 + color: var(--secondary-color); 483 + font-size: 0.875rem; 484 + } 485 + 486 + .load-more { 487 + text-align: center; 488 + padding: 16px; 489 + } 490 + 491 + .load-more a { 492 + color: var(--brand-color); 493 + text-decoration: none; 494 + font-size: 0.875rem; 495 + font-weight: 500; 496 + } 497 + 498 + .load-more a:hover { 499 + text-decoration: underline; 500 + } 501 + 502 + .pagination { 503 + display: flex; 504 + align-items: center; 505 + justify-content: center; 506 + gap: 16px; 507 + padding: 16px 0; 508 + } 509 + 510 + .pagination-info { 511 + font-size: 0.8125rem; 512 + color: var(--secondary-color); 513 + } 514 + 515 + /* --- Dashboard: Cards --------------------------------------- */ 516 + 517 + .cards { 518 + display: grid; 519 + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); 520 + gap: 16px; 521 + margin-bottom: 32px; 522 + } 523 + 524 + .card { 525 + background: var(--bg-primary-color); 526 + border: 1px solid var(--border-color); 527 + border-radius: 10px; 528 + padding: 20px; 529 + } 530 + 531 + .card-label { 532 + font-size: 0.75rem; 533 + font-weight: 500; 534 + color: var(--secondary-color); 535 + text-transform: uppercase; 536 + letter-spacing: 0.5px; 537 + margin-bottom: 6px; 538 + } 539 + 540 + .card-value { 541 + font-size: 1.25rem; 542 + font-weight: 600; 543 + } 544 + 545 + .card-value.success { 546 + color: var(--success-color); 547 + } 548 + 549 + .card-value.danger { 550 + color: var(--danger-color); 551 + } 552 + 553 + /* --- Accounts: DID Cell ------------------------------------- */ 554 + 555 + .did-cell { 556 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 557 + font-size: 0.75rem; 558 + color: var(--secondary-color); 559 + } 560 + 561 + /* --- Account Detail ----------------------------------------- */ 562 + 563 + .actions { 564 + display: flex; 565 + flex-wrap: wrap; 566 + gap: 8px; 567 + margin-top: 8px; 568 + } 569 + 570 + .actions form { 571 + display: inline; 572 + } 573 + 574 + .actions .btn { 575 + padding: 8px 16px; 576 + font-size: 0.8125rem; 577 + border: 1px solid var(--border-color); 578 + background: var(--bg-primary-color); 579 + color: var(--primary-color); 580 + } 581 + 582 + .actions .btn-primary { 583 + background: var(--brand-color); 584 + color: #fff; 585 + border-color: var(--brand-color); 586 + } 587 + 588 + .actions .btn-danger { 589 + background: var(--danger-color); 590 + color: #fff; 591 + border-color: var(--danger-color); 592 + } 593 + 594 + .actions .btn-warning { 595 + background: var(--warning-color); 596 + color: #000; 597 + border-color: var(--warning-color); 598 + } 599 + 600 + .password-box { 601 + background: rgba(22, 163, 74, 0.08); 602 + border: 1px solid rgba(22, 163, 74, 0.2); 603 + border-radius: 10px; 604 + padding: 16px 20px; 605 + margin-bottom: 16px; 606 + } 607 + 608 + .password-box .pw-label { 609 + font-size: 0.75rem; 610 + font-weight: 600; 611 + color: var(--success-color); 612 + margin-bottom: 6px; 613 + } 614 + 615 + .password-box .pw-value { 616 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 617 + font-size: 1rem; 618 + font-weight: 600; 619 + user-select: all; 620 + } 621 + 622 + .back-link { 623 + display: inline-block; 624 + color: var(--brand-color); 625 + text-decoration: none; 626 + font-size: 0.8125rem; 627 + margin-bottom: 16px; 628 + } 629 + 630 + .back-link:hover { 631 + text-decoration: underline; 632 + } 633 + 634 + .collection-list { 635 + max-height: 200px; 636 + overflow-y: auto; 637 + padding: 8px 0; 638 + } 639 + 640 + .collection-item { 641 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 642 + font-size: 0.75rem; 643 + padding: 4px 0; 644 + color: var(--secondary-color); 645 + border-bottom: 1px solid var(--border-color); 646 + } 647 + 648 + .collection-item:last-child { 649 + border-bottom: none; 650 + } 651 + 652 + .threat-sig { 653 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 654 + font-size: 0.75rem; 655 + padding: 4px 0; 656 + color: var(--secondary-color); 657 + } 658 + 659 + /* --- Create Account ----------------------------------------- */ 660 + 661 + .success-card { 662 + background: var(--bg-primary-color); 663 + border: 1px solid var(--border-color); 664 + border-radius: 10px; 665 + padding: 24px; 666 + max-width: 480px; 667 + } 668 + 669 + .success-card h3 { 670 + font-size: 1rem; 671 + font-weight: 600; 672 + color: var(--success-color); 673 + margin-bottom: 16px; 674 + } 675 + 676 + .success-card .detail-row .value { 677 + display: flex; 678 + align-items: center; 679 + gap: 6px; 680 + } 681 + 682 + .password-highlight { 683 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 684 + background: rgba(22, 163, 74, 0.08); 685 + padding: 2px 6px; 686 + border-radius: 4px; 687 + user-select: all; 688 + } 689 + 690 + .copy-btn { 691 + background: none; 692 + border: 1px solid var(--border-color); 693 + border-radius: 4px; 694 + padding: 2px 6px; 695 + font-size: 0.6875rem; 696 + cursor: pointer; 697 + color: var(--secondary-color); 698 + transition: color 0.15s, border-color 0.15s; 699 + white-space: nowrap; 700 + } 701 + 702 + .copy-btn:hover { 703 + color: var(--primary-color); 704 + border-color: var(--primary-color); 705 + } 706 + 707 + /* --- Invite Codes ------------------------------------------- */ 708 + 709 + .code-box { 710 + background: rgba(22, 163, 74, 0.08); 711 + border: 1px solid rgba(22, 163, 74, 0.2); 712 + border-radius: 10px; 713 + padding: 16px 20px; 714 + margin-bottom: 24px; 715 + } 716 + 717 + .code-box .code-label { 718 + font-size: 0.75rem; 719 + font-weight: 600; 720 + color: var(--success-color); 721 + margin-bottom: 6px; 722 + } 723 + 724 + .code-box .code-value { 725 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 726 + font-size: 1rem; 727 + font-weight: 600; 728 + user-select: all; 729 + } 730 + 731 + .code-cell { 732 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 733 + font-size: 0.75rem; 734 + } 735 + 736 + /* --- Login Page --------------------------------------------- */ 737 + 738 + .login-card { 739 + background: var(--bg-primary-color); 740 + border: 1px solid var(--border-color); 741 + border-radius: 12px; 742 + padding: 40px; 743 + width: 100%; 744 + max-width: 400px; 745 + margin: 20px; 746 + } 747 + 748 + .login-title { 749 + font-size: 1.5rem; 750 + font-weight: 700; 751 + text-align: center; 752 + margin-bottom: 4px; 753 + } 754 + 755 + .login-subtitle { 756 + font-size: 0.875rem; 757 + color: var(--secondary-color); 758 + text-align: center; 759 + margin-bottom: 32px; 760 + } 761 + 762 + .login-card .form-group { 763 + margin-bottom: 20px; 764 + } 765 + 766 + .login-card .form-group label { 767 + font-size: 0.875rem; 768 + } 769 + 770 + .login-card .btn-primary { 771 + width: 100%; 772 + } 773 + 774 + .error-msg { 775 + background: rgba(220, 38, 38, 0.1); 776 + color: var(--danger-color); 777 + border: 1px solid rgba(220, 38, 38, 0.2); 778 + border-radius: 8px; 779 + padding: 10px 14px; 780 + font-size: 0.875rem; 781 + margin-bottom: 20px; 782 + } 783 + 784 + /* --- Error Page ---------------------------------------------- */ 785 + 786 + .centered { 787 + display: flex; 788 + align-items: center; 789 + justify-content: center; 790 + min-height: 100vh; 791 + } 792 + 793 + .error-card { 794 + background: var(--bg-primary-color); 795 + border: 1px solid var(--border-color); 796 + border-radius: 12px; 797 + padding: 40px; 798 + text-align: center; 799 + max-width: 480px; 800 + width: 100%; 801 + margin: 20px; 802 + } 803 + 804 + .error-card--inset { 805 + margin: 60px auto; 806 + } 807 + 808 + .error-icon { 809 + font-size: 2.5rem; 810 + margin-bottom: 16px; 811 + color: var(--danger-color); 812 + } 813 + 814 + .error-title { 815 + font-size: 1.25rem; 816 + font-weight: 700; 817 + margin-bottom: 8px; 818 + } 819 + 820 + .error-message { 821 + font-size: 0.875rem; 822 + color: var(--secondary-color); 823 + margin-bottom: 24px; 824 + line-height: 1.5; 825 + } 826 + 827 + .error-link { 828 + display: inline-flex; 829 + align-items: center; 830 + justify-content: center; 831 + padding: 10px 20px; 832 + font-size: 0.875rem; 833 + font-weight: 500; 834 + border: none; 835 + border-radius: 8px; 836 + cursor: pointer; 837 + text-decoration: none; 838 + background: var(--brand-color); 839 + color: #fff; 840 + transition: opacity 0.15s; 841 + } 842 + 843 + .error-link:hover { 844 + opacity: 0.85; 845 + } 846 + 847 + /* --- Mobile: Topbar + Hamburger ----------------------------- */ 848 + 849 + .topbar { 850 + display: none; 851 + position: fixed; 852 + top: 0; 853 + left: 0; 854 + right: 0; 855 + height: 52px; 856 + background: var(--bg-primary-color); 857 + border-bottom: 1px solid var(--border-color); 858 + align-items: center; 859 + padding: 0 16px; 860 + gap: 12px; 861 + z-index: 100; 862 + } 863 + 864 + .topbar-title { 865 + font-size: 0.875rem; 866 + font-weight: 600; 867 + overflow: hidden; 868 + text-overflow: ellipsis; 869 + white-space: nowrap; 870 + flex: 1; 871 + } 872 + 873 + .hamburger { 874 + background: none; 875 + border: none; 876 + cursor: pointer; 877 + padding: 4px; 878 + display: flex; 879 + flex-direction: column; 880 + gap: 5px; 881 + flex-shrink: 0; 882 + } 883 + 884 + .hamburger span { 885 + display: block; 886 + width: 22px; 887 + height: 2px; 888 + background: var(--primary-color); 889 + border-radius: 2px; 890 + transition: transform 0.25s ease, opacity 0.25s ease; 891 + } 892 + 893 + body.sidebar-open .hamburger span:nth-child(1) { 894 + transform: translateY(7px) rotate(45deg); 895 + } 896 + 897 + body.sidebar-open .hamburger span:nth-child(2) { 898 + opacity: 0; 899 + } 900 + 901 + body.sidebar-open .hamburger span:nth-child(3) { 902 + transform: translateY(-7px) rotate(-45deg); 903 + } 904 + 905 + .sidebar-overlay { 906 + display: none; 907 + position: fixed; 908 + inset: 0; 909 + background: rgba(0, 0, 0, 0.4); 910 + z-index: 149; 911 + } 912 + 913 + /* --- Responsive --------------------------------------------- */ 914 + 915 + @media (max-width: 768px) { 916 + .topbar { 917 + display: flex; 918 + } 919 + 920 + .sidebar { 921 + transform: translateX(-220px); 922 + transition: transform 0.25s ease; 923 + z-index: 150; 924 + } 925 + 926 + body.sidebar-open .sidebar { 927 + transform: translateX(0); 928 + } 929 + 930 + body.sidebar-open .sidebar-overlay { 931 + display: block; 932 + } 933 + 934 + .main { 935 + margin-left: 0; 936 + padding: 24px 16px; 937 + padding-top: 76px; /* 52px topbar + 24px breathing room */ 938 + } 939 + 940 + /* Tables scroll horizontally */ 941 + .table-container { 942 + overflow-x: auto; 943 + -webkit-overflow-scrolling: touch; 944 + } 945 + 946 + /* Cards: single column */ 947 + .cards { 948 + grid-template-columns: 1fr; 949 + } 950 + 951 + /* Form cards: full width */ 952 + .form-card, 953 + .success-card { 954 + max-width: 100%; 955 + } 956 + 957 + /* Search / create forms: stack vertically */ 958 + .search-form, 959 + .create-form { 960 + flex-direction: column; 961 + align-items: stretch; 962 + } 963 + 964 + .create-form input[type="number"] { 965 + width: 100%; 966 + } 967 + 968 + /* Detail rows wrap on narrow screens */ 969 + .detail-row { 970 + flex-wrap: wrap; 971 + gap: 4px; 972 + } 973 + 974 + .detail-row .value { 975 + max-width: 100%; 976 + text-align: left; 977 + } 978 + 979 + /* Actions wrap tightly */ 980 + .actions { 981 + flex-direction: column; 982 + align-items: flex-start; 983 + } 984 + 985 + .actions form, 986 + .actions .btn { 987 + width: 100%; 988 + display: block; 989 + text-align: center; 990 + } 991 + }
+27
static/js/admin.js
···
··· 1 + document.addEventListener('DOMContentLoaded', function () { 2 + var hamburger = document.getElementById('hamburger'); 3 + var overlay = document.getElementById('sidebar-overlay'); 4 + 5 + function openSidebar() { 6 + document.body.classList.add('sidebar-open'); 7 + } 8 + 9 + function closeSidebar() { 10 + document.body.classList.remove('sidebar-open'); 11 + } 12 + 13 + if (hamburger) { 14 + hamburger.addEventListener('click', function () { 15 + document.body.classList.toggle('sidebar-open'); 16 + }); 17 + } 18 + 19 + if (overlay) { 20 + overlay.addEventListener('click', closeSidebar); 21 + } 22 + 23 + // Close sidebar when a nav link is tapped on mobile 24 + document.querySelectorAll('.sidebar nav a').forEach(function (link) { 25 + link.addEventListener('click', closeSidebar); 26 + }); 27 + });

History

1 round 0 comments
sign up or login to add to the discussion
14 commits
expand
Adding an RBAC functionality for Admins.
feat(admin): fix 7 bugs, add RBAC enforcement, request-crawl, and polish
down graded to a public client
persistence sessions
wip of template partials
moved to a css file
mobile side bar
direct look up
accounts pagination
wip
combined search/accounts
removed search for now since it doesnt look to work
fixed back button
get rid of send_email funtionality
no conflicts, ready to merge
expand 0 comments