Microservice to bring 2FA to self hosted PDSes

RBAC + UI for Admins #12

This needs testing

  1. I haven't tested this just yet. I'd need to stand up a proper PDS + RBAC yaml to formally test this. I'm open to ideas on how to best test!

What this has#

  • Heavily inspired admin UX from pds-admin
  • OAuth login functionality using jacquard
  • Support for RBAC for admin teams that way PDS_ADMIN_PASSWORD does not become a shared password.
Labels

None yet.

Participants 1
AT URI
at://did:plc:n6jx25m5pr3bndqtmjot62xw/sh.tangled.repo.pull/3mfxiaobt4422
+5002 -6
Diff #0
+203
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.searchAccounts" 91 + - "com.atproto.admin.getSubjectStatus" 92 + - "com.atproto.admin.updateSubjectStatus" 93 + - "com.atproto.admin.sendEmail" 94 + - "com.atproto.admin.getInviteCodes" 95 + 96 + invite-manager: 97 + endpoints: 98 + - "com.atproto.server.createInviteCode" 99 + - "com.atproto.admin.getInviteCodes" 100 + - "com.atproto.admin.disableInviteCodes" 101 + - "com.atproto.admin.enableAccountInvites" 102 + - "com.atproto.admin.disableAccountInvites" 103 + 104 + members: 105 + - did: "did:plc:abcdef1234567890" 106 + roles: 107 + - pds-admin 108 + 109 + - did: "did:plc:bbbbbbbbbbbbbbbb" 110 + roles: 111 + - moderator 112 + - invite-manager 113 + ``` 114 + 115 + ## Available Roles (Reference) 116 + 117 + ### Suggested role templates 118 + 119 + You can make your own roles and teams 120 + 121 + | Role | Description | Endpoints | 122 + |---|---|---| 123 + | `pds-admin` | Full administrative access | `com.atproto.admin.*`, `createInviteCode`, `createAccount` | 124 + | `moderator` | View accounts, manage takedowns, search, send email, view invite codes | `getAccountInfo`, `getAccountInfos`, `searchAccounts`, `getSubjectStatus`, `updateSubjectStatus`, `sendEmail`, `getInviteCodes` | 125 + | `invite-manager` | Manage invite codes and per-account invite permissions | `createInviteCode`, `getInviteCodes`, `disableInviteCodes`, `enableAccountInvites`, `disableAccountInvites` | 126 + 127 + ### All admin XRPC endpoints 128 + 129 + | Endpoint | Description | 130 + |---|---| 131 + | `com.atproto.admin.getAccountInfo` | View single account details | 132 + | `com.atproto.admin.getAccountInfos` | View multiple accounts | 133 + | `com.atproto.admin.searchAccounts` | Search accounts | 134 + | `com.atproto.admin.getSubjectStatus` | Get takedown status | 135 + | `com.atproto.admin.updateSubjectStatus` | Apply or remove takedowns | 136 + | `com.atproto.admin.deleteAccount` | Permanently delete an account | 137 + | `com.atproto.admin.updateAccountPassword` | Reset account password | 138 + | `com.atproto.admin.enableAccountInvites` | Enable invites for an account | 139 + | `com.atproto.admin.disableAccountInvites` | Disable invites for an account | 140 + | `com.atproto.admin.getInviteCodes` | List invite codes | 141 + | `com.atproto.admin.disableInviteCodes` | Disable specific invite codes | 142 + | `com.atproto.admin.sendEmail` | Send email to an account | 143 + | `com.atproto.server.createInviteCode` | Create a new invite code | 144 + | `com.atproto.server.createAccount` | Create a new account | 145 + 146 + ## How It Works 147 + 148 + ### 1. OAuth Login 149 + 150 + 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. 151 + 152 + ### 2. Session Creation 153 + 154 + 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. 155 + 156 + ### 3. Request Flow 157 + 158 + When the user performs an admin action, gatekeeper: 159 + 160 + 1. Validates the session cookie signature and expiration 161 + 2. Looks up the user's DID in the RBAC configuration 162 + 3. Checks whether the user's roles grant access to the target XRPC endpoint 163 + 4. If authorized, proxies the request to the PDS with `Authorization: Basic` using `PDS_ADMIN_PASSWORD` 164 + 5. Returns the PDS response to the user 165 + 166 + ### 4. UI Rendering 167 + 168 + 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. 169 + 170 + ## Environment Variables 171 + 172 + | Variable | Required | Default | Description | 173 + |---|---|---|---| 174 + | `GATEKEEPER_ADMIN_RBAC_CONFIG` | Yes | — | Path to the RBAC YAML configuration file | 175 + | `PDS_ADMIN_PASSWORD` | Yes | — | PDS admin password used for proxied requests | 176 + | `GATEKEEPER_ADMIN_COOKIE_SECRET` | No | Derived from `GATEKEEPER_JWE_KEY` | 32-byte hex key for signing session cookies | 177 + | `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` | No | `24` | Admin session lifetime in hours | 178 + 179 + ## Security Considerations 180 + 181 + - **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. 182 + - **OAuth security**: The OAuth flow uses DPoP binding and PKCE to prevent token interception and replay attacks. 183 + - **Cookie protections**: Session cookies are signed (tamper-proof) and set with `HttpOnly`, `Secure`, and `SameSite=Lax` attributes. 184 + - **Server-side enforcement**: RBAC is enforced in route handlers, not just in template rendering. Manipulating the UI cannot bypass access controls. 185 + - **Session lifecycle**: Sessions expire after a configurable TTL. Expired sessions are cleaned up automatically. 186 + - **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. 187 + 188 + ## Troubleshooting 189 + 190 + **OAuth callback failures** 191 + 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. 192 + 193 + **"Access Denied" after login** 194 + 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. 195 + 196 + **Session expired** 197 + Sessions expire after the configured TTL (default 24 hours). Either increase `GATEKEEPER_ADMIN_SESSION_TTL_HOURS` or log in again. 198 + 199 + **403 on admin action** 200 + 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. 201 + 202 + **Admin portal not appearing** 203 + 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.
+330 -5
Cargo.lock
··· 215 215 ] 216 216 217 217 [[package]] 218 + name = "axum-extra" 219 + version = "0.10.3" 220 + source = "registry+https://github.com/rust-lang/crates.io-index" 221 + checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" 222 + dependencies = [ 223 + "axum", 224 + "axum-core", 225 + "bytes", 226 + "cookie", 227 + "futures-util", 228 + "http", 229 + "http-body", 230 + "http-body-util", 231 + "mime", 232 + "pin-project-lite", 233 + "rustversion", 234 + "serde_core", 235 + "tower-layer", 236 + "tower-service", 237 + "tracing", 238 + ] 239 + 240 + [[package]] 218 241 name = "axum-macros" 219 242 version = "0.5.0" 220 243 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 552 575 version = "0.4.3" 553 576 source = "registry+https://github.com/rust-lang/crates.io-index" 554 577 checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 578 + 579 + [[package]] 580 + name = "cookie" 581 + version = "0.18.1" 582 + source = "registry+https://github.com/rust-lang/crates.io-index" 583 + checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" 584 + dependencies = [ 585 + "base64", 586 + "hmac", 587 + "percent-encoding", 588 + "rand 0.8.5", 589 + "sha2", 590 + "subtle", 591 + "time", 592 + "version_check", 593 + ] 555 594 556 595 [[package]] 557 596 name = "cordyceps" ··· 930 969 checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 931 970 dependencies = [ 932 971 "base16ct", 972 + "base64ct", 933 973 "crypto-bigint", 934 974 "digest", 935 975 "ff", ··· 939 979 "pkcs8", 940 980 "rand_core 0.6.4", 941 981 "sec1", 982 + "serde_json", 983 + "serdect", 942 984 "subtle", 943 985 "zeroize", 944 986 ] ··· 993 1035 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 994 1036 dependencies = [ 995 1037 "libc", 996 - "windows-sys 0.59.0", 1038 + "windows-sys 0.61.2", 997 1039 ] 998 1040 999 1041 [[package]] ··· 1286 1328 "r-efi", 1287 1329 "wasip2", 1288 1330 "wasm-bindgen", 1331 + ] 1332 + 1333 + [[package]] 1334 + name = "getrandom" 1335 + version = "0.4.1" 1336 + source = "registry+https://github.com/rust-lang/crates.io-index" 1337 + checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" 1338 + dependencies = [ 1339 + "cfg-if", 1340 + "libc", 1341 + "r-efi", 1342 + "wasip2", 1343 + "wasip3", 1289 1344 ] 1290 1345 1291 1346 [[package]] ··· 1740 1795 "zerotrie", 1741 1796 "zerovec", 1742 1797 ] 1798 + 1799 + [[package]] 1800 + name = "id-arena" 1801 + version = "2.3.0" 1802 + source = "registry+https://github.com/rust-lang/crates.io-index" 1803 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 1743 1804 1744 1805 [[package]] 1745 1806 name = "ident_case" ··· 1979 2040 ] 1980 2041 1981 2042 [[package]] 2043 + name = "jacquard-oauth" 2044 + version = "0.9.6" 2045 + source = "registry+https://github.com/rust-lang/crates.io-index" 2046 + checksum = "68bf0b0e061d85b09cfa78588dc098918d5b62f539a719165c6a806a1d2c0ef2" 2047 + dependencies = [ 2048 + "base64", 2049 + "bytes", 2050 + "chrono", 2051 + "dashmap", 2052 + "elliptic-curve", 2053 + "http", 2054 + "jacquard-common", 2055 + "jacquard-identity", 2056 + "jose-jwa", 2057 + "jose-jwk", 2058 + "miette", 2059 + "p256", 2060 + "rand 0.8.5", 2061 + "serde", 2062 + "serde_html_form", 2063 + "serde_json", 2064 + "sha2", 2065 + "smol_str", 2066 + "thiserror 2.0.17", 2067 + "tokio", 2068 + "trait-variant", 2069 + "url", 2070 + ] 2071 + 2072 + [[package]] 1982 2073 name = "jobserver" 1983 2074 version = "0.1.34" 1984 2075 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1989 2080 ] 1990 2081 1991 2082 [[package]] 2083 + name = "jose-b64" 2084 + version = "0.1.2" 2085 + source = "registry+https://github.com/rust-lang/crates.io-index" 2086 + checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 2087 + dependencies = [ 2088 + "base64ct", 2089 + "serde", 2090 + "subtle", 2091 + "zeroize", 2092 + ] 2093 + 2094 + [[package]] 2095 + name = "jose-jwa" 2096 + version = "0.1.2" 2097 + source = "registry+https://github.com/rust-lang/crates.io-index" 2098 + checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 2099 + dependencies = [ 2100 + "serde", 2101 + ] 2102 + 2103 + [[package]] 2104 + name = "jose-jwk" 2105 + version = "0.1.2" 2106 + source = "registry+https://github.com/rust-lang/crates.io-index" 2107 + checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 2108 + dependencies = [ 2109 + "jose-b64", 2110 + "jose-jwa", 2111 + "p256", 2112 + "p384", 2113 + "rsa", 2114 + "serde", 2115 + "zeroize", 2116 + ] 2117 + 2118 + [[package]] 1992 2119 name = "josekit" 1993 2120 version = "0.10.3" 1994 2121 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2068 2195 dependencies = [ 2069 2196 "spin 0.9.8", 2070 2197 ] 2198 + 2199 + [[package]] 2200 + name = "leb128fmt" 2201 + version = "0.1.0" 2202 + source = "registry+https://github.com/rust-lang/crates.io-index" 2203 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 2071 2204 2072 2205 [[package]] 2073 2206 name = "lettre" ··· 2349 2482 source = "registry+https://github.com/rust-lang/crates.io-index" 2350 2483 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 2351 2484 dependencies = [ 2352 - "windows-sys 0.59.0", 2485 + "windows-sys 0.61.2", 2353 2486 ] 2354 2487 2355 2488 [[package]] ··· 2506 2639 "elliptic-curve", 2507 2640 "primeorder", 2508 2641 "sha2", 2642 + ] 2643 + 2644 + [[package]] 2645 + name = "p384" 2646 + version = "0.13.1" 2647 + source = "registry+https://github.com/rust-lang/crates.io-index" 2648 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 2649 + dependencies = [ 2650 + "elliptic-curve", 2651 + "primeorder", 2509 2652 ] 2510 2653 2511 2654 [[package]] ··· 2565 2708 "anyhow", 2566 2709 "aws-lc-rs", 2567 2710 "axum", 2711 + "axum-extra", 2568 2712 "axum-template", 2713 + "base64", 2569 2714 "chrono", 2570 2715 "dashmap", 2571 2716 "dotenvy", ··· 2573 2718 "hex", 2574 2719 "html-escape", 2575 2720 "hyper-util", 2721 + "jacquard-api", 2576 2722 "jacquard-common", 2577 2723 "jacquard-identity", 2724 + "jacquard-oauth", 2725 + "jose-jwk", 2578 2726 "josekit", 2579 2727 "jwt-compact", 2580 2728 "lettre", 2581 2729 "multibase", 2730 + "p256", 2582 2731 "rand 0.9.2", 2583 2732 "reqwest", 2584 2733 "rust-embed", ··· 2586 2735 "scrypt", 2587 2736 "serde", 2588 2737 "serde_json", 2738 + "serde_yaml", 2589 2739 "sha2", 2590 2740 "sqlx", 2591 2741 "tokio", ··· 2594 2744 "tower_governor", 2595 2745 "tracing", 2596 2746 "tracing-subscriber", 2747 + "url", 2597 2748 "urlencoding", 2749 + "uuid", 2598 2750 ] 2599 2751 2600 2752 [[package]] ··· 2899 3051 "once_cell", 2900 3052 "socket2", 2901 3053 "tracing", 2902 - "windows-sys 0.59.0", 3054 + "windows-sys 0.60.2", 2903 3055 ] 2904 3056 2905 3057 [[package]] ··· 3331 3483 "der", 3332 3484 "generic-array", 3333 3485 "pkcs8", 3486 + "serdect", 3334 3487 "subtle", 3335 3488 "zeroize", 3336 3489 ] ··· 3510 3663 ] 3511 3664 3512 3665 [[package]] 3666 + name = "serde_yaml" 3667 + version = "0.9.34+deprecated" 3668 + source = "registry+https://github.com/rust-lang/crates.io-index" 3669 + checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 3670 + dependencies = [ 3671 + "indexmap 2.12.1", 3672 + "itoa", 3673 + "ryu", 3674 + "serde", 3675 + "unsafe-libyaml", 3676 + ] 3677 + 3678 + [[package]] 3679 + name = "serdect" 3680 + version = "0.2.0" 3681 + source = "registry+https://github.com/rust-lang/crates.io-index" 3682 + checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" 3683 + dependencies = [ 3684 + "base16ct", 3685 + "serde", 3686 + ] 3687 + 3688 + [[package]] 3513 3689 name = "sha1" 3514 3690 version = "0.10.6" 3515 3691 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4370 4546 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 4371 4547 4372 4548 [[package]] 4549 + name = "unsafe-libyaml" 4550 + version = "0.2.11" 4551 + source = "registry+https://github.com/rust-lang/crates.io-index" 4552 + checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 4553 + 4554 + [[package]] 4373 4555 name = "unsigned-varint" 4374 4556 version = "0.8.0" 4375 4557 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4418 4600 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 4419 4601 4420 4602 [[package]] 4603 + name = "uuid" 4604 + version = "1.21.0" 4605 + source = "registry+https://github.com/rust-lang/crates.io-index" 4606 + checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" 4607 + dependencies = [ 4608 + "getrandom 0.4.1", 4609 + "js-sys", 4610 + "wasm-bindgen", 4611 + ] 4612 + 4613 + [[package]] 4421 4614 name = "valuable" 4422 4615 version = "0.1.1" 4423 4616 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4466 4659 source = "registry+https://github.com/rust-lang/crates.io-index" 4467 4660 checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" 4468 4661 dependencies = [ 4469 - "wit-bindgen", 4662 + "wit-bindgen 0.46.0", 4663 + ] 4664 + 4665 + [[package]] 4666 + name = "wasip3" 4667 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 4668 + source = "registry+https://github.com/rust-lang/crates.io-index" 4669 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 4670 + dependencies = [ 4671 + "wit-bindgen 0.51.0", 4470 4672 ] 4471 4673 4472 4674 [[package]] ··· 4534 4736 ] 4535 4737 4536 4738 [[package]] 4739 + name = "wasm-encoder" 4740 + version = "0.244.0" 4741 + source = "registry+https://github.com/rust-lang/crates.io-index" 4742 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 4743 + dependencies = [ 4744 + "leb128fmt", 4745 + "wasmparser", 4746 + ] 4747 + 4748 + [[package]] 4749 + name = "wasm-metadata" 4750 + version = "0.244.0" 4751 + source = "registry+https://github.com/rust-lang/crates.io-index" 4752 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 4753 + dependencies = [ 4754 + "anyhow", 4755 + "indexmap 2.12.1", 4756 + "wasm-encoder", 4757 + "wasmparser", 4758 + ] 4759 + 4760 + [[package]] 4761 + name = "wasmparser" 4762 + version = "0.244.0" 4763 + source = "registry+https://github.com/rust-lang/crates.io-index" 4764 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 4765 + dependencies = [ 4766 + "bitflags", 4767 + "hashbrown 0.15.5", 4768 + "indexmap 2.12.1", 4769 + "semver", 4770 + ] 4771 + 4772 + [[package]] 4537 4773 name = "web-sys" 4538 4774 version = "0.3.83" 4539 4775 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4603 4839 source = "registry+https://github.com/rust-lang/crates.io-index" 4604 4840 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 4605 4841 dependencies = [ 4606 - "windows-sys 0.48.0", 4842 + "windows-sys 0.61.2", 4607 4843 ] 4608 4844 4609 4845 [[package]] ··· 4920 5156 checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" 4921 5157 4922 5158 [[package]] 5159 + name = "wit-bindgen" 5160 + version = "0.51.0" 5161 + source = "registry+https://github.com/rust-lang/crates.io-index" 5162 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 5163 + dependencies = [ 5164 + "wit-bindgen-rust-macro", 5165 + ] 5166 + 5167 + [[package]] 5168 + name = "wit-bindgen-core" 5169 + version = "0.51.0" 5170 + source = "registry+https://github.com/rust-lang/crates.io-index" 5171 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 5172 + dependencies = [ 5173 + "anyhow", 5174 + "heck 0.5.0", 5175 + "wit-parser", 5176 + ] 5177 + 5178 + [[package]] 5179 + name = "wit-bindgen-rust" 5180 + version = "0.51.0" 5181 + source = "registry+https://github.com/rust-lang/crates.io-index" 5182 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 5183 + dependencies = [ 5184 + "anyhow", 5185 + "heck 0.5.0", 5186 + "indexmap 2.12.1", 5187 + "prettyplease", 5188 + "syn 2.0.112", 5189 + "wasm-metadata", 5190 + "wit-bindgen-core", 5191 + "wit-component", 5192 + ] 5193 + 5194 + [[package]] 5195 + name = "wit-bindgen-rust-macro" 5196 + version = "0.51.0" 5197 + source = "registry+https://github.com/rust-lang/crates.io-index" 5198 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 5199 + dependencies = [ 5200 + "anyhow", 5201 + "prettyplease", 5202 + "proc-macro2", 5203 + "quote", 5204 + "syn 2.0.112", 5205 + "wit-bindgen-core", 5206 + "wit-bindgen-rust", 5207 + ] 5208 + 5209 + [[package]] 5210 + name = "wit-component" 5211 + version = "0.244.0" 5212 + source = "registry+https://github.com/rust-lang/crates.io-index" 5213 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 5214 + dependencies = [ 5215 + "anyhow", 5216 + "bitflags", 5217 + "indexmap 2.12.1", 5218 + "log", 5219 + "serde", 5220 + "serde_derive", 5221 + "serde_json", 5222 + "wasm-encoder", 5223 + "wasm-metadata", 5224 + "wasmparser", 5225 + "wit-parser", 5226 + ] 5227 + 5228 + [[package]] 5229 + name = "wit-parser" 5230 + version = "0.244.0" 5231 + source = "registry+https://github.com/rust-lang/crates.io-index" 5232 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 5233 + dependencies = [ 5234 + "anyhow", 5235 + "id-arena", 5236 + "indexmap 2.12.1", 5237 + "log", 5238 + "semver", 5239 + "serde", 5240 + "serde_derive", 5241 + "serde_json", 5242 + "unicode-xid", 5243 + "wasmparser", 5244 + ] 5245 + 5246 + [[package]] 4923 5247 name = "writeable" 4924 5248 version = "0.6.2" 4925 5249 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5001 5325 source = "registry+https://github.com/rust-lang/crates.io-index" 5002 5326 checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 5003 5327 dependencies = [ 5328 + "serde", 5004 5329 "zeroize_derive", 5005 5330 ] 5006 5331
+9
Cargo.toml
··· 30 30 anyhow = "1.0.100" 31 31 chrono = { version = "0.4.42", features = ["default", "serde"] } 32 32 sha2 = "0.10" 33 + jacquard-api = { version = "0.9.5", features = ["com_atproto"] } 33 34 jacquard-common = "0.9.5" 34 35 jacquard-identity = "0.9.5" 35 36 multibase = "0.9.2" ··· 39 40 josekit = "0.10.3" 40 41 dashmap = "6.1" 41 42 tower = "0.5" 43 + serde_yaml = "0.9" 44 + jacquard-oauth = "0.9.6" 45 + axum-extra = { version = "0.10", features = ["cookie-signed"] } 46 + uuid = { version = "1", features = ["v4"] } 47 + p256 = { version = "0.13", features = ["ecdsa", "jwk"] } 48 + jose-jwk = { version = "0.1", features = ["p256"] } 49 + base64 = "0.22" 50 + url = "2"
+68
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.searchAccounts" 38 + - "com.atproto.admin.getInviteCodes" 39 + 40 + invite-manager: 41 + description: "Invite code management — create and manage invite codes" 42 + endpoints: 43 + - "com.atproto.admin.getInviteCodes" 44 + - "com.atproto.admin.disableInviteCodes" 45 + - "com.atproto.admin.enableAccountInvites" 46 + - "com.atproto.admin.disableAccountInvites" 47 + - "com.atproto.server.createInviteCode" 48 + - "com.atproto.server.createInviteCodes" 49 + 50 + members: 51 + # Replace these with your team members' DIDs. 52 + # Resolve a handle to its DID with: goat resolve {handle} 53 + 54 + # Example: Full admin 55 + - did: "did:plc:your-admin-did-here" 56 + roles: 57 + - pds-admin 58 + 59 + # Example: Moderator only 60 + - did: "did:plc:your-moderator-did-here" 61 + roles: 62 + - moderator 63 + 64 + # Example: Someone with both moderator and invite manager roles 65 + - did: "did:plc:your-team-member-did-here" 66 + roles: 67 + - moderator 68 + - invite-manager
+357
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + --warning-color: rgb(234, 179, 8); 20 + --table-stripe: rgba(0, 0, 0, 0.02); 21 + } 22 + 23 + @media (prefers-color-scheme: dark) { 24 + :root { 25 + --brand-color: rgb(16, 131, 254); 26 + --primary-color: rgb(255, 255, 255); 27 + --secondary-color: rgb(133, 152, 173); 28 + --bg-primary-color: rgb(7, 10, 13); 29 + --bg-secondary-color: rgb(13, 18, 23); 30 + --border-color: rgb(40, 45, 55); 31 + --table-stripe: rgba(255, 255, 255, 0.02); 32 + } 33 + } 34 + 35 + :root.dark-mode { 36 + --brand-color: rgb(16, 131, 254); 37 + --primary-color: rgb(255, 255, 255); 38 + --secondary-color: rgb(133, 152, 173); 39 + --bg-primary-color: rgb(7, 10, 13); 40 + --bg-secondary-color: rgb(13, 18, 23); 41 + --border-color: rgb(40, 45, 55); 42 + --table-stripe: rgba(255, 255, 255, 0.02); 43 + } 44 + 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + 47 + body { 48 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 49 + background: var(--bg-secondary-color); 50 + color: var(--primary-color); 51 + text-rendering: optimizeLegibility; 52 + -webkit-font-smoothing: antialiased; 53 + } 54 + 55 + .layout { display: flex; min-height: 100vh; } 56 + 57 + .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 + .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 + .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 + .sidebar nav { flex: 1; } 61 + .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 + .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 + .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 + .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 + .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 + .sidebar-footer form { display: inline; } 67 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 + .sidebar-footer button:hover { color: var(--primary-color); } 69 + 70 + .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 + .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 4px; } 72 + .page-subtitle { font-size: 0.8125rem; color: var(--secondary-color); font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; margin-bottom: 24px; word-break: break-all; } 73 + 74 + .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 75 + .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 76 + 77 + .detail-section { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 20px; margin-bottom: 16px; } 78 + .detail-section h3 { font-size: 0.875rem; font-weight: 600; margin-bottom: 12px; } 79 + .detail-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 0.8125rem; border-bottom: 1px solid var(--border-color); } 80 + .detail-row:last-child { border-bottom: none; } 81 + .detail-row .label { color: var(--secondary-color); flex-shrink: 0; } 82 + .detail-row .value { font-weight: 500; word-break: break-all; text-align: right; max-width: 65%; } 83 + 84 + .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } 85 + .badge-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); } 86 + .badge-danger { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); } 87 + .badge-warning { background: rgba(234, 179, 8, 0.1); color: var(--warning-color); } 88 + 89 + .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } 90 + .actions form { display: inline; } 91 + 92 + .btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 16px; font-size: 0.8125rem; font-weight: 500; border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; background: var(--bg-primary-color); color: var(--primary-color); } 93 + .btn:hover { opacity: 0.85; } 94 + .btn-primary { background: var(--brand-color); color: #fff; border-color: var(--brand-color); } 95 + .btn-danger { background: var(--danger-color); color: #fff; border-color: var(--danger-color); } 96 + .btn-warning { background: var(--warning-color); color: #000; border-color: var(--warning-color); } 97 + 98 + .password-box { background: rgba(22, 163, 74, 0.08); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 10px; padding: 16px 20px; margin-bottom: 16px; } 99 + .password-box .pw-label { font-size: 0.75rem; font-weight: 600; color: var(--success-color); margin-bottom: 6px; } 100 + .password-box .pw-value { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 1rem; font-weight: 600; user-select: all; } 101 + 102 + .back-link { display: inline-block; color: var(--brand-color); text-decoration: none; font-size: 0.8125rem; margin-bottom: 16px; } 103 + .back-link:hover { text-decoration: underline; } 104 + 105 + .collection-list { max-height: 200px; overflow-y: auto; padding: 8px 0; } 106 + .collection-item { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; padding: 4px 0; color: var(--secondary-color); border-bottom: 1px solid var(--border-color); } 107 + .collection-item:last-child { border-bottom: none; } 108 + 109 + .threat-sig { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; padding: 4px 0; color: var(--secondary-color); } 110 + 111 + @media (max-width: 768px) { 112 + .sidebar { display: none; } 113 + .main { margin-left: 0; } 114 + } 115 + </style> 116 + </head> 117 + <body> 118 + <div class="layout"> 119 + <aside class="sidebar"> 120 + <div class="sidebar-title">{{pds_hostname}}</div> 121 + <div class="sidebar-subtitle">Admin Portal</div> 122 + <nav> 123 + <a href="/admin/">Dashboard</a> 124 + {{#if can_view_accounts}} 125 + <a href="/admin/accounts" class="active">Accounts</a> 126 + {{/if}} 127 + {{#if can_manage_invites}} 128 + <a href="/admin/invite-codes">Invite Codes</a> 129 + {{/if}} 130 + {{#if can_create_account}} 131 + <a href="/admin/create-account">Create Account</a> 132 + {{/if}} 133 + {{#if can_request_crawl}} 134 + <a href="/admin/request-crawl">Request Crawl</a> 135 + {{/if}} 136 + </nav> 137 + <div class="sidebar-footer"> 138 + <div class="session-info">Signed in as {{handle}}</div> 139 + <form method="POST" action="/admin/logout"> 140 + <button type="submit">Sign out</button> 141 + </form> 142 + </div> 143 + </aside> 144 + 145 + <main class="main"> 146 + {{#if flash_success}} 147 + <div class="flash-success">{{flash_success}}</div> 148 + {{/if}} 149 + {{#if flash_error}} 150 + <div class="flash-error">{{flash_error}}</div> 151 + {{/if}} 152 + 153 + <a href="/admin/accounts" class="back-link">&larr; Back to Accounts</a> 154 + 155 + <h1 class="page-title">{{account.handle}}</h1> 156 + <div class="page-subtitle">{{account.did}}</div> 157 + 158 + {{#if new_password}} 159 + <div class="password-box"> 160 + <div class="pw-label">New Password (copy now -- it will not be shown again)</div> 161 + <div class="pw-value">{{new_password}}</div> 162 + </div> 163 + {{/if}} 164 + 165 + <div class="detail-section"> 166 + <h3>Account Information</h3> 167 + <div class="detail-row"> 168 + <span class="label">Handle</span> 169 + <span class="value">{{account.handle}}</span> 170 + </div> 171 + <div class="detail-row"> 172 + <span class="label">DID</span> 173 + <span class="value">{{account.did}}</span> 174 + </div> 175 + <div class="detail-row"> 176 + <span class="label">Email</span> 177 + <span class="value">{{account.email}}</span> 178 + </div> 179 + {{#if account.indexedAt}} 180 + <div class="detail-row"> 181 + <span class="label">Indexed At</span> 182 + <span class="value">{{account.indexedAt}}</span> 183 + </div> 184 + {{/if}} 185 + {{#if handle_resolution_checked}} 186 + <div class="detail-row"> 187 + <span class="label">Handle Resolution</span> 188 + <span class="value"> 189 + {{#if handle_is_correct}} 190 + <span class="badge badge-success">Valid</span> 191 + {{else}} 192 + <span class="badge badge-danger">Failed</span> 193 + {{/if}} 194 + </span> 195 + </div> 196 + {{/if}} 197 + </div> 198 + 199 + <div class="detail-section"> 200 + <h3>Status</h3> 201 + <div class="detail-row"> 202 + <span class="label">Takedown</span> 203 + <span class="value"> 204 + {{#if is_taken_down}} 205 + <span class="badge badge-danger">Taken Down</span> 206 + {{else}} 207 + <span class="badge badge-success">Active</span> 208 + {{/if}} 209 + </span> 210 + </div> 211 + {{#if takedown_ref}} 212 + <div class="detail-row"> 213 + <span class="label">Takedown Reference</span> 214 + <span class="value">{{takedown_ref}}</span> 215 + </div> 216 + {{/if}} 217 + <div class="detail-row"> 218 + <span class="label">Email Confirmed</span> 219 + <span class="value"> 220 + {{#if account.emailConfirmedAt}} 221 + <span class="badge badge-success">Confirmed</span> 222 + {{else}} 223 + <span class="badge badge-warning">Unconfirmed</span> 224 + {{/if}} 225 + </span> 226 + </div> 227 + <div class="detail-row"> 228 + <span class="label">Deactivated</span> 229 + <span class="value"> 230 + {{#if account.deactivatedAt}} 231 + <span class="badge badge-warning">{{account.deactivatedAt}}</span> 232 + {{else}} 233 + <span class="badge badge-success">No</span> 234 + {{/if}} 235 + </span> 236 + </div> 237 + </div> 238 + 239 + {{#if repo_status_checked}} 240 + <div class="detail-section"> 241 + <h3>Repo Status</h3> 242 + <div class="detail-row"> 243 + <span class="label">Active</span> 244 + <span class="value"> 245 + {{#if repo_active}} 246 + <span class="badge badge-success">Active</span> 247 + {{else}} 248 + <span class="badge badge-danger">Inactive</span> 249 + {{/if}} 250 + </span> 251 + </div> 252 + {{#if repo_status_reason}} 253 + <div class="detail-row"> 254 + <span class="label">Status Reason</span> 255 + <span class="value">{{repo_status_reason}}</span> 256 + </div> 257 + {{/if}} 258 + {{#if repo_rev}} 259 + <div class="detail-row"> 260 + <span class="label">Revision</span> 261 + <span class="value" style="font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem;">{{repo_rev}}</span> 262 + </div> 263 + {{/if}} 264 + </div> 265 + {{/if}} 266 + 267 + <div class="detail-section"> 268 + <h3>Invite Information</h3> 269 + {{#if account.invitedBy}} 270 + <div class="detail-row"> 271 + <span class="label">Invited By</span> 272 + <span class="value">{{account.invitedBy}}</span> 273 + </div> 274 + {{/if}} 275 + <div class="detail-row"> 276 + <span class="label">Invites Disabled</span> 277 + <span class="value"> 278 + {{#if account.invitesDisabled}} 279 + <span class="badge badge-danger">Yes</span> 280 + {{else}} 281 + <span class="badge badge-success">No</span> 282 + {{/if}} 283 + </span> 284 + </div> 285 + {{#if account.inviteNote}} 286 + <div class="detail-row"> 287 + <span class="label">Invite Note</span> 288 + <span class="value">{{account.inviteNote}}</span> 289 + </div> 290 + {{/if}} 291 + </div> 292 + 293 + {{#if collections}} 294 + <div class="detail-section"> 295 + <h3>Collections</h3> 296 + <div class="collection-list"> 297 + {{#each collections}} 298 + <div class="collection-item">{{this}}</div> 299 + {{/each}} 300 + </div> 301 + </div> 302 + {{/if}} 303 + 304 + {{#if threat_signatures}} 305 + <div class="detail-section"> 306 + <h3>Threat Signatures</h3> 307 + {{#each threat_signatures}} 308 + <div class="threat-sig">{{this.property}}: {{this.value}}</div> 309 + {{/each}} 310 + </div> 311 + {{/if}} 312 + 313 + <div class="detail-section"> 314 + <h3>Actions</h3> 315 + <div class="actions"> 316 + {{#if can_manage_takedowns}} 317 + {{#if is_taken_down}} 318 + <form method="POST" action="/admin/accounts/{{account.did}}/untakedown"> 319 + <button type="submit" class="btn btn-primary">Remove Takedown</button> 320 + </form> 321 + {{else}} 322 + <form method="POST" action="/admin/accounts/{{account.did}}/takedown"> 323 + <button type="submit" class="btn btn-warning">Takedown Account</button> 324 + </form> 325 + {{/if}} 326 + {{/if}} 327 + 328 + {{#if can_reset_password}} 329 + <form method="POST" action="/admin/accounts/{{account.did}}/reset-password" onsubmit="return confirm('Are you sure you want to reset this account password? The current password will be invalidated.');"> 330 + <button type="submit" class="btn">Reset Password</button> 331 + </form> 332 + {{/if}} 333 + 334 + {{#if can_manage_invites}} 335 + {{#if account.invitesDisabled}} 336 + <form method="POST" action="/admin/accounts/{{account.did}}/enable-invites"> 337 + <button type="submit" class="btn">Enable Invites</button> 338 + </form> 339 + {{else}} 340 + <form method="POST" action="/admin/accounts/{{account.did}}/disable-invites"> 341 + <button type="submit" class="btn">Disable Invites</button> 342 + </form> 343 + {{/if}} 344 + {{/if}} 345 + 346 + {{#if can_delete_account}} 347 + <form method="POST" action="/admin/accounts/{{account.did}}/delete" onsubmit="return confirm('PERMANENTLY DELETE this account? This action cannot be undone.');"> 348 + <button type="submit" class="btn btn-danger">Delete Account</button> 349 + </form> 350 + {{/if}} 351 + </div> 352 + </div> 353 + </main> 354 + </div> 355 + 356 + </body> 357 + </html>
+188
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + --warning-color: rgb(234, 179, 8); 20 + --table-stripe: rgba(0, 0, 0, 0.02); 21 + } 22 + 23 + @media (prefers-color-scheme: dark) { 24 + :root { 25 + --brand-color: rgb(16, 131, 254); 26 + --primary-color: rgb(255, 255, 255); 27 + --secondary-color: rgb(133, 152, 173); 28 + --bg-primary-color: rgb(7, 10, 13); 29 + --bg-secondary-color: rgb(13, 18, 23); 30 + --border-color: rgb(40, 45, 55); 31 + --table-stripe: rgba(255, 255, 255, 0.02); 32 + } 33 + } 34 + 35 + :root.dark-mode { 36 + --brand-color: rgb(16, 131, 254); 37 + --primary-color: rgb(255, 255, 255); 38 + --secondary-color: rgb(133, 152, 173); 39 + --bg-primary-color: rgb(7, 10, 13); 40 + --bg-secondary-color: rgb(13, 18, 23); 41 + --border-color: rgb(40, 45, 55); 42 + --table-stripe: rgba(255, 255, 255, 0.02); 43 + } 44 + 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + 47 + body { 48 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 49 + background: var(--bg-secondary-color); 50 + color: var(--primary-color); 51 + text-rendering: optimizeLegibility; 52 + -webkit-font-smoothing: antialiased; 53 + } 54 + 55 + .layout { display: flex; min-height: 100vh; } 56 + 57 + .sidebar { 58 + width: 220px; 59 + background: var(--bg-primary-color); 60 + border-right: 1px solid var(--border-color); 61 + padding: 20px 0; 62 + position: fixed; 63 + top: 0; left: 0; bottom: 0; 64 + overflow-y: auto; 65 + display: flex; 66 + flex-direction: column; 67 + } 68 + 69 + .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 70 + .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 71 + .sidebar nav { flex: 1; } 72 + .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 73 + .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 74 + .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 75 + .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 76 + .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 77 + .sidebar-footer form { display: inline; } 78 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 79 + .sidebar-footer button:hover { color: var(--primary-color); } 80 + 81 + .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 82 + .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; } 83 + 84 + .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 85 + .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 86 + 87 + .search-form { display: flex; gap: 8px; margin-bottom: 24px; } 88 + .search-form input { flex: 1; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; } 89 + .search-form input:focus { border-color: var(--brand-color); } 90 + .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 91 + .btn:hover { opacity: 0.85; } 92 + .btn-primary { background: var(--brand-color); color: #fff; } 93 + 94 + .table-container { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; } 95 + table { width: 100%; border-collapse: collapse; } 96 + thead th { text-align: left; padding: 12px 16px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-color); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border-color); } 97 + tbody tr { border-bottom: 1px solid var(--border-color); } 98 + tbody tr:last-child { border-bottom: none; } 99 + tbody tr:nth-child(even) { background: var(--table-stripe); } 100 + tbody td { padding: 10px 16px; font-size: 0.8125rem; } 101 + tbody td a { color: var(--brand-color); text-decoration: none; } 102 + tbody td a:hover { text-decoration: underline; } 103 + .empty-state { text-align: center; padding: 40px 20px; color: var(--secondary-color); font-size: 0.875rem; } 104 + .did-cell { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; color: var(--secondary-color); } 105 + 106 + @media (max-width: 768px) { 107 + .sidebar { display: none; } 108 + .main { margin-left: 0; } 109 + } 110 + </style> 111 + </head> 112 + <body> 113 + <div class="layout"> 114 + <aside class="sidebar"> 115 + <div class="sidebar-title">{{pds_hostname}}</div> 116 + <div class="sidebar-subtitle">Admin Portal</div> 117 + <nav> 118 + <a href="/admin/">Dashboard</a> 119 + {{#if can_view_accounts}} 120 + <a href="/admin/accounts" class="active">Accounts</a> 121 + {{/if}} 122 + {{#if can_manage_invites}} 123 + <a href="/admin/invite-codes">Invite Codes</a> 124 + {{/if}} 125 + {{#if can_create_account}} 126 + <a href="/admin/create-account">Create Account</a> 127 + {{/if}} 128 + {{#if can_request_crawl}} 129 + <a href="/admin/request-crawl">Request Crawl</a> 130 + {{/if}} 131 + </nav> 132 + <div class="sidebar-footer"> 133 + <div class="session-info">Signed in as {{handle}}</div> 134 + <form method="POST" action="/admin/logout"> 135 + <button type="submit">Sign out</button> 136 + </form> 137 + </div> 138 + </aside> 139 + 140 + <main class="main"> 141 + {{#if flash_success}} 142 + <div class="flash-success">{{flash_success}}</div> 143 + {{/if}} 144 + {{#if flash_error}} 145 + <div class="flash-error">{{flash_error}}</div> 146 + {{/if}} 147 + 148 + <h1 class="page-title">Accounts</h1> 149 + 150 + <form class="search-form" method="GET" action="/admin/search"> 151 + <input type="text" name="q" placeholder="Search by email..." value="{{search_query}}" /> 152 + <button type="submit" class="btn btn-primary">Search</button> 153 + </form> 154 + 155 + {{#if accounts}} 156 + <div class="table-container"> 157 + <table> 158 + <thead> 159 + <tr> 160 + <th>Handle</th> 161 + <th>DID</th> 162 + <th>Email</th> 163 + </tr> 164 + </thead> 165 + <tbody> 166 + {{#each accounts}} 167 + <tr> 168 + <td><a href="/admin/accounts/{{this.did}}">{{this.handle}}</a></td> 169 + <td class="did-cell">{{this.did}}</td> 170 + <td>{{this.email}}</td> 171 + </tr> 172 + {{/each}} 173 + </tbody> 174 + </table> 175 + </div> 176 + {{else}} 177 + <div class="empty-state"> 178 + {{#if search_query}} 179 + No accounts matching "{{search_query}}" 180 + {{else}} 181 + No accounts found 182 + {{/if}} 183 + </div> 184 + {{/if}} 185 + </main> 186 + </div> 187 + </body> 188 + </html>
+213
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + --warning-color: rgb(234, 179, 8); 20 + --table-stripe: rgba(0, 0, 0, 0.02); 21 + } 22 + 23 + @media (prefers-color-scheme: dark) { 24 + :root { 25 + --brand-color: rgb(16, 131, 254); 26 + --primary-color: rgb(255, 255, 255); 27 + --secondary-color: rgb(133, 152, 173); 28 + --bg-primary-color: rgb(7, 10, 13); 29 + --bg-secondary-color: rgb(13, 18, 23); 30 + --border-color: rgb(40, 45, 55); 31 + --table-stripe: rgba(255, 255, 255, 0.02); 32 + } 33 + } 34 + 35 + :root.dark-mode { 36 + --brand-color: rgb(16, 131, 254); 37 + --primary-color: rgb(255, 255, 255); 38 + --secondary-color: rgb(133, 152, 173); 39 + --bg-primary-color: rgb(7, 10, 13); 40 + --bg-secondary-color: rgb(13, 18, 23); 41 + --border-color: rgb(40, 45, 55); 42 + --table-stripe: rgba(255, 255, 255, 0.02); 43 + } 44 + 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + 47 + body { 48 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 49 + background: var(--bg-secondary-color); 50 + color: var(--primary-color); 51 + text-rendering: optimizeLegibility; 52 + -webkit-font-smoothing: antialiased; 53 + } 54 + 55 + .layout { display: flex; min-height: 100vh; } 56 + 57 + .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 + .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 + .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 + .sidebar nav { flex: 1; } 61 + .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 + .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 + .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 + .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 + .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 + .sidebar-footer form { display: inline; } 67 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 + .sidebar-footer button:hover { color: var(--primary-color); } 69 + 70 + .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 + .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; } 72 + 73 + .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 74 + .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 75 + 76 + .form-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; } 77 + .form-group { margin-bottom: 16px; } 78 + .form-group label { display: block; font-size: 0.8125rem; font-weight: 500; margin-bottom: 6px; color: var(--primary-color); } 79 + .form-group input { width: 100%; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; transition: border-color 0.15s; } 80 + .form-group input:focus { border-color: var(--brand-color); } 81 + .form-group .hint { font-size: 0.75rem; color: var(--secondary-color); margin-top: 4px; } 82 + 83 + .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 84 + .btn:hover { opacity: 0.85; } 85 + .btn-primary { background: var(--brand-color); color: #fff; } 86 + 87 + .success-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; } 88 + .success-card h3 { font-size: 1rem; font-weight: 600; color: var(--success-color); margin-bottom: 16px; } 89 + 90 + .detail-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 0.8125rem; border-bottom: 1px solid var(--border-color); } 91 + .detail-row:last-child { border-bottom: none; } 92 + .detail-row .label { color: var(--secondary-color); } 93 + .detail-row .value { font-weight: 500; word-break: break-all; text-align: right; max-width: 65%; display: flex; align-items: center; gap: 6px; } 94 + 95 + .password-highlight { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; background: rgba(22, 163, 74, 0.08); padding: 2px 6px; border-radius: 4px; user-select: all; } 96 + 97 + .copy-btn { background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 2px 6px; font-size: 0.6875rem; cursor: pointer; color: var(--secondary-color); transition: color 0.15s, border-color 0.15s; white-space: nowrap; } 98 + .copy-btn:hover { color: var(--primary-color); border-color: var(--primary-color); } 99 + 100 + @media (max-width: 768px) { 101 + .sidebar { display: none; } 102 + .main { margin-left: 0; } 103 + } 104 + </style> 105 + </head> 106 + <body> 107 + <div class="layout"> 108 + <aside class="sidebar"> 109 + <div class="sidebar-title">{{pds_hostname}}</div> 110 + <div class="sidebar-subtitle">Admin Portal</div> 111 + <nav> 112 + <a href="/admin/">Dashboard</a> 113 + {{#if can_view_accounts}} 114 + <a href="/admin/accounts">Accounts</a> 115 + {{/if}} 116 + {{#if can_manage_invites}} 117 + <a href="/admin/invite-codes">Invite Codes</a> 118 + {{/if}} 119 + {{#if can_create_account}} 120 + <a href="/admin/create-account" class="active">Create Account</a> 121 + {{/if}} 122 + {{#if can_request_crawl}} 123 + <a href="/admin/request-crawl">Request Crawl</a> 124 + {{/if}} 125 + </nav> 126 + <div class="sidebar-footer"> 127 + <div class="session-info">Signed in as {{handle}}</div> 128 + <form method="POST" action="/admin/logout"> 129 + <button type="submit">Sign out</button> 130 + </form> 131 + </div> 132 + </aside> 133 + 134 + <main class="main"> 135 + {{#if flash_success}} 136 + <div class="flash-success">{{flash_success}}</div> 137 + {{/if}} 138 + {{#if flash_error}} 139 + <div class="flash-error">{{flash_error}}</div> 140 + {{/if}} 141 + 142 + <h1 class="page-title">Create Account</h1> 143 + 144 + {{#if created}} 145 + <div class="success-card"> 146 + <h3>Account Created Successfully</h3> 147 + <div class="detail-row"> 148 + <span class="label">DID</span> 149 + <span class="value">{{created.did}} <button class="copy-btn" onclick="copyToClipboard('{{created.did}}', this)">Copy</button></span> 150 + </div> 151 + <div class="detail-row"> 152 + <span class="label">Handle</span> 153 + <span class="value">{{created.handle}} <button class="copy-btn" onclick="copyToClipboard('{{created.handle}}', this)">Copy</button></span> 154 + </div> 155 + <div class="detail-row"> 156 + <span class="label">Email</span> 157 + <span class="value">{{created.email}} <button class="copy-btn" onclick="copyToClipboard('{{created.email}}', this)">Copy</button></span> 158 + </div> 159 + <div class="detail-row"> 160 + <span class="label">Password</span> 161 + <span class="value"><span class="password-highlight">{{created.password}}</span> <button class="copy-btn" onclick="copyToClipboard('{{created.password}}', this)">Copy</button></span> 162 + </div> 163 + {{#if created.inviteCode}} 164 + <div class="detail-row"> 165 + <span class="label">Invite Code Used</span> 166 + <span class="value">{{created.inviteCode}} <button class="copy-btn" onclick="copyToClipboard('{{created.inviteCode}}', this)">Copy</button></span> 167 + </div> 168 + {{/if}} 169 + </div> 170 + {{else}} 171 + <div class="form-card"> 172 + <form method="POST" action="/admin/create-account"> 173 + <div class="form-group"> 174 + <label for="email">Email</label> 175 + <input type="email" id="email" name="email" placeholder="user@example.com" required /> 176 + </div> 177 + <div class="form-group"> 178 + <label for="handle">Handle</label> 179 + <input type="text" id="handle" name="handle" placeholder="user.example.com" required /> 180 + <div class="hint">Must be a valid handle for this PDS</div> 181 + </div> 182 + <button type="submit" class="btn btn-primary">Create Account</button> 183 + </form> 184 + </div> 185 + {{/if}} 186 + </main> 187 + </div> 188 + 189 + <script> 190 + function copyToClipboard(text, btn) { 191 + if (navigator.clipboard && navigator.clipboard.writeText) { 192 + navigator.clipboard.writeText(text).then(function() { 193 + var orig = btn.textContent; 194 + btn.textContent = 'Copied'; 195 + setTimeout(function() { btn.textContent = orig; }, 1500); 196 + }); 197 + } else { 198 + var el = document.createElement('textarea'); 199 + el.value = text; 200 + el.style.position = 'fixed'; 201 + el.style.opacity = '0'; 202 + document.body.appendChild(el); 203 + el.select(); 204 + document.execCommand('copy'); 205 + document.body.removeChild(el); 206 + var orig = btn.textContent; 207 + btn.textContent = 'Copied'; 208 + setTimeout(function() { btn.textContent = orig; }, 1500); 209 + } 210 + } 211 + </script> 212 + </body> 213 + </html>
+371
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + --warning-color: rgb(234, 179, 8); 20 + --table-stripe: rgba(0, 0, 0, 0.02); 21 + } 22 + 23 + @media (prefers-color-scheme: dark) { 24 + :root { 25 + --brand-color: rgb(16, 131, 254); 26 + --primary-color: rgb(255, 255, 255); 27 + --secondary-color: rgb(133, 152, 173); 28 + --bg-primary-color: rgb(7, 10, 13); 29 + --bg-secondary-color: rgb(13, 18, 23); 30 + --border-color: rgb(40, 45, 55); 31 + --table-stripe: rgba(255, 255, 255, 0.02); 32 + } 33 + } 34 + 35 + :root.dark-mode { 36 + --brand-color: rgb(16, 131, 254); 37 + --primary-color: rgb(255, 255, 255); 38 + --secondary-color: rgb(133, 152, 173); 39 + --bg-primary-color: rgb(7, 10, 13); 40 + --bg-secondary-color: rgb(13, 18, 23); 41 + --border-color: rgb(40, 45, 55); 42 + --table-stripe: rgba(255, 255, 255, 0.02); 43 + } 44 + 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + 47 + body { 48 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 49 + background: var(--bg-secondary-color); 50 + color: var(--primary-color); 51 + text-rendering: optimizeLegibility; 52 + -webkit-font-smoothing: antialiased; 53 + } 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 + .page-title { 152 + font-size: 1.5rem; 153 + font-weight: 700; 154 + margin-bottom: 24px; 155 + } 156 + 157 + .flash-success { 158 + background: rgba(22, 163, 74, 0.1); 159 + color: var(--success-color); 160 + border: 1px solid rgba(22, 163, 74, 0.2); 161 + border-radius: 8px; 162 + padding: 10px 14px; 163 + font-size: 0.875rem; 164 + margin-bottom: 20px; 165 + } 166 + 167 + .flash-error { 168 + background: rgba(220, 38, 38, 0.1); 169 + color: var(--danger-color); 170 + border: 1px solid rgba(220, 38, 38, 0.2); 171 + border-radius: 8px; 172 + padding: 10px 14px; 173 + font-size: 0.875rem; 174 + margin-bottom: 20px; 175 + } 176 + 177 + .cards { 178 + display: grid; 179 + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); 180 + gap: 16px; 181 + margin-bottom: 32px; 182 + } 183 + 184 + .card { 185 + background: var(--bg-primary-color); 186 + border: 1px solid var(--border-color); 187 + border-radius: 10px; 188 + padding: 20px; 189 + } 190 + 191 + .card-label { 192 + font-size: 0.75rem; 193 + font-weight: 500; 194 + color: var(--secondary-color); 195 + text-transform: uppercase; 196 + letter-spacing: 0.5px; 197 + margin-bottom: 6px; 198 + } 199 + 200 + .card-value { 201 + font-size: 1.25rem; 202 + font-weight: 600; 203 + } 204 + 205 + .card-value.success { color: var(--success-color); } 206 + .card-value.danger { color: var(--danger-color); } 207 + 208 + .detail-section { 209 + background: var(--bg-primary-color); 210 + border: 1px solid var(--border-color); 211 + border-radius: 10px; 212 + padding: 20px; 213 + margin-bottom: 16px; 214 + } 215 + 216 + .detail-section h3 { 217 + font-size: 0.875rem; 218 + font-weight: 600; 219 + margin-bottom: 12px; 220 + } 221 + 222 + .detail-row { 223 + display: flex; 224 + justify-content: space-between; 225 + padding: 6px 0; 226 + font-size: 0.8125rem; 227 + border-bottom: 1px solid var(--border-color); 228 + } 229 + 230 + .detail-row:last-child { 231 + border-bottom: none; 232 + } 233 + 234 + .detail-row .label { 235 + color: var(--secondary-color); 236 + } 237 + 238 + .detail-row .value { 239 + font-weight: 500; 240 + word-break: break-all; 241 + text-align: right; 242 + max-width: 60%; 243 + } 244 + 245 + .detail-row .value a { 246 + color: var(--brand-color); 247 + text-decoration: none; 248 + } 249 + 250 + .detail-row .value a:hover { 251 + text-decoration: underline; 252 + } 253 + 254 + @media (max-width: 768px) { 255 + .sidebar { 256 + display: none; 257 + } 258 + .main { 259 + margin-left: 0; 260 + } 261 + } 262 + </style> 263 + </head> 264 + <body> 265 + <div class="layout"> 266 + <aside class="sidebar"> 267 + <div class="sidebar-title">{{pds_hostname}}</div> 268 + <div class="sidebar-subtitle">Admin Portal</div> 269 + <nav> 270 + <a href="/admin/" class="active">Dashboard</a> 271 + {{#if can_view_accounts}} 272 + <a href="/admin/accounts">Accounts</a> 273 + {{/if}} 274 + {{#if can_manage_invites}} 275 + <a href="/admin/invite-codes">Invite Codes</a> 276 + {{/if}} 277 + {{#if can_create_account}} 278 + <a href="/admin/create-account">Create Account</a> 279 + {{/if}} 280 + {{#if can_request_crawl}} 281 + <a href="/admin/request-crawl">Request Crawl</a> 282 + {{/if}} 283 + </nav> 284 + <div class="sidebar-footer"> 285 + <div class="session-info">Signed in as {{handle}}</div> 286 + <form method="POST" action="/admin/logout"> 287 + <button type="submit">Sign out</button> 288 + </form> 289 + </div> 290 + </aside> 291 + 292 + <main class="main"> 293 + {{#if flash_success}} 294 + <div class="flash-success">{{flash_success}}</div> 295 + {{/if}} 296 + {{#if flash_error}} 297 + <div class="flash-error">{{flash_error}}</div> 298 + {{/if}} 299 + 300 + <h1 class="page-title">Dashboard</h1> 301 + 302 + <div class="cards"> 303 + <div class="card"> 304 + <div class="card-label">PDS Version</div> 305 + <div class="card-value">{{version}}</div> 306 + </div> 307 + {{#if can_view_accounts}} 308 + <div class="card"> 309 + <div class="card-label">Total Accounts</div> 310 + <div class="card-value">{{account_count}}</div> 311 + </div> 312 + {{/if}} 313 + <div class="card"> 314 + <div class="card-label">Invite Code Required</div> 315 + <div class="card-value">{{#if invite_code_required}}Yes{{else}}No{{/if}}</div> 316 + </div> 317 + <div class="card"> 318 + <div class="card-label">Phone Verification</div> 319 + <div class="card-value">{{#if phone_verification_required}}Required{{else}}Not Required{{/if}}</div> 320 + </div> 321 + </div> 322 + 323 + <div class="detail-section"> 324 + <h3>Server Information</h3> 325 + <div class="detail-row"> 326 + <span class="label">Server DID</span> 327 + <span class="value">{{server_did}}</span> 328 + </div> 329 + <div class="detail-row"> 330 + <span class="label">Available Domains</span> 331 + <span class="value">{{available_domains}}</span> 332 + </div> 333 + {{#if contact_email}} 334 + <div class="detail-row"> 335 + <span class="label">Contact Email</span> 336 + <span class="value">{{contact_email}}</span> 337 + </div> 338 + {{/if}} 339 + </div> 340 + 341 + {{#if privacy_policy}} 342 + <div class="detail-section"> 343 + <h3>Server Links</h3> 344 + {{#if terms_of_service}} 345 + <div class="detail-row"> 346 + <span class="label">Terms of Service</span> 347 + <span class="value"><a href="{{terms_of_service}}" target="_blank" rel="noopener">{{terms_of_service}}</a></span> 348 + </div> 349 + {{/if}} 350 + {{#if privacy_policy}} 351 + <div class="detail-row"> 352 + <span class="label">Privacy Policy</span> 353 + <span class="value"><a href="{{privacy_policy}}" target="_blank" rel="noopener">{{privacy_policy}}</a></span> 354 + </div> 355 + {{/if}} 356 + </div> 357 + {{else}} 358 + {{#if terms_of_service}} 359 + <div class="detail-section"> 360 + <h3>Server Links</h3> 361 + <div class="detail-row"> 362 + <span class="label">Terms of Service</span> 363 + <span class="value"><a href="{{terms_of_service}}" target="_blank" rel="noopener">{{terms_of_service}}</a></span> 364 + </div> 365 + </div> 366 + {{/if}} 367 + {{/if}} 368 + </main> 369 + </div> 370 + </body> 371 + </html>
+241
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + } 20 + 21 + @media (prefers-color-scheme: dark) { 22 + :root { 23 + --brand-color: rgb(16, 131, 254); 24 + --primary-color: rgb(255, 255, 255); 25 + --secondary-color: rgb(133, 152, 173); 26 + --bg-primary-color: rgb(7, 10, 13); 27 + --bg-secondary-color: rgb(13, 18, 23); 28 + --border-color: rgb(40, 45, 55); 29 + } 30 + } 31 + 32 + :root.dark-mode { 33 + --brand-color: rgb(16, 131, 254); 34 + --primary-color: rgb(255, 255, 255); 35 + --secondary-color: rgb(133, 152, 173); 36 + --bg-primary-color: rgb(7, 10, 13); 37 + --bg-secondary-color: rgb(13, 18, 23); 38 + --border-color: rgb(40, 45, 55); 39 + } 40 + 41 + * { margin: 0; padding: 0; box-sizing: border-box; } 42 + 43 + body { 44 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 45 + background: var(--bg-secondary-color); 46 + color: var(--primary-color); 47 + text-rendering: optimizeLegibility; 48 + -webkit-font-smoothing: antialiased; 49 + min-height: 100vh; 50 + } 51 + 52 + /* When logged in, use sidebar layout */ 53 + .layout { display: flex; min-height: 100vh; } 54 + 55 + .sidebar { 56 + width: 220px; 57 + background: var(--bg-primary-color); 58 + border-right: 1px solid var(--border-color); 59 + padding: 20px 0; 60 + position: fixed; 61 + top: 0; left: 0; bottom: 0; 62 + overflow-y: auto; 63 + display: flex; 64 + flex-direction: column; 65 + } 66 + 67 + .sidebar-title { 68 + font-size: 0.8125rem; 69 + font-weight: 700; 70 + padding: 0 20px; 71 + margin-bottom: 4px; 72 + white-space: nowrap; 73 + overflow: hidden; 74 + text-overflow: ellipsis; 75 + } 76 + 77 + .sidebar-subtitle { 78 + font-size: 0.6875rem; 79 + color: var(--secondary-color); 80 + padding: 0 20px; 81 + margin-bottom: 20px; 82 + } 83 + 84 + .sidebar nav { flex: 1; } 85 + 86 + .sidebar nav a { 87 + display: block; 88 + padding: 8px 20px; 89 + font-size: 0.8125rem; 90 + color: var(--secondary-color); 91 + text-decoration: none; 92 + transition: background 0.1s, color 0.1s; 93 + } 94 + 95 + .sidebar nav a:hover { 96 + background: var(--bg-secondary-color); 97 + color: var(--primary-color); 98 + } 99 + 100 + .sidebar-footer { 101 + padding: 16px 20px 0; 102 + border-top: 1px solid var(--border-color); 103 + margin-top: 16px; 104 + } 105 + 106 + .sidebar-footer .session-info { 107 + font-size: 0.75rem; 108 + color: var(--secondary-color); 109 + margin-bottom: 8px; 110 + } 111 + 112 + .sidebar-footer form { display: inline; } 113 + 114 + .sidebar-footer button { 115 + background: none; 116 + border: none; 117 + font-size: 0.75rem; 118 + color: var(--secondary-color); 119 + cursor: pointer; 120 + padding: 0; 121 + text-decoration: underline; 122 + } 123 + 124 + .sidebar-footer button:hover { color: var(--primary-color); } 125 + 126 + .main { 127 + margin-left: 220px; 128 + flex: 1; 129 + padding: 32px; 130 + max-width: 960px; 131 + } 132 + 133 + /* Standalone centered layout (when not logged in) */ 134 + .centered { 135 + display: flex; 136 + align-items: center; 137 + justify-content: center; 138 + min-height: 100vh; 139 + } 140 + 141 + .error-card { 142 + background: var(--bg-primary-color); 143 + border: 1px solid var(--border-color); 144 + border-radius: 12px; 145 + padding: 40px; 146 + text-align: center; 147 + max-width: 480px; 148 + width: 100%; 149 + margin: 20px; 150 + } 151 + 152 + .error-icon { 153 + font-size: 2.5rem; 154 + margin-bottom: 16px; 155 + color: var(--danger-color); 156 + } 157 + 158 + .error-title { 159 + font-size: 1.25rem; 160 + font-weight: 700; 161 + margin-bottom: 8px; 162 + } 163 + 164 + .error-message { 165 + font-size: 0.875rem; 166 + color: var(--secondary-color); 167 + margin-bottom: 24px; 168 + line-height: 1.5; 169 + } 170 + 171 + .error-link { 172 + display: inline-flex; 173 + align-items: center; 174 + justify-content: center; 175 + padding: 10px 20px; 176 + font-size: 0.875rem; 177 + font-weight: 500; 178 + border: none; 179 + border-radius: 8px; 180 + cursor: pointer; 181 + text-decoration: none; 182 + background: var(--brand-color); 183 + color: #fff; 184 + transition: opacity 0.15s; 185 + } 186 + 187 + .error-link:hover { opacity: 0.85; } 188 + 189 + @media (max-width: 768px) { 190 + .sidebar { display: none; } 191 + .main { margin-left: 0; } 192 + } 193 + </style> 194 + </head> 195 + <body> 196 + {{#if handle}} 197 + {{!-- Logged-in user: show sidebar layout --}} 198 + <div class="layout"> 199 + <aside class="sidebar"> 200 + <div class="sidebar-title">{{pds_hostname}}</div> 201 + <div class="sidebar-subtitle">Admin Portal</div> 202 + <nav> 203 + <a href="/admin/">Dashboard</a> 204 + <a href="/admin/accounts">Accounts</a> 205 + {{#if can_manage_invites}} 206 + <a href="/admin/invite-codes">Invite Codes</a> 207 + {{/if}} 208 + {{#if can_create_account}} 209 + <a href="/admin/create-account">Create Account</a> 210 + {{/if}} 211 + </nav> 212 + <div class="sidebar-footer"> 213 + <div class="session-info">Signed in as {{handle}}</div> 214 + <form method="POST" action="/admin/logout"> 215 + <button type="submit">Sign out</button> 216 + </form> 217 + </div> 218 + </aside> 219 + 220 + <main class="main"> 221 + <div class="error-card" style="text-align:center; margin: 60px auto;"> 222 + <div class="error-icon">!</div> 223 + <div class="error-title">{{error_title}}</div> 224 + <div class="error-message">{{error_message}}</div> 225 + <a href="/admin/" class="error-link">Back to Dashboard</a> 226 + </div> 227 + </main> 228 + </div> 229 + {{else}} 230 + {{!-- Not logged in: standalone centered layout --}} 231 + <div class="centered"> 232 + <div class="error-card"> 233 + <div class="error-icon">!</div> 234 + <div class="error-title">{{error_title}}</div> 235 + <div class="error-message">{{error_message}}</div> 236 + <a href="/admin/login" class="error-link">Go to Login</a> 237 + </div> 238 + </div> 239 + {{/if}} 240 + </body> 241 + </html>
+227
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + --warning-color: rgb(234, 179, 8); 20 + --table-stripe: rgba(0, 0, 0, 0.02); 21 + } 22 + 23 + @media (prefers-color-scheme: dark) { 24 + :root { 25 + --brand-color: rgb(16, 131, 254); 26 + --primary-color: rgb(255, 255, 255); 27 + --secondary-color: rgb(133, 152, 173); 28 + --bg-primary-color: rgb(7, 10, 13); 29 + --bg-secondary-color: rgb(13, 18, 23); 30 + --border-color: rgb(40, 45, 55); 31 + --table-stripe: rgba(255, 255, 255, 0.02); 32 + } 33 + } 34 + 35 + :root.dark-mode { 36 + --brand-color: rgb(16, 131, 254); 37 + --primary-color: rgb(255, 255, 255); 38 + --secondary-color: rgb(133, 152, 173); 39 + --bg-primary-color: rgb(7, 10, 13); 40 + --bg-secondary-color: rgb(13, 18, 23); 41 + --border-color: rgb(40, 45, 55); 42 + --table-stripe: rgba(255, 255, 255, 0.02); 43 + } 44 + 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + 47 + body { 48 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 49 + background: var(--bg-secondary-color); 50 + color: var(--primary-color); 51 + text-rendering: optimizeLegibility; 52 + -webkit-font-smoothing: antialiased; 53 + } 54 + 55 + .layout { display: flex; min-height: 100vh; } 56 + 57 + .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 + .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 + .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 + .sidebar nav { flex: 1; } 61 + .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 + .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 + .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 + .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 + .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 + .sidebar-footer form { display: inline; } 67 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 + .sidebar-footer button:hover { color: var(--primary-color); } 69 + 70 + .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 + .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; } 72 + 73 + .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 74 + .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 75 + 76 + .create-form { display: flex; gap: 8px; align-items: flex-end; margin-bottom: 24px; } 77 + .create-form .form-group { display: flex; flex-direction: column; gap: 4px; } 78 + .create-form label { font-size: 0.75rem; font-weight: 500; color: var(--secondary-color); } 79 + .create-form input[type="number"] { padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; width: 120px; } 80 + .create-form input[type="number"]:focus { border-color: var(--brand-color); } 81 + 82 + .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 83 + .btn:hover { opacity: 0.85; } 84 + .btn-primary { background: var(--brand-color); color: #fff; } 85 + .btn-small { padding: 6px 12px; font-size: 0.75rem; } 86 + .btn-outline-danger { background: transparent; color: var(--danger-color); border: 1px solid var(--danger-color); } 87 + 88 + .code-box { background: rgba(22, 163, 74, 0.08); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 10px; padding: 16px 20px; margin-bottom: 24px; } 89 + .code-box .code-label { font-size: 0.75rem; font-weight: 600; color: var(--success-color); margin-bottom: 6px; } 90 + .code-box .code-value { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 1rem; font-weight: 600; user-select: all; } 91 + 92 + .table-container { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; } 93 + table { width: 100%; border-collapse: collapse; } 94 + thead th { text-align: left; padding: 12px 16px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-color); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border-color); } 95 + tbody tr { border-bottom: 1px solid var(--border-color); } 96 + tbody tr:last-child { border-bottom: none; } 97 + tbody tr:nth-child(even) { background: var(--table-stripe); } 98 + tbody td { padding: 10px 16px; font-size: 0.8125rem; } 99 + .code-cell { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; } 100 + 101 + .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } 102 + .badge-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); } 103 + .badge-danger { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); } 104 + 105 + .empty-state { text-align: center; padding: 40px 20px; color: var(--secondary-color); font-size: 0.875rem; } 106 + 107 + .load-more { text-align: center; padding: 16px; } 108 + .load-more a { color: var(--brand-color); text-decoration: none; font-size: 0.875rem; font-weight: 500; } 109 + .load-more a:hover { text-decoration: underline; } 110 + 111 + @media (max-width: 768px) { 112 + .sidebar { display: none; } 113 + .main { margin-left: 0; } 114 + } 115 + </style> 116 + </head> 117 + <body> 118 + <div class="layout"> 119 + <aside class="sidebar"> 120 + <div class="sidebar-title">{{pds_hostname}}</div> 121 + <div class="sidebar-subtitle">Admin Portal</div> 122 + <nav> 123 + <a href="/admin/">Dashboard</a> 124 + {{#if can_view_accounts}} 125 + <a href="/admin/accounts">Accounts</a> 126 + {{/if}} 127 + {{#if can_manage_invites}} 128 + <a href="/admin/invite-codes" class="active">Invite Codes</a> 129 + {{/if}} 130 + {{#if can_create_account}} 131 + <a href="/admin/create-account">Create Account</a> 132 + {{/if}} 133 + {{#if can_request_crawl}} 134 + <a href="/admin/request-crawl">Request Crawl</a> 135 + {{/if}} 136 + </nav> 137 + <div class="sidebar-footer"> 138 + <div class="session-info">Signed in as {{handle}}</div> 139 + <form method="POST" action="/admin/logout"> 140 + <button type="submit">Sign out</button> 141 + </form> 142 + </div> 143 + </aside> 144 + 145 + <main class="main"> 146 + {{#if flash_success}} 147 + <div class="flash-success">{{flash_success}}</div> 148 + {{/if}} 149 + {{#if flash_error}} 150 + <div class="flash-error">{{flash_error}}</div> 151 + {{/if}} 152 + 153 + <h1 class="page-title">Invite Codes</h1> 154 + 155 + {{#if can_create_invite}} 156 + <form class="create-form" method="POST" action="/admin/invite-codes/create"> 157 + <div class="form-group"> 158 + <label for="use_count">Max Uses</label> 159 + <input type="number" id="use_count" name="use_count" value="1" min="1" max="100" /> 160 + </div> 161 + <button type="submit" class="btn btn-primary">Create Invite Code</button> 162 + </form> 163 + {{/if}} 164 + 165 + {{#if new_code}} 166 + <div class="code-box"> 167 + <div class="code-label">New Invite Code Created</div> 168 + <div class="code-value">{{new_code}}</div> 169 + </div> 170 + {{/if}} 171 + 172 + {{#if codes}} 173 + <div class="table-container"> 174 + <table> 175 + <thead> 176 + <tr> 177 + <th>Code</th> 178 + <th>Remaining / Total</th> 179 + <th>Status</th> 180 + <th>Created By</th> 181 + <th>Created At</th> 182 + {{#if can_manage_invites}} 183 + <th></th> 184 + {{/if}} 185 + </tr> 186 + </thead> 187 + <tbody> 188 + {{#each codes}} 189 + <tr> 190 + <td class="code-cell">{{this.code}}</td> 191 + <td>{{this.remaining}} / {{this.available}}</td> 192 + <td> 193 + {{#if this.disabled}} 194 + <span class="badge badge-danger">Disabled</span> 195 + {{else}} 196 + <span class="badge badge-success">Active</span> 197 + {{/if}} 198 + </td> 199 + <td>{{this.createdBy}}</td> 200 + <td>{{this.createdAt}}</td> 201 + {{#if ../can_manage_invites}} 202 + <td> 203 + {{#unless this.disabled}} 204 + <form method="POST" action="/admin/invite-codes/disable" style="display:inline;"> 205 + <input type="hidden" name="codes" value="{{this.code}}" /> 206 + <button type="submit" class="btn btn-small btn-outline-danger">Disable</button> 207 + </form> 208 + {{/unless}} 209 + </td> 210 + {{/if}} 211 + </tr> 212 + {{/each}} 213 + </tbody> 214 + </table> 215 + </div> 216 + {{#if has_more}} 217 + <div class="load-more"> 218 + <a href="/admin/invite-codes?cursor={{next_cursor}}">Load More</a> 219 + </div> 220 + {{/if}} 221 + {{else}} 222 + <div class="empty-state">No invite codes found</div> 223 + {{/if}} 224 + </main> 225 + </div> 226 + </body> 227 + </html>
+158
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + } 20 + 21 + @media (prefers-color-scheme: dark) { 22 + :root { 23 + --brand-color: rgb(16, 131, 254); 24 + --primary-color: rgb(255, 255, 255); 25 + --secondary-color: rgb(133, 152, 173); 26 + --bg-primary-color: rgb(7, 10, 13); 27 + --bg-secondary-color: rgb(13, 18, 23); 28 + --border-color: rgb(40, 45, 55); 29 + } 30 + } 31 + 32 + :root.dark-mode { 33 + --brand-color: rgb(16, 131, 254); 34 + --primary-color: rgb(255, 255, 255); 35 + --secondary-color: rgb(133, 152, 173); 36 + --bg-primary-color: rgb(7, 10, 13); 37 + --bg-secondary-color: rgb(13, 18, 23); 38 + --border-color: rgb(40, 45, 55); 39 + } 40 + 41 + * { margin: 0; padding: 0; box-sizing: border-box; } 42 + 43 + body { 44 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 45 + background: var(--bg-secondary-color); 46 + color: var(--primary-color); 47 + text-rendering: optimizeLegibility; 48 + -webkit-font-smoothing: antialiased; 49 + min-height: 100vh; 50 + display: flex; 51 + align-items: center; 52 + justify-content: center; 53 + } 54 + 55 + .login-card { 56 + background: var(--bg-primary-color); 57 + border: 1px solid var(--border-color); 58 + border-radius: 12px; 59 + padding: 40px; 60 + width: 100%; 61 + max-width: 400px; 62 + margin: 20px; 63 + } 64 + 65 + .login-title { 66 + font-size: 1.5rem; 67 + font-weight: 700; 68 + text-align: center; 69 + margin-bottom: 4px; 70 + } 71 + 72 + .login-subtitle { 73 + font-size: 0.875rem; 74 + color: var(--secondary-color); 75 + text-align: center; 76 + margin-bottom: 32px; 77 + } 78 + 79 + .form-group { 80 + margin-bottom: 20px; 81 + } 82 + 83 + .form-group label { 84 + display: block; 85 + font-size: 0.875rem; 86 + font-weight: 500; 87 + margin-bottom: 6px; 88 + color: var(--primary-color); 89 + } 90 + 91 + .form-group input { 92 + width: 100%; 93 + padding: 10px 12px; 94 + font-size: 0.875rem; 95 + border: 1px solid var(--border-color); 96 + border-radius: 8px; 97 + background: var(--bg-primary-color); 98 + color: var(--primary-color); 99 + outline: none; 100 + transition: border-color 0.15s; 101 + } 102 + 103 + .form-group input:focus { 104 + border-color: var(--brand-color); 105 + } 106 + 107 + .btn { 108 + display: inline-flex; 109 + align-items: center; 110 + justify-content: center; 111 + padding: 10px 20px; 112 + font-size: 0.875rem; 113 + font-weight: 500; 114 + border: none; 115 + border-radius: 8px; 116 + cursor: pointer; 117 + transition: opacity 0.15s; 118 + text-decoration: none; 119 + } 120 + 121 + .btn:hover { opacity: 0.85; } 122 + 123 + .btn-primary { 124 + background: var(--brand-color); 125 + color: #fff; 126 + width: 100%; 127 + } 128 + 129 + .error-msg { 130 + background: rgba(220, 38, 38, 0.1); 131 + color: var(--danger-color); 132 + border: 1px solid rgba(220, 38, 38, 0.2); 133 + border-radius: 8px; 134 + padding: 10px 14px; 135 + font-size: 0.875rem; 136 + margin-bottom: 20px; 137 + } 138 + </style> 139 + </head> 140 + <body> 141 + <div class="login-card"> 142 + <div class="login-title">{{pds_hostname}}</div> 143 + <div class="login-subtitle">Admin Portal</div> 144 + 145 + {{#if error}} 146 + <div class="error-msg">{{error}}</div> 147 + {{/if}} 148 + 149 + <form method="POST" action="/admin/login"> 150 + <div class="form-group"> 151 + <label for="handle">Handle</label> 152 + <input type="text" id="handle" name="handle" placeholder="you.bsky.social" required autofocus /> 153 + </div> 154 + <button type="submit" class="btn btn-primary">Login with OAuth</button> 155 + </form> 156 + </div> 157 + </body> 158 + </html>
+146
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 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + --warning-color: rgb(234, 179, 8); 20 + --table-stripe: rgba(0, 0, 0, 0.02); 21 + } 22 + 23 + @media (prefers-color-scheme: dark) { 24 + :root { 25 + --brand-color: rgb(16, 131, 254); 26 + --primary-color: rgb(255, 255, 255); 27 + --secondary-color: rgb(133, 152, 173); 28 + --bg-primary-color: rgb(7, 10, 13); 29 + --bg-secondary-color: rgb(13, 18, 23); 30 + --border-color: rgb(40, 45, 55); 31 + --table-stripe: rgba(255, 255, 255, 0.02); 32 + } 33 + } 34 + 35 + :root.dark-mode { 36 + --brand-color: rgb(16, 131, 254); 37 + --primary-color: rgb(255, 255, 255); 38 + --secondary-color: rgb(133, 152, 173); 39 + --bg-primary-color: rgb(7, 10, 13); 40 + --bg-secondary-color: rgb(13, 18, 23); 41 + --border-color: rgb(40, 45, 55); 42 + --table-stripe: rgba(255, 255, 255, 0.02); 43 + } 44 + 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + 47 + body { 48 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 49 + background: var(--bg-secondary-color); 50 + color: var(--primary-color); 51 + text-rendering: optimizeLegibility; 52 + -webkit-font-smoothing: antialiased; 53 + } 54 + 55 + .layout { display: flex; min-height: 100vh; } 56 + 57 + .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 + .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 + .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 + .sidebar nav { flex: 1; } 61 + .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 + .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 + .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 + .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 + .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 + .sidebar-footer form { display: inline; } 67 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 + .sidebar-footer button:hover { color: var(--primary-color); } 69 + 70 + .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 + .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; } 72 + .page-description { font-size: 0.875rem; color: var(--secondary-color); margin-bottom: 24px; } 73 + 74 + .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 75 + .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 76 + 77 + .form-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; } 78 + .form-group { margin-bottom: 16px; } 79 + .form-group label { display: block; font-size: 0.8125rem; font-weight: 500; margin-bottom: 6px; color: var(--primary-color); } 80 + .form-group input { width: 100%; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; transition: border-color 0.15s; } 81 + .form-group input:focus { border-color: var(--brand-color); } 82 + .form-group .hint { font-size: 0.75rem; color: var(--secondary-color); margin-top: 4px; } 83 + 84 + .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 85 + .btn:hover { opacity: 0.85; } 86 + .btn-primary { background: var(--brand-color); color: #fff; } 87 + 88 + @media (max-width: 768px) { 89 + .sidebar { display: none; } 90 + .main { margin-left: 0; } 91 + } 92 + </style> 93 + </head> 94 + <body> 95 + <div class="layout"> 96 + <aside class="sidebar"> 97 + <div class="sidebar-title">{{pds_hostname}}</div> 98 + <div class="sidebar-subtitle">Admin Portal</div> 99 + <nav> 100 + <a href="/admin/">Dashboard</a> 101 + {{#if can_view_accounts}} 102 + <a href="/admin/accounts">Accounts</a> 103 + {{/if}} 104 + {{#if can_manage_invites}} 105 + <a href="/admin/invite-codes">Invite Codes</a> 106 + {{/if}} 107 + {{#if can_create_account}} 108 + <a href="/admin/create-account">Create Account</a> 109 + {{/if}} 110 + {{#if can_request_crawl}} 111 + <a href="/admin/request-crawl" class="active">Request Crawl</a> 112 + {{/if}} 113 + </nav> 114 + <div class="sidebar-footer"> 115 + <div class="session-info">Signed in as {{handle}}</div> 116 + <form method="POST" action="/admin/logout"> 117 + <button type="submit">Sign out</button> 118 + </form> 119 + </div> 120 + </aside> 121 + 122 + <main class="main"> 123 + {{#if flash_success}} 124 + <div class="flash-success">{{flash_success}}</div> 125 + {{/if}} 126 + {{#if flash_error}} 127 + <div class="flash-error">{{flash_error}}</div> 128 + {{/if}} 129 + 130 + <h1 class="page-title">Request Crawl</h1> 131 + <p class="page-description">Request a relay to crawl this PDS. This sends your PDS hostname to the relay so it can discover and index your content.</p> 132 + 133 + <div class="form-card"> 134 + <form method="POST" action="/admin/request-crawl"> 135 + <div class="form-group"> 136 + <label for="relay_host">Relay Host</label> 137 + <input type="text" id="relay_host" name="relay_host" value="{{default_relay}}" required /> 138 + <div class="hint">The relay hostname to send the crawl request to (e.g., bsky.network)</div> 139 + </div> 140 + <button type="submit" class="btn btn-primary">Request Crawl</button> 141 + </form> 142 + </div> 143 + </main> 144 + </div> 145 + </body> 146 + </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);
+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 + }
+73
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 + 8 + use axum::{Router, middleware as ax_middleware, routing::get, routing::post}; 9 + 10 + use crate::AppState; 11 + 12 + /// Build the admin sub-router. 13 + /// Public routes (login, OAuth callback, client-metadata) are not behind auth middleware. 14 + /// All other admin routes require a valid session. 15 + pub fn router(state: AppState) -> Router<AppState> { 16 + // Routes that do NOT require authentication 17 + let public_routes = Router::new() 18 + .route("/login", get(oauth::get_login).post(oauth::post_login)) 19 + .route("/oauth/callback", get(oauth::oauth_callback)) 20 + .route("/client-metadata.json", get(oauth::client_metadata_json)); 21 + 22 + // Routes that DO require authentication (via admin_auth middleware) 23 + let protected_routes = Router::new() 24 + .route("/", get(routes::dashboard)) 25 + .route("/accounts", get(routes::accounts_list)) 26 + .route("/accounts/{did}", get(routes::account_detail)) 27 + .route( 28 + "/accounts/{did}/takedown", 29 + post(routes::takedown_account), 30 + ) 31 + .route( 32 + "/accounts/{did}/untakedown", 33 + post(routes::untakedown_account), 34 + ) 35 + .route( 36 + "/accounts/{did}/delete", 37 + post(routes::delete_account), 38 + ) 39 + .route( 40 + "/accounts/{did}/reset-password", 41 + post(routes::reset_password), 42 + ) 43 + .route( 44 + "/accounts/{did}/disable-invites", 45 + post(routes::disable_account_invites), 46 + ) 47 + .route( 48 + "/accounts/{did}/enable-invites", 49 + post(routes::enable_account_invites), 50 + ) 51 + .route("/invite-codes", get(routes::invite_codes_list)) 52 + .route("/invite-codes/create", post(routes::create_invite_code)) 53 + .route( 54 + "/invite-codes/disable", 55 + post(routes::disable_invite_codes), 56 + ) 57 + .route( 58 + "/create-account", 59 + get(routes::get_create_account).post(routes::post_create_account), 60 + ) 61 + .route("/search", get(routes::search_accounts)) 62 + .route( 63 + "/request-crawl", 64 + get(routes::get_request_crawl).post(routes::post_request_crawl), 65 + ) 66 + .route("/logout", post(routes::logout)) 67 + .layer(ax_middleware::from_fn_with_state( 68 + state.clone(), 69 + middleware::admin_auth_middleware, 70 + )); 71 + 72 + Router::new().merge(public_routes).merge(protected_routes) 73 + }
+301
src/admin/oauth.rs
··· 1 + use axum::{ 2 + extract::{Query, State}, 3 + http::StatusCode, 4 + response::{Html, IntoResponse, Redirect, Response}, 5 + }; 6 + use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; 7 + use base64::Engine as _; 8 + use jacquard_identity::JacquardResolver; 9 + use jacquard_oauth::{ 10 + atproto::{AtprotoClientMetadata, GrantType}, 11 + authstore::MemoryAuthStore, 12 + client::OAuthClient, 13 + keyset::Keyset, 14 + session::ClientData, 15 + types::{AuthorizeOptions, CallbackParams}, 16 + }; 17 + use jose_jwk::Jwk; 18 + use serde::Deserialize; 19 + 20 + use crate::AppState; 21 + 22 + use super::session; 23 + 24 + /// Type alias for the concrete OAuthClient we use. 25 + pub type AdminOAuthClient = OAuthClient<JacquardResolver, MemoryAuthStore>; 26 + 27 + /// Initialize the OAuth client for admin portal authentication. 28 + pub fn init_oauth_client(pds_hostname: &str) -> Result<AdminOAuthClient, anyhow::Error> { 29 + // Generate ES256 keypair 30 + let secret_key = p256::SecretKey::random(&mut p256::elliptic_curve::rand_core::OsRng); 31 + let public_key = secret_key.public_key(); 32 + 33 + // Build JWK JSON manually with both public and private components 34 + let public_jwk_str = public_key.to_jwk_string(); 35 + let mut jwk: serde_json::Value = serde_json::from_str(&public_jwk_str)?; 36 + 37 + // Add the private key component 'd' 38 + let secret_scalar = secret_key.to_bytes(); 39 + let d_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret_scalar.as_slice()); 40 + let jwk_obj = jwk 41 + .as_object_mut() 42 + .ok_or_else(|| anyhow::anyhow!("JWK is not an object"))?; 43 + jwk_obj.insert("d".to_string(), serde_json::Value::String(d_b64)); 44 + 45 + // Add kid and alg 46 + let kid = uuid::Uuid::new_v4().to_string(); 47 + jwk_obj.insert("kid".to_string(), serde_json::Value::String(kid)); 48 + jwk_obj.insert( 49 + "alg".to_string(), 50 + serde_json::Value::String("ES256".to_string()), 51 + ); 52 + jwk_obj.insert( 53 + "use".to_string(), 54 + serde_json::Value::String("sig".to_string()), 55 + ); 56 + 57 + // Parse into jose-jwk type for Keyset 58 + let jose_jwk: Jwk = serde_json::from_value(jwk)?; 59 + let keyset = Keyset::try_from(vec![jose_jwk])?; 60 + 61 + // Build client metadata 62 + let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname) 63 + .parse() 64 + .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_id URL"))?; 65 + let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname) 66 + .parse() 67 + .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid redirect_uri URL"))?; 68 + let client_uri = format!("https://{}/admin/", pds_hostname) 69 + .parse() 70 + .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_uri URL"))?; 71 + 72 + let config = AtprotoClientMetadata::new( 73 + client_id, 74 + Some(client_uri), 75 + vec![redirect_uri], 76 + vec![GrantType::AuthorizationCode], 77 + vec![ 78 + jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope"), 79 + ], 80 + None, 81 + ); 82 + 83 + let client_data = ClientData::new(Some(keyset), config); 84 + let store = MemoryAuthStore::new(); 85 + let client = OAuthClient::new(store, client_data); 86 + 87 + Ok(client) 88 + } 89 + 90 + /// GET /admin/client-metadata.json — Serves the OAuth client metadata. 91 + pub async fn client_metadata_json(State(state): State<AppState>) -> Response { 92 + let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 93 + Some(client) => client, 94 + None => return StatusCode::NOT_FOUND.into_response(), 95 + }; 96 + 97 + let pds_hostname = &state.app_config.pds_hostname; 98 + let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname); 99 + let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname); 100 + let client_uri = format!("https://{}/admin/", pds_hostname); 101 + 102 + let jwks = oauth_client.jwks(); 103 + 104 + let metadata = serde_json::json!({ 105 + "client_id": client_id, 106 + "client_uri": client_uri, 107 + "redirect_uris": [redirect_uri], 108 + "grant_types": ["authorization_code"], 109 + "response_types": ["code"], 110 + "scope": "atproto", 111 + "token_endpoint_auth_method": "private_key_jwt", 112 + "token_endpoint_auth_signing_alg": "ES256", 113 + "application_type": "web", 114 + "dpop_bound_access_tokens": true, 115 + "jwks": jwks, 116 + }); 117 + 118 + ( 119 + StatusCode::OK, 120 + [(axum::http::header::CONTENT_TYPE, "application/json")], 121 + serde_json::to_string_pretty(&metadata).unwrap_or_default(), 122 + ) 123 + .into_response() 124 + } 125 + 126 + /// GET /admin/login — Renders the login page. 127 + pub async fn get_login( 128 + State(state): State<AppState>, 129 + Query(params): Query<LoginQueryParams>, 130 + ) -> Response { 131 + let mut data = serde_json::json!({ 132 + "pds_hostname": state.app_config.pds_hostname, 133 + }); 134 + 135 + if let Some(error) = params.error { 136 + data["error"] = serde_json::Value::String(error); 137 + } 138 + 139 + use axum_template::TemplateEngine; 140 + match state.template_engine.render("admin/login.hbs", data) 141 + { 142 + Ok(html) => Html(html).into_response(), 143 + Err(e) => { 144 + tracing::error!("Failed to render login template: {}", e); 145 + StatusCode::INTERNAL_SERVER_ERROR.into_response() 146 + } 147 + } 148 + } 149 + 150 + #[derive(Debug, Deserialize)] 151 + pub struct LoginQueryParams { 152 + pub error: Option<String>, 153 + } 154 + 155 + #[derive(Debug, Deserialize)] 156 + pub struct LoginForm { 157 + pub handle: String, 158 + } 159 + 160 + /// POST /admin/login — Initiates the OAuth flow. 161 + pub async fn post_login( 162 + State(state): State<AppState>, 163 + axum::extract::Form(form): axum::extract::Form<LoginForm>, 164 + ) -> Response { 165 + let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 166 + Some(client) => client, 167 + None => return StatusCode::NOT_FOUND.into_response(), 168 + }; 169 + 170 + let pds_hostname = &state.app_config.pds_hostname; 171 + let redirect_uri: url::Url = 172 + match format!("https://{}/admin/oauth/callback", pds_hostname).parse() { 173 + Ok(u) => u, 174 + Err(_) => { 175 + return Redirect::to("/admin/login?error=Invalid+server+configuration") 176 + .into_response() 177 + } 178 + }; 179 + 180 + let options = AuthorizeOptions { 181 + redirect_uri: Some(redirect_uri), 182 + scopes: vec![ 183 + jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope"), 184 + jacquard_oauth::scopes::Scope::parse("transition:generic").expect("valid scope"), 185 + ], 186 + prompt: None, 187 + state: None, 188 + }; 189 + 190 + match oauth_client.start_auth(&form.handle, options).await { 191 + Ok(auth_url) => Redirect::to(&auth_url).into_response(), 192 + Err(e) => { 193 + tracing::error!("OAuth start_auth failed: {}", e); 194 + let msg = format!("Login failed: {}", e); 195 + let error_msg = urlencoding::encode(&msg); 196 + Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response() 197 + } 198 + } 199 + } 200 + 201 + #[derive(Debug, Deserialize)] 202 + pub struct OAuthCallbackParams { 203 + pub code: String, 204 + pub state: Option<String>, 205 + pub iss: Option<String>, 206 + } 207 + 208 + /// GET /admin/oauth/callback — Handles the OAuth callback. 209 + pub async fn oauth_callback( 210 + State(state): State<AppState>, 211 + Query(params): Query<OAuthCallbackParams>, 212 + jar: SignedCookieJar, 213 + ) -> Response { 214 + let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 215 + Some(client) => client, 216 + None => return StatusCode::NOT_FOUND.into_response(), 217 + }; 218 + 219 + let rbac = match &state.admin_rbac_config { 220 + Some(rbac) => rbac, 221 + None => return StatusCode::NOT_FOUND.into_response(), 222 + }; 223 + 224 + let callback_params = CallbackParams { 225 + code: params.code.as_str().into(), 226 + state: params.state.as_deref().map(Into::into), 227 + iss: params.iss.as_deref().map(Into::into), 228 + }; 229 + 230 + // Exchange authorization code for session 231 + let oauth_session = match oauth_client.callback(callback_params).await { 232 + Ok(session) => session, 233 + Err(e) => { 234 + tracing::error!("OAuth callback failed: {}", e); 235 + let msg = format!("Authentication failed: {}", e); 236 + let error_msg = urlencoding::encode(&msg); 237 + return Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response(); 238 + } 239 + }; 240 + 241 + // Extract DID and handle from the OAuth session 242 + let (did, handle) = oauth_session.session_info().await; 243 + let did_str = did.to_string(); 244 + let handle_str = handle.to_string(); 245 + 246 + // Check if this DID is a member in the RBAC config 247 + if !rbac.is_member(&did_str) { 248 + tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str); 249 + return render_error( 250 + &state, 251 + "Access Denied", 252 + &format!( 253 + "Your identity ({}) is not authorized to access the admin portal. Contact your PDS administrator.", 254 + handle_str 255 + ), 256 + ); 257 + } 258 + 259 + // Create admin session 260 + let ttl_hours = state.app_config.admin_session_ttl_hours; 261 + let session_id = match session::create_session( 262 + &state.pds_gatekeeper_pool, 263 + &did_str, 264 + &handle_str, 265 + ttl_hours, 266 + ) 267 + .await 268 + { 269 + Ok(id) => id, 270 + Err(e) => { 271 + tracing::error!("Failed to create admin session: {}", e); 272 + return Redirect::to("/admin/login?error=Session+creation+failed").into_response(); 273 + } 274 + }; 275 + 276 + // Set signed cookie 277 + let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id); 278 + cookie.set_http_only(true); 279 + cookie.set_secure(true); 280 + cookie.set_same_site(SameSite::Lax); 281 + cookie.set_path("/admin/"); 282 + 283 + let updated_jar = jar.add(cookie); 284 + 285 + (updated_jar, Redirect::to("/admin/")).into_response() 286 + } 287 + 288 + fn render_error(state: &AppState, title: &str, message: &str) -> Response { 289 + let data = serde_json::json!({ 290 + "error_title": title, 291 + "error_message": message, 292 + "pds_hostname": state.app_config.pds_hostname, 293 + }); 294 + 295 + use axum_template::TemplateEngine; 296 + match state.template_engine.render("admin/error.hbs", data) 297 + { 298 + Ok(html) => Html(html).into_response(), 299 + Err(_) => (StatusCode::FORBIDDEN, format!("{}: {}", title, message)).into_response(), 300 + } 301 + }
+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 + }
+263
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.searchAccounts" 120 + - "com.atproto.admin.getInviteCodes" 121 + invite-manager: 122 + description: "Invite management" 123 + endpoints: 124 + - "com.atproto.admin.getInviteCodes" 125 + - "com.atproto.admin.disableInviteCodes" 126 + - "com.atproto.server.createInviteCode" 127 + 128 + members: 129 + - did: "did:plc:admin123" 130 + roles: 131 + - pds-admin 132 + - did: "did:plc:mod456" 133 + roles: 134 + - moderator 135 + - did: "did:plc:both789" 136 + roles: 137 + - moderator 138 + - invite-manager 139 + "#; 140 + serde_yaml::from_str(yaml).unwrap() 141 + } 142 + 143 + #[test] 144 + fn test_is_member() { 145 + let config = test_config(); 146 + assert!(config.is_member("did:plc:admin123")); 147 + assert!(config.is_member("did:plc:mod456")); 148 + assert!(!config.is_member("did:plc:unknown")); 149 + } 150 + 151 + #[test] 152 + fn test_get_member_roles() { 153 + let config = test_config(); 154 + assert_eq!(config.get_member_roles("did:plc:admin123"), vec!["pds-admin"]); 155 + assert_eq!( 156 + config.get_member_roles("did:plc:both789"), 157 + vec!["moderator", "invite-manager"] 158 + ); 159 + assert!(config.get_member_roles("did:plc:unknown").is_empty()); 160 + } 161 + 162 + #[test] 163 + fn test_wildcard_matching() { 164 + assert!(matches_endpoint_pattern( 165 + "com.atproto.admin.*", 166 + "com.atproto.admin.getAccountInfo" 167 + )); 168 + assert!(matches_endpoint_pattern( 169 + "com.atproto.admin.*", 170 + "com.atproto.admin.deleteAccount" 171 + )); 172 + assert!(!matches_endpoint_pattern( 173 + "com.atproto.admin.*", 174 + "com.atproto.server.createAccount" 175 + )); 176 + } 177 + 178 + #[test] 179 + fn test_exact_matching() { 180 + assert!(matches_endpoint_pattern( 181 + "com.atproto.server.createInviteCode", 182 + "com.atproto.server.createInviteCode" 183 + )); 184 + assert!(!matches_endpoint_pattern( 185 + "com.atproto.server.createInviteCode", 186 + "com.atproto.server.createAccount" 187 + )); 188 + } 189 + 190 + #[test] 191 + fn test_admin_can_access_all_admin_endpoints() { 192 + let config = test_config(); 193 + assert!(config.can_access_endpoint("did:plc:admin123", "com.atproto.admin.getAccountInfo")); 194 + assert!(config.can_access_endpoint("did:plc:admin123", "com.atproto.admin.deleteAccount")); 195 + assert!(config.can_access_endpoint( 196 + "did:plc:admin123", 197 + "com.atproto.server.createInviteCode" 198 + )); 199 + assert!(config.can_access_endpoint("did:plc:admin123", "com.atproto.server.createAccount")); 200 + } 201 + 202 + #[test] 203 + fn test_moderator_limited_access() { 204 + let config = test_config(); 205 + assert!(config.can_access_endpoint("did:plc:mod456", "com.atproto.admin.getAccountInfo")); 206 + assert!(config.can_access_endpoint( 207 + "did:plc:mod456", 208 + "com.atproto.admin.updateSubjectStatus" 209 + )); 210 + assert!(!config.can_access_endpoint("did:plc:mod456", "com.atproto.admin.deleteAccount")); 211 + assert!(!config.can_access_endpoint( 212 + "did:plc:mod456", 213 + "com.atproto.server.createInviteCode" 214 + )); 215 + } 216 + 217 + #[test] 218 + fn test_combined_roles() { 219 + let config = test_config(); 220 + // Has moderator + invite-manager 221 + assert!(config.can_access_endpoint("did:plc:both789", "com.atproto.admin.getAccountInfo")); 222 + assert!(config.can_access_endpoint("did:plc:both789", "com.atproto.admin.getInviteCodes")); 223 + assert!(config.can_access_endpoint( 224 + "did:plc:both789", 225 + "com.atproto.server.createInviteCode" 226 + )); 227 + assert!(config.can_access_endpoint( 228 + "did:plc:both789", 229 + "com.atproto.admin.disableInviteCodes" 230 + )); 231 + // But not delete or create account 232 + assert!(!config.can_access_endpoint("did:plc:both789", "com.atproto.admin.deleteAccount")); 233 + assert!(!config.can_access_endpoint( 234 + "did:plc:both789", 235 + "com.atproto.server.createAccount" 236 + )); 237 + } 238 + 239 + #[test] 240 + fn test_non_member_no_access() { 241 + let config = test_config(); 242 + assert!(!config.can_access_endpoint( 243 + "did:plc:unknown", 244 + "com.atproto.admin.getAccountInfo" 245 + )); 246 + } 247 + 248 + #[test] 249 + fn test_validate_rejects_undefined_role() { 250 + let yaml = r#" 251 + roles: 252 + admin: 253 + description: "Admin" 254 + endpoints: ["com.atproto.admin.*"] 255 + members: 256 + - did: "did:plc:test" 257 + roles: 258 + - nonexistent 259 + "#; 260 + let config: RbacConfig = serde_yaml::from_str(yaml).unwrap(); 261 + assert!(config.validate().is_err()); 262 + } 263 + }
+1267
src/admin/routes.rs
··· 1 + use axum::{ 2 + extract::{Extension, Path, Query, State}, 3 + http::StatusCode, 4 + response::{Html, IntoResponse, Redirect, Response}, 5 + }; 6 + use axum_extra::extract::cookie::{Cookie, SignedCookieJar}; 7 + use serde::Deserialize; 8 + 9 + use crate::AppState; 10 + 11 + use super::middleware::{AdminPermissions, AdminSession}; 12 + use super::pds_proxy; 13 + use super::session; 14 + 15 + // ─── Query parameter types ─────────────────────────────────────────────────── 16 + 17 + #[derive(Debug, Deserialize)] 18 + pub struct FlashParams { 19 + pub flash_success: Option<String>, 20 + pub flash_error: Option<String>, 21 + } 22 + 23 + #[derive(Debug, Deserialize)] 24 + pub struct AccountDetailParams { 25 + pub flash_success: Option<String>, 26 + pub flash_error: Option<String>, 27 + pub new_password: Option<String>, 28 + } 29 + 30 + #[derive(Debug, Deserialize)] 31 + pub struct SearchParams { 32 + pub q: Option<String>, 33 + pub flash_success: Option<String>, 34 + pub flash_error: Option<String>, 35 + } 36 + 37 + #[derive(Debug, Deserialize)] 38 + pub struct InviteCodesParams { 39 + pub cursor: Option<String>, 40 + pub flash_success: Option<String>, 41 + pub flash_error: Option<String>, 42 + } 43 + 44 + // ─── Form types ────────────────────────────────────────────────────────────── 45 + 46 + #[derive(Debug, Deserialize)] 47 + pub struct CreateInviteForm { 48 + pub use_count: Option<i64>, 49 + } 50 + 51 + #[derive(Debug, Deserialize)] 52 + pub struct DisableInviteCodesForm { 53 + pub codes: String, // comma-separated 54 + } 55 + 56 + #[derive(Debug, Deserialize)] 57 + pub struct CreateAccountForm { 58 + pub email: String, 59 + pub handle: String, 60 + } 61 + 62 + #[derive(Debug, Deserialize)] 63 + pub struct RequestCrawlForm { 64 + pub relay_host: String, 65 + } 66 + 67 + // ─── Helper functions ──────────────────────────────────────────────────────── 68 + 69 + fn admin_password(state: &AppState) -> &str { 70 + state 71 + .app_config 72 + .pds_admin_password 73 + .as_deref() 74 + .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( 130 + "can_send_email".into(), 131 + permissions.can_send_email.into(), 132 + ); 133 + obj.insert( 134 + "can_request_crawl".into(), 135 + permissions.can_request_crawl.into(), 136 + ); 137 + } 138 + 139 + fn flash_redirect(base_path: &str, success: Option<&str>, error: Option<&str>) -> Response { 140 + let mut url = base_path.to_string(); 141 + let mut sep = '?'; 142 + if let Some(msg) = success { 143 + url.push_str(&format!("{}flash_success={}", sep, urlencoding::encode(msg))); 144 + sep = '&'; 145 + } 146 + if let Some(msg) = error { 147 + url.push_str(&format!("{}flash_error={}", sep, urlencoding::encode(msg))); 148 + } 149 + Redirect::to(&url).into_response() 150 + } 151 + 152 + fn generate_random_password() -> String { 153 + use rand::Rng; 154 + let chars = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; 155 + let mut rng = rand::rng(); 156 + (0..24) 157 + .map(|_| chars[rng.random_range(0..chars.len())] as char) 158 + .collect() 159 + } 160 + 161 + // ─── Route handlers ────────────────────────────────────────────────────────── 162 + 163 + /// GET /admin/ — Dashboard 164 + pub async fn dashboard( 165 + State(state): State<AppState>, 166 + Extension(session): Extension<AdminSession>, 167 + Extension(permissions): Extension<AdminPermissions>, 168 + Query(flash): Query<FlashParams>, 169 + ) -> Response { 170 + let pds = pds_url(&state); 171 + 172 + // Fetch health and server description in parallel 173 + let health_fut = pds_proxy::get_text(pds, "xrpc/_health"); 174 + let desc_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 175 + pds, 176 + "com.atproto.server.describeServer", 177 + &[], 178 + ); 179 + 180 + let (health_res, desc_res) = tokio::join!(health_fut, desc_fut); 181 + 182 + let version = health_res 183 + .ok() 184 + .and_then(|t| { 185 + serde_json::from_str::<serde_json::Value>(&t) 186 + .ok() 187 + .and_then(|h| h["version"].as_str().map(|s| s.to_string())) 188 + }) 189 + .unwrap_or_else(|| "Unknown".to_string()); 190 + 191 + let desc = desc_res.ok(); 192 + 193 + // Conditionally fetch account count only if admin can view accounts 194 + let account_count = if permissions.can_view_accounts { 195 + pds_proxy::public_xrpc_get::<serde_json::Value>( 196 + pds, 197 + "com.atproto.sync.listRepos", 198 + &[("limit", "1000")], 199 + ) 200 + .await 201 + .ok() 202 + .and_then(|r| r["repos"].as_array().map(|a| a.len())) 203 + .unwrap_or(0) 204 + } else { 205 + 0 206 + }; 207 + 208 + // Extract describeServer fields (Gap 1) 209 + let server_did = desc 210 + .as_ref() 211 + .and_then(|d| d["did"].as_str()) 212 + .unwrap_or_default() 213 + .to_string(); 214 + let available_domains: Vec<String> = desc 215 + .as_ref() 216 + .and_then(|d| d["availableUserDomains"].as_array()) 217 + .map(|arr| { 218 + arr.iter() 219 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 220 + .collect() 221 + }) 222 + .unwrap_or_default(); 223 + let invite_code_required = desc 224 + .as_ref() 225 + .and_then(|d| d["inviteCodeRequired"].as_bool()) 226 + .unwrap_or(false); 227 + let phone_verification_required = desc 228 + .as_ref() 229 + .and_then(|d| d["phoneVerificationRequired"].as_bool()) 230 + .unwrap_or(false); 231 + 232 + // Gap 1: server links 233 + let privacy_policy = desc 234 + .as_ref() 235 + .and_then(|d| d["links"]["privacyPolicy"].as_str()) 236 + .map(|s| s.to_string()); 237 + let terms_of_service = desc 238 + .as_ref() 239 + .and_then(|d| d["links"]["termsOfService"].as_str()) 240 + .map(|s| s.to_string()); 241 + 242 + // Gap 1: contact email 243 + let contact_email = desc 244 + .as_ref() 245 + .and_then(|d| d["contact"]["email"].as_str()) 246 + .map(|s| s.to_string()); 247 + 248 + let mut data = serde_json::json!({ 249 + "version": version, 250 + "account_count": account_count, 251 + "server_did": server_did, 252 + "available_domains": available_domains, 253 + "invite_code_required": invite_code_required, 254 + "phone_verification_required": phone_verification_required, 255 + "pds_hostname": state.app_config.pds_hostname, 256 + "active_page": "dashboard", 257 + }); 258 + 259 + if let Some(url) = privacy_policy { 260 + data["privacy_policy"] = url.into(); 261 + } 262 + if let Some(url) = terms_of_service { 263 + data["terms_of_service"] = url.into(); 264 + } 265 + if let Some(email) = contact_email { 266 + data["contact_email"] = email.into(); 267 + } 268 + 269 + if let Some(msg) = flash.flash_success { 270 + data["flash_success"] = msg.into(); 271 + } 272 + if let Some(msg) = flash.flash_error { 273 + data["flash_error"] = msg.into(); 274 + } 275 + 276 + inject_nav_data(&mut data, &session, &permissions); 277 + 278 + render_template(&state, "admin/dashboard.hbs", data) 279 + } 280 + 281 + /// GET /admin/accounts — Account list 282 + pub async fn accounts_list( 283 + State(state): State<AppState>, 284 + Extension(session): Extension<AdminSession>, 285 + Extension(permissions): Extension<AdminPermissions>, 286 + Query(flash): Query<FlashParams>, 287 + ) -> Response { 288 + if !permissions.can_view_accounts { 289 + return flash_redirect("/admin/", None, Some("Access denied")); 290 + } 291 + 292 + let pds = pds_url(&state); 293 + let password = admin_password(&state); 294 + 295 + // Get all repos first 296 + let repos = match pds_proxy::public_xrpc_get::<serde_json::Value>( 297 + pds, 298 + "com.atproto.sync.listRepos", 299 + &[("limit", "1000")], 300 + ) 301 + .await 302 + { 303 + Ok(r) => r, 304 + Err(e) => { 305 + tracing::error!("Failed to list repos: {}", e); 306 + return flash_redirect( 307 + "/admin/", 308 + None, 309 + Some(&format!("Failed to list accounts: {}", e)), 310 + ); 311 + } 312 + }; 313 + 314 + let dids: Vec<String> = repos["repos"] 315 + .as_array() 316 + .map(|arr| { 317 + arr.iter() 318 + .filter_map(|r| r["did"].as_str().map(|s| s.to_string())) 319 + .collect() 320 + }) 321 + .unwrap_or_default(); 322 + 323 + let accounts: serde_json::Value = if !dids.is_empty() { 324 + let did_params: Vec<(&str, &str)> = dids.iter().map(|d| ("dids", d.as_str())).collect(); 325 + match pds_proxy::admin_xrpc_get::<serde_json::Value>( 326 + pds, 327 + password, 328 + "com.atproto.admin.getAccountInfos", 329 + &did_params, 330 + ) 331 + .await 332 + { 333 + Ok(res) => res["infos"].clone(), 334 + Err(e) => { 335 + tracing::error!("Failed to get account infos: {}", e); 336 + serde_json::json!([]) 337 + } 338 + } 339 + } else { 340 + serde_json::json!([]) 341 + }; 342 + 343 + let account_count = accounts.as_array().map(|a| a.len()).unwrap_or(0); 344 + 345 + let mut data = serde_json::json!({ 346 + "accounts": accounts, 347 + "account_count": account_count, 348 + "pds_hostname": state.app_config.pds_hostname, 349 + "active_page": "accounts", 350 + }); 351 + 352 + if let Some(msg) = flash.flash_success { 353 + data["flash_success"] = msg.into(); 354 + } 355 + if let Some(msg) = flash.flash_error { 356 + data["flash_error"] = msg.into(); 357 + } 358 + 359 + inject_nav_data(&mut data, &session, &permissions); 360 + 361 + render_template(&state, "admin/accounts.hbs", data) 362 + } 363 + 364 + /// GET /admin/accounts/:did — Account detail 365 + pub async fn account_detail( 366 + State(state): State<AppState>, 367 + Extension(session): Extension<AdminSession>, 368 + Extension(permissions): Extension<AdminPermissions>, 369 + Path(did): Path<String>, 370 + Query(params): Query<AccountDetailParams>, 371 + ) -> Response { 372 + if !permissions.can_view_accounts { 373 + return flash_redirect("/admin/", None, Some("Access denied")); 374 + } 375 + 376 + let pds = pds_url(&state); 377 + let password = admin_password(&state); 378 + 379 + // Fetch account info, subject status, repo description, and repo status in parallel 380 + let did_param: Vec<(&str, &str)> = vec![("did", did.as_str())]; 381 + let account_fut = pds_proxy::admin_xrpc_get::<serde_json::Value>( 382 + pds, 383 + password, 384 + "com.atproto.admin.getAccountInfo", 385 + &did_param, 386 + ); 387 + 388 + let did_param2: Vec<(&str, &str)> = vec![("did", did.as_str())]; 389 + let status_fut = pds_proxy::admin_xrpc_get::<serde_json::Value>( 390 + pds, 391 + password, 392 + "com.atproto.admin.getSubjectStatus", 393 + &did_param2, 394 + ); 395 + 396 + // Gap 2: fetch repo description (public, no auth needed) 397 + let repo_param: Vec<(&str, &str)> = vec![("repo", did.as_str())]; 398 + let describe_repo_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 399 + pds, 400 + "com.atproto.repo.describeRepo", 401 + &repo_param, 402 + ); 403 + 404 + // Gap 2: fetch repo status (public, no auth needed) 405 + let did_param3: Vec<(&str, &str)> = vec![("did", did.as_str())]; 406 + let repo_status_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 407 + pds, 408 + "com.atproto.sync.getRepoStatus", 409 + &did_param3, 410 + ); 411 + 412 + let (account_res, status_res, describe_repo_res, repo_status_res) = 413 + tokio::join!(account_fut, status_fut, describe_repo_fut, repo_status_fut); 414 + 415 + let account = match account_res { 416 + Ok(a) => a, 417 + Err(e) => { 418 + tracing::error!("Failed to get account info for {}: {}", did, e); 419 + return flash_redirect( 420 + "/admin/accounts", 421 + None, 422 + Some(&format!("Failed to get account: {}", e)), 423 + ); 424 + } 425 + }; 426 + 427 + let status_val = status_res.ok(); 428 + let takedown_applied = status_val 429 + .as_ref() 430 + .and_then(|s| s["takedown"]["applied"].as_bool()) 431 + .unwrap_or(false); 432 + let takedown_ref = status_val 433 + .as_ref() 434 + .and_then(|s| s["takedown"]["ref"].as_str()) 435 + .map(|s| s.to_string()); 436 + 437 + // Gap 2: extract repo description data 438 + let describe_repo = describe_repo_res.ok(); 439 + let handle_is_correct = describe_repo 440 + .as_ref() 441 + .and_then(|d| d["handleIsCorrect"].as_bool()); 442 + let collections: Vec<String> = describe_repo 443 + .as_ref() 444 + .and_then(|d| d["collections"].as_array()) 445 + .map(|arr| { 446 + arr.iter() 447 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 448 + .collect() 449 + }) 450 + .unwrap_or_default(); 451 + 452 + // Gap 2: extract repo status data 453 + let repo_status = repo_status_res.ok(); 454 + let repo_active = repo_status 455 + .as_ref() 456 + .and_then(|r| r["active"].as_bool()); 457 + let repo_status_reason = repo_status 458 + .as_ref() 459 + .and_then(|r| r["status"].as_str()) 460 + .map(|s| s.to_string()); 461 + let repo_rev = repo_status 462 + .as_ref() 463 + .and_then(|r| r["rev"].as_str()) 464 + .map(|s| s.to_string()); 465 + 466 + // Extract threat signatures from account view 467 + let threat_signatures = account["threatSignatures"].clone(); 468 + 469 + let mut data = serde_json::json!({ 470 + "account": account, 471 + "is_taken_down": takedown_applied, 472 + "pds_hostname": state.app_config.pds_hostname, 473 + "active_page": "accounts", 474 + }); 475 + 476 + // Gap 2: add repo description data 477 + if let Some(correct) = handle_is_correct { 478 + data["handle_is_correct"] = correct.into(); 479 + data["handle_resolution_checked"] = true.into(); 480 + } 481 + if !collections.is_empty() { 482 + data["collections"] = serde_json::json!(collections); 483 + } 484 + 485 + // Gap 2: add repo status data 486 + if let Some(active) = repo_active { 487 + data["repo_active"] = active.into(); 488 + data["repo_status_checked"] = true.into(); 489 + } 490 + if let Some(reason) = repo_status_reason { 491 + data["repo_status_reason"] = reason.into(); 492 + } 493 + if let Some(rev) = repo_rev { 494 + data["repo_rev"] = rev.into(); 495 + } 496 + 497 + // Takedown reference 498 + if let Some(tref) = takedown_ref { 499 + data["takedown_ref"] = tref.into(); 500 + } 501 + 502 + // Threat signatures 503 + if threat_signatures.is_array() 504 + && !threat_signatures.as_array().unwrap().is_empty() 505 + { 506 + data["threat_signatures"] = threat_signatures; 507 + } 508 + 509 + // Bug 3 fix: pass new_password from query params into template data 510 + if let Some(pw) = params.new_password { 511 + data["new_password"] = pw.into(); 512 + } 513 + 514 + if let Some(msg) = params.flash_success { 515 + data["flash_success"] = msg.into(); 516 + } 517 + if let Some(msg) = params.flash_error { 518 + data["flash_error"] = msg.into(); 519 + } 520 + 521 + inject_nav_data(&mut data, &session, &permissions); 522 + 523 + render_template(&state, "admin/account_detail.hbs", data) 524 + } 525 + 526 + /// POST /admin/accounts/:did/takedown 527 + pub async fn takedown_account( 528 + State(state): State<AppState>, 529 + Extension(session): Extension<AdminSession>, 530 + Extension(_permissions): Extension<AdminPermissions>, 531 + Path(did): Path<String>, 532 + ) -> Response { 533 + let rbac = match &state.admin_rbac_config { 534 + Some(r) => r, 535 + None => return StatusCode::NOT_FOUND.into_response(), 536 + }; 537 + 538 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.updateSubjectStatus") { 539 + return flash_redirect( 540 + &format!("/admin/accounts/{}", did), 541 + None, 542 + Some("Access denied: cannot manage takedowns"), 543 + ); 544 + } 545 + 546 + let body = serde_json::json!({ 547 + "subject": { 548 + "$type": "com.atproto.admin.defs#repoRef", 549 + "did": did, 550 + }, 551 + "takedown": { 552 + "applied": true, 553 + "ref": chrono::Utc::now().timestamp().to_string(), 554 + }, 555 + }); 556 + 557 + match pds_proxy::admin_xrpc_post_no_response( 558 + pds_url(&state), 559 + admin_password(&state), 560 + "com.atproto.admin.updateSubjectStatus", 561 + &body, 562 + ) 563 + .await 564 + { 565 + Ok(()) => flash_redirect( 566 + &format!("/admin/accounts/{}", did), 567 + Some("Account taken down successfully"), 568 + None, 569 + ), 570 + Err(e) => flash_redirect( 571 + &format!("/admin/accounts/{}", did), 572 + None, 573 + Some(&format!("Takedown failed: {}", e)), 574 + ), 575 + } 576 + } 577 + 578 + /// POST /admin/accounts/:did/untakedown 579 + pub async fn untakedown_account( 580 + State(state): State<AppState>, 581 + Extension(session): Extension<AdminSession>, 582 + Extension(_permissions): Extension<AdminPermissions>, 583 + Path(did): Path<String>, 584 + ) -> Response { 585 + let rbac = match &state.admin_rbac_config { 586 + Some(r) => r, 587 + None => return StatusCode::NOT_FOUND.into_response(), 588 + }; 589 + 590 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.updateSubjectStatus") { 591 + return flash_redirect( 592 + &format!("/admin/accounts/{}", did), 593 + None, 594 + Some("Access denied"), 595 + ); 596 + } 597 + 598 + let body = serde_json::json!({ 599 + "subject": { 600 + "$type": "com.atproto.admin.defs#repoRef", 601 + "did": did, 602 + }, 603 + "takedown": { 604 + "applied": false, 605 + }, 606 + }); 607 + 608 + match pds_proxy::admin_xrpc_post_no_response( 609 + pds_url(&state), 610 + admin_password(&state), 611 + "com.atproto.admin.updateSubjectStatus", 612 + &body, 613 + ) 614 + .await 615 + { 616 + Ok(()) => flash_redirect( 617 + &format!("/admin/accounts/{}", did), 618 + Some("Takedown removed successfully"), 619 + None, 620 + ), 621 + Err(e) => flash_redirect( 622 + &format!("/admin/accounts/{}", did), 623 + None, 624 + Some(&format!("Failed to remove takedown: {}", e)), 625 + ), 626 + } 627 + } 628 + 629 + /// POST /admin/accounts/:did/delete 630 + pub async fn delete_account( 631 + State(state): State<AppState>, 632 + Extension(session): Extension<AdminSession>, 633 + Extension(_permissions): Extension<AdminPermissions>, 634 + Path(did): Path<String>, 635 + ) -> Response { 636 + let rbac = match &state.admin_rbac_config { 637 + Some(r) => r, 638 + None => return StatusCode::NOT_FOUND.into_response(), 639 + }; 640 + 641 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.deleteAccount") { 642 + return flash_redirect( 643 + &format!("/admin/accounts/{}", did), 644 + None, 645 + Some("Access denied: cannot delete accounts"), 646 + ); 647 + } 648 + 649 + let body = serde_json::json!({ "did": did }); 650 + 651 + match pds_proxy::admin_xrpc_post_no_response( 652 + pds_url(&state), 653 + admin_password(&state), 654 + "com.atproto.admin.deleteAccount", 655 + &body, 656 + ) 657 + .await 658 + { 659 + Ok(()) => flash_redirect( 660 + "/admin/accounts", 661 + Some(&format!("Account {} deleted", did)), 662 + None, 663 + ), 664 + Err(e) => flash_redirect( 665 + &format!("/admin/accounts/{}", did), 666 + None, 667 + Some(&format!("Delete failed: {}", e)), 668 + ), 669 + } 670 + } 671 + 672 + /// POST /admin/accounts/:did/reset-password 673 + pub async fn reset_password( 674 + State(state): State<AppState>, 675 + Extension(session): Extension<AdminSession>, 676 + Extension(_permissions): Extension<AdminPermissions>, 677 + Path(did): Path<String>, 678 + Query(_flash): Query<FlashParams>, 679 + ) -> Response { 680 + let rbac = match &state.admin_rbac_config { 681 + Some(r) => r, 682 + None => return StatusCode::NOT_FOUND.into_response(), 683 + }; 684 + 685 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.updateAccountPassword") { 686 + return flash_redirect( 687 + &format!("/admin/accounts/{}", did), 688 + None, 689 + Some("Access denied: cannot reset passwords"), 690 + ); 691 + } 692 + 693 + let new_password = generate_random_password(); 694 + let body = serde_json::json!({ 695 + "did": did, 696 + "password": new_password, 697 + }); 698 + 699 + match pds_proxy::admin_xrpc_post_no_response( 700 + pds_url(&state), 701 + admin_password(&state), 702 + "com.atproto.admin.updateAccountPassword", 703 + &body, 704 + ) 705 + .await 706 + { 707 + Ok(()) => { 708 + // Bug 3 fix: redirect with new_password param so account_detail can display it 709 + let encoded = urlencoding::encode(&new_password); 710 + Redirect::to(&format!( 711 + "/admin/accounts/{}?flash_success=Password+reset+successfully&new_password={}", 712 + did, encoded 713 + )) 714 + .into_response() 715 + } 716 + Err(e) => flash_redirect( 717 + &format!("/admin/accounts/{}", did), 718 + None, 719 + Some(&format!("Password reset failed: {}", e)), 720 + ), 721 + } 722 + } 723 + 724 + /// POST /admin/accounts/:did/disable-invites 725 + pub async fn disable_account_invites( 726 + State(state): State<AppState>, 727 + Extension(session): Extension<AdminSession>, 728 + Extension(_permissions): Extension<AdminPermissions>, 729 + Path(did): Path<String>, 730 + ) -> Response { 731 + let rbac = match &state.admin_rbac_config { 732 + Some(r) => r, 733 + None => return StatusCode::NOT_FOUND.into_response(), 734 + }; 735 + 736 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.disableAccountInvites") { 737 + return flash_redirect( 738 + &format!("/admin/accounts/{}", did), 739 + None, 740 + Some("Access denied"), 741 + ); 742 + } 743 + 744 + let body = serde_json::json!({ "account": did }); 745 + 746 + match pds_proxy::admin_xrpc_post_no_response( 747 + pds_url(&state), 748 + admin_password(&state), 749 + "com.atproto.admin.disableAccountInvites", 750 + &body, 751 + ) 752 + .await 753 + { 754 + Ok(()) => flash_redirect( 755 + &format!("/admin/accounts/{}", did), 756 + Some("Invites disabled"), 757 + None, 758 + ), 759 + Err(e) => flash_redirect( 760 + &format!("/admin/accounts/{}", did), 761 + None, 762 + Some(&format!("Failed: {}", e)), 763 + ), 764 + } 765 + } 766 + 767 + /// POST /admin/accounts/:did/enable-invites 768 + pub async fn enable_account_invites( 769 + State(state): State<AppState>, 770 + Extension(session): Extension<AdminSession>, 771 + Extension(_permissions): Extension<AdminPermissions>, 772 + Path(did): Path<String>, 773 + ) -> Response { 774 + let rbac = match &state.admin_rbac_config { 775 + Some(r) => r, 776 + None => return StatusCode::NOT_FOUND.into_response(), 777 + }; 778 + 779 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.enableAccountInvites") { 780 + return flash_redirect( 781 + &format!("/admin/accounts/{}", did), 782 + None, 783 + Some("Access denied"), 784 + ); 785 + } 786 + 787 + let body = serde_json::json!({ "account": did }); 788 + 789 + match pds_proxy::admin_xrpc_post_no_response( 790 + pds_url(&state), 791 + admin_password(&state), 792 + "com.atproto.admin.enableAccountInvites", 793 + &body, 794 + ) 795 + .await 796 + { 797 + Ok(()) => flash_redirect( 798 + &format!("/admin/accounts/{}", did), 799 + Some("Invites enabled"), 800 + None, 801 + ), 802 + Err(e) => flash_redirect( 803 + &format!("/admin/accounts/{}", did), 804 + None, 805 + Some(&format!("Failed: {}", e)), 806 + ), 807 + } 808 + } 809 + 810 + /// GET /admin/invite-codes — List invite codes (with pagination) 811 + pub async fn invite_codes_list( 812 + State(state): State<AppState>, 813 + Extension(session): Extension<AdminSession>, 814 + Extension(permissions): Extension<AdminPermissions>, 815 + Query(params): Query<InviteCodesParams>, 816 + ) -> Response { 817 + let rbac = match &state.admin_rbac_config { 818 + Some(r) => r, 819 + None => return StatusCode::NOT_FOUND.into_response(), 820 + }; 821 + 822 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.getInviteCodes") { 823 + return flash_redirect("/admin/", None, Some("Access denied")); 824 + } 825 + 826 + // Phase 4: pagination support with cursor 827 + let mut query_params = vec![("limit", "100"), ("sort", "recent")]; 828 + let cursor_val; 829 + if let Some(ref c) = params.cursor { 830 + cursor_val = c.clone(); 831 + query_params.push(("cursor", &cursor_val)); 832 + } 833 + 834 + let response = pds_proxy::admin_xrpc_get::<serde_json::Value>( 835 + pds_url(&state), 836 + admin_password(&state), 837 + "com.atproto.admin.getInviteCodes", 838 + &query_params, 839 + ) 840 + .await; 841 + 842 + let (codes_raw, next_cursor) = match response { 843 + Ok(res) => { 844 + let codes = res["codes"].clone(); 845 + let cursor = res["cursor"].as_str().map(|s| s.to_string()); 846 + (codes, cursor) 847 + } 848 + Err(e) => { 849 + tracing::error!("Failed to get invite codes: {}", e); 850 + (serde_json::json!([]), None) 851 + } 852 + }; 853 + 854 + // Bug 5 fix: post-process codes to compute used_count and remaining 855 + let codes = if let Some(arr) = codes_raw.as_array() { 856 + let processed: Vec<serde_json::Value> = arr 857 + .iter() 858 + .map(|code| { 859 + let mut c = code.clone(); 860 + let available = c["available"].as_i64().unwrap_or(0); 861 + let used_count = c["uses"] 862 + .as_array() 863 + .map(|u| u.len() as i64) 864 + .unwrap_or(0); 865 + let remaining = (available - used_count).max(0); 866 + c["used_count"] = used_count.into(); 867 + c["remaining"] = remaining.into(); 868 + c 869 + }) 870 + .collect(); 871 + serde_json::json!(processed) 872 + } else { 873 + serde_json::json!([]) 874 + }; 875 + 876 + let mut data = serde_json::json!({ 877 + "codes": codes, 878 + "pds_hostname": state.app_config.pds_hostname, 879 + "active_page": "invite_codes", 880 + }); 881 + 882 + // Phase 4: pagination 883 + if let Some(cursor) = next_cursor { 884 + data["next_cursor"] = cursor.into(); 885 + data["has_more"] = true.into(); 886 + } 887 + 888 + if let Some(msg) = params.flash_success { 889 + data["flash_success"] = msg.into(); 890 + } 891 + if let Some(msg) = params.flash_error { 892 + data["flash_error"] = msg.into(); 893 + } 894 + 895 + inject_nav_data(&mut data, &session, &permissions); 896 + 897 + render_template(&state, "admin/invite_codes.hbs", data) 898 + } 899 + 900 + /// POST /admin/invite-codes/create 901 + pub async fn create_invite_code( 902 + State(state): State<AppState>, 903 + Extension(session): Extension<AdminSession>, 904 + Extension(_permissions): Extension<AdminPermissions>, 905 + axum::extract::Form(form): axum::extract::Form<CreateInviteForm>, 906 + ) -> Response { 907 + let rbac = match &state.admin_rbac_config { 908 + Some(r) => r, 909 + None => return StatusCode::NOT_FOUND.into_response(), 910 + }; 911 + 912 + if !rbac.can_access_endpoint(&session.did, "com.atproto.server.createInviteCode") { 913 + return flash_redirect("/admin/invite-codes", None, Some("Access denied")); 914 + } 915 + 916 + let use_count = form.use_count.unwrap_or(1).max(1); 917 + let body = serde_json::json!({ "useCount": use_count }); 918 + 919 + match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 920 + pds_url(&state), 921 + admin_password(&state), 922 + "com.atproto.server.createInviteCode", 923 + &body, 924 + ) 925 + .await 926 + { 927 + Ok(res) => { 928 + let code = res["code"].as_str().unwrap_or("unknown"); 929 + flash_redirect( 930 + "/admin/invite-codes", 931 + Some(&format!("Invite code created: {}", code)), 932 + None, 933 + ) 934 + } 935 + Err(e) => flash_redirect( 936 + "/admin/invite-codes", 937 + None, 938 + Some(&format!("Failed to create invite code: {}", e)), 939 + ), 940 + } 941 + } 942 + 943 + /// POST /admin/invite-codes/disable 944 + pub async fn disable_invite_codes( 945 + State(state): State<AppState>, 946 + Extension(session): Extension<AdminSession>, 947 + Extension(_permissions): Extension<AdminPermissions>, 948 + axum::extract::Form(form): axum::extract::Form<DisableInviteCodesForm>, 949 + ) -> Response { 950 + let rbac = match &state.admin_rbac_config { 951 + Some(r) => r, 952 + None => return StatusCode::NOT_FOUND.into_response(), 953 + }; 954 + 955 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.disableInviteCodes") { 956 + return flash_redirect("/admin/invite-codes", None, Some("Access denied")); 957 + } 958 + 959 + let codes: Vec<String> = form 960 + .codes 961 + .split(',') 962 + .map(|s| s.trim().to_string()) 963 + .filter(|s| !s.is_empty()) 964 + .collect(); 965 + 966 + let body = serde_json::json!({ 967 + "codes": codes, 968 + "accounts": [], 969 + }); 970 + 971 + match pds_proxy::admin_xrpc_post_no_response( 972 + pds_url(&state), 973 + admin_password(&state), 974 + "com.atproto.admin.disableInviteCodes", 975 + &body, 976 + ) 977 + .await 978 + { 979 + Ok(()) => flash_redirect( 980 + "/admin/invite-codes", 981 + Some("Invite codes disabled"), 982 + None, 983 + ), 984 + Err(e) => flash_redirect( 985 + "/admin/invite-codes", 986 + None, 987 + Some(&format!("Failed: {}", e)), 988 + ), 989 + } 990 + } 991 + 992 + /// GET /admin/create-account — Form 993 + pub async fn get_create_account( 994 + State(state): State<AppState>, 995 + Extension(session): Extension<AdminSession>, 996 + Extension(permissions): Extension<AdminPermissions>, 997 + Query(flash): Query<FlashParams>, 998 + ) -> Response { 999 + let rbac = match &state.admin_rbac_config { 1000 + Some(r) => r, 1001 + None => return StatusCode::NOT_FOUND.into_response(), 1002 + }; 1003 + 1004 + if !rbac.can_access_endpoint(&session.did, "com.atproto.server.createAccount") { 1005 + return flash_redirect("/admin/", None, Some("Access denied")); 1006 + } 1007 + 1008 + let mut data = serde_json::json!({ 1009 + "pds_hostname": state.app_config.pds_hostname, 1010 + "active_page": "create_account", 1011 + }); 1012 + 1013 + if let Some(msg) = flash.flash_success { 1014 + data["flash_success"] = msg.into(); 1015 + } 1016 + if let Some(msg) = flash.flash_error { 1017 + data["flash_error"] = msg.into(); 1018 + } 1019 + 1020 + inject_nav_data(&mut data, &session, &permissions); 1021 + 1022 + render_template(&state, "admin/create_account.hbs", data) 1023 + } 1024 + 1025 + /// POST /admin/create-account — Create account 1026 + pub async fn post_create_account( 1027 + State(state): State<AppState>, 1028 + Extension(session): Extension<AdminSession>, 1029 + Extension(permissions): Extension<AdminPermissions>, 1030 + axum::extract::Form(form): axum::extract::Form<CreateAccountForm>, 1031 + ) -> Response { 1032 + let rbac = match &state.admin_rbac_config { 1033 + Some(r) => r, 1034 + None => return StatusCode::NOT_FOUND.into_response(), 1035 + }; 1036 + 1037 + if !rbac.can_access_endpoint(&session.did, "com.atproto.server.createAccount") { 1038 + return flash_redirect("/admin/create-account", None, Some("Access denied")); 1039 + } 1040 + 1041 + let pds = pds_url(&state); 1042 + let password_str = admin_password(&state); 1043 + 1044 + // Step 1: Create invite code 1045 + let invite_body = serde_json::json!({ "useCount": 1 }); 1046 + let invite_res = match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 1047 + pds, 1048 + password_str, 1049 + "com.atproto.server.createInviteCode", 1050 + &invite_body, 1051 + ) 1052 + .await 1053 + { 1054 + Ok(res) => res, 1055 + Err(e) => { 1056 + return flash_redirect( 1057 + "/admin/create-account", 1058 + None, 1059 + Some(&format!("Failed to create invite code: {}", e)), 1060 + ); 1061 + } 1062 + }; 1063 + 1064 + let invite_code = invite_res["code"] 1065 + .as_str() 1066 + .unwrap_or("") 1067 + .to_string(); 1068 + 1069 + // Step 2: Create account 1070 + let account_password = generate_random_password(); 1071 + let account_body = serde_json::json!({ 1072 + "email": form.email, 1073 + "handle": form.handle, 1074 + "password": account_password, 1075 + "inviteCode": invite_code, 1076 + }); 1077 + 1078 + match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 1079 + pds, 1080 + password_str, 1081 + "com.atproto.server.createAccount", 1082 + &account_body, 1083 + ) 1084 + .await 1085 + { 1086 + Ok(res) => { 1087 + // Bug 4 fix: nest data under "created" object so template can use 1088 + // {{created.did}}, {{created.handle}}, etc. 1089 + let mut data = serde_json::json!({ 1090 + "pds_hostname": state.app_config.pds_hostname, 1091 + "active_page": "create_account", 1092 + "created": { 1093 + "did": res["did"].as_str().unwrap_or(""), 1094 + "handle": res["handle"].as_str().unwrap_or(""), 1095 + "email": form.email, 1096 + "password": account_password, 1097 + "inviteCode": invite_code, 1098 + }, 1099 + }); 1100 + 1101 + inject_nav_data(&mut data, &session, &permissions); 1102 + 1103 + render_template(&state, "admin/create_account.hbs", data) 1104 + } 1105 + Err(e) => flash_redirect( 1106 + "/admin/create-account", 1107 + None, 1108 + Some(&format!("Failed to create account: {}", e)), 1109 + ), 1110 + } 1111 + } 1112 + 1113 + /// GET /admin/search — Search accounts 1114 + /// Bug 7 fix: per lexicon com.atproto.admin.searchAccounts, the search param is "email" not "query" 1115 + pub async fn search_accounts( 1116 + State(state): State<AppState>, 1117 + Extension(session): Extension<AdminSession>, 1118 + Extension(permissions): Extension<AdminPermissions>, 1119 + Query(params): Query<SearchParams>, 1120 + ) -> Response { 1121 + let rbac = match &state.admin_rbac_config { 1122 + Some(r) => r, 1123 + None => return StatusCode::NOT_FOUND.into_response(), 1124 + }; 1125 + 1126 + if !rbac.can_access_endpoint(&session.did, "com.atproto.admin.searchAccounts") { 1127 + return flash_redirect("/admin/", None, Some("Access denied")); 1128 + } 1129 + 1130 + let query = params.q.unwrap_or_default(); 1131 + 1132 + let accounts: serde_json::Value = if !query.is_empty() { 1133 + // Bug 7 fix: use "email" parameter per the lexicon spec 1134 + match pds_proxy::admin_xrpc_get::<serde_json::Value>( 1135 + pds_url(&state), 1136 + admin_password(&state), 1137 + "com.atproto.admin.searchAccounts", 1138 + &[("email", &query)], 1139 + ) 1140 + .await 1141 + { 1142 + Ok(res) => res["accounts"].clone(), 1143 + Err(e) => { 1144 + tracing::error!("Search failed: {}", e); 1145 + serde_json::json!([]) 1146 + } 1147 + } 1148 + } else { 1149 + serde_json::json!([]) 1150 + }; 1151 + 1152 + let account_count = accounts.as_array().map(|a| a.len()).unwrap_or(0); 1153 + 1154 + let mut data = serde_json::json!({ 1155 + "accounts": accounts, 1156 + "account_count": account_count, 1157 + "search_query": query, 1158 + "pds_hostname": state.app_config.pds_hostname, 1159 + "active_page": "accounts", 1160 + }); 1161 + 1162 + if let Some(msg) = params.flash_success { 1163 + data["flash_success"] = msg.into(); 1164 + } 1165 + if let Some(msg) = params.flash_error { 1166 + data["flash_error"] = msg.into(); 1167 + } 1168 + 1169 + inject_nav_data(&mut data, &session, &permissions); 1170 + 1171 + render_template(&state, "admin/accounts.hbs", data) 1172 + } 1173 + 1174 + /// GET /admin/request-crawl — Request Crawl form (Gap 3) 1175 + pub async fn get_request_crawl( 1176 + State(state): State<AppState>, 1177 + Extension(session): Extension<AdminSession>, 1178 + Extension(permissions): Extension<AdminPermissions>, 1179 + Query(flash): Query<FlashParams>, 1180 + ) -> Response { 1181 + if !permissions.can_request_crawl { 1182 + return flash_redirect("/admin/", None, Some("Access denied")); 1183 + } 1184 + 1185 + let mut data = serde_json::json!({ 1186 + "pds_hostname": state.app_config.pds_hostname, 1187 + "active_page": "request_crawl", 1188 + "default_relay": "bsky.network", 1189 + }); 1190 + 1191 + if let Some(msg) = flash.flash_success { 1192 + data["flash_success"] = msg.into(); 1193 + } 1194 + if let Some(msg) = flash.flash_error { 1195 + data["flash_error"] = msg.into(); 1196 + } 1197 + 1198 + inject_nav_data(&mut data, &session, &permissions); 1199 + 1200 + render_template(&state, "admin/request_crawl.hbs", data) 1201 + } 1202 + 1203 + /// POST /admin/request-crawl — Submit crawl request to relay (Gap 3) 1204 + pub async fn post_request_crawl( 1205 + State(state): State<AppState>, 1206 + Extension(session): Extension<AdminSession>, 1207 + Extension(_permissions): Extension<AdminPermissions>, 1208 + axum::extract::Form(form): axum::extract::Form<RequestCrawlForm>, 1209 + ) -> Response { 1210 + let rbac = match &state.admin_rbac_config { 1211 + Some(r) => r, 1212 + None => return StatusCode::NOT_FOUND.into_response(), 1213 + }; 1214 + 1215 + if !rbac.can_access_endpoint(&session.did, "com.atproto.sync.requestCrawl") { 1216 + return flash_redirect("/admin/request-crawl", None, Some("Access denied")); 1217 + } 1218 + 1219 + let relay_base = format!("https://{}", form.relay_host.trim_end_matches('/')); 1220 + let pds_hostname = &state.app_config.pds_hostname; 1221 + 1222 + let body = serde_json::json!({ 1223 + "hostname": pds_hostname, 1224 + }); 1225 + 1226 + match pds_proxy::public_xrpc_post( 1227 + &relay_base, 1228 + "com.atproto.sync.requestCrawl", 1229 + &body, 1230 + ) 1231 + .await 1232 + { 1233 + Ok(()) => flash_redirect( 1234 + "/admin/request-crawl", 1235 + Some(&format!( 1236 + "Crawl requested from {} for {}", 1237 + form.relay_host, pds_hostname 1238 + )), 1239 + None, 1240 + ), 1241 + Err(e) => flash_redirect( 1242 + "/admin/request-crawl", 1243 + None, 1244 + Some(&format!("Request crawl failed: {}", e)), 1245 + ), 1246 + } 1247 + } 1248 + 1249 + /// POST /admin/logout — Clear session and redirect to login 1250 + pub async fn logout( 1251 + State(state): State<AppState>, 1252 + jar: SignedCookieJar, 1253 + ) -> Response { 1254 + if let Some(cookie) = jar.get("__gatekeeper_admin_session") { 1255 + let session_id = cookie.value().to_string(); 1256 + let _ = session::delete_session(&state.pds_gatekeeper_pool, &session_id).await; 1257 + } 1258 + 1259 + let mut removal = Cookie::build("__gatekeeper_admin_session") 1260 + .path("/admin/") 1261 + .build(); 1262 + removal.make_removal(); 1263 + 1264 + let updated_jar = jar.remove(removal); 1265 + 1266 + (updated_jar, Redirect::to("/admin/login")).into_response() 1267 + }
+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 + }
+94 -1
src/main.rs
··· 37 37 use tracing::log; 38 38 use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 39 39 40 + mod admin; 40 41 mod auth; 41 42 mod gate; 42 43 pub mod helpers; ··· 70 71 pds_service_did: Did<'static>, 71 72 gate_jwe_key: Vec<u8>, 72 73 captcha_success_redirects: Vec<String>, 74 + // Admin portal config 75 + pub pds_admin_password: Option<String>, 76 + pub pds_hostname: String, 77 + pub admin_session_ttl_hours: u64, 73 78 } 74 79 75 80 impl AppConfig { ··· 129 134 } 130 135 }; 131 136 137 + let pds_hostname = env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 138 + 139 + let pds_admin_password = env::var("PDS_ADMIN_PASSWORD").ok(); 140 + 141 + let admin_session_ttl_hours = env::var("GATEKEEPER_ADMIN_SESSION_TTL_HOURS") 142 + .ok() 143 + .and_then(|v| v.parse().ok()) 144 + .unwrap_or(24u64); 145 + 132 146 AppConfig { 133 147 pds_base_url, 134 148 mailer_from, ··· 142 156 .expect("PDS_SERVICE_DID is not a valid did or could not infer from PDS_HOSTNAME"), 143 157 gate_jwe_key, 144 158 captcha_success_redirects, 159 + pds_admin_password, 160 + pds_hostname, 161 + admin_session_ttl_hours, 145 162 } 146 163 } 147 164 } ··· 156 173 resolver: Arc<PublicResolver>, 157 174 handle_cache: auth::HandleCache, 158 175 app_config: AppConfig, 176 + // Admin portal 177 + admin_rbac_config: Option<Arc<admin::rbac::RbacConfig>>, 178 + admin_oauth_client: Option<Arc<admin::oauth::AdminOAuthClient>>, 179 + cookie_key: axum_extra::extract::cookie::Key, 180 + } 181 + 182 + impl axum::extract::FromRef<AppState> for axum_extra::extract::cookie::Key { 183 + fn from_ref(state: &AppState) -> Self { 184 + state.cookie_key.clone() 185 + } 159 186 } 160 187 161 188 async fn root_handler() -> impl axum::response::IntoResponse { ··· 273 300 let mut resolver = PublicResolver::default(); 274 301 resolver = resolver.with_plc_source(plc_source.clone()); 275 302 303 + let app_config = AppConfig::new(); 304 + 305 + // Admin portal setup (opt-in via GATEKEEPER_ADMIN_RBAC_CONFIG) 306 + let admin_rbac_config = env::var("GATEKEEPER_ADMIN_RBAC_CONFIG") 307 + .ok() 308 + .map(|path| { 309 + let config = admin::rbac::RbacConfig::load_from_file(&path) 310 + .unwrap_or_else(|e| panic!("Failed to load RBAC config from {}: {}", path, e)); 311 + log::info!("Loaded admin RBAC config from {} ({} members)", path, config.members.len()); 312 + Arc::new(config) 313 + }); 314 + 315 + let admin_oauth_client = if admin_rbac_config.is_some() { 316 + match admin::oauth::init_oauth_client(&app_config.pds_hostname) { 317 + Ok(client) => { 318 + log::info!("Admin OAuth client initialized for {}", app_config.pds_hostname); 319 + Some(Arc::new(client)) 320 + } 321 + Err(e) => { 322 + log::error!("Failed to initialize admin OAuth client: {}. Admin portal will be disabled.", e); 323 + None 324 + } 325 + } 326 + } else { 327 + None 328 + }; 329 + 330 + // Cookie signing key for admin sessions 331 + let cookie_key = env::var("GATEKEEPER_ADMIN_COOKIE_SECRET") 332 + .ok() 333 + .and_then(|hex_str| hex::decode(hex_str).ok()) 334 + .unwrap_or_else(|| app_config.gate_jwe_key.clone()); 335 + let cookie_key = { 336 + // Key::from requires at least 64 bytes; derive by repeating if needed 337 + let mut key_bytes = cookie_key.clone(); 338 + while key_bytes.len() < 64 { 339 + key_bytes.extend_from_slice(&cookie_key); 340 + } 341 + axum_extra::extract::cookie::Key::from(&key_bytes[..64]) 342 + }; 343 + 276 344 let state = AppState { 277 345 account_pool, 278 346 pds_gatekeeper_pool, ··· 281 349 template_engine: Engine::from(hbs), 282 350 resolver: Arc::new(resolver), 283 351 handle_cache: auth::HandleCache::new(), 284 - app_config: AppConfig::new(), 352 + app_config, 353 + admin_rbac_config, 354 + admin_oauth_client, 355 + cookie_key, 285 356 }; 286 357 287 358 // Rate limiting ··· 384 455 get(get_gate).post(post_gate.layer(GovernorLayer::new(captcha_governor_conf))), 385 456 ); 386 457 } 458 + 459 + // Mount admin portal if RBAC config is loaded 460 + if state.admin_rbac_config.is_some() { 461 + let admin_router = admin::router(state.clone()); 462 + app = app.nest("/admin", admin_router); 463 + log::info!("Admin portal mounted at /admin/"); 464 + } 465 + 466 + // Background cleanup for admin sessions 467 + let cleanup_pool = state.pds_gatekeeper_pool.clone(); 468 + let admin_enabled = state.admin_rbac_config.is_some(); 469 + tokio::spawn(async move { 470 + let mut interval = tokio::time::interval(Duration::from_secs(300)); 471 + loop { 472 + interval.tick().await; 473 + if admin_enabled { 474 + if let Err(e) = admin::session::cleanup_expired_sessions(&cleanup_pool).await { 475 + tracing::error!("Failed to cleanup expired admin sessions: {}", e); 476 + } 477 + } 478 + } 479 + }); 387 480 388 481 let app = app 389 482 .layer(CompressionLayer::new())

History

1 round 0 comments
sign up or login to add to the discussion
2 commits
expand
Adding an RBAC functionality for Admins.
feat(admin): fix 7 bugs, add RBAC enforcement, request-crawl, and polish
merge conflicts detected
expand
  • Cargo.lock:2594
  • Cargo.toml:39
  • src/main.rs:37
expand 0 comments