Compare commits

...
Author SHA1 Message Date
HampusandGitHub f00c6ee47a docs(http-api): name the endpoint a third-party client reads (#2564) 2026-09-06 23:50:52 +02:00
HampusandGitHub 82859dc2f6 fix(api): keep a deferral while the phone gate state is unknown (#2554) 2026-09-06 23:41:29 +02:00
HampusandGitHub 328dc06ab0 feat(installer): drive podman as well as docker (#2563) 2026-09-06 23:28:40 +02:00
HampusandGitHub ea6e4a75db fix(markdown): let a backslash escape a code fence (#2562) 2026-09-06 22:45:29 +02:00
HampusandGitHub 69ca462930 fix(app): keep popouts in the window they were opened in (#2561) 2026-09-06 22:42:32 +02:00
HampusandGitHub f38619d974 fix(app): isolate bidi usernames from message timestamps (#2560) 2026-09-06 22:41:57 +02:00
HampusandGitHub 6c0ce9369b fix(app): refresh mutual communities on membership change (#2559) 2026-09-06 22:38:31 +02:00
HampusandGitHub 00c1b19809 fix(app): inherit category mute when hiding muted channels (#2558) 2026-09-06 22:10:09 +02:00
HampusandGitHub 2fd5daf104 fix(app): keep the client active while the user is typing (#2557) 2026-09-06 21:29:06 +02:00
HampusandGitHub fbf0f6adfe fix(app): load more bookmarks as the list scrolls (#2556) 2026-09-06 21:05:57 +02:00
HampusandGitHub d91b5bec66 fix(app): show unread channels in muted collapsed categories (#2555) 2026-09-06 20:45:11 +02:00
HampusandGitHub 0f24cfb6ef ci(docs): check the installer upgrade key lists for drift (#2553) 2026-09-06 20:43:50 +02:00
HampusandGitHub 7a6691cdbe fix(installer): make the record and rollback paths trustworthy (#2552) 2026-09-06 20:36:59 +02:00
HampusandGitHub 73d3a4f843 fix(app): widen the custom status modal (#2551) 2026-09-06 20:19:28 +02:00
HampusandGitHub 091755fe78 fix(self-hosting): adapt the upgrade to existing instances (#2550) 2026-09-06 19:40:32 +02:00
HampusandGitHub 1fb2790bb9 fix(api): stop bounding the pin listing by the wall clock (#2549) 2026-09-06 19:03:15 +02:00
HampusandGitHub 798e64b224 refactor(app): remove the report modal path selection step (#2548) 2026-09-06 18:49:35 +02:00
HampusandGitHub a2d6477b42 fix(admin): route the bulk user deletion action correctly (#2545) 2026-09-06 18:42:37 +02:00
HampusandGitHub a2ca24eeb4 fix(admin): search archives across both subject types (#2542) 2026-09-06 18:42:33 +02:00
HampusandGitHub 8dcd00a8fe fix(admin): batch user id lookups on the users page (#2547) 2026-09-06 18:41:46 +02:00
HampusandGitHub be8a52c823 fix(admin): bound the reports page offset (#2546) 2026-09-06 18:41:18 +02:00
HampusandGitHub 43e420b0ab fix(admin): require paired voice server coordinates (#2544) 2026-09-06 18:40:50 +02:00
HampusandGitHub f8947adf62 fix(admin): map the index refresh status response union (#2543) 2026-09-06 18:40:20 +02:00
HampusandGitHub 81fccaf0ab docs(media-proxy): stop documenting literal response bodies (#2541) 2026-09-06 18:39:50 +02:00
HampusandGitHub 7a42291baf fix(api): search all reports when no status filter is given (#2540) 2026-09-06 18:39:18 +02:00
HampusandGitHub d9f983b08e fix(api): return the terminated count from terminate sessions (#2539) 2026-09-06 18:38:46 +02:00
HampusandGitHub 2f159852a7 fix(api): make an empty admin guild patch apply no change (#2538) 2026-09-06 18:38:13 +02:00
HampusandGitHub 5ef402b8ee fix(api): apply the nsfw and content warning guild settings (#2537) 2026-09-06 18:37:38 +02:00
HampusandGitHub a8d6e5ab73 refactor(api): remove premium-based voice track muting (#2536) 2026-09-06 18:37:01 +02:00
HampusandGitHub e805a3797f fix(api): stop entrance sound play probing channel existence (#2535) 2026-09-06 18:36:24 +02:00
HampusandGitHub 8f4fa82a9e fix(api): always return the page total when listing reports (#2534) 2026-09-06 17:38:21 +02:00
HampusandGitHub d2438b2fdd docs(operator): note the upload relay secret an upgrade now needs (#2533) 2026-09-06 17:21:06 +02:00
HampusandGitHub 133640ef2b fix(docs): allow unused pnpm patches in the docs image deploy (#2531) 2026-09-06 16:19:46 +02:00
HampusandGitHub 8e0516a8c3 feat(api): drop explicit media classification on asset uploads (#2530) 2026-09-06 16:12:25 +02:00
HampusandGitHub d784c0692e fix(app): set the jsx runtime in tsconfig so vitest parses tsx (#2529) 2026-09-06 15:51:18 +02:00
HampusandGitHub fffa265117 chore(i18n): refresh the client message catalogues (#2528) 2026-09-06 15:41:52 +02:00
HampusandGitHub 5367c0ab42 docs: move the reference site to astro starlight (#2527) 2026-09-06 15:40:22 +02:00
HampusandGitHub 7f8f09ee51 feat(admin)!: move the admin api to rest and fix its defects (#2515) 2026-09-06 15:36:41 +02:00
HampusandGitHub a70924d4b0 fix(admin): require the admin secret key base at boot (#2514) 2026-09-06 15:36:08 +02:00
HampusandGitHub 1a5925f9cb chore(app): remove message scheduling and a dead descriptor (#2513) 2026-09-06 15:35:36 +02:00
HampusandGitHub 43c778aae4 fix(api): guard the rpc session init test harness route (#2512) 2026-09-06 15:34:57 +02:00
HampusandGitHub 34cf8f821f refactor(api)!: drop unused helpers, parameters and a route (#2511) 2026-09-06 15:34:25 +02:00
HampusandGitHub 226cfd062e fix(worker): rebuild the deletion queue and cancel system dms (#2510) 2026-09-06 15:33:53 +02:00
HampusandGitHub e8f4e35c32 fix(api): gate stream keys by channel type and cover previews (#2509) 2026-09-06 15:33:20 +02:00
HampusandGitHub ef559f3d8c fix(api): handle bad manifests, unfurl errors and the apns key (#2508) 2026-09-06 15:32:47 +02:00
HampusandGitHub ee7206ac66 fix(api): batch connection reorders, dispatch on failed recheck (#2507) 2026-09-06 15:32:16 +02:00
HampusandGitHub 1ba9592308 fix(api): correct webhook dedupe and the instatus transforms (#2506) 2026-09-06 15:31:43 +02:00
HampusandGitHub d07f520b13 fix(api)!: correct pagination and locking, drop toggle routes (#2505) 2026-09-06 15:31:13 +02:00
HampusandGitHub 632f4c7b6c fix(api): correct report targets and ticket handling (#2504) 2026-09-06 15:30:41 +02:00
HampusandGitHub 1f627c9cc5 fix(api): correct user content, read state and harvest paths (#2501) 2026-09-06 15:30:08 +02:00
HampusandGitHub cc110b9f5a fix(api): raise coded errors for prerequisites and bounds (#2502) 2026-09-06 15:29:36 +02:00
HampusandGitHub f06d65db54 fix(api): reject unparsable bodies and screen non-form ones (#2503) 2026-09-06 15:29:04 +02:00
HampusandGitHub f8a04b8985 fix(api)!: enforce declared rate limits and correct route auth (#2500) 2026-09-06 15:28:32 +02:00
HampusandGitHub 908e1b8bd4 fix(api): correct guild permission and mfa checks (#2499) 2026-09-06 15:28:01 +02:00
HampusandGitHub f392636857 fix(auth): correct mfa errors, sudo methods and birth dates (#2498) 2026-09-06 15:27:30 +02:00
HampusandGitHub 150115cc0c fix(media-proxy): bound the relay body and drop the unread ttl (#2496) 2026-09-06 15:26:57 +02:00
HampusandGitHub 710a6f1c5d fix(media-proxy): correct route errors and test the ip gate (#2495) 2026-09-06 15:26:33 +02:00
HampusandGitHub 7c3e722085 chore(gateway): delete modules with no callers (#2494) 2026-09-06 15:26:08 +02:00
HampusandGitHub d8f2aa3184 feat(gateway): add an undrain endpoint and sweep orphan tables (#2493) 2026-09-06 15:25:43 +02:00
HampusandGitHub e828398e06 fix(gateway): close oversized bot identify with code 4011 (#2497) 2026-09-06 15:25:19 +02:00
HampusandGitHub 31d7cb81d6 fix(gateway): return precise rpc errors and bound snowflakes (#2491) 2026-09-06 15:24:54 +02:00
HampusandGitHub 214d19d45a fix(gateway): resync permissions and validate voice leaves (#2492) 2026-09-06 15:24:23 +02:00
HampusandGitHub d3170fc320 fix(gateway): repair the session lifecycle, limits and dead code (#2490) 2026-09-06 15:23:59 +02:00
HampusandGitHub 9a229c1b73 fix(api): answer 403 when the client ip header is unparsable (#2483) 2026-09-06 15:23:35 +02:00
HampusandGitHub a036d9a2e1 fix(api): resolve missing user rows for webhooks and sessions (#2487) 2026-09-06 15:23:03 +02:00
HampusandGitHub d5bfa5a73d fix(api)!: align error codes with throw sites and image bounds (#2488) 2026-09-06 15:21:38 +02:00
HampusandGitHub 4534822355 fix(config): derive the VAPID public point to verify the pair (#2521) 2026-09-06 15:13:32 +02:00
HampusandGitHub bff29d8f07 fix(api)!: always send Retry-After and reclassify two limits (#2489) 2026-09-06 15:07:00 +02:00
HampusandGitHub bec34ea147 fix(api)!: correct declared bounds and hide public bot mfa (#2485) 2026-09-06 15:06:15 +02:00
Hampus Kraft 3be4171256 feat(self-host)!: rework the compose stack and demand secrets (#2486) 2026-09-06 15:02:20 +02:00
Hampus Kraft 5bcaa7cfac fix(config)!: validate and derive config, drop the unread keys (#2482) 2026-09-06 15:02:20 +02:00
HampusandGitHub ea93ef5352 fix(api): find every live route when generating openapi.json (#2484) 2026-09-06 14:54:21 +02:00
HampusandGitHub 8734956d86 fix(auth): explain why a phone number was rejected (#2480) 2026-09-06 03:09:26 +02:00
HampusandGitHub 519b3a6127 fix(i18n): translate shipped English, repair broken catalogs (#2479) 2026-09-06 02:58:56 +02:00
HampusandGitHub 9b3773c1e6 feat(auth): let phone-gated accounts set the check aside (#2478) 2026-09-06 01:11:24 +02:00
HampusandGitHub e12b60078a fix(self-host): probe the seaweedfs s3 health endpoint (#2476) 2026-09-06 00:24:31 +02:00
HampusandGitHub 7cb8f9f4ae fix(app): pick default channel when guild channels arrive late (#2475) 2026-09-06 00:04:43 +02:00
HampusandGitHub 622bd124b9 fix(fonts): stop SC and TC from claiming kana (#2473) 2026-09-05 22:25:44 +02:00
HampusandGitHub fed8b2d089 feat(admin): add bulk delete user messages tool (#2472) 2026-09-05 22:20:14 +02:00
HampusandGitHub 12718eabbc refactor(api): drop cookie support for sudo mode (#2466) 2026-09-05 01:29:34 +02:00
HampusandGitHub ab68b61653 refactor: remove the CTP_MEMBER user flag (#2465) 2026-09-05 01:13:34 +02:00
HampusandGitHub 639ade3802 refactor(app-proxy): drop invite metadata and database access (#2464) 2026-09-05 00:13:04 +02:00
HampusandGitHub 3f1f899b23 fix(api): keep a deprecated nsfw field for older clients (#2463) 2026-09-04 23:08:34 +02:00
HampusandGitHub 51cb750502 feat(api): drop NSFW classification for emojis and stickers (#2462) 2026-09-04 22:55:58 +02:00
HampusandGitHub 1e6c332eae fix(app): show empty categories when hiding muted channels (#2460) 2026-09-04 21:04:34 +02:00
HampusandGitHub 18ae2e563e fix(worker): fit the job streams to the jetstream budget (#2458) 2026-09-04 19:31:20 +02:00
HampusandGitHub a4d039c910 fix(app-proxy): publish source maps with the asset tree (#2457) 2026-09-04 19:10:30 +02:00
HampusandGitHub 6163fd5644 fix(message): turn mentions red in failed messages (#2456) 2026-09-04 19:03:30 +02:00
HampusandGitHub 3e2ddaca4f fix(message): turn links red in failed messages (#2455) 2026-09-04 18:21:27 +02:00
HampusandGitHub 054a59e622 fix(message): keep reply previews on one line after a mention (#2454) 2026-09-04 18:20:14 +02:00
HampusandGitHub 84d7290ed9 fix(api): tighten guild emoji and sticker mutation limits (#2453) 2026-09-04 17:58:08 +02:00
HampusandGitHub f4c1254d91 fix(media-proxy): stop serving animated originals as stills (#2452) 2026-09-04 16:56:58 +02:00
HampusandGitHub c01d22dc05 fix(self-host): unfurl media hosted by the instance itself (#2451) 2026-09-04 16:56:35 +02:00
HampusandGitHub 4c0f02d8a5 chore(i18n): refresh catalogs for the window share audio scope (#2450) 2026-09-04 16:41:53 +02:00
HampusandGitHub 2770482baf perf(app-proxy): hold the frozen snapshot by reference (#2449) 2026-09-04 16:00:23 +02:00
HampusandGitHub 8e39a00e34 perf(app-proxy): run on jemalloc to curb arena growth (#2448) 2026-09-04 15:57:47 +02:00
HampusandGitHub 7bd0d3a962 fix(app-proxy): remove the SPA document render reservation (#2447) 2026-09-04 15:55:59 +02:00
HampusandGitHub bc7f701e87 feat(voice): give window shares their own audio scope (#2446) 2026-09-04 15:48:37 +02:00
HampusandGitHub 0d8116d73e fix(workspace): restore the devcontainer compose project name (#2445) 2026-09-04 15:41:38 +02:00
HampusandGitHub 255cbc1248 perf(app-proxy): drop dynamic brotli compression (#2444) 2026-09-04 14:47:49 +02:00
HampusandGitHub 098aeef412 fix(media-proxy): stop lifting bt709 video thumbnails (#2443) 2026-09-04 13:17:30 +02:00
HampusandGitHub baa18aed5b fix(media-proxy): link source-built native libs first (#2442) 2026-09-04 03:42:56 +02:00
HampusandGitHub b7c8dab019 fix(media-proxy): pin builder libheif, fix the image build (#2441) 2026-09-04 02:06:02 +02:00
HampusandGitHub 587324fa38 fix(voice): reuse the Linux audio capture across routing changes (#2440) 2026-09-04 01:00:32 +02:00
HampusandGitHub cc3a9c8613 fix(app): jitter gateway reconnects and recover status nagbar (#2439) 2026-09-03 23:21:32 +02:00
HampusandGitHub b0645300ec fix(i18n): reaction tooltip word order and plural agreement (#2438) 2026-09-03 22:11:59 +02:00
HampusandGitHub d7d4e8da03 refactor(media-proxy): split into modules and harden streaming (#2437) 2026-09-03 22:04:00 +02:00
HampusandGitHub 16ae98e189 fix(auth): regenerate backup codes with the emailed challenge (#2436) 2026-09-03 21:29:16 +02:00
HampusandGitHub add0a3dfc6 fix(voice): start bitrate for non-SVC screen share codecs (#2434) 2026-09-03 21:07:42 +02:00
HampusandGitHub 90c349392b fix(auth): email code to view backup codes, fix login matching (#2433) 2026-09-03 21:06:30 +02:00
HampusandGitHub eb4562b6a6 feat(voice): add screen share subscription debug helper (#2432) 2026-09-03 20:22:59 +02:00
HampusandGitHub 6c634b686f feat(voice): rework screen share audio source selection (#2431) 2026-09-03 20:01:28 +02:00
HampusandGitHub 48e03edccc chore(desktop): upgrade Electron to 44.1.1 (#2430) 2026-09-03 18:53:23 +02:00
HampusandGitHub cef6f11fd0 fix(app): correct the connection nagbar button styling (#2428) 2026-09-03 18:17:06 +02:00
HampusandGitHub 2757659989 fix(gateway): satisfy dialyzer after the hotpatch reconcile (#2427) 2026-09-03 17:26:51 +02:00
HampusandGitHub f090395c21 fix(voice): republish screen share when its codec goes stale (#2426) 2026-09-03 17:09:48 +02:00
HampusandGitHub dd1d554cc6 fix(gateway): reconcile hotpatched member-list and push fixes (#2425) 2026-09-03 17:04:32 +02:00
HampusandGitHub 9c1b38aeaf fix(api): always allow opening a dm channel (#2423) 2026-09-03 16:35:01 +02:00
HampusandGitHub 902dd60ff9 fix(voice): keep published codecs inside the opt-in policy (#2422) 2026-09-03 15:06:48 +02:00
HampusandGitHub 2426a5769d feat(voice): drive screen share quality from viewer demand (#2421) 2026-09-03 04:49:29 +02:00
HampusandGitHub 66517c925b feat(voice): show a passive badge when a stream underperforms (#2420) 2026-09-03 04:49:07 +02:00
HampusandGitHub 5f90e7d535 fix(api): downgrade oversized video instead of ending the call (#2419) 2026-09-03 04:47:02 +02:00
HampusandGitHub 9d63eb15a9 fix(voice): request a real camera frame rate at capture (#2418) 2026-09-03 04:46:32 +02:00
HampusandGitHub c97bc53342 refactor(voice): remove screen share codec renegotiation (#2417) 2026-09-03 04:46:11 +02:00
HampusandGitHub f5f60c66e7 refactor(voice): remove adaptive screen share quality system (#2416) 2026-09-03 04:41:04 +02:00
HampusandGitHub 3e15b97c8c fix(voice): stop clamping stored video quality preferences (#2415) 2026-09-03 04:36:04 +02:00
HampusandGitHub 6c08813f9b feat(voice): scale screen share bitrate to the selected rung (#2414) 2026-09-03 04:35:37 +02:00
HampusandGitHub 8158d44732 fix(voice): keep screen share resolution under constraint (#2413) 2026-09-03 04:35:12 +02:00
HampusandGitHub 9bd0019759 fix(api): declare undici for the bundled http client (#2410) 2026-09-02 19:55:11 +02:00
HampusandGitHub a74f1b0e7b fix(ci): clear knip, refresh openapi, close kv schema race (#2409) 2026-09-02 18:12:48 +02:00
HampusandGitHub 2b12f5db6c feat(voice): enable web camera background effects (#2408) 2026-09-02 17:40:13 +02:00
HampusandGitHub af4a52173c fix(voice): dispatch sourceLifecycle.removed on unbind (#2407) 2026-09-02 17:40:00 +02:00
HampusandGitHub 5bc1f21d46 fix(voice): drive call tiles from gateway voice state (#2406) 2026-09-02 17:39:48 +02:00
HampusandGitHub 917e437939 feat(voice-menus): add call controls to private call user menus (#2405) 2026-09-02 17:39:25 +02:00
HampusandGitHub 7ee4fb37d6 feat(voice): add a live input level meter to voice menus (#2404) 2026-09-02 17:39:01 +02:00
HampusandGitHub 6155eec804 feat(voice): flatten voice menus, gate ptt on a bound key (#2403) 2026-09-02 17:38:37 +02:00
HampusandGitHub 43d8637153 fix(voice): gate the camera preview and update effects in place (#2402) 2026-09-02 17:38:14 +02:00
HampusandGitHub 250db2fdab fix(voice): hide stream volume without remote share audio (#2401) 2026-09-02 17:37:52 +02:00
HampusandGitHub 7bd021cd5c feat(voice): open voice popouts in the browser (#2400) 2026-09-02 17:37:28 +02:00
HampusandGitHub adb9a689f0 feat(voice): share the voice room across popout trees (#2399) 2026-09-02 17:37:04 +02:00
HampusandGitHub d8e0c2ec20 fix(settings): stop auto-requesting devices, warn when none (#2398) 2026-09-02 17:36:49 +02:00
HampusandGitHub f98a74170a feat(settings): move macos permission review into desktop tab (#2397) 2026-09-02 17:36:26 +02:00
HampusandGitHub 17b9821879 fix(voice-menus): drive stream actions from the published source (#2396) 2026-09-02 17:36:05 +02:00
HampusandGitHub 4b5bdefcb9 fix(voice-menus): sentence-case participant menu labels (#2395) 2026-09-02 17:35:49 +02:00
HampusandGitHub 45cbabd94d fix(voice): abort screen share when its audio cannot start (#2394) 2026-09-02 17:35:26 +02:00
HampusandGitHub 8402eb53c8 feat(voice): skip the screen-share picker modal on web (#2393) 2026-09-02 17:35:15 +02:00
HampusandGitHub d04ace5789 feat(voice): redesign the screen-share source picker (#2392) 2026-09-02 17:35:01 +02:00
HampusandGitHub e94f587535 feat(voice): sequence join chimes ahead of entrance sounds (#2391) 2026-09-02 17:34:36 +02:00
HampusandGitHub e73285060e fix(voice): honour the codec preference in fallback selection (#2390) 2026-09-02 17:34:24 +02:00
HampusandGitHub f1734704ef fix(voice): guard stale screen-share negotiation and probes (#2389) 2026-09-02 17:34:10 +02:00
HampusandGitHub 59f6217267 refactor(voice): bound the screen-share codec wire formats (#2388) 2026-09-02 17:33:57 +02:00
HampusandGitHub 90f4a222b7 refactor(voice): request mic and camera permission separately (#2387) 2026-09-02 17:33:45 +02:00
HampusandGitHub 749d2091eb fix(voice): log local voice state hydration failures (#2386) 2026-09-02 17:33:32 +02:00
HampusandGitHub 8d34c6bcaa refactor(voice): make screen-share source swaps atomic (#2385) 2026-09-02 17:33:18 +02:00
HampusandGitHub 62577b25bb feat(voice): request web share audio via the browser picker (#2384) 2026-09-02 17:32:55 +02:00
HampusandGitHub 475f5a7dae fix(voice): refresh camera capture when the device changes (#2383) 2026-09-02 17:32:43 +02:00
HampusandGitHub 1772b3aad0 fix(voice): polish the stream settings menu (#2382) 2026-09-02 17:32:26 +02:00
HampusandGitHub 7263b21a06 refactor(voice): pin screen share to a fixed 7 Mbps bitrate (#2381) 2026-09-02 17:32:01 +02:00
HampusandGitHub 501adff13d refactor(voice): clamp premium video quality at read time (#2380) 2026-09-02 17:31:39 +02:00
HampusandGitHub 12c6fb7b7f feat(voice): add screen-share audio and rollback error handling (#2379) 2026-09-02 17:31:27 +02:00
HampusandGitHub 376168c922 feat(voice): add the camera background effects pipeline (#2378) 2026-09-02 17:31:04 +02:00
HampusandGitHub cadab239a2 feat(backgrounds): accept webm custom call backgrounds (#2377) 2026-09-02 17:30:50 +02:00
HampusandGitHub f57dc77c6d vendor(livekit): make local track swaps transactional (#2376) 2026-09-02 17:30:35 +02:00
HampusandGitHub 497a494c37 vendor(livekit): await setParameters in setDegradationPreference (#2375) 2026-09-02 17:30:22 +02:00
HampusandGitHub 02069e8e5d feat(voice-engine): add a sourceLifecycle.removed event (#2374) 2026-09-02 17:30:08 +02:00
HampusandGitHub 626293392c fix(ui): let callers style the audio level meter (#2373) 2026-09-02 17:29:55 +02:00
HampusandGitHub dfe42ae3e3 feat(sound): play one-shot sounds immediately and abortably (#2372) 2026-09-02 17:29:43 +02:00
HampusandGitHub 453abfa145 fix(ui): keep context menus clear of the native titlebar (#2371) 2026-09-02 17:29:29 +02:00
HampusandGitHub 8ebc9400ce fix(permissions): gate role hierarchy on known membership (#2370) 2026-09-02 17:29:06 +02:00
HampusandGitHub 1598f48edd fix(guild): invalidate member sidebar on role changes (#2369) 2026-09-02 17:28:44 +02:00
HampusandGitHub cc92f37f0c test(app): stop reading gl calls as react hooks (#2368) 2026-09-02 17:28:32 +02:00
HampusandGitHub 9322aca6cb fix(channel): restore composer draft and message focus (#2367) 2026-09-02 17:28:19 +02:00
HampusandGitHub ee8fbd6f4f fix(ui): keep the focus ring stable across refocus (#2366) 2026-09-02 17:27:57 +02:00
HampusandGitHub 1acd61a112 fix(ui): hand tooltips over between adjacent triggers (#2365) 2026-09-02 17:27:46 +02:00
HampusandGitHub e836686a71 fix(permissions): map not-determined media status to prompt (#2364) 2026-09-02 17:27:35 +02:00
HampusandGitHub ea6c417378 fix(discovery): keep category counts when a search resolves (#2363) 2026-09-02 17:27:22 +02:00
HampusandGitHub 16cc9a9e69 fix(app): clamp persisted accessibility values (#2362) 2026-09-02 17:27:10 +02:00
HampusandGitHub 6b5316fa84 fix(desktop): return a generic clipboard copy error (#2361) 2026-09-02 17:26:57 +02:00
HampusandGitHub a53f5d1289 fix(desktop): verify privileged ipc senders (#2360) 2026-09-02 17:26:45 +02:00
HampusandGitHub de2ea99928 fix(desktop): pin outbound fetches to a validated address (#2359) 2026-09-02 17:26:32 +02:00
HampusandGitHub 25f4332b9e fix(auth): guard stale submissions and rework form error mapping (#2358) 2026-09-02 17:26:18 +02:00
HampusandGitHub f703969e80 fix(auth): require a hashed poll secret for desktop handoff (#2357) 2026-09-02 17:25:54 +02:00
HampusandGitHub b82681b77a fix(auth): revoke the parsed token on logout (#2356) 2026-09-02 17:25:33 +02:00
HampusandGitHub ef248a8515 fix(platform): parse api error responses in one place (#2355) 2026-09-02 17:25:21 +02:00
HampusandGitHub 73a2345c26 fix(app-proxy): validate config, csp sources, cap resource use (#2354) 2026-09-02 17:25:10 +02:00
HampusandGitHub 9f33177eab fix(gateway): rate limit resume like identify (#2353) 2026-09-02 17:24:58 +02:00
HampusandGitHub d5a752c338 fix(gateway): only trust the client ip header when enabled (#2352) 2026-09-02 17:24:46 +02:00
HampusandGitHub 4021a2d697 fix(gateway): bound the zstd decompression window (#2351) 2026-09-02 17:24:35 +02:00
HampusandGitHub bea4a6dcbc fix(read-state): keep a failed ack dispatch from failing acks (#2350) 2026-09-02 17:24:24 +02:00
HampusandGitHub 4f48e04cad feat(api): serve well-known discovery with etag and 304 (#2349) 2026-09-02 17:24:11 +02:00
HampusandGitHub 5719dfe8a3 fix(auth): bucket phone attempt risk by v4 and v6 subnet (#2348) 2026-09-02 17:23:58 +02:00
HampusandGitHub 4fb14e85e8 fix(auth): harden login rate keys and totp reuse (#2347) 2026-09-02 17:23:47 +02:00
HampusandGitHub 1d84689b45 fix(api): validate push and domain verification targets (#2346) 2026-09-02 17:23:34 +02:00
HampusandGitHub 693aec2b4d fix(api): trust recorded upload types and bound edit sizes (#2345) 2026-09-02 17:23:23 +02:00
HampusandGitHub c79c0ee138 fix(api): bound storage listings, ranges and search paging (#2344) 2026-09-02 17:23:12 +02:00
HampusandGitHub e86e24a2db fix(captcha): drop the body-email contact policy exemption (#2343) 2026-09-02 17:23:02 +02:00
HampusandGitHub 0f7ad484ce fix(constants): add captcha, cache and feature headers (#2342) 2026-09-02 17:22:51 +02:00
HampusandGitHub bcd95b2af9 fix(http-client): validate public addresses at connect time (#2341) 2026-09-02 17:22:37 +02:00
HampusandGitHub 32dcd5ed1c chore(i18n): resync message catalogs with source (#2340) 2026-09-02 17:22:21 +02:00
HampusandGitHub 5119febb5b fix(api): send stickers through webhooks (#2338) 2026-09-02 13:55:54 +02:00
HampusandGitHub 20cdfd3009 fix(api): stop exporting unused worker heartbeat symbols (#2334) 2026-09-01 21:03:26 +02:00
HampusandGitHub 9f739427c4 fix(desktop): update rtrb past the double free advisory (#2336) 2026-09-01 21:03:19 +02:00
HampusandGitHub 0201cafd7e fix(admin): mark the crate unpublished so cargo deny passes (#2335) 2026-09-01 21:03:12 +02:00
HampusandGitHub 37f57bb29f fix(desktop): restore offline Flatpak builds (#2332) 2026-09-01 20:51:18 +02:00
HampusandGitHub ebd723679b docs(operator): refresh the bundle pin and tunnel setup (#2333) 2026-09-01 20:51:00 +02:00
HampusandGitHub 961fa1f007 fix(self-hosting): correct compose probes and origins (#2330) 2026-09-01 20:47:20 +02:00
HampusandGitHub 7900a4da0c feat(ci): pin releases to an immutable image set (#2327) 2026-09-01 20:47:20 +02:00
HampusandGitHub 8a24730884 fix(kv): align rust and typescript schema migration (#2328) 2026-09-01 20:47:19 +02:00
HampusandGitHub 1688e7dc50 fix(api): survive transient database errors in the worker (#2326) 2026-09-01 20:47:19 +02:00
HampusandGitHub aa267b54ec fix(api): dead-letter retired worker task types (#2323) 2026-09-01 20:47:19 +02:00
HampusandGitHub bc40073a02 fix(config): carry the public port into derived endpoints (#2329) 2026-09-01 20:47:18 +02:00
HampusandGitHub a93f9dd0af fix(app-proxy): separate readiness from liveness (#2322) 2026-09-01 20:47:18 +02:00
HampusandGitHub 53a9fdc4b6 fix(app-proxy): share one asset tree across architectures (#2325) 2026-09-01 20:47:17 +02:00
HampusandGitHub cef600277c fix(media-proxy): probe health with the binary not /dev/tcp (#2324) 2026-09-01 20:47:17 +02:00
HampusandGitHub bdac438329 fix(docker): emit consistent OCI metadata on every image (#2321) 2026-09-01 20:47:16 +02:00
HampusandGitHub 2d77f36a0b fix(ci): generate locale and channel files before typecheck (#2320) 2026-09-01 20:47:16 +02:00
HampusandGitHub 90aa810ce4 fix(gateway): share the relay dispatch bound across producers (#2315) 2026-09-01 04:34:41 +02:00
HampusandGitHub cf3af50464 fix(kv): bound multi key fan out by pipelining per hash slot (#2313) 2026-09-01 02:59:00 +02:00
HampusandGitHub 3b5b20c139 fix(gateway): bound relay dispatch without per-event probes (#2312) 2026-09-01 02:56:21 +02:00
HampusandGitHub 24cd163acd fix(kv): restore keyset paging for numeric key scans (#2314) 2026-09-01 02:54:10 +02:00
HampusandGitHub 49f76e5b40 fix(api): abort startup on unverifiable deletion queue state (#2311) 2026-09-01 02:45:29 +02:00
HampusandGitHub 871788f0a9 fix(gateway): reclaim ip connection counts from dead sockets (#2308) 2026-09-01 01:39:25 +02:00
HampusandGitHub 24138b70f1 fix(kv): stop multi-key commands spanning cluster slots (#2310) 2026-09-01 01:39:16 +02:00
HampusandGitHub da3332e711 fix(gateway): bound relay worker mailboxes without reordering (#2309) 2026-09-01 01:38:36 +02:00
HampusandGitHub 06e5cf2032 perf(gateway): evict presence tombstones in insertion order (#2307) 2026-09-01 01:34:57 +02:00
HampusandGitHub d4b1923c23 fix(gateway): stop presence evictions suppressing repair (#2305) 2026-09-01 01:33:13 +02:00
HampusandGitHub 42df4f6731 fix(kv): drop the row_key order probe that cliffed paged scans (#2306) 2026-09-01 01:32:33 +02:00
HampusandGitHub f1e6e94041 fix(cache): stop a timed out produce pinning its tracking entry (#2304) 2026-09-01 01:28:12 +02:00
HampusandGitHub 0cd12b2f32 test(api): build deletion queue users from the real row type (#2303) 2026-09-01 00:57:41 +02:00
HampusandGitHub 5da4d24d38 fix(kv): page scans by keyset so deletes cannot skip rows (#2302) 2026-09-01 00:29:24 +02:00
HampusandGitHub 7806d2ac02 fix(gateway): stop stale guild connect timers aborting connects (#2301) 2026-09-01 00:23:42 +02:00
HampusandGitHub 2c4d182d1f fix(gateway): keep dispatch ordered under relay backpressure (#2300) 2026-09-01 00:17:12 +02:00
HampusandGitHub dcd5f88d65 fix(gateway): stop rate limit tables dying with their creator (#2299) 2026-09-01 00:16:26 +02:00
HampusandGitHub 662f4ac93b fix(worker): stop skipped accounts starving the deletion queue (#2294) 2026-09-01 00:16:08 +02:00
HampusandGitHub a2480c6a02 fix(cache): time out a getOrSet produce that never settles (#2297) 2026-09-01 00:15:44 +02:00
HampusandGitHub c49460a44f fix(gateway): stop anti-entropy resurrecting deleted presence (#2298) 2026-09-01 00:14:32 +02:00
HampusandGitHub 6786dfe7e3 fix(gateway): stop dropping newly requested lazy ranges (#2293) 2026-09-01 00:14:24 +02:00
HampusandGitHub 7d710d881a fix(worker): lease premium reconciliation queue entries (#2296) 2026-09-01 00:14:08 +02:00
HampusandGitHub 2ea2e79f6f fix(voice): stop occupancy writes spanning kv cluster slots (#2295) 2026-09-01 00:11:29 +02:00
HampusandGitHub cd42dd8ca7 fix(api): rebuild the deletion queue under its lock (#2292) 2026-09-01 00:06:33 +02:00
HampusandGitHub f2eddeae4d fix(gateway): stop rate limit sweepers outliving their table (#2291) 2026-08-31 23:25:00 +02:00
HampusandGitHub 8e1a8fc7e3 fix(gateway): sweep stale shared ip and user rate buckets (#2290) 2026-08-31 22:53:37 +02:00
HampusandGitHub 87c08b051f fix(voice): finish the reconciliation sweep before stopping (#2289) 2026-08-31 22:38:04 +02:00
HampusandGitHub 9d95a80857 fix(worker): renew the deletion queue lock during a rebuild (#2287) 2026-08-31 22:38:00 +02:00
HampusandGitHub 9371b6d5de fix(worker): count each channel once in a bulk reindex (#2286) 2026-08-31 22:37:56 +02:00
HampusandGitHub bdcf4b25c0 chore(expressions): remove the pack residue cleanup tool (#2288) 2026-08-31 22:31:54 +02:00
HampusandGitHub 3dc344be65 fix(worker): keep attachment decay state on a stale expiry row (#2285) 2026-08-31 22:26:21 +02:00
HampusandGitHub 17ed0f70aa fix(gateway): release the user session count on a handoff fence (#2284) 2026-08-31 22:25:14 +02:00
HampusandGitHub be3e12e60d fix(gateway): clamp a heartbeat ack to the session sequence (#2283) 2026-08-31 22:23:43 +02:00
HampusandGitHub 4261cc2ea5 fix(worker): resubscribe when the job stream ends unexpectedly (#2282) 2026-08-31 22:22:14 +02:00
HampusandGitHub 88dbc27019 fix(gateway): group debounced reactions by their own message (#2281) 2026-08-31 22:21:14 +02:00
HampusandGitHub f38fc80c31 fix(gateway): clear the presence pid cache on a presence down (#2279) 2026-08-31 22:19:25 +02:00
HampusandGitHub 803fdaf443 fix(worker): stop replaying requeued asset deletions in a run (#2280) 2026-08-31 22:19:22 +02:00
HampusandGitHub 55d85db401 fix(gateway): drop the channel engine on an empty range list (#2278) 2026-08-31 22:17:19 +02:00
HampusandGitHub 7ce3d71c44 fix(gateway): stop a non-map opcode payload crashing the socket (#2277) 2026-08-31 22:14:26 +02:00
HampusandGitHub 04e150e4bf fix(gateway): keep the replay buffer across a session transfer (#2276) 2026-08-31 22:10:44 +02:00
HampusandGitHub 0f6b118921 fix(worker): catch up cron jobs missed by a delayed tick (#2275) 2026-08-31 22:06:15 +02:00
HampusandGitHub 44277e6aa2 fix(worker): drain in-flight jobs before the runner stops (#2274) 2026-08-31 22:06:12 +02:00
HampusandGitHub bb7e8cc6f1 fix(gateway): flush buffered presences in arrival order (#2273) 2026-08-31 22:04:49 +02:00
HampusandGitHub 32a64fb097 fix(cache): refcount produce tracking so deletes are not lost (#2272) 2026-08-31 22:00:48 +02:00
HampusandGitHub c4594397e7 fix(desktop): accept array-form AppRun sandbox fallback (#2271) 2026-08-31 21:42:03 +02:00
HampusandGitHub 6a188a4cdf fix(api): enforce guild bans when approving registrations (#2270) 2026-08-31 20:19:18 +02:00
HampusandGitHub 0ca0defd24 fix(desktop): keep notification sounds during fullscreen apps (#2269) 2026-08-31 19:51:42 +02:00
HampusandGitHub b0b84f9c98 fix(media-proxy): cap external streams with no declared length (#2267) 2026-08-31 19:23:48 +02:00
HampusandGitHub ef8d1225b5 refactor(api): split webhook attachment schemas (#2268) 2026-08-31 19:13:51 +02:00
HampusandGitHub 240b7e4388 feat(expressions): add an expression pack residue cleanup tool (#2265) 2026-08-31 19:06:31 +02:00
HampusandGitHub bd205d2250 fix(app-proxy): honour the shared Postgres settings (#2262) 2026-08-31 19:00:43 +02:00
HampusandGitHub e5e5bcccee docs(operator): pin self-hosting downloads to stable revision (#2266) 2026-08-31 18:57:58 +02:00
HampusandGitHub a5395b0109 fix(api): support presigned webhook attachments (#2264) 2026-08-31 18:54:25 +02:00
HampusandGitHub 09cea4394f fix(app-proxy): give each test fixture its own temp directory (#2261) 2026-08-31 18:49:30 +02:00
HampusandGitHub afeaddea22 fix(cache): do not fan a failed getOrSet out to its joiners (#2260) 2026-08-31 18:44:31 +02:00
HampusandGitHub 45f694310a fix(api): make the http header and request timeouts tunable (#2259) 2026-08-31 18:43:14 +02:00
HampusandGitHub 995f5118b2 fix(message): reap orphaned rows on the build paths (#2257) 2026-08-31 17:23:46 +02:00
HampusandGitHub 415888a615 fix(admin): stop a stale CSRF cookie wedging actions (#2258) 2026-08-31 17:23:35 +02:00
HampusandGitHub 542fb9176a fix(svc): allow disabling named Postgres prepared statements (#2256) 2026-08-31 17:19:44 +02:00
HampusandGitHub b8f8d8d859 feat(expressions): remove the unfinished packs feature (#2250) 2026-08-31 16:34:22 +02:00
HampusandGitHub 0eef611b6d test(message): pin response mapping across batch boundaries (#2255) 2026-08-31 16:24:33 +02:00
HampusandGitHub f0612ee860 fix(message): key batched message responses by message id (#2254) 2026-08-31 16:19:29 +02:00
HampusandGitHub 8c85cce75c fix(message): bound batched message response requests by size (#2252) 2026-08-31 16:13:36 +02:00
HampusandGitHub 5036ac3efa fix(api): allow disabling named Postgres prepared statements (#2251) 2026-08-31 16:10:52 +02:00
HampusandGitHub 9025e03422 feat(admin): remove the unfinished billing APIs and panel UI (#2248) 2026-08-31 15:57:11 +02:00
HampusandGitHub e82e8529bf fix(gateway): replay voice state updates after a resume (#2249) 2026-08-31 15:56:32 +02:00
HampusandGitHub eb1ed69489 chore(api): drop scheduled messages from the openapi spec (#2247) 2026-08-31 15:43:34 +02:00
HampusandGitHub e81f3f7eae fix(cache): do not resurrect a key deleted during getOrSet (#2246) 2026-08-31 15:41:05 +02:00
HampusandGitHub 4b9964bc89 fix(api): stop bounding request body receipt at the header timeout (#2245) 2026-08-31 15:36:54 +02:00
HampusandGitHub b4a2af75d0 fix(media-proxy): read content length from the response header (#2244) 2026-08-31 15:33:49 +02:00
HampusandGitHub 8c3e3285f7 fix(gateway): clamp the derived BEAM scheduler count (#2243) 2026-08-31 15:29:58 +02:00
HampusandGitHub 0a4f6ff9fb fix(test): surface docker errors when a test container fails (#2242) 2026-08-31 15:16:53 +02:00
HampusandGitHub bfa1367ca2 fix(gateway): restore erlfmt style in the presence rpc module (#2241) 2026-08-31 15:10:59 +02:00
HampusandGitHub bb81a2f165 fix(self-host): persist valkey and stop evicting durable state (#2240) 2026-08-31 14:48:58 +02:00
HampusandGitHub 7f448b1cab fix(message): always flag a message when a reaction is added (#2239) 2026-08-31 14:47:16 +02:00
HampusandGitHub 2dd35c0d6e fix(gateway): reject unknown rpc methods instead of crashing (#2238) 2026-08-31 14:34:35 +02:00
HampusandGitHub 0e73346c5f fix(svc): treat a shard overload reply as retryable backpressure (#2237) 2026-08-31 14:23:11 +02:00
HampusandGitHub 8476595507 fix(api): let node size its heap from the container limit (#2236) 2026-08-31 14:09:13 +02:00
HampusandGitHub 7b9284edb9 fix(build): allow unused patches when deploying the api subset (#2235) 2026-08-31 14:06:07 +02:00
HampusandGitHub c982b33212 fix(self-host): size memory limits and make them overridable (#2234) 2026-08-31 13:42:12 +02:00
HampusandGitHub 3c8466d714 feat(message): remove the unfinished scheduled messages feature (#2233) 2026-08-31 13:32:55 +02:00
HampusandGitHub eeea391b63 fix(kv): claim parked jobs by member instead of secondary key (#2232) 2026-08-31 13:16:52 +02:00
HampusandGitHub 5c2dca1c51 chore(workspace): tighten quality gates (#2230) 2026-08-31 04:43:20 +02:00
HampusandGitHub e6e4c6f7b5 perf(api): stop exempting self-hosted from response gating (#2228) 2026-08-31 02:04:07 +02:00
HampusandGitHub 5f6f9428ac build(api): bundle the api instead of transpiling at boot (#2227) 2026-08-31 01:26:30 +02:00
HampusandGitHub ba54b61dcf perf(guild): add a lean channel auth context rpc (#2226) 2026-08-31 01:04:55 +02:00
HampusandGitHub 8dc2bad843 perf(auth): cache auth session lookups by token hash (#2225) 2026-08-31 01:04:51 +02:00
HampusandGitHub 3594cbd5ca perf(svc): forward messages shard replies without transcoding (#2224) 2026-08-31 01:04:47 +02:00
HampusandGitHub 21b1e4e719 perf(ready): stop sending read state twice per session (#2223) 2026-08-31 01:04:43 +02:00
HampusandGitHub 50a17b6263 fix(metrics): reject non-loopback callers on metrics endpoints (#2222) 2026-08-31 00:23:00 +02:00
HampusandGitHub 0a920def2b fix(kv): mark the messages migration done instead of rescanning (#2221) 2026-08-31 00:22:56 +02:00
HampusandGitHub c4b1471923 perf(api): cache channel and guild reads for the request (#2220) 2026-08-31 00:22:52 +02:00
HampusandGitHub ab08ed0d7c chore(self-host): give every compose service a memory ceiling (#2212) 2026-08-31 00:22:48 +02:00
HampusandGitHub 34c13a747d chore(self-host): probe the api readiness during startup (#2216) 2026-08-31 00:17:09 +02:00
HampusandGitHub d559d8853d perf(gateway): size replay buffer entries once per dispatch (#2210) 2026-08-31 00:14:57 +02:00
HampusandGitHub a96d9cd075 perf(worker): skip the bunny purge cron when purging is off (#2208) 2026-08-31 00:14:39 +02:00
HampusandGitHub b163888cf3 perf(gateway): stop building discarded debug logs on fanout (#2219) 2026-08-31 00:13:27 +02:00
HampusandGitHub b07e2c397c perf(api): resolve the client ip once per request (#2218) 2026-08-31 00:13:24 +02:00
HampusandGitHub 3eeba1da2b fix(gateway): stop discarding container logs and add readiness (#2217) 2026-08-31 00:13:20 +02:00
HampusandGitHub e160b1bf07 perf(api): fast path json bodies with no large integers (#2214) 2026-08-31 00:13:17 +02:00
HampusandGitHub 1199b36d1a perf(worker): stop rereading rows in the discovery index sync (#2211) 2026-08-31 00:13:13 +02:00
HampusandGitHub 7a506478c7 perf(message): unlog bucket index writes on the read path (#2209) 2026-08-31 00:13:09 +02:00
HampusandGitHub 6faa40e0c2 fix(worker): bound the jobs stream and shed on overflow (#2204) 2026-08-31 00:13:04 +02:00
HampusandGitHub 768657d7e5 perf(worker): batch the inactivity sweep activity lookups (#2215) 2026-08-31 00:07:07 +02:00
HampusandGitHub 7157cca22f perf(worker): match the user export zip level to the guild one (#2213) 2026-08-31 00:07:03 +02:00
HampusandGitHub 7aec79d3ad perf(worker): throttle the cancel check in the domain sync (#2207) 2026-08-31 00:06:59 +02:00
HampusandGitHub 4357d5ec5d fix(search): stop leaking meilisearch task ids on the api (#2206) 2026-08-31 00:06:56 +02:00
HampusandGitHub 7c1c8b2749 perf(rate-limit): precompute bucket hash and client identifier (#2205) 2026-08-31 00:06:52 +02:00
HampusandGitHub 15656bd5c8 perf(users): read only the partial columns for partial requests (#2203) 2026-08-31 00:06:48 +02:00
HampusandGitHub c4897a7026 fix(svc): bound scylla request timeout below the rpc budget (#2202) 2026-08-31 00:06:44 +02:00
HampusandGitHub e993a47720 perf(guild): fetch guild members a thousand at a time (#2201) 2026-08-31 00:06:41 +02:00
HampusandGitHub 0d4c65ad79 perf(cassandra): skip re-registering identical select metadata (#2200) 2026-08-31 00:06:37 +02:00
HampusandGitHub f09bdb2b00 fix(cache): single-flight getOrSet and cache null results (#2199) 2026-08-31 00:06:33 +02:00
HampusandGitHub f1400ae58e fix(cassandra): shorten the read timeout below the rpc deadline (#2198) 2026-08-31 00:06:29 +02:00
HampusandGitHub b5496097d2 perf(message): reuse the resolved channel for dm send checks (#2197) 2026-08-31 00:06:25 +02:00
HampusandGitHub d5fb495e19 perf(read-state): read acked read states in one query (#2196) 2026-08-31 00:06:20 +02:00
HampusandGitHub 00e716bc3f perf(worker): skip the premium sweep on self-hosted instances (#2195) 2026-08-31 00:06:16 +02:00
HampusandGitHub ea4edd668f fix(worker): heartbeat long running jobs to hold the ack (#2194) 2026-08-31 00:06:12 +02:00
HampusandGitHub a22db125a9 perf(ready): send timing diagnostics only to staff sessions (#2190) 2026-08-30 23:50:09 +02:00
HampusandGitHub e7f68c2e20 test(message): count permission fetches instead of view checks (#2193) 2026-08-30 23:25:47 +02:00
HampusandGitHub 933b13f3fa perf(gateway): re-enable generational GC on hot processes (#2188) 2026-08-30 23:23:26 +02:00
HampusandGitHub 0be6c9c734 perf(gateway): skip permission cache rebuild on no-op updates (#2187) 2026-08-30 23:23:22 +02:00
HampusandGitHub 5aac331368 perf(gateway): skip materialising member list subscribers (#2184) 2026-08-30 23:23:18 +02:00
HampusandGitHub 57ec484626 fix(app): persist input access nagbar dismissal (#2192) 2026-08-30 23:20:51 +02:00
HampusandGitHub 9cd832bfa9 fix(app): let backspace cross a composer soft-wrap boundary (#2191) 2026-08-30 23:19:02 +02:00
HampusandGitHub 299cc40ff5 fix(gateway): split inbound and outbound rpc concurrency keys (#2186) 2026-08-30 23:16:53 +02:00
HampusandGitHub 6d305bacdf build(rust): enable fat lto and one codegen unit in release (#2185) 2026-08-30 23:16:49 +02:00
HampusandGitHub 6fd3177844 fix(auth): time-bound outbound fetches and re-key pwned cache (#2183) 2026-08-30 23:16:44 +02:00
HampusandGitHub 5586d34293 fix(app): preserve input access nagbar dismissal (#2189) 2026-08-30 23:11:57 +02:00
HampusandGitHub d43d242b16 perf(api): compile the phrase blocklist into one matcher (#2164) 2026-08-30 23:05:55 +02:00
HampusandGitHub 9f620e8c4b perf(user): prefetch user partials for list endpoints (#2182) 2026-08-30 23:05:17 +02:00
HampusandGitHub 6c9afcc734 perf(gateway): skip member list resync on inert presence deltas (#2180) 2026-08-30 23:05:13 +02:00
HampusandGitHub 43d6c85f7e perf(push): batch badge count invalidation per mention chunk (#2178) 2026-08-30 23:05:08 +02:00
HampusandGitHub 78056e0041 perf(gateway): pre-encode voice state update fanout (#2177) 2026-08-30 23:05:03 +02:00
HampusandGitHub e83a2d6aec perf(user): stop double-writing last active on every request (#2148) 2026-08-30 23:04:59 +02:00
HampusandGitHub bac06fe182 perf(gateway): restore generational GC as the vm default (#2181) 2026-08-30 23:01:25 +02:00
HampusandGitHub 8ff6518797 perf(postgres): name the fixed kv statement shapes (#2173) 2026-08-30 23:01:21 +02:00
HampusandGitHub f0e7c25e4c perf(kv): collate key columns in C and drop the duplicate index (#2162) 2026-08-30 23:01:06 +02:00
HampusandGitHub 0da94965dc perf(push): cache empty subscriptions and chunk fanout lookups (#2176) 2026-08-30 22:53:07 +02:00
HampusandGitHub d82eed16b7 perf(push): batch guild settings lookups for push eligibility (#2175) 2026-08-30 22:53:03 +02:00
HampusandGitHub b26748a2a7 fix(api): bound http server limits and shed on overload (#2170) 2026-08-30 22:53:00 +02:00
HampusandGitHub a2d7f5e8cc perf(app-proxy): serve precompressed assets and pass through (#2165) 2026-08-30 22:52:15 +02:00
HampusandGitHub 72ffa3bd9a perf(gateway): memoise the parsed internal rpc url (#2174) 2026-08-30 22:48:11 +02:00
HampusandGitHub e2fccaee74 fix(gateway): derive BEAM scheduler count from the environment (#2169) 2026-08-30 22:48:07 +02:00
HampusandGitHub e41b209cb8 perf(media-proxy): cache-probe and stream external fetches (#2168) 2026-08-30 22:48:03 +02:00
HampusandGitHub 2517caf674 fix(svc): shed overload instead of buffering router requests (#2171) 2026-08-30 22:45:50 +02:00
HampusandGitHub b9ec0d5f53 perf(gateway): avoid per-key exceptions in guild data wire (#2167) 2026-08-30 22:45:47 +02:00
HampusandGitHub 02e614632f perf(api): construct request services lazily (#2166) 2026-08-30 22:45:43 +02:00
HampusandGitHub 3ca73901c9 perf(gateway): cache zstd availability instead of reprobing it (#2160) 2026-08-30 22:45:38 +02:00
HampusandGitHub c379eed266 perf(gateway): split circuit breaker state from its window (#2154) 2026-08-30 22:45:34 +02:00
HampusandGitHub 5bac4fd719 perf(api): gate response schema revalidation to non-production (#2134) 2026-08-30 22:45:29 +02:00
HampusandGitHub dd610e4c0f fix(messages): pass message refs to the mention context helpers (#2179) 2026-08-30 22:44:12 +02:00
HampusandGitHub bf080cb001 fix(search): omit referenced message from search results (#2172) 2026-08-30 22:42:07 +02:00
HampusandGitHub 169088df26 perf(gateway): drop per-recipient mailbox probe on dispatch (#2146) 2026-08-30 22:42:03 +02:00
HampusandGitHub 4a2a29f154 perf(kv): merge row data in one statement on upsert and patch (#2161) 2026-08-30 22:37:18 +02:00
HampusandGitHub 1054962008 perf(kv): chunk oversized IN lists instead of scanning (#2163) 2026-08-30 22:36:51 +02:00
HampusandGitHub 8bc8603460 perf(message): skip redundant has_reaction writes on reactions (#2149) 2026-08-30 22:36:47 +02:00
HampusandGitHub 6538b0bfeb perf(message): skip reaction deletes for unreacted messages (#2147) 2026-08-30 22:36:44 +02:00
HampusandGitHub 9d2339bb3b perf(cassandra): memoize CQL statement metadata per query (#2155) 2026-08-30 22:34:49 +02:00
HampusandGitHub 096f38d365 perf(media-proxy): bound mime sniff scans and sniff once (#2159) 2026-08-30 22:29:00 +02:00
HampusandGitHub 1046edd903 perf(media-proxy): drop the extra HEAD on passthrough GETs (#2157) 2026-08-30 22:27:51 +02:00
HampusandGitHub cbf504dbb8 perf(api): memoise the effective bluesky oauth config (#2156) 2026-08-30 22:27:47 +02:00
HampusandGitHub 8491872908 perf(user): batch mention and saved message reads (#2139) 2026-08-30 22:27:41 +02:00
HampusandGitHub c207918e90 perf(api): fetch channel permissions in one gateway call (#2145) 2026-08-30 22:17:23 +02:00
HampusandGitHub 12717f692b chore(self-host): match shard admission to the postgres pool (#2153) 2026-08-30 22:17:12 +02:00
HampusandGitHub 36d630b37b perf(cassandra): drop allocations from the undefined param guard (#2152) 2026-08-30 22:16:26 +02:00
HampusandGitHub 5333fe7c3a perf(guild): unlog the audit log index batch (#2151) 2026-08-30 22:16:23 +02:00
HampusandGitHub efae78056e fix(worker): park far-future jobs in a KV due queue (#2144) 2026-08-30 22:16:18 +02:00
HampusandGitHub 6eca64a8f7 fix(user): stop invalidating user cache on profile reads (#2142) 2026-08-30 22:16:14 +02:00
HampusandGitHub fcb629ca06 perf(app-proxy): stream local assets instead of buffering them (#2141) 2026-08-30 22:16:10 +02:00
HampusandGitHub 289f1af253 perf(worker): skip guild settings for direct mentions (#2140) 2026-08-30 22:16:06 +02:00
HampusandGitHub 8adc3ecb0b perf(message): build pin responses in one messages round trip (#2138) 2026-08-30 22:16:02 +02:00
HampusandGitHub e328c001a1 perf(messages): prefilter mention extraction and drop a pass (#2137) 2026-08-30 22:15:58 +02:00
HampusandGitHub f796a31613 perf(worker): stop ledgering the two per-message tasks (#2136) 2026-08-30 22:15:54 +02:00
HampusandGitHub e1bab2e353 perf(message): reuse channel auth across send and edit (#2135) 2026-08-30 22:15:49 +02:00
HampusandGitHub 1c135176d2 perf(messages): stop channel history scan once the page is full (#2133) 2026-08-30 22:15:45 +02:00
HampusandGitHub 723f0d6e6e perf(logger): stop building a throwaway pino root per child (#2132) 2026-08-30 22:15:40 +02:00
HampusandGitHub e14d193b43 fix(api): order service timeouts so inner hops expire first (#2131) 2026-08-30 22:15:36 +02:00
HampusandGitHub 9d1733bdb3 perf(kv-client): use EVALSHA for rate limit scripts (#2130) 2026-08-30 22:15:32 +02:00
HampusandGitHub e06436d6f5 perf(svc): use cached prepared statements for postgres kv reads (#2129) 2026-08-30 22:15:28 +02:00
HampusandGitHub 4f67e2b362 perf(messages): overlap user partial fetch with response joins (#2128) 2026-08-30 22:15:24 +02:00
HampusandGitHub 8aa3415d73 perf(message): unlog bulk message delete batches (#2126) 2026-08-30 22:15:19 +02:00
HampusandGitHub 74f22e89ce perf(svc): reuse the decoded request instead of reparsing it (#2125) 2026-08-30 22:15:15 +02:00
HampusandGitHub 7d826d1602 perf(rpc): bound concurrency in user batch fanout handlers (#2124) 2026-08-30 22:15:11 +02:00
HampusandGitHub 8aa39bc7db perf(message): drop two reads and a write from message create (#2123) 2026-08-30 22:15:07 +02:00
HampusandGitHub 36dcf51024 fix(cassandra): bound driver in-flight requests per connection (#2122) 2026-08-30 22:15:02 +02:00
HampusandGitHub 3e74180bdc chore(self-host): budget postgres pool sizes across services (#2127) 2026-08-30 22:14:56 +02:00
HampusandGitHub 45ed740575 chore(self-host): tune bundled postgres for the shipped box (#2143) 2026-08-30 22:14:52 +02:00
HampusandGitHub 8092ad8c4d fix(media-proxy): support current FFmpeg APIs (#2158) 2026-08-30 22:08:55 +02:00
HampusandGitHub b4d9cdc584 refactor(voice): remove heartbeat and debug logging sessions (#2121) 2026-08-30 21:08:46 +02:00
HampusandGitHub 36b85512c6 chore(api): drop knip-unused Postgres KV exports (#2119) 2026-08-30 18:58:50 +02:00
HampusandGitHub 14ae64f5f3 perf(api): stop Postgres KV reads scanning whole tables (#2118) 2026-08-30 18:34:23 +02:00
M0N7Y5andGitHub 990176ac7c feat(markdown): add a binary AST envelope to the native ABI (#2117) 2026-08-30 17:47:58 +02:00
M0N7Y5andGitHub 69ef46356b feat(markdown): add a native C ABI for 64-bit FFI hosts (#2115) 2026-08-30 15:47:34 +02:00
HampusandGitHub bd88c7b04b fix(desktop): don't fail startup on inconclusive native probe (#2114) 2026-08-30 15:22:48 +02:00
HampusandGitHub 03641f622f refactor(voice): flatten participant context menu and extract stream menus (#2112) 2026-08-30 03:02:30 +02:00
HampusandGitHub 3b1eb56713 fix(voice): never auto-select AV1 or HEVC for screen sharing (#2111) 2026-08-30 02:05:21 +02:00
HampusandGitHub 059bcc6c53 chore(app): regenerate theme variable manifest for font fallbacks (#2110) 2026-08-30 01:30:00 +02:00
HampusandGitHub 82043ce2a8 fix(guild): show duplicated role in its final spot without flicker (#2109) 2026-08-30 01:25:34 +02:00
HampusandGitHub 470e752fba chore(i18n): refresh catalogs for role and permission menus (#2108) 2026-08-30 00:17:31 +02:00
HampusandGitHub 3cc7b9050c fix(app): clear stuck spellcheck reload banner (#2107) 2026-08-30 00:12:48 +02:00
HampusandGitHub b1f7c78c7e fix(app): overwrite context menu hover and delete danger item (#2106) 2026-08-30 00:12:41 +02:00
HampusandGitHub 8f20b29b16 feat(guild): duplicate role, plus delete and hover in roles sidebar (#2105) 2026-08-30 00:12:36 +02:00
HampusandGitHub 19efbd3d61 fix(app): scope the show-send-button toggle to the main composer (#2104) 2026-08-30 00:12:31 +02:00
HampusandGitHub f35c0effa2 chore(i18n): drop unused gift redemption string (#2102) 2026-08-29 23:31:28 +02:00
HampusandGitHub 8363cc0844 fix(app): send only the gift link when gifting to a friend (#2101) 2026-08-29 23:29:49 +02:00
HampusandGitHub 5a11cacbae chore(i18n): refresh catalogs for spellcheck copy (#2100) 2026-08-29 23:03:01 +02:00
HampusandGitHub 27151a9487 fix(desktop): restore Linux spellcheck, prefer OS engine (#2099) 2026-08-29 22:58:48 +02:00
HampusandGitHub c152b25deb chore(i18n): refresh catalogs for global shortcut string (#2098) 2026-08-29 22:57:18 +02:00
HampusandGitHub 04d3afaf7b fix(app): default voice shortcuts to global and add a toggle (#2097) 2026-08-29 22:46:30 +02:00
HampusandGitHub dbc63e9ef7 fix(voice): surface stream audio volume for screen-share viewers (#2095) 2026-08-29 22:05:06 +02:00
HampusandGitHub ce91ff95ab fix(app): render raw unicode emoji with OS color emoji fonts (#2094) 2026-08-29 21:09:08 +02:00
HampusandGitHub d75a29f099 feat(app): composer send-button toggle and active button hover (#2093) 2026-08-29 20:25:49 +02:00
HampusandGitHub cb889b1160 fix(api): correct apns clear payload and badge handling (#2092) 2026-08-29 20:07:18 +02:00
HampusandGitHub 8db4f5cb63 feat(app): full-Unicode font fallback with self-hosted Noto (#2091) 2026-08-29 19:27:47 +02:00
HampusandGitHub d297dc5805 feat(api): exempt APP_STORE_REVIEWER accounts from captcha (#2090) 2026-08-29 18:00:52 +02:00
HampusandGitHub 9b8659a40b fix(desktop): stop the updater button flickering after download (#2089) 2026-08-29 17:44:43 +02:00
HampusandGitHub 96c5db3f5d fix(markdown): open code blocks for fences after text (#2088) 2026-08-29 17:32:43 +02:00
HampusandGitHub 78e403819e fix(admin): set delete_message_seconds on ban request (#2087) 2026-08-29 16:35:40 +02:00
HampusandGitHub 805acf4e5e feat(ban): accept delete_message_seconds and app options (#2086) 2026-08-29 16:22:12 +02:00
HampusandGitHub 9f099a9127 fix(api): keep the saved placeholder on sent memes (#2085) 2026-08-29 16:04:58 +02:00
HampusandGitHub 4d15c39cd7 fix(app): give mature media blur the real media dimensions (#2084) 2026-08-29 16:04:55 +02:00
HampusandGitHub 827451d12d fix(unfurl): trust curated klipy media without rescanning (#2083) 2026-08-29 16:04:52 +02:00
HampusandGitHub 03603662c6 fix(media-proxy): require corroborating frames for nsfw (#2082) 2026-08-29 16:04:48 +02:00
HampusandGitHub 34eb10cd88 fix(markdown): only open code fences at line start (#2081) 2026-08-29 15:59:04 +02:00
HampusandGitHub 82941c08c9 fix(api): enforce single-role MFA, fix reindex and NCMEC scans (#2080) 2026-08-29 15:31:42 +02:00
HampusandGitHub 8d21c97d08 fix(admin): sort session imports to satisfy rustfmt (#2079) 2026-08-29 15:05:04 +02:00
HampusandGitHub 71a56f590d fix(ci): read dependent load flags at the pe32+ offset (#2078) 2026-08-29 15:00:38 +02:00
HampusandGitHub 38297c4fe7 chore(api): drop knip-unused search and SSO exports (#2077) 2026-08-29 15:00:09 +02:00
HampusandGitHub cf7ec06d85 fix(api): enforce scoped perms, age gates, audit logs (#2076) 2026-08-29 14:55:13 +02:00
HampusandGitHub f2ea10f951 fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074) 2026-08-29 14:55:09 +02:00
HampusandGitHub bbd93df239 fix(api): expire auth tokens and harden login checks (#2073) 2026-08-29 14:55:04 +02:00
HampusandGitHub 9a6ab93e01 fix(media-proxy): bound GIF decode and BMFF box walking (#2075) 2026-08-29 14:55:00 +02:00
HampusandGitHub 38eed7cce6 fix(gateway): restrict /_metrics to loopback callers (#2072) 2026-08-29 14:54:56 +02:00
HampusandGitHub 79064c3399 fix(admin): sign CSRF tokens and escape them in scripts (#2071) 2026-08-29 14:54:51 +02:00
HampusandGitHub 0496b2f530 fix(ci): reject path traversal in S3 prefix downloads (#2070) 2026-08-29 14:54:37 +02:00
HampusandGitHub 9d0be1ebd1 fix(desktop): drop game capture injection and pin dll search (#2069) 2026-08-29 14:26:28 +02:00
HampusandGitHub 9ad026b8ce fix(app): repair theme CSS sync persistence and data loss (#2067) 2026-08-29 03:37:41 +02:00
HampusandGitHub 14de5971d5 chore(api): drop unused ELEVATED_MFA_PERMISSIONS export (#2066) 2026-08-29 03:12:24 +02:00
HampusandGitHub 5474be3efa fix(api): close auth, billing and authorization bypasses (#2065) 2026-08-29 02:59:38 +02:00
fluxer-ci[bot]andGitHub 9a54bbba2d chore(i18n): update public marketing catalogs (#2064) 2026-08-29 01:52:48 +02:00
fluxer-ci[bot]andGitHub 5a0110ccc8 chore(marketing): advance pointer 0c78170 → 5908507 (#2063) 2026-08-29 01:52:44 +02:00
HampusandGitHub d032d577bf fix(app-proxy): drop leaked canary debug cert from assetlinks (#2062) 2026-08-29 01:43:26 +02:00
HampusandGitHub 243954c9c5 test(api): use a future baseline in invoice-skip premium tests (#2061) 2026-08-29 01:22:29 +02:00
HampusandGitHub ba1be73389 fix(media-proxy): cap ISO-BMFF box walker recursion depth (#2059) 2026-08-29 01:04:46 +02:00
HampusandGitHub af3ad02962 fix(voice): default noise suppression to standard 2026-08-28 20:43:22 +02:00
HampusandGitHub 53ddca725e fix(desktop): deduplicate macOS release feeds (#2056) 2026-08-28 19:16:35 +02:00
HampusandGitHub 094fb0d1c8 feat(downloads): route desktop releases through GitHub 2026-08-28 18:19:08 +02:00
HampusandGitHub dc230926a4 fix(desktop): skip glibc check for directory packs 2026-08-28 15:09:00 +02:00
HampusandGitHub 026ace6747 fix(ci): publish draft releases by ID (#2050) 2026-08-28 00:38:09 +02:00
HampusandGitHub 374db9ed2b fix(desktop): harden capture and release packaging (#2049) 2026-08-27 23:54:38 +02:00
5ee59c4675 chore(i18n): update public marketing catalogs (#2047)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:52 +01:00
33605171a8 chore(marketing): advance pointer 530c44e → 0c78170 (#2046)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:38 +01:00
HampusandGitHub 89fac5b088 fix(api): use webhook ID for mention author (#2045) 2026-08-27 17:01:26 +02:00
HampusandGitHub 4a34b942b7 fix(api): key mention chunks by content (#2044) 2026-08-27 16:02:24 +02:00
HampusandGitHub fc3065ebe4 fix(desktop): build with compatible PipeWire headers (#2043) 2026-08-27 15:18:15 +02:00
HampusandGitHub 23493b4ac2 fix(desktop): build libfido2 on Linux runners (#2042) 2026-08-27 14:44:44 +02:00
HampusandGitHub 13344096b7 fix(desktop): keep Linux builds compatible with glibc 2.35 (#2041) 2026-08-27 13:41:08 +02:00
HampusandGitHub a800430997 fix(app): sort interface languages by locale code (#2040) 2026-08-27 13:30:39 +02:00
HampusandGitHub 509562e6da feat(app): delete attachments from the media viewer (#2039) 2026-08-27 13:26:18 +02:00
HampusandGitHub 88d85919f1 fix(app): trim pasted friend tags (#2038) 2026-08-27 13:12:12 +02:00
HampusandGitHub 154e223284 fix(app): keep sticker sizes fixed while resizing the expression picker 2026-08-27 12:59:38 +02:00
HampusandGitHub 58732f7770 fix(api): configure email app base URL separately 2026-08-27 03:26:11 +02:00
HampusandGitHub cb4c847d41 fix(app): separate unread state from unread counts 2026-08-27 02:35:56 +02:00
HampusandGitHub d3976e33f8 fix(app): keep unread channel opens anchored to the divider 2026-08-27 02:02:45 +02:00
HampusandGitHub 8e17970632 fix(app): submit message edits in background 2026-08-26 23:54:28 +02:00
3231 changed files with 294294 additions and 179264 deletions
+29 -1
View File
@@ -8,7 +8,7 @@ ARG USER_GID=1000
ARG NODE_MAJOR=24
ARG ELP_VERSION=2026-02-27
ARG PNPM_VERSION=10.29.3
ARG WASM_BINDGEN_VERSION=0.2.122
ARG WASM_BINDGEN_VERSION=0.2.123
ENV DEBIAN_FRONTEND=noninteractive
@@ -32,6 +32,7 @@ RUN apt-get update \
jq \
libasound2 \
libatk-bridge2.0-0 \
libaom-dev \
libavcodec-dev \
libavfilter-dev \
libavformat-dev \
@@ -51,9 +52,15 @@ RUN apt-get update \
libcurl4-openssl-dev \
libswresample-dev \
libswscale-dev \
libdav1d-dev \
libde265-dev \
liblcms2-dev \
libvips-dev \
libyuv-dev \
libwayland-dev \
libwebp-dev \
nasm \
yasm \
libssl-dev \
libx11-xcb1 \
libxcb-dri3-0 \
@@ -78,6 +85,7 @@ RUN apt-get update \
unzip \
xz-utils \
xdg-utils \
zlib1g-dev \
zstd \
&& rm -rf /var/lib/apt/lists/*
@@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh
RUN /tmp/fluxer-install-native-deps.sh /usr/local \
&& rm /tmp/fluxer-install-native-deps.sh
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig
ENV LD_LIBRARY_PATH=/usr/local/lib
RUN printf '%s\n' \
'#include <libheif/heif.h>' \
'#include <string.h>' \
'#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \
'#error the source-built libheif headers must win the include search' \
'#endif' \
'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \
>/tmp/fluxer-heif-probe.c \
&& cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \
&& /tmp/fluxer-heif-probe \
&& [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \
&& rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
&& rm /tmp/fluxer-fonts-requirements.txt \
+2 -2
View File
@@ -1,3 +1,5 @@
name: fluxer-dev
services:
workspace:
build:
@@ -241,7 +243,6 @@ services:
volume:
nocopy: true
- pnpm-store:/home/vscode/.local/share/pnpm/store
- docs-venv:/workspaces/fluxer/fluxer_docs/.venv
- cargo-registry:/home/vscode/.cargo/registry
- cargo-git:/home/vscode/.cargo/git
- rust-target:/workspaces/fluxer/target
@@ -349,7 +350,6 @@ services:
volumes:
pnpm-store:
docs-venv:
root-node-modules:
fluxer-api-node-modules:
fluxer-app-node-modules:
+1 -2
View File
@@ -28,8 +28,7 @@ for path in \
/home/vscode/.cargo/registry \
/home/vscode/.cargo/git \
/home/vscode/.local \
/home/vscode/.local/share/pnpm/store \
/workspaces/fluxer/fluxer_docs/.venv; do
/home/vscode/.local/share/pnpm/store; do
repair_tree "$path"
done
+11 -4
View File
@@ -7,10 +7,16 @@ QUICK=0
SKIP_INSTALL=0
for arg in "$@"; do
case "$arg" in
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h | --help)
sed -n '2,25p' "$0"
exit 0
;;
*)
echo "unknown argument: $arg" >&2
exit 2
;;
esac
done
@@ -64,6 +70,7 @@ stage "app: typecheck" pnpm --filter fluxer_app typecheck
stage "app: unit tests" pnpm --filter fluxer_app exec vitest run
if [ "$QUICK" -eq 0 ]; then
stage "desktop: typecheck" pnpm --filter fluxer_desktop typecheck
stage "app: production build" pnpm --filter fluxer_app build
fi
+2 -1
View File
@@ -29,7 +29,8 @@
**/node_modules/
**/target/
**/test-results.json
/fluxer_docs/site/
/fluxer_docs/dist/
/fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
+14 -14
View File
@@ -104,6 +104,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
@@ -115,11 +118,13 @@ jobs:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: true
provenance: false
provenance: mode=min
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
${{ inputs.extra-build-args }}
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
@@ -185,17 +190,12 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
VERSION: ${{ needs.meta.outputs.build_version }}
MOVING_TAGS: ${{ inputs.moving-tags }}
run: |
set -euo pipefail
tag_args=()
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
for raw in "${moving[@]}"; do
tag="$(echo "$raw" | xargs)"
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
done
if (( ${#tag_args[@]} > 0 )); then
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
fi
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component "${{ inputs.image }}"
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags "${MOVING_TAGS}"
@@ -15,6 +15,13 @@ permissions:
contents: write
packages: write
concurrency:
group: publish-fluxer-app-proxy-self-hosted
cancel-in-progress: false
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve build release
@@ -26,13 +33,209 @@ jobs:
- name: approved
run: echo "Build release approved."
build:
meta:
name: resolve metadata
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-app-proxy-self-hosted
dockerfile: fluxer_app_proxy/Dockerfile
build-version: ${{ inputs['build-version'] }}
extra-build-args: |
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: set variables
id: vars
run: >-
tools/ci/run.sh build-app-proxy
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
dist:
name: build the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
actions: read
contents: read
packages: write
env:
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/fluxer-app-proxy-self-hosted
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: ""
BUNDLE_LOCAL_ASSETS: "true"
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build the dist once and publish it as the canonical asset image
env:
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_dist
- name: generate asset manifest
run: >-
tools/ci/run.sh build-app-proxy
--step generate_asset_manifest
- name: verify every manifest asset ships in the image
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build ${{ matrix.platform }}
needs: [meta, dist]
runs-on: ${{ matrix.runner }}
timeout-minutes: 75
permissions:
actions: read
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
with:
context: .
file: fluxer_app_proxy/Dockerfile
push: true
provenance: false
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
merge:
name: merge multi-arch manifest
needs: [meta, build]
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-amd64
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: create and push multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
VERSION: ${{ needs.meta.outputs.build_version }}
run: |
set -euo pipefail
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: >-
tools/ci/run.sh release
publish
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
- name: Advance moving image tags
env:
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+85 -16
View File
@@ -57,11 +57,11 @@ jobs:
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
build:
name: build app-proxy (amd64)
dist:
name: build and publish the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 60
permissions:
actions: read
contents: read
@@ -87,17 +87,17 @@ jobs:
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image + extract assets
- name: build the dist once and publish it as the canonical asset image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_and_extract
--step build_dist
- name: generate asset manifest
run: >-
@@ -114,9 +114,63 @@ jobs:
tools/ci/run.sh build-app-proxy
--step upload_assets
- name: verify every uploaded asset is readable
env:
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build app-proxy (amd64)
needs: [meta, dist]
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
SOURCE_SHA: ${{ github.sha }}
SOURCE_DATE: ${{ steps.source.outputs.date }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_image
build-arm64:
name: build app-proxy (arm64)
needs: meta
needs: [meta, dist]
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
permissions:
@@ -127,6 +181,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
@@ -143,8 +200,10 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
BUNDLE_LOCAL_ASSETS=false
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
@@ -155,7 +214,7 @@ jobs:
name: merge multi-arch manifest
needs: [meta, build, build-arm64]
runs-on: ubuntu-24.04
timeout-minutes: 10
timeout-minutes: 20
permissions:
contents: write
packages: write
@@ -173,6 +232,15 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: fuse amd64 + arm64 into a multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
@@ -212,10 +280,11 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
docker buildx imagetools create
-t "${IMAGE}:v1"
-t "${IMAGE}:latest"
"${IMAGE}:${VERSION}"
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+42 -1
View File
@@ -524,6 +524,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
@@ -553,11 +554,29 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
@@ -577,9 +596,17 @@ jobs:
- upload
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 10
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -590,6 +617,12 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
@@ -614,8 +647,16 @@ jobs:
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
-1
View File
@@ -33,5 +33,4 @@ jobs:
with:
image: fluxer-docs
dockerfile: fluxer_docs/Dockerfile
context: fluxer_docs
build-version: ${{ inputs['build-version'] }}
+142
View File
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: release image set
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
from-tag:
description: "Image tag every component is read from (v1 snapshots today's moving tags, a CalVer pins a coordinated build)"
type: string
required: false
default: "v1"
component-versions:
description: "Per-component overrides, one <image>=<version> entry per line (for example fluxer-api=2026.830.191141)"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: read
concurrency:
group: release-image-set
cancel-in-progress: false
defaults:
run:
shell: bash
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve image set release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Image set release approved."
manifest:
name: resolve and publish the image set
needs: approve
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
permission-packages: read
- name: set variables
id: vars
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
run: >-
tools/ci/run.sh resolve-calver
--github-output
- name: resolve release image set
id: resolve
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
FROM_TAG: ${{ inputs['from-tag'] }}
COMPONENT_VERSIONS: ${{ inputs['component-versions'] }}
run: |
set -euo pipefail
args=(
image-set resolve
--version "${VERSION}"
--registry "ghcr.io/${GHCR_OWNER}"
--from-tag "${FROM_TAG}"
--out-dir release-out
--github-output
)
while IFS= read -r entry; do
entry="$(echo "$entry" | xargs)"
if [ -n "$entry" ]; then
args+=( --component-version "$entry" )
fi
done <<< "${COMPONENT_VERSIONS}"
tools/ci/run.sh "${args[@]}"
- name: verify release image set
env:
VERSION: ${{ steps.vars.outputs.build_version }}
run: >-
tools/ci/run.sh image-set verify
--manifest "release-out/fluxer-release-${VERSION}.json"
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
BUNDLE_COMMIT: ${{ steps.resolve.outputs.bundle_commit }}
run: |
set -euo pipefail
if [ -z "${BUNDLE_COMMIT}" ]; then
echo "image-set resolve reported no bundle commit" >&2
exit 1
fi
gh release create "fluxer-release@${VERSION}" \
--repo fluxerapp/fluxer \
--target "${BUNDLE_COMMIT}" \
--title "fluxer-release ${VERSION}" \
--latest=true \
--notes "Immutable image set for ${VERSION}. Every image in the set contains ${BUNDLE_COMMIT}, the commit this tag points at, so the bundle here is never newer than the images. Pin with: docker compose -f docker-compose.yml -f fluxer-release-${VERSION}.yml up -d" \
"release-out/fluxer-release-${VERSION}.json" \
"release-out/fluxer-release-${VERSION}.yml"
+97 -17
View File
@@ -152,8 +152,8 @@ jobs:
'packages/markdown_parser/rust/src/**') }}
rust:
runs-on: ubuntu-24.04
timeout-minutes: 30
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -174,24 +174,67 @@ jobs:
cache: 'pnpm'
- name: Cache cargo
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
workspaces: |
. -> target
fluxer_desktop/native/rust -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.19.6 --locked
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
- name: Cache native media dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: /opt/fluxer-native
key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}
- name: Install native dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
build-essential \
libcurl4-openssl-dev \
libvips-dev \
binutils \
clang \
cmake \
curl \
libaom-dev \
libavfilter-dev \
libclang-dev \
libcurl4-openssl-dev \
libdav1d-dev \
libde265-dev \
libfido2-dev \
libheif-dev \
libwebp-dev
liblcms2-dev \
libpipewire-0.3-dev \
libspa-0.2-dev \
libssl-dev \
libudev-dev \
libvips-dev \
libwebp-dev \
libyuv-dev \
meson \
nasm \
ninja-build \
pkg-config \
xz-utils \
yasm \
zlib1g-dev
sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native
echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV"
echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH"
- name: Install Node.js dependencies
run: pnpm --filter fluxer_admin install
@@ -199,17 +242,29 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (desktop native)
run: cargo fmt --manifest-path fluxer_desktop/native/rust/Cargo.toml --all -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
- name: Clippy (warnings as errors)
run: cargo clippy --workspace -- -D warnings
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
run: tools/ci/check-desktop-native-workspaces.sh clippy
- name: Verify the source-built ffmpeg CLI is on PATH
run: |
set -euo pipefail
command -v ffmpeg
test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg
ffmpeg -hide_banner -version
- name: Run tests
run: cargo test --workspace
env:
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
run: cargo test --workspace --all-features --locked
- name: Run desktop native tests
run: cargo test --manifest-path fluxer_desktop/native/rust/Cargo.toml
- name: Run desktop native workspace tests on Linux
run: tools/ci/check-desktop-native-workspaces.sh test
gateway:
runs-on: ubuntu-24.04
@@ -405,6 +460,31 @@ jobs:
exit 1
fi
docs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter fluxer_docs...
- name: Verify documentation matches the live API
run: pnpm --filter fluxer_docs verify
- name: Build documentation
run: pnpm --filter fluxer_docs build
fonts:
runs-on: ubuntu-24.04
timeout-minutes: 10
Generated
+81 -370
View File
@@ -49,14 +49,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser 0.35.0",
"html5ever 0.35.0",
"cssparser",
"html5ever",
"maplit",
"tendril 0.4.3",
"url",
]
@@ -127,9 +126,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arc-swap"
@@ -182,7 +181,7 @@ dependencies = [
"ring",
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki",
"serde",
"serde_json",
"serde_nanos",
@@ -190,7 +189,7 @@ dependencies = [
"thiserror",
"time",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-stream",
"tokio-util",
"tokio-websockets",
@@ -528,23 +527,17 @@ dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
"h2 0.4.14",
"http 0.2.12",
"h2",
"http 1.4.2",
"http-body 0.4.6",
"hyper 0.14.32",
"hyper 1.10.1",
"hyper-rustls 0.24.2",
"hyper-rustls 0.27.9",
"hyper",
"hyper-rustls",
"hyper-util",
"pin-project-lite",
"rustls 0.21.12",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tower",
"tracing",
]
@@ -709,7 +702,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper",
"hyper-util",
"itoa",
"matchit",
@@ -933,9 +926,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -1218,9 +1211,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1268,42 +1261,19 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
@@ -1652,7 +1622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1779,7 +1749,7 @@ dependencies = [
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper",
"hyper-util",
"image",
"libc",
@@ -1839,6 +1809,7 @@ dependencies = [
"clap",
"criterion",
"fluxer_common",
"futures-util",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1923,8 +1894,7 @@ dependencies = [
"libc",
"moka",
"rmp-serde",
"rustls 0.23.40",
"rustls-pemfile",
"rustls",
"scylla",
"serde",
"serde_json",
@@ -1989,6 +1959,7 @@ dependencies = [
"base64",
"chrono",
"cookie",
"fluxer_common",
"hmac 0.13.0",
"maud",
"openapiv3",
@@ -2019,16 +1990,14 @@ dependencies = [
"anyhow",
"axum",
"base64",
"fluxer-svc",
"fluxer_common",
"hex",
"moka",
"rand 0.10.1",
"reqwest",
"scylla",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
@@ -2100,16 +2069,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -2282,28 +2241,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.27"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h2"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -2399,17 +2339,6 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -2417,7 +2346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever 0.39.0",
"markup5ever",
]
[[package]]
@@ -2496,30 +2425,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper"
version = "1.10.1"
@@ -2530,7 +2435,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2",
"http 1.4.2",
"http-body 1.0.1",
"httparse",
@@ -2542,21 +2447,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"log",
"rustls 0.21.12",
"tokio",
"tokio-rustls 0.24.1",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
@@ -2564,12 +2454,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.2",
"hyper 1.10.1",
"hyper",
"hyper-util",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tower-service",
]
@@ -2585,12 +2475,12 @@ dependencies = [
"futures-util",
"http 1.4.2",
"http-body 1.0.1",
"hyper 1.10.1",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -2984,29 +2874,12 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -3014,19 +2887,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms 0.2.4",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"tendril",
"web_atoms",
]
[[package]]
@@ -3191,7 +3053,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3362,55 +3224,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
"phf_generator",
"phf_shared",
]
[[package]]
@@ -3420,20 +3252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"phf_shared",
]
[[package]]
@@ -3442,22 +3261,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -3758,8 +3568,8 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.40",
"socket2 0.6.3",
"rustls",
"socket2 0.5.10",
"thiserror",
"tokio",
"tracing",
@@ -3779,7 +3589,7 @@ dependencies = [
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls 0.23.40",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
@@ -3797,7 +3607,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
]
@@ -4015,15 +3825,15 @@ dependencies = [
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper-rustls 0.27.9",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.40",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
@@ -4031,7 +3841,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4068,7 +3878,7 @@ dependencies = [
"futures",
"getrandom 0.2.17",
"http 1.4.2",
"hyper 1.10.1",
"hyper",
"reqwest",
"reqwest-middleware",
"retry-policies",
@@ -4155,19 +3965,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring",
"rustls-webpki 0.101.7",
"sct",
"windows-sys 0.52.0",
]
[[package]]
@@ -4181,7 +3979,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki",
"subtle",
"zeroize",
]
@@ -4198,15 +3996,6 @@ dependencies = [
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -4228,14 +4017,14 @@ dependencies = [
"jni",
"log",
"once_cell",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.13",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4244,16 +4033,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -4346,23 +4125,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser 0.37.0",
"cssparser",
"ego-tree",
"getopts",
"html5ever 0.39.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril 0.5.0",
]
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
"tendril",
]
[[package]]
@@ -4466,12 +4235,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser 0.37.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -4765,9 +4534,9 @@ dependencies = [
[[package]]
name = "spin"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spki"
@@ -4785,19 +4554,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -4806,30 +4562,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -4904,18 +4648,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
"windows-sys 0.52.0",
]
[[package]]
@@ -5086,7 +4819,7 @@ dependencies = [
"log",
"parking_lot",
"percent-encoding",
"phf 0.13.1",
"phf",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
@@ -5103,32 +4836,22 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb"
dependencies = [
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tokio-postgres",
"tokio-rustls 0.26.4",
"tokio-rustls",
"x509-cert",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.40",
"rustls",
"tokio",
]
@@ -5172,7 +4895,7 @@ dependencies = [
"ring",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-util",
"webpki-roots 0.26.11",
]
@@ -5713,28 +5436,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
@@ -5805,7 +5516,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+5
View File
@@ -25,3 +25,8 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "AGPL-3.0-or-later"
[profile.release]
lto = "fat"
codegen-units = 1
strip = "symbols"
+12 -1
View File
@@ -20,7 +20,7 @@
"bracketSpacing": false,
"bracketSameLine": false
},
"globals": ["React"]
"globals": ["React", "__webpack_base_uri__"]
},
"json": {
"formatter": {
@@ -115,6 +115,17 @@
}
},
"assist": {"actions": {"source": {"organizeImports": "on"}}},
"overrides": [
{
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
},
{
"includes": ["**/*.astro"],
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
"assist": {"actions": {"source": {"organizeImports": "off"}}}
}
],
"vcs": {
"enabled": true,
"clientKind": "git",
+2 -4
View File
@@ -43,7 +43,6 @@ FLUXER_SVC_NATS_URL=nats://nats:4222
FLUXER_SVC_SHARD_COUNT=1
FLUXER_SVC_CACHE_TTL_MS=30000
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
FLUXER_SVC_MAX_CONCURRENT_REQUESTS=64
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
@@ -90,7 +89,6 @@ FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
FLUXER_ADMIN_PORT=3020
FLUXER_ADMIN_BASE_PATH=/admin
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
FLUXER_ADMIN_OAUTH_CLIENT_ID=1234567890123456789
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
FLUXER_MARKETING_PORT=3010
@@ -100,8 +98,8 @@ FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
FLUXER_VAPID_PUBLIC_KEY=dev-vapid-public-key
FLUXER_VAPID_PRIVATE_KEY=dev-vapid-private-key
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
FLUXER_VAPID_EMAIL=dev@localhost
FLUXER_PASSKEY_RP_NAME='Fluxer Dev'
FLUXER_PASSKEY_RP_ID=localhost
+35 -14
View File
@@ -1,13 +1,9 @@
# cargo-deny configuration for the Fluxer workspace.
#
# Applies to the root workspace (Cargo.toml at the repo root) AND to every
# per-addon crate under fluxer_desktop/native/* (each addon has its own
# [workspace], so we invoke cargo-deny with --config pointing here).
#
# Used by the native desktop security gate in CI.
# Applies to the root workspace (Cargo.toml at the repo root).
[graph]
all-features = false
all-features = true
no-default-features = false
[output]
@@ -44,13 +40,11 @@ allow = [
"BSD-3-Clause",
"ISC",
"MPL-2.0",
"Unicode-DFS-2016",
"Unicode-3.0",
"Zlib",
"CC0-1.0",
"AGPL-3.0-or-later",
"BSL-1.0",
"OpenSSL",
"CDLA-Permissive-2.0",
]
# Explicitly deny GPL-only / strong-copyleft licenses that don't compose with
@@ -72,21 +66,48 @@ license-files = [
[bans]
multiple-versions = "warn"
wildcards = "deny"
# Per-addon crates path-depend on ../rust (the shared `fluxer_desktop_native`
# crate) without a version. cargo-deny flags that as a wildcard; we allow it
# because path deps can't realistically pin a SemVer range, and this only
# affects intra-repo workspace links (registry wildcards remain denied).
# Internal workspace crates use path dependencies without registry versions.
# Registry wildcards remain denied.
allow-wildcard-paths = true
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
# Keep desktop packaging and native addons away from the obsolete libfuse2 stack.
# Keep workspace artifacts away from the obsolete libfuse2 stack.
# AppImage packaging must use the static electron-builder runtime instead.
deny = [
{ crate = "fuse", reason = "libfuse2-based Rust wrapper; use a maintained FUSE3-native crate only if Fluxer ever needs FUSE directly" },
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
]
skip = []
skip = [
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older crypto API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older socket API" },
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI binding API" },
]
skip-tree = []
# ---------------------------------------------------------------------------
+226 -3
View File
@@ -1,7 +1,86 @@
# Every variable docker-compose.yml reads from this file is named here:
# uncommented when it has no default, commented with its default when it has one.
# A name absent from this file is one Compose does not forward, and it reaches a
# service only through a Compose override file that adds it to that service's
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
# a Compose edit forgets the matching line here.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
# How browsers reach this instance.
#
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
# Point DNS at this host and there is nothing else to configure.
#
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
# proxy serves, not this local port.
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
# is on another machine, and firewall the port to that machine.
#FLUXER_EDGE_BIND=127.0.0.1:8080
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
# this to the real client address, so IP bans, rate limits and abuse detection
# see the caller rather than the proxy. The default covers proxies on private or
# loopback addresses, which is every same-host setup. Set it to your proxy's
# address if it reaches Fluxer from a public IP.
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
# The public origin browsers use, without a trailing slash. Derived from the three
# values above and correct for the usual https-on-443 setup, so leave it alone
# unless you serve Fluxer on a non-default port, where the port must appear here.
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
# file win, so a value here is discarded under the proxy overlay with no warning.
# Set it only for an unusual default-mode layout, such as serving several
# hostnames or binding a non-default TLS port.
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
# The old name for the value above. It is read only when
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
#FLUXER_CADDY_SITE_ADDRESS=
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
# origin written with a default port never matches a browser Origin header.
# Serving on any other port means setting all three, plus the published port
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
# stay below the two values it reads. Above them it silently expands to a bare
# host with a trailing colon.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
#FLUXER_HTTP_PORT=19080
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
# the container, so change only these when something else already owns the
# standard ports or another proxy sits in front. Both take an optional bind
# address in front of the port, and 127.0.0.1 keeps the publish off every
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
# together, because HTTP/3 needs both on the same port.
#FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80
#FLUXER_HTTPS_PORT=127.0.0.1:443
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
# with a single loopback HTTP publish, so nothing binds 443. FLUXER_HTTP_PORT
# still moves that one publish. Set the line below and plain docker compose
# commands pick the file up, or add it to your own -f flags if you pass any. The
# file uses the !override tag, which needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
FLUXER_REGISTRY_OWNER=fluxerapp
FLUXER_REGISTRY=ghcr.io/${FLUXER_REGISTRY_OWNER}
@@ -15,6 +94,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
@@ -22,7 +102,10 @@ FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
FLUXER_VAPID_EMAIL=[email protected]
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
# only if that mailbox does not exist.
#[email protected]
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
@@ -30,14 +113,27 @@ [email protected]
#FLUXER_PASSKEY_RP_ID=chat.example.com
#FLUXER_PASSKEY_RP_NAME=Fluxer
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
# only when a browser must reach an origin the defaults do not cover, such as a
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
# sources with spaces or commas.
# sources with spaces or commas. Every one of them is empty by default, and the
# three carrying a value below are illustrations, not defaults.
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
#FLUXER_CSP_EXTRA_MEDIA_SRC=
#FLUXER_CSP_EXTRA_FONT_SRC=
#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com
#FLUXER_CSP_EXTRA_STYLE_SRC=
#FLUXER_CSP_EXTRA_FRAME_SRC=
#FLUXER_CSP_EXTRA_WORKER_SRC=
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
# directive off the header.
#FLUXER_CSP_REPORT_URI=
# Allow the SSO identity provider to resolve to a private or internal address.
# Off by default: the API refuses to call non-public addresses so a misconfigured
@@ -46,15 +142,38 @@ [email protected]
# identity provider, and only when you trust everyone who can configure SSO.
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
# Set it only when LiveKit is served from another host.
#FLUXER_LIVEKIT_URL=
# Media ports. LiveKit advertises these in ICE candidates, so the host must
# forward the same numbers.
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# LiveKit finds the address browsers dial by asking a STUN server. A host that
# cannot reach one over UDP stops with "could not resolve external IP", and the
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
# server to keep the lookup and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
FLUXER_KLIPY_API_KEY=
FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
FLUXER_EMAIL_FROM_NAME=Fluxer
FLUXER_EMAIL_APP_BASE_URL=
FLUXER_EMAIL_SMTP_HOST=
FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=
@@ -63,4 +182,108 @@ FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. The 25 limits sum to 16.75 GiB, which is a sum of ceilings and
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
# The four reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from the services whose death takes the whole instance down. They do
# not reserve anything. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
#FLUXER_NATS_MEMORY_LIMIT=256mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=512mb
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
#FLUXER_API_MEMORY_LIMIT=2560mb
#FLUXER_API_MEMORY_RESERVATION=1gb
#FLUXER_WORKER_MEMORY_LIMIT=2560mb
#FLUXER_WORKER_MEMORY_RESERVATION=1gb
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
#FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT=256mb
#FLUXER_USERS_MEMORY_LIMIT=128mb
#FLUXER_USERS_SHARD_MEMORY_LIMIT=256mb
#FLUXER_GIFS_MEMORY_LIMIT=128mb
#FLUXER_GIFS_SHARD_MEMORY_LIMIT=256mb
#FLUXER_MESSAGES_MEMORY_LIMIT=128mb
#FLUXER_MESSAGES_SHARD_MEMORY_LIMIT=256mb
#FLUXER_UNFURL_MEMORY_LIMIT=128mb
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
# which is the container ceiling the indexer shares with the search process.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
# Node sizes its own heap from the container memory limit by default, at roughly
# 55 percent of it, which always leaves room for the buffers and stacks that live
# outside the heap. Leave these unset unless you have a reason to pin the value.
# Any value set here must stay well below the container limit above: a heap ceiling
# above the container limit makes the kernel OOM-kill the container (exit 137, no
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
#FLUXER_API_NODE_HEAP_MB=1792
#FLUXER_WORKER_NODE_HEAP_MB=1792
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
# budget roughly shared_buffers + (server max_connections x 12 MB) +
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
# pool sizes used by the api, worker and shards.
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
#FLUXER_POSTGRES_WORK_MEM=8MB
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
# The bundled Valkey holds durable state as well as cache. The bulk message
# deletion queue and the account deletion queue are sorted sets with no expiry,
# and nothing else stores the first of the two. It therefore runs with an
# append-only file on a named volume and with noeviction, so an over-limit write
# fails loudly instead of silently deleting queued work. Distributed locks all
# carry a TTL and are not what the durability is for. Only change the policy if
# you have moved that durable state elsewhere.
#FLUXER_VALKEY_MAXMEMORY=192mb
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
# The gateway derives its BEAM scheduler count from the container CPU quota,
# clamped to this range. The floor matters: a single scheduler lets one blocking
# operation stall every websocket on the node. The ceiling stops a large host
# from starting far more schedulers than the container can actually use.
#FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# In-flight request ceiling for the four services Compose forwards it to: the
# users and messages routers and their shards. The Rust built-in defaults are 192
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
# The api and the Rust services name their fixed Postgres statement shapes so the
# server can reuse their plans. Named prepared statements require a session that
# outlives the transaction, so set this to false if you put a transaction-pooling
# connection pooler such as PgBouncer in front of Postgres. One setting governs
# every service. The bundled compose talks to Postgres directly, where naming is
# a win and the default is correct.
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
# The api bounds how long a client may take to send a request. The header timeout
# covers the request line and headers only, while the request timeout covers the
# whole exchange, so a slow uploader is bounded by the second value and not by
# the first. Raise both if you front large uploads or serve clients on high
# latency links. The header timeout is clamped down to the request timeout, so
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
+41 -14
View File
@@ -1,58 +1,85 @@
{
servers {
trusted_proxies static private_ranges
trusted_proxies static {$FLUXER_EDGE_TRUSTED_PROXIES:private_ranges}
trusted_proxies_strict
}
}
{$FLUXER_CADDY_SITE_ADDRESS} {
{$FLUXER_EDGE_SITE_ADDRESS} {
encode zstd gzip
handle /_health {
respond "OK" 200
}
handle_path /api/* {
reverse_proxy api:8080
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle /gateway {
rewrite * /
reverse_proxy gateway:8080
reverse_proxy gateway:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /gateway/* {
reverse_proxy gateway:8080
reverse_proxy gateway:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /media/* {
reverse_proxy media-proxy:8080
reverse_proxy media-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /livekit/* {
reverse_proxy livekit:7880
reverse_proxy livekit:7880 {
header_up X-Forwarded-For {client_ip}
}
}
handle /admin {
rewrite * /
reverse_proxy admin:8080
reverse_proxy admin:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /admin/* {
reverse_proxy admin:8080
reverse_proxy admin:8080 {
header_up X-Forwarded-For {client_ip}
}
}
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
handle @staticAssets {
reverse_proxy static-proxy:8080
reverse_proxy static-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle /.well-known/fluxer {
reverse_proxy api:8080
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle {
reverse_proxy app-proxy:8080
reverse_proxy app-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
}
}
:8088 {
handle_path /api/* {
reverse_proxy api:8080
handle /.well-known/fluxer {
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
}
}
@@ -0,0 +1,17 @@
# Overlay for running Fluxer behind your own reverse proxy.
#
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
#
# Or set this once in .env and keep using plain `docker compose up -d`:
#
# COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
#
# Fluxer stops binding 80 and 443 and serves plain HTTP on one port instead.
# That port already does all internal routing, so the proxy in front needs a
# single rule: send everything to it. Terminate TLS there.
services:
edge:
ports: !override
- "${FLUXER_EDGE_BIND:-127.0.0.1:8080}:8080"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":8080"
+343 -73
View File
@@ -1,6 +1,17 @@
name: fluxer
x-fluxer-postgres-env: &fluxer-postgres-env
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
x-fluxer-env: &fluxer-env
<<: *fluxer-postgres-env
FLUXER_ENV: production
NODE_ENV: production
FLUXER_SELF_HOSTED: "true"
@@ -9,14 +20,8 @@ x-fluxer-env: &fluxer-env
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
FLUXER_KV_URL: redis://valkey:6379/0
FLUXER_NATS_URL: nats://nats:4222
@@ -39,8 +44,8 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?}
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"
@@ -50,6 +55,7 @@ x-fluxer-env: &fluxer-env
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit}
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
@@ -57,6 +63,7 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-none}
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-Fluxer}
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-587}
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
@@ -66,6 +73,10 @@ x-fluxer-env: &fluxer-env
FLUXER_SMS_ENABLED: "false"
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
FLUXER_STRIPE_ENABLED: "false"
FLUXER_NCMEC_ENABLED: "false"
FLUXER_CLAMAV_ENABLED: "false"
@@ -79,7 +90,7 @@ x-fluxer-env: &fluxer-env
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
@@ -89,36 +100,90 @@ x-fluxer-env: &fluxer-env
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
FLUXER_INTERNAL_GATEWAY_ENDPOINT: http://gateway:8080
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
x-fluxer-service: &fluxer-service
restart: unless-stopped
networks: [fluxer]
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
services:
caddy:
edge:
image: caddy:2.10-alpine
deploy:
resources:
limits:
memory: ${FLUXER_CADDY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
ports:
- "80:80"
- "443:443"
- "443:443/udp"
- "${FLUXER_HTTP_PORT:-80}:80"
- "${FLUXER_HTTPS_PORT:-443}:443"
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
environment:
FLUXER_CADDY_SITE_ADDRESS: ${FLUXER_CADDY_SITE_ADDRESS:?set FLUXER_CADDY_SITE_ADDRESS in .env}
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on: [api, gateway, media-proxy, static-proxy, admin]
- edge-data:/data
- edge-config:/config
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 10s
timeout: 5s
retries: 10
depends_on:
api: {condition: service_started}
gateway: {condition: service_healthy}
media-proxy: {condition: service_started}
static-proxy: {condition: service_started}
admin: {condition: service_started}
postgres:
image: postgres:16-alpine
deploy:
resources:
limits:
memory: ${FLUXER_POSTGRES_MEMORY_LIMIT:-5gb}
reservations:
memory: ${FLUXER_POSTGRES_MEMORY_RESERVATION:-3gb}
restart: unless-stopped
networks: [fluxer]
command: >
postgres
-c max_connections=${FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS:-150}
-c shared_buffers=${FLUXER_POSTGRES_SHARED_BUFFERS:-512MB}
-c effective_cache_size=${FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE:-2GB}
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
-c maintenance_work_mem=${FLUXER_POSTGRES_MAINTENANCE_WORK_MEM:-256MB}
-c autovacuum_work_mem=${FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM:-128MB}
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c default_statistics_target=200
-c jit=off
-c min_wal_size=512MB
-c max_wal_size=2GB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c wal_compression=zstd
-c bgwriter_delay=50ms
-c bgwriter_lru_maxpages=1000
-c autovacuum_vacuum_scale_factor=0.05
-c autovacuum_analyze_scale_factor=0.02
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
@@ -133,9 +198,17 @@ services:
valkey:
image: valkey/valkey:8.1-alpine
deploy:
resources:
limits:
memory: ${FLUXER_VALKEY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["valkey-server", "--save", "", "--appendonly", "no"]
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data",
"--maxmemory", "${FLUXER_VALKEY_MAXMEMORY:-192mb}",
"--maxmemory-policy", "${FLUXER_VALKEY_MAXMEMORY_POLICY:-noeviction}"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
@@ -144,36 +217,73 @@ services:
nats:
image: nats:2.14-alpine
deploy:
resources:
limits:
memory: ${FLUXER_NATS_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
interval: 10s
timeout: 5s
retries: 10
meilisearch:
image: getmeili/meilisearch:v1.12
deploy:
resources:
limits:
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
restart: unless-stopped
networks: [fluxer]
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
- meilisearch-data:/meili_data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7700/health"]
interval: 10s
timeout: 5s
retries: 10
seaweedfs:
image: chrislusf/seaweedfs:4.34
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
command: ["server", "-s3", "-dir=/data"]
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
seaweedfs-init:
image: chrislusf/seaweedfs:4.34
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT:-128mb}
networks: [fluxer]
depends_on: [seaweedfs]
depends_on:
seaweedfs: {condition: service_healthy}
restart: "no"
environment:
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
entrypoint:
- /bin/sh
- -c
@@ -191,6 +301,10 @@ services:
echo "$$listed" | grep -q "^[[:space:]]*$$b[[:space:]]" || missing="$${missing:+$$missing }$$b";
done;
if [ -z "$$missing" ]; then
if ! echo "s3.configure -user=fluxer -access_key=$$FLUXER_S3_ACCESS_KEY -secret_key=$$FLUXER_S3_SECRET_KEY -actions=Admin,Read,Write,List,Tagging -apply" | timeout 10 weed shell -master=seaweedfs:9333 >/dev/null 2>&1; then
echo "seaweedfs-init could not configure the S3 identity" >&2;
exit 1;
fi;
echo "buckets ready";
exit 0;
fi;
@@ -204,105 +318,182 @@ services:
livekit:
image: livekit/livekit-server:v1.12.0
deploy:
resources:
limits:
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
command: ["--config", "/etc/livekit.yaml"]
environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}"
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
LIVEKIT_CONFIG: |
port: 7880
log_level: info
rtc:
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
use_external_ip: ${FLUXER_LIVEKIT_USE_EXTERNAL_IP:-true}
node_ip: "${FLUXER_LIVEKIT_NODE_IP:-}"
stun_servers:
- ${FLUXER_LIVEKIT_STUN_PRIMARY:-stun.l.google.com:19302}
- ${FLUXER_LIVEKIT_STUN_SECONDARY:-stun1.l.google.com:19302}
webhook:
api_key: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
urls:
- http://api:8080/webhooks/livekit
ports:
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:7881"
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:7882/udp"
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:${FLUXER_LIVEKIT_TCP_PORT:-7881}"
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:${FLUXER_LIVEKIT_UDP_PORT:-7882}/udp"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
interval: 10s
timeout: 5s
retries: 10
api:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_API_MEMORY_LIMIT:-2560mb}
reservations:
memory: ${FLUXER_API_MEMORY_RESERVATION:-1gb}
environment:
<<: *fluxer-env
FLUXER_API_PORT: "8080"
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_started}
meilisearch: {condition: service_started}
nats: {condition: service_healthy}
meilisearch: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
gifs: {condition: service_started}
gifs-shard: {condition: service_started}
snowflakes: {condition: service_started}
snowflakes-shard: {condition: service_started}
messages: {condition: service_started}
messages-shard: {condition: service_started}
users: {condition: service_started}
users-shard: {condition: service_started}
gifs: {condition: service_healthy}
gifs-shard: {condition: service_healthy}
snowflakes: {condition: service_healthy}
snowflakes-shard: {condition: service_healthy}
messages: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users: {condition: service_healthy}
users-shard: {condition: service_healthy}
worker:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_WORKER_MEMORY_LIMIT:-2560mb}
reservations:
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
working_dir: /usr/src/app/fluxer_api
command: ["./node_modules/.bin/tsx", "src/WorkerEntrypoint.ts"]
command: ["sh", "-c", "if [ -f dist/WorkerEntrypoint.js ]; then exec node dist/WorkerEntrypoint.js; else exec ./node_modules/.bin/tsx src/WorkerEntrypoint.ts; fi"]
environment:
<<: *fluxer-env
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
interval: 10s
timeout: 5s
retries: 3
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_started}
nats: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
snowflakes-shard: {condition: service_started}
messages-shard: {condition: service_started}
users-shard: {condition: service_started}
snowflakes-shard: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users-shard: {condition: service_healthy}
gateway:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gateway:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GATEWAY_MEMORY_LIMIT:-1gb}
reservations:
memory: ${FLUXER_GATEWAY_MEMORY_RESERVATION:-384mb}
environment:
<<: *fluxer-env
FLUXER_GATEWAY_PORT: "8080"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_GATEWAY_LOGGER_LEVEL: info
FLUXER_ERLANG_COOKIE: ${FLUXER_ERLANG_COOKIE:?set FLUXER_ERLANG_COOKIE in .env}
FLUXER_ERLANG_SCHEDULERS_MIN: "${FLUXER_ERLANG_SCHEDULERS_MIN:-2}"
FLUXER_ERLANG_SCHEDULERS_MAX: "${FLUXER_ERLANG_SCHEDULERS_MAX:-16}"
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
valkey: {condition: service_healthy}
media-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-media-proxy:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MEDIA_PROXY_MEMORY_LIMIT:-512mb}
environment:
<<: *fluxer-env
FLUXER_MEDIA_PROXY_HOST: 0.0.0.0
FLUXER_MEDIA_PROXY_PORT: "8080"
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
healthcheck:
disable: true
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_S3_READ_SIGNED: "true"
depends_on:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_started}
nats: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_STATIC_PROXY_MEMORY_LIMIT:-256mb}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/avatars/0.png"]
interval: 10s
timeout: 5s
retries: 10
app-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-app-proxy-self-hosted:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
environment:
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
@@ -316,124 +507,202 @@ services:
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
depends_on:
api: {condition: service_healthy}
caddy: {condition: service_started}
postgres: {condition: service_healthy}
edge: {condition: service_healthy}
snowflakes:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_SNOWFLAKES_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: router
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
snowflakes-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
users:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_USERS_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
users-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_USERS_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
gifs:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GIFS_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
gifs-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GIFS_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
messages:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MESSAGES_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
messages-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MESSAGES_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
unfurl:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_UNFURL_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
unfurl-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_UNFURL_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
admin:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-admin:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_ADMIN_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_ADMIN_HOST: 0.0.0.0
FLUXER_ADMIN_PORT: "8080"
FLUXER_ADMIN_BASE_PATH: /admin
FLUXER_API_ENDPOINT: http://api:8080
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin/oauth2_callback
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin/oauth2_callback
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
api: {condition: service_healthy}
@@ -442,9 +711,10 @@ networks:
driver: bridge
volumes:
caddy-data:
caddy-config:
edge-data:
edge-config:
postgres-data:
valkey-data:
nats-data:
meilisearch-data:
seaweedfs-data:
-15
View File
@@ -1,15 +0,0 @@
port: 7880
log_level: info
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
stun_servers:
- stun.l.google.com:19302
- stun1.l.google.com:19302
webhook:
api_key: fluxer
urls:
- http://api:8080/webhooks/livekit
+4
View File
@@ -0,0 +1,4 @@
services:
edge:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
+3 -1
View File
@@ -3,14 +3,16 @@ name = "fluxer_admin"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
build = "build.rs"
[dependencies]
anyhow = "1.0.102"
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.1"
fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10"
+21 -1
View File
@@ -24,6 +24,7 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
COPY Cargo.lock Cargo.lock
COPY fluxer_admin fluxer_admin
COPY fluxer_common fluxer_common
COPY packages/fonts/manifest.json packages/fonts/manifest.json
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
@@ -31,12 +32,17 @@ COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
RUN printf '%s\n' \
'[workspace]' \
'members = ["fluxer_admin"]' \
'members = ["fluxer_admin", "fluxer_common"]' \
'resolver = "2"' \
'' \
'[workspace.package]' \
'edition = "2024"' \
'license = "AGPL-3.0-or-later"' \
'' \
'[profile.release]' \
'lto = "fat"' \
'codegen-units = 1' \
'strip = "symbols"' \
> Cargo.toml
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
@@ -54,6 +60,20 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
FROM debian:bookworm-slim AS runtime
ARG BUILD_VERSION=""
ARG SOURCE_SHA=""
ARG SOURCE_DATE=""
LABEL org.opencontainers.image.title="fluxer-admin"
LABEL org.opencontainers.image.description="Fluxer admin console"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.vendor="Fluxer"
LABEL org.opencontainers.image.url="https://fluxer.app"
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/local/bin
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -41,11 +41,9 @@ pub const BAN_AVATAR_HASH_REMOVE: &str = "ban:avatar_hash:remove";
pub const BAN_PROFILE_SUBSTRING_ADD: &str = "ban:profile_substring:add";
pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
pub const BILLING_MANAGE_SUBSCRIPTION: &str = "billing:manage_subscription";
pub const BILLING_REFUND: &str = "billing:refund";
pub const BILLING_VIEW: &str = "billing:view";
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
pub const BULK_UPDATE_SUSPICIOUS_ACTIVITY: &str = "bulk:update:suspicious_activity";
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
@@ -124,6 +122,7 @@ pub const ALL_ACLS: &[&str] = &[
ARCHIVE_TRIGGER_GUILD,
ARCHIVE_TRIGGER_USER,
ARCHIVE_VIEW_ALL,
ASSET_PURGE,
AUDIT_LOG_VIEW,
AUTHENTICATE,
JOBS_VIEW,
@@ -155,11 +154,9 @@ pub const ALL_ACLS: &[&str] = &[
BAN_PROFILE_SUBSTRING_ADD,
BAN_PROFILE_SUBSTRING_CHECK,
BAN_PROFILE_SUBSTRING_REMOVE,
BILLING_MANAGE_SUBSCRIPTION,
BILLING_REFUND,
BILLING_VIEW,
BULK_ADD_GUILD_MEMBERS,
BULK_DELETE_USERS,
BULK_DELETE_USER_MESSAGES,
BULK_UPDATE_GUILD_FEATURES,
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
BULK_UPDATE_USER_FLAGS,
-5
View File
@@ -12,7 +12,6 @@ pub struct I32Flag {
pub mod user_flag_bits {
pub const STAFF: u64 = 1 << 0;
pub const CTP_MEMBER: u64 = 1 << 1;
pub const PARTNER: u64 = 1 << 2;
pub const BUG_HUNTER: u64 = 1 << 3;
pub const FRIENDLY_BOT: u64 = 1 << 4;
@@ -40,10 +39,6 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "STAFF",
value: user_flag_bits::STAFF,
},
U64Flag {
name: "CTP_MEMBER",
value: user_flag_bits::CTP_MEMBER,
},
U64Flag {
name: "PARTNER",
value: user_flag_bits::PARTNER,
+12 -2
View File
@@ -12,7 +12,7 @@ impl AdminApiClient {
acls: &[String],
) -> ApiResult<CreateAdminApiKeyResponse> {
let body = generated_types::CreateAdminApiKeyRequest {
acls: acls.to_vec(),
acls: parse_acls(acls)?,
expires_in_days: None,
name: generated_types::CreateAdminApiKeyRequestName::try_from(name)
.map_err(|e| ApiError::Parse(e.to_string()))?,
@@ -35,10 +35,20 @@ impl AdminApiClient {
}
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
self.generated()
.delete_admin_api_key(key_id)
.delete_admin_api_key(&key_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
pub(super) fn parse_acls(acls: &[String]) -> ApiResult<Vec<generated_types::AdminAclType>> {
acls.iter()
.map(|acl| {
generated_types::AdminAclType::try_from(acl.as_str())
.map_err(|e| ApiError::Parse(e.to_string()))
})
.collect()
}
+29 -24
View File
@@ -4,35 +4,36 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse};
use serde::Serialize;
#[derive(Serialize)]
struct LookupApplicationRequest<'a> {
application_id: &'a str,
}
#[derive(Serialize)]
struct ListUserApplicationsRequest<'a> {
user_id: &'a str,
}
#[derive(Serialize)]
struct TransferApplicationOwnershipRequest<'a> {
application_id: &'a str,
new_owner_id: &'a str,
}
impl AdminApiClient {
pub async fn lookup_application(&self, application_id: &str) -> ApiResult<Option<Application>> {
let body = LookupApplicationRequest { application_id };
let resp: LookupApplicationResponse =
self.post_typed("/admin/applications/lookup", &body).await?;
let resp: LookupApplicationResponse = self
.get(
&format!(
"/admin/applications/{}",
urlencoding::encode(application_id)
),
None,
)
.await?;
Ok(resp.application)
}
pub async fn list_user_applications(&self, user_id: &str) -> ApiResult<Vec<Application>> {
let body = ListUserApplicationsRequest { user_id };
let resp: super::types::ListUserApplicationsResponse = self
.post_typed("/admin/applications/list-by-owner", &body)
.await?;
let query_params = [("owner_id", user_id)];
let resp: super::types::ListUserApplicationsResponse =
self.get("/admin/applications", Some(&query_params)).await?;
Ok(resp.applications)
}
pub async fn list_guild_applications(&self, guild_id: &str) -> ApiResult<Vec<Application>> {
let query_params = [("guild_id", guild_id)];
let resp: super::types::ListUserApplicationsResponse =
self.get("/admin/applications", Some(&query_params)).await?;
Ok(resp.applications)
}
@@ -41,11 +42,15 @@ impl AdminApiClient {
application_id: &str,
new_owner_id: &str,
) -> ApiResult<ApplicationUpdateResponse> {
let body = TransferApplicationOwnershipRequest {
application_id,
new_owner_id,
};
self.post_typed("/admin/applications/transfer-ownership", &body)
.await
let body = TransferApplicationOwnershipRequest { new_owner_id };
self.patch_typed_with_reason(
&format!(
"/admin/applications/{}",
urlencoding::encode(application_id)
),
&body,
None,
)
.await
}
}
+29 -22
View File
@@ -11,13 +11,12 @@ impl AdminApiClient {
user_id: &str,
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::TriggerUserArchiveRequest {
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
user_id: snowflake(user_id),
};
let response = self
.generated()
.trigger_user_archive(&body)
.create_admin_user_archive(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -28,13 +27,12 @@ impl AdminApiClient {
guild_id: &str,
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::TriggerGuildArchiveRequest {
guild_id: snowflake(guild_id),
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
};
let response = self
.generated()
.trigger_guild_archive(&body)
.create_admin_guild_archive(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -47,22 +45,31 @@ impl AdminApiClient {
include_expired: bool,
requested_by: Option<&str>,
) -> ApiResult<ListArchivesResponse> {
let body = generated_types::ListArchivesRequest {
include_expired: Some(include_expired),
limit: None,
requested_by: requested_by.map(snowflake),
subject_id: subject_id.map(snowflake),
subject_type: Some(
generated_types::ListArchivesRequestSubjectType::try_from(subject_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
),
let subject_id = subject_id.filter(|id| !id.is_empty());
let search_every_subject_type = subject_type == "all" && subject_id.is_some();
let subject_types: &[&str] = if search_every_subject_type {
&["user", "guild"]
} else {
std::slice::from_ref(&subject_type)
};
let response = self
.generated()
.list_archives(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let mut archives = Vec::new();
for &subject_type in subject_types {
let query_params = [
("subject_type", subject_type),
("subject_id", subject_id.unwrap_or_default()),
("requested_by", requested_by.unwrap_or_default()),
(
"include_expired",
if include_expired { "true" } else { "false" },
),
];
match self.get("/admin/archives", Some(&query_params)).await {
Ok(ListArchivesResponse { archives: page }) => archives.extend(page),
Err(ApiError::Http { status: 403, .. }) if search_every_subject_type => {}
Err(error) => return Err(error),
}
}
Ok(ListArchivesResponse { archives })
}
pub async fn get_archive_download_url(
@@ -73,7 +80,7 @@ impl AdminApiClient {
) -> ApiResult<ArchiveDownloadUrlResponse> {
let response = self
.generated()
.get_archive_download_url(subject_type, subject_id, archive_id)
.get_admin_archive_download(subject_type, subject_id, archive_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+9 -2
View File
@@ -5,11 +5,18 @@ use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiResult};
impl AdminApiClient {
pub async fn purge_assets(&self, ids: &[String]) -> ApiResult<serde_json::Value> {
pub async fn purge_assets(
&self,
guild_id: &str,
ids: &[String],
) -> ApiResult<serde_json::Value> {
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
let response = self
.generated()
.purge_guild_assets(&body)
.purge_admin_guild_assets(
&generated_types::SnowflakeType::from(guild_id.to_owned()),
&body,
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+45 -24
View File
@@ -23,26 +23,47 @@ impl AdminApiClient {
&self,
params: &SearchAuditLogsParams,
) -> ApiResult<AuditLogsListResponse> {
let body = generated_types::SearchAuditLogsRequest {
admin_user_id: nonempty_string(params.admin_user_id.as_deref())
.map(generated_types::SnowflakeType::from),
limit: Some(
crate::api::generated::nonzero_u32(params.limit, "limit")
.map_err(ApiError::Parse)?,
let sort_by = params
.sort_by
.as_deref()
.map(audit_sort_by)
.transpose()?
.map(|value| value.to_string());
let sort_order = params
.sort_order
.as_deref()
.map(audit_sort_order)
.transpose()?
.map(|value| value.to_string());
let limit = params.limit.to_string();
let offset = params.offset.to_string();
let query_params = [
(
"q",
nonempty_string(params.query.as_deref()).unwrap_or_default(),
),
offset: Some(i64::from(params.offset)),
query: nonempty_string(params.query.as_deref()),
sort_by: params.sort_by.as_deref().map(audit_sort_by).transpose()?,
sort_order: params
.sort_order
.as_deref()
.map(audit_sort_order)
.transpose()?,
target_id: nonempty_string(params.target_id.as_deref()),
target_type: nonempty_string(params.target_type.as_deref()),
};
let body = serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?;
self.post("/admin/audit-logs/search", Some(&body)).await
(
"admin_user_id",
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
),
(
"target_type",
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
),
(
"target_id",
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
),
("sort_by", sort_by.unwrap_or_default()),
("sort_order", sort_order.unwrap_or_default()),
("limit", limit),
("offset", offset),
];
let query_params: Vec<(&str, &str)> = query_params
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect();
self.get("/admin/audit-logs", Some(&query_params)).await
}
}
@@ -58,7 +79,7 @@ fn audit_logs_response(
}
#[cfg(test)]
fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem) -> AuditLogEntry {
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
AuditLogEntry {
log_id: String::from(entry.log_id),
admin_user_id: String::from(entry.admin_user_id),
@@ -78,17 +99,17 @@ fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem)
}
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::SearchAuditLogsRequestSortBy> {
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
let value = match value {
"created_at" => "createdAt",
value => value,
};
generated_types::SearchAuditLogsRequestSortBy::try_from(value)
generated_types::ListAdminAuditLogsSortBy::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn audit_sort_order(value: &str) -> ApiResult<generated_types::SearchAuditLogsRequestSortOrder> {
generated_types::SearchAuditLogsRequestSortOrder::try_from(value)
fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortOrder> {
generated_types::ListAdminAuditLogsSortOrder::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
+176 -216
View File
@@ -7,209 +7,123 @@ use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
impl AdminApiClient {
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
let body = generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
};
self.generated()
.add_email_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"email",
generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}
.into(),
)
.await
}
pub async fn unban_email(&self, email: &str) -> ApiResult<()> {
let body = generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
};
self.generated()
.remove_email_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("email", email, None).await
}
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
};
let response = self
.generated()
.check_email_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("email", email, None).await
}
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
self.generated()
.add_ip_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"ip",
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
)
.await
}
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> {
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
self.generated()
.remove_ip_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("ip", ip, None).await
}
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
let response = self
.generated()
.check_ip_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("ip", ip, None).await
}
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
let body = suspicious_email_domain_request(domain)?;
self.generated()
.add_suspicious_email_domain(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST,
suspicious_email_domain_request(domain)?.into(),
)
.await
}
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
let body = suspicious_email_domain_request(domain)?;
self.generated()
.remove_suspicious_email_domain(&body)
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult<BanCheckResult> {
let body = suspicious_email_domain_request(domain)?;
let response = self
.generated()
.check_suspicious_email_domain(&body)
self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
let body = generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
};
self.generated()
.add_phrase_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"phrase",
generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}
.into(),
)
.await
}
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> {
let body = generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
};
self.generated()
.remove_phrase_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("phrase", phrase, None).await
}
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
};
let response = self
.generated()
.check_phrase_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("phrase", phrase, None).await
}
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
let body = generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
};
self.generated()
.add_url_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"url",
generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}
.into(),
)
.await
}
pub async fn unban_url(&self, url: &str) -> ApiResult<()> {
let body = generated_types::UnbanUrlRequest {
url: url.to_owned(),
};
self.generated()
.remove_url_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("url", url, None).await
}
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::CheckUrlBlocklistRequest {
url: url.to_owned(),
};
let response = self
.generated()
.check_url_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("url", url, None).await
}
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
let body = generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains: Some(match_subdomains),
notes: None,
severity: None,
source_url: None,
};
self.generated()
.add_url_domain_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"url-domain",
generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains: Some(match_subdomains),
notes: None,
severity: None,
source_url: None,
}
.into(),
)
.await
}
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> {
let body = generated_types::UnbanUrlDomainRequest {
domain: domain.to_owned(),
};
self.generated()
.remove_url_domain_ban(&body)
self.delete_blocklist_entry("url-domain", domain, None)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_url_domain_ban(&self, domain: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains: None,
notes: None,
severity: None,
source_url: None,
};
let response = self
.generated()
.check_url_domain_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("url-domain", domain, None).await
}
pub async fn ban_file_sha(
@@ -217,16 +131,22 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
};
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/add", &body, audit_log_reason)
.await
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
},
);
self.post_void_with_reason(
"/admin/blocklists/file-sha/entries",
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
audit_log_reason,
)
.await
}
pub async fn unban_file_sha(
@@ -234,23 +154,17 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::UnbanFileShaRequest {
sha256_hex: sha256_hex.to_owned(),
};
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/remove", &body, audit_log_reason)
.await
self.delete_void_with_reason(
&blocklist_entry_path("file-sha", sha256_hex),
None,
audit_log_reason,
)
.await
}
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::CheckFileShaRequest {
sha256_hex: sha256_hex.to_owned(),
};
let response = self
.generated()
.check_file_sha_ban_status(&body)
self.check_blocklist_entry("file-sha", sha256_hex, None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn bulk_ban_file_shas(
@@ -261,54 +175,45 @@ impl AdminApiClient {
let body = generated_types::BulkBanFileShasRequest {
sha256_list: sha256_list.to_vec(),
};
self.post_typed_with_reason("/admin/bans/file-sha/bulk-add", &body, audit_log_reason)
.await
self.put_typed_with_reason(
"/admin/blocklists/file-sha/entries",
&body,
audit_log_reason,
)
.await
}
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
let body = generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
};
self.generated()
.add_avatar_hash_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"avatar-hash",
generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
}
.into(),
)
.await
}
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
let body = generated_types::CheckAvatarHashRequest {
hashes: vec![hash_short.to_owned()],
};
self.generated()
.remove_avatar_hash_ban(&body)
self.delete_blocklist_entry("avatar-hash", hash_short, None)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_avatar_hash_ban(&self, hash_short: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::CheckAvatarHashRequest {
hashes: vec![hash_short.to_owned()],
};
let response = self
.generated()
.check_avatar_hash_ban_status(&body)
self.check_blocklist_entry("avatar-hash", hash_short, None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
let body = generated_types::BanUserAvatarRequest::default();
let response = self
.generated()
.ban_user_avatar(
.ban_admin_user_avatar(
&generated_types::SnowflakeType::from(user_id.to_owned()),
&body,
)
@@ -318,21 +223,16 @@ impl AdminApiClient {
}
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
let body = profile_substring_request(scope, substring)?;
self.generated()
.add_profile_substring_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
profile_substring_request(scope, substring)?.into(),
)
.await
}
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
let body = profile_substring_request(scope, substring)?;
self.generated()
.remove_profile_substring_ban(&body)
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_profile_substring_ban(
@@ -340,16 +240,76 @@ impl AdminApiClient {
scope: &str,
substring: &str,
) -> ApiResult<BanCheckResult> {
let body = profile_substring_request(scope, substring)?;
self.check_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
.await
}
async fn create_blocklist_entry(
&self,
list_type: &str,
body: generated_types::AdminBlocklistEntryCreateRequest,
) -> ApiResult<()> {
self.generated()
.create_admin_blocklist_entry(list_type, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
async fn delete_blocklist_entry(
&self,
list_type: &str,
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<()> {
let scope = scope.map(blocklist_delete_scope).transpose()?;
self.generated()
.delete_admin_blocklist_entry(list_type, entry_value, scope)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
async fn check_blocklist_entry(
&self,
list_type: &str,
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<BanCheckResult> {
let scope = scope.map(blocklist_get_scope).transpose()?;
let response = self
.generated()
.check_profile_substring_ban_status(&body)
.get_admin_blocklist_entry(list_type, entry_value, scope)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
format!(
"/admin/blocklists/{}/entries/{}",
urlencoding::encode(list_type),
urlencoding::encode(entry_value)
)
}
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
generated_types::GetAdminBlocklistEntryScope::try_from(scope)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn blocklist_delete_scope(
scope: &str,
) -> ApiResult<generated_types::DeleteAdminBlocklistEntryScope> {
generated_types::DeleteAdminBlocklistEntryScope::try_from(scope)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn suspicious_email_domain_request(
domain: &str,
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
-154
View File
@@ -1,154 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BillingOverview, InvoiceListResponse, PaymentListResponse, PaymentMethodListResponse,
RefundCancelResponse, SubscriptionResponse,
};
impl AdminApiClient {
pub async fn get_billing_overview(&self, user_id: &str) -> ApiResult<BillingOverview> {
let response = self
.generated()
.admin_billing_overview(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payments(&self, user_id: &str) -> ApiResult<PaymentListResponse> {
let response = self
.generated()
.admin_billing_list_payments(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_subscription(&self, user_id: &str) -> ApiResult<SubscriptionResponse> {
let response = self
.generated()
.admin_billing_get_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payment_methods(
&self,
user_id: &str,
) -> ApiResult<PaymentMethodListResponse> {
let response = self
.generated()
.admin_billing_list_payment_methods(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_invoices(
&self,
user_id: &str,
limit: u32,
starting_after: Option<&str>,
) -> ApiResult<InvoiceListResponse> {
let limit_str = limit.to_string();
let mut params: Vec<(&str, &str)> = vec![("limit", &limit_str)];
if let Some(sa) = starting_after {
params.push(("starting_after", sa));
}
self.get(
&format!("/admin/billing/users/{user_id}/invoices"),
Some(&params),
)
.await
}
pub async fn issue_refund(
&self,
user_id: &str,
payment_intent_id: &str,
amount_cents: Option<u64>,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingRefundRequest {
amount_cents: amount_cents
.map(|value| crate::api::generated::nonzero_u64(value, "amount_cents"))
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
payment_intent_id: payment_intent_id.to_owned(),
reason: reason
.map(generated_types::AdminBillingRefundRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_refund(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn refund_policy_cancel_now(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<RefundCancelResponse> {
let body = generated_types::AdminBillingRefundLatestInvoiceCancelRequest {
reason: reason
.map(generated_types::AdminBillingRefundLatestInvoiceCancelRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.admin_billing_refund_policy_cancel_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn cancel_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_cancel_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn cancel_subscription_immediately(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingCancelImmediatelyRequest {
reason: reason
.map(generated_types::AdminBillingCancelImmediatelyRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_cancel_subscription_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn reactivate_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_reactivate_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn end_premium_grace_period(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_end_premium_grace_period(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
+72 -43
View File
@@ -13,12 +13,16 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateUserFlagsRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk/update-user-flags", &body, audit_log_reason)
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
task:
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -29,17 +33,16 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateSuspiciousActivityFlagsRequest {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason(
"/admin/bulk/update-suspicious-activity-flags",
&body,
audit_log_reason,
)
.await
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
pub async fn bulk_update_guild_features(
@@ -49,12 +52,15 @@ impl AdminApiClient {
remove_features: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateGuildFeaturesRequest {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
};
self.post_typed_with_reason("/admin/bulk/update-guild-features", &body, audit_log_reason)
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -64,11 +70,31 @@ impl AdminApiClient {
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkAddGuildMembersRequest {
guild_id: snowflake(guild_id),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk/add-guild-members", &body, audit_log_reason)
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
guild_id: snowflake(guild_id),
task:
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
pub async fn bulk_delete_user_messages(
&self,
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
task:
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -80,21 +106,24 @@ impl AdminApiClient {
public_reason: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkScheduleUserDeletionRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason(
"/admin/bulk/schedule-user-deletion",
&body,
audit_log_reason,
)
.await
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
"reason_code",
)
.map_err(ApiError::Parse)?,
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
}
+93 -5
View File
@@ -188,17 +188,105 @@ impl AdminApiClient {
path: &str,
body: Option<&serde_json::Value>,
) -> ApiResult<T> {
let builder = Self::with_json_body(self.request(Method::PATCH, path, None), body);
let response = Self::send_request(builder).await?;
self.patch_with_reason(path, body, None).await
}
pub async fn patch_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
}
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
let builder = Self::with_json_body(self.request(Method::DELETE, path, None), body);
let response = Self::send_request(builder).await?;
pub async fn patch_typed_with_reason<T, B>(
&self,
path: &str,
body: &B,
audit_log_reason: Option<&str>,
) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
}
pub async fn put_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
}
pub async fn put_typed_with_reason<T, B>(
&self,
path: &str,
body: &B,
audit_log_reason: Option<&str>,
) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
}
pub async fn put_void_with_reason(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
Self::parse_void_response(response).await
}
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
self.delete_void_with_reason(path, body, None).await
}
pub async fn delete_void_with_reason(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
Self::parse_void_response(response).await
}
pub async fn delete_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
}
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
if response.status().is_success() {
Ok(())
+1 -1
View File
@@ -26,7 +26,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.generate_gift_codes(&body)
.create_admin_gift_codes(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+25 -15
View File
@@ -13,7 +13,7 @@ impl AdminApiClient {
) -> ApiResult<Vec<DiscoveryPendingApplication>> {
let response = self
.generated()
.list_pending_discovery_applications()
.list_admin_discovery_applications()
.await
.map_err(|e| self.generated_error(e))?;
response
@@ -26,7 +26,7 @@ impl AdminApiClient {
pub async fn list_discovery_listed_guilds(&self) -> ApiResult<Vec<DiscoveryListedGuild>> {
let response = self
.generated()
.list_discovery_listed_guilds()
.list_admin_discovery_listings()
.await
.map_err(|e| self.generated_error(e))?;
response
@@ -42,15 +42,18 @@ impl AdminApiClient {
reason: Option<&str>,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let body = generated_types::DiscoveryAdminReviewRequest {
reason: reason
.map(generated_types::DiscoveryAdminReviewRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
reason: reason
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
},
);
let response = self
.generated()
.approve_discovery_application(&guild_id, &body)
.update_admin_discovery_application(&guild_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -62,13 +65,20 @@ impl AdminApiClient {
reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let body = generated_types::DiscoveryAdminRejectRequest {
reason: generated_types::DiscoveryAdminRejectRequestReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
reason:
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
reason,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
status:
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
},
);
let response = self
.generated()
.reject_discovery_application(&guild_id, &body)
.update_admin_discovery_application(&guild_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -86,7 +96,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.remove_from_discovery(&guild_id, &body)
.delete_admin_discovery_listing(&guild_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+6 -2
View File
@@ -32,8 +32,12 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
pub(crate) fn nonzero_u64(value: u64, field: &str) -> Result<std::num::NonZeroU64, String> {
std::num::NonZeroU64::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
pub(crate) fn deletion_reason_code(
value: i32,
field: &str,
) -> Result<types::DeletionReasonCode, String> {
types::DeletionReasonCode::try_from(value)
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
}
#[cfg(test)]
+2 -2
View File
@@ -10,7 +10,7 @@ impl AdminApiClient {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let response = self
.generated()
.admin_list_guild_emojis(&guild_id)
.list_admin_guild_emojis(&guild_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -23,7 +23,7 @@ impl AdminApiClient {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let response = self
.generated()
.admin_list_guild_stickers(&guild_id)
.list_admin_guild_stickers(&guild_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+77 -133
View File
@@ -17,28 +17,20 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchGuildsResponse> {
let body = generated_types::SearchGuildsRequest {
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
query: Some(query.to_owned()),
};
let limit = limit.to_string();
let offset = offset.to_string();
let response = self
.generated()
.search_guilds(&body)
.list_admin_guilds(Some(limit.as_str()), Some(offset.as_str()), Some(query))
.await
.map_err(|e| self.generated_error(e))?;
search_guilds_response(response.into_inner())
}
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
let body = generated_types::LookupGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.lookup_guild(&body)
.get_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
@@ -51,12 +43,9 @@ impl AdminApiClient {
}
pub async fn lookup_guild(&self, guild_id: &str) -> ApiResult<Option<GuildDetailInfo>> {
let body = generated_types::LookupGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.lookup_guild(&body)
.get_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
@@ -69,26 +58,23 @@ impl AdminApiClient {
add_features: &[String],
remove_features: &[String],
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildFeaturesRequest {
let body = generated_types::UpdateGuildRequest {
add_features: guild_features(add_features),
guild_id: snowflake(guild_id),
remove_features: guild_features(remove_features),
..Default::default()
};
let response = self
.generated()
.update_guild_features(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::DeleteGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.admin_delete_guild(&body)
.delete_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -99,13 +85,13 @@ impl AdminApiClient {
guild_id: &str,
new_owner_id: &str,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::TransferGuildOwnershipRequest {
guild_id: snowflake(guild_id),
new_owner_id: snowflake(new_owner_id),
let body = generated_types::UpdateGuildRequest {
new_owner_id: Some(snowflake(new_owner_id)),
..Default::default()
};
let response = self
.generated()
.admin_transfer_guild_ownership(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -117,43 +103,32 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<ListGuildMembersResponse> {
let body = generated_types::ListGuildMembersRequest {
guild_id: snowflake(guild_id),
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
};
let limit = limit.to_string();
let offset = offset.to_string();
let response = self
.generated()
.admin_list_guild_members(&body)
.list_admin_guild_members(
&snowflake(guild_id),
Some(limit.as_str()),
Some(offset.as_str()),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
let body = generated_types::BanGuildMemberRequest {
ban_duration_seconds: None,
delete_message_days: None,
guild_id: snowflake(guild_id),
reason: None,
user_id: snowflake(user_id),
};
let body = generated_types::BanGuildMemberBody::default();
self.generated()
.admin_ban_guild_member(&body)
.ban_admin_guild_member(&snowflake(guild_id), &snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn kick_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
let body = generated_types::KickGuildMemberRequest {
guild_id: snowflake(guild_id),
user_id: snowflake(user_id),
};
self.generated()
.kick_guild_member(&body)
.kick_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -165,21 +140,22 @@ impl AdminApiClient {
limit: Option<u32>,
before: Option<&str>,
) -> ApiResult<GuildAuditLogResponse> {
let body = generated_types::ListGuildAuditLogsRequest {
action_type: None,
after: None,
before: before.map(snowflake),
guild_id: snowflake(guild_id),
limit: limit
.map(i32::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?
.map(generated_types::Int32Type::from),
user_id: None,
};
let before = before.map(snowflake);
let limit = limit
.map(i32::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?
.map(generated_types::Int32Type::from);
let response = self
.generated()
.list_guild_audit_logs_admin(&body)
.list_admin_guild_audit_logs(
&snowflake(guild_id),
None,
None,
before.as_ref(),
limit.as_ref(),
None,
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -188,15 +164,15 @@ impl AdminApiClient {
pub async fn clear_guild_fields(&self, guild_id: &str, fields: &[String]) -> ApiResult<()> {
let fields = fields
.iter()
.map(generated_types::ClearGuildFieldsRequestFieldsItem::try_from)
.map(|field| generated_types::UpdateGuildRequestFieldsItem::try_from(field.as_str()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::ClearGuildFieldsRequest {
let body = generated_types::UpdateGuildRequest {
fields,
guild_id: snowflake(guild_id),
..Default::default()
};
self.generated()
.clear_guild_fields(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -207,10 +183,10 @@ impl AdminApiClient {
guild_id: &str,
settings: &serde_json::Value,
) -> ApiResult<GuildUpdateResponse> {
let body = guild_settings_request(guild_id, settings)?;
let body = guild_settings_request(settings)?;
let response = self
.generated()
.update_guild_settings(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
guild_update_response(response.into_inner())
@@ -221,13 +197,13 @@ impl AdminApiClient {
guild_id: &str,
name: &str,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildNameRequest {
guild_id: snowflake(guild_id),
name: name.to_owned(),
let body = generated_types::UpdateGuildRequest {
name: Some(name.to_owned()),
..Default::default()
};
let response = self
.generated()
.update_guild_name(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -238,37 +214,27 @@ impl AdminApiClient {
guild_id: &str,
vanity: Option<&str>,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildVanityRequest {
guild_id: snowflake(guild_id),
vanity_url_code: vanity.map(std::borrow::ToOwned::to_owned),
};
let response = self
.generated()
.update_guild_vanity(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let body = serde_json::json!({"vanity_url_code": vanity});
self.patch(
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
Some(&body),
)
.await
}
pub async fn reload_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::ReloadGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.reload_guild(&body)
.create_admin_guild_reload(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn shutdown_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::ShutdownGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.shutdown_guild(&body)
.create_admin_guild_shutdown(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -279,13 +245,9 @@ impl AdminApiClient {
user_id: &str,
guild_id: &str,
) -> ApiResult<SuccessResponse> {
let body = generated_types::ForceAddUserToGuildRequest {
guild_id: snowflake(guild_id),
user_id: snowflake(user_id),
};
let response = self
.generated()
.force_add_user_to_guild(&body)
.add_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -297,39 +259,23 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
let body = generated_types::SearchReportsRequest {
category: None,
guild_context_id: None,
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
query: None,
report_type: None,
reported_channel_id: None,
reported_guild_id: Some(snowflake(guild_id)),
reported_user_id: None,
reporter_id: None,
resolved_by_admin_id: None,
sort_by: None,
sort_order: None,
status: None,
};
let response = self
.generated()
.search_reports(&body)
.await
.map_err(|e| self.generated_error(e))?;
let response = response.into_inner();
Ok(SearchReportsResponse {
reports: self.generated_value(response.reports)?,
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
offset: crate::api::generated::number_to_u64(response.offset, "offset")
.map_err(ApiError::Parse)?,
limit: crate::api::generated::number_to_u64(response.limit, "limit")
.map_err(ApiError::Parse)?,
})
self.search_reports(
None,
None,
None,
None,
None,
None,
Some(guild_id),
None,
None,
None,
None,
None,
limit,
offset,
)
.await
}
}
@@ -416,22 +362,21 @@ fn guild_update_response(
}
fn guild_settings_request(
guild_id: &str,
settings: &serde_json::Value,
) -> ApiResult<generated_types::UpdateGuildSettingsRequest> {
) -> ApiResult<generated_types::UpdateGuildRequest> {
let patch = serde_json::from_value::<GuildSettingsPatch>(settings.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
Ok(generated_types::UpdateGuildSettingsRequest {
Ok(generated_types::UpdateGuildRequest {
content_warning_level: patch.content_warning_level,
content_warning_text: patch.content_warning_text,
default_message_notifications: patch.default_message_notifications,
disabled_operations: patch.disabled_operations,
explicit_content_filter: patch.explicit_content_filter,
guild_id: snowflake(guild_id),
mfa_level: patch.mfa_level,
nsfw: patch.nsfw,
nsfw_level: patch.nsfw_level,
verification_level: patch.verification_level,
..Default::default()
})
}
@@ -458,9 +403,8 @@ mod tests {
"nsfw": true,
"verification_level": 2,
});
let request = guild_settings_request("123", &settings).unwrap();
let request = guild_settings_request(&settings).unwrap();
let json = serde_json::to_value(request).unwrap();
assert_eq!(json["guild_id"], "123");
assert_eq!(json["disabled_operations"], 5);
assert_eq!(json["nsfw"], true);
assert_eq!(json["verification_level"], 2);
+30 -22
View File
@@ -4,19 +4,18 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
PendingRegistrationActionRequest, RegistrationUrlActionRequest,
};
impl AdminApiClient {
pub async fn get_instance_config(&self) -> ApiResult<InstanceConfigResponse> {
self.post("/admin/instance-config/get", None).await
self.get("/admin/instance/config", None).await
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
) -> ApiResult<InstanceConfigResponse> {
self.post_typed("/admin/instance-config/update", update)
self.patch_typed_with_reason("/admin/instance/config", update, None)
.await
}
@@ -24,7 +23,7 @@ impl AdminApiClient {
&self,
request: &InstanceEmailSmtpTestRequest,
) -> ApiResult<InstanceEmailSmtpTestResponse> {
self.post_typed("/admin/instance-config/integrations/smtp/test", request)
self.post_typed("/admin/instance/config/smtp-tests", request)
.await
}
@@ -32,40 +31,49 @@ impl AdminApiClient {
&self,
request: &CreateRegistrationUrlRequest,
) -> ApiResult<CreateRegistrationUrlResponse> {
self.post_typed("/admin/instance-config/registration-urls/create", request)
self.post_typed("/admin/instance/registration-urls", request)
.await
}
pub async fn revoke_registration_url(&self, id: &str) -> ApiResult<InstanceConfigResponse> {
let request = RegistrationUrlActionRequest { id: id.to_owned() };
self.post_typed("/admin/instance-config/registration-urls/revoke", &request)
.await
self.delete_with_reason(
&format!(
"/admin/instance/registration-urls/{}",
urlencoding::encode(id)
),
None,
None,
)
.await
}
pub async fn approve_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
let request = PendingRegistrationActionRequest {
user_id: user_id.to_owned(),
};
self.post_typed(
"/admin/instance-config/pending-registrations/approve",
&request,
)
.await
self.decide_pending_registration(user_id, "approved").await
}
pub async fn reject_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
let request = PendingRegistrationActionRequest {
user_id: user_id.to_owned(),
};
self.post_typed(
"/admin/instance-config/pending-registrations/reject",
&request,
self.decide_pending_registration(user_id, "rejected").await
}
async fn decide_pending_registration(
&self,
user_id: &str,
status: &str,
) -> ApiResult<InstanceConfigResponse> {
let body = serde_json::json!({"status": status});
self.patch_with_reason(
&format!(
"/admin/instance/pending-registrations/{}",
urlencoding::encode(user_id)
),
Some(&body),
None,
)
.await
}
+36 -48
View File
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::client::{AdminApiClient, ApiResult};
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
pub struct ListJobsParams {
@@ -16,51 +14,32 @@ pub struct ListJobsParams {
impl AdminApiClient {
pub async fn list_jobs(&self, params: &ListJobsParams) -> ApiResult<ListJobsResponse> {
let cursor = params
.cursor
.clone()
.map(serde_json::from_value::<generated_types::ListJobsRequestCursor>)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let status = params
.status
.as_deref()
.map(generated_types::ListJobsRequestStatus::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::ListJobsRequest {
cursor,
limit: Some(
crate::api::generated::nonzero_u32(params.limit, "limit")
.map_err(ApiError::Parse)?,
let cursor = params.cursor.as_ref();
let cursor_bucket_day = cursor_field(cursor, "bucket_day");
let cursor_created_at = cursor_field(cursor, "created_at");
let cursor_job_id = cursor_field(cursor, "job_id");
let limit = params.limit.to_string();
let max_lookback_days = params.max_lookback_days.to_string();
let query_params = [
("limit", limit.as_str()),
("cursor_bucket_day", cursor_bucket_day.as_str()),
("cursor_created_at", cursor_created_at.as_str()),
("cursor_job_id", cursor_job_id.as_str()),
("max_lookback_days", max_lookback_days.as_str()),
("status", params.status.as_deref().unwrap_or_default()),
("task_type", params.task_type.as_deref().unwrap_or_default()),
(
"requested_by_user_id",
params.requested_by_user_id.as_deref().unwrap_or_default(),
),
max_lookback_days: Some(
crate::api::generated::nonzero_u32(params.max_lookback_days, "max_lookback_days")
.map_err(ApiError::Parse)?,
),
requested_by_user_id: params
.requested_by_user_id
.as_ref()
.cloned()
.map(generated_types::SnowflakeType::from),
status,
task_type: params.task_type.clone(),
};
let response = self
.generated()
.list_jobs(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
];
self.get("/admin/jobs", Some(&query_params)).await
}
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
let body = generated_types::GetJobRequest {
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
};
let response = self
.generated()
.get_job(&body)
.get_admin_job(job_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -71,19 +50,28 @@ impl AdminApiClient {
job_id: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<CancelJobResponse> {
let body = generated_types::CancelJobRequest {
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
};
self.post_typed_with_reason("/admin/jobs/cancel", &body, audit_log_reason)
.await
self.put_with_reason(
&format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)),
None,
audit_log_reason,
)
.await
}
pub async fn list_active_jobs(&self) -> ApiResult<ActiveJobsResponse> {
let response = self
.generated()
.list_active_jobs()
.list_admin_active_jobs()
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String {
cursor
.and_then(|cursor| cursor.get(field))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned()
}
+3 -3
View File
@@ -5,14 +5,14 @@ use super::types::{LimitConfigResponse, LimitConfigUpdateRequest};
impl AdminApiClient {
pub async fn get_limit_config(&self) -> ApiResult<LimitConfigResponse> {
self.post("/admin/limit-config/get", Some(&serde_json::json!({})))
.await
self.get("/admin/limit-config", None).await
}
pub async fn update_limit_config(
&self,
request: &LimitConfigUpdateRequest,
) -> ApiResult<LimitConfigResponse> {
self.post_typed("/admin/limit-config/update", request).await
self.put_typed_with_reason("/admin/limit-config", request, None)
.await
}
}
+51 -60
View File
@@ -16,12 +16,16 @@ impl AdminApiClient {
message_id: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::DeleteMessageRequest {
channel_id: snowflake(channel_id),
message_id: snowflake(message_id),
};
let _: serde_json::Value = self
.post_typed_with_reason("/admin/messages/delete", &body, audit_log_reason)
.delete_with_reason(
&format!(
"/admin/channels/{}/messages/{}",
urlencoding::encode(channel_id),
urlencoding::encode(message_id)
),
None,
audit_log_reason,
)
.await?;
Ok(())
}
@@ -50,7 +54,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.report_message_attachment_to_ncmec(&body)
.create_admin_ncmec_report(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -62,17 +66,14 @@ impl AdminApiClient {
message_id: &str,
context_limit: u32,
) -> ApiResult<LookupMessageResponse> {
let body = generated_types::LookupMessageRequest {
channel_id: snowflake(channel_id),
context_limit: Some(
crate::api::generated::nonzero_u32(context_limit, "context_limit")
.map_err(ApiError::Parse)?,
),
message_id: snowflake(message_id),
};
let context_limit = context_limit.to_string();
let response = self
.generated()
.lookup_message(&body)
.get_admin_message(
&snowflake(channel_id),
&snowflake(message_id),
Some(context_limit.as_str()),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -86,16 +87,13 @@ impl AdminApiClient {
let entries = entries
.iter()
.cloned()
.map(serde_json::from_value::<generated_types::MessageShredRequestEntriesItem>)
.map(serde_json::from_value::<generated_types::AdminUserMessageShredRequestEntriesItem>)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::MessageShredRequest {
entries,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserMessageShredRequest { entries };
let response = self
.generated()
.queue_message_shred(&body)
.shred_admin_user_messages(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -106,13 +104,10 @@ impl AdminApiClient {
user_id: &str,
dry_run: bool,
) -> ApiResult<DeleteAllUserMessagesResponse> {
let body = generated_types::DeleteAllUserMessagesRequest {
dry_run: Some(dry_run),
user_id: snowflake(user_id),
};
let dry_run = if dry_run { "true" } else { "false" };
let response = self
.generated()
.delete_all_user_messages(&body)
.delete_admin_user_messages(&snowflake(user_id), Some(dry_run))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -122,12 +117,9 @@ impl AdminApiClient {
&self,
job_id: &str,
) -> ApiResult<MessageShredStatusResponse> {
let body = generated_types::MessageShredStatusRequest {
job_id: job_id.to_owned(),
};
let response = self
.generated()
.get_message_shred_status(&body)
.get_admin_message_shred(job_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -140,18 +132,18 @@ impl AdminApiClient {
filename: &str,
context_limit: u32,
) -> ApiResult<LookupMessageResponse> {
let body = generated_types::LookupMessageByAttachmentRequest {
attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id),
context_limit: Some(
crate::api::generated::nonzero_u32(context_limit, "context_limit")
.map_err(ApiError::Parse)?,
),
filename: filename.to_owned(),
};
let context_limit = context_limit.to_string();
let response = self
.generated()
.lookup_message_by_attachment(&body)
.search_admin_messages(
Some(&snowflake(attachment_id)),
&snowflake(channel_id),
Some(context_limit.as_str()),
Some(filename),
None,
None,
None,
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -164,18 +156,17 @@ impl AdminApiClient {
after: Option<&str>,
limit: Option<u32>,
) -> ApiResult<BrowseChannelResponse> {
let body = generated_types::BrowseChannelRequest {
after: after.map(snowflake),
before: before.map(snowflake),
channel_id: snowflake(channel_id),
limit: limit
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
.transpose()
.map_err(ApiError::Parse)?,
};
let after = after.map(snowflake);
let before = before.map(snowflake);
let limit = limit.map(|value| value.to_string());
let response = self
.generated()
.browse_channel_messages(&body)
.list_admin_channel_messages(
&snowflake(channel_id),
after.as_ref(),
before.as_ref(),
limit.as_deref(),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -187,18 +178,18 @@ impl AdminApiClient {
query: &str,
limit: Option<u32>,
) -> ApiResult<SearchChannelMessagesResponse> {
let body = generated_types::SearchChannelMessagesRequest {
channel_id: snowflake(channel_id),
limit: limit
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
.transpose()
.map_err(ApiError::Parse)?,
query: generated_types::SearchChannelMessagesRequestQuery::try_from(query)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let limit = limit.map(|value| value.to_string());
let response = self
.generated()
.search_channel_messages(&body)
.search_admin_messages(
None,
&snowflake(channel_id),
None,
None,
limit.as_deref(),
None,
Some(query),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
-1
View File
@@ -8,7 +8,6 @@ pub mod archives;
pub mod assets;
pub mod audit;
pub mod bans;
pub mod billing;
pub mod bulk;
pub mod client;
pub mod codes;
+74 -72
View File
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
@@ -14,28 +12,21 @@ impl AdminApiClient {
limit: u32,
offset: Option<u32>,
) -> ApiResult<ListReportsResponse> {
let body = generated_types::ListReportsRequest {
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: offset.map(i64::from),
status: status
.map(generated_types::ReportStatus::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.list_reports(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let status = status.map(report_status).transpose()?.unwrap_or_default();
let limit = limit.to_string();
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
let query_params = [
("status", status),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
self.get("/admin/reports", Some(&query_params)).await
}
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
let response = self
.generated()
.get_report(report_id)
.get_admin_report(report_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -47,12 +38,16 @@ impl AdminApiClient {
public_comment: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let body = generated_types::ResolveReportRequest {
public_comment: public_comment.map(std::borrow::ToOwned::to_owned),
report_id: generated_types::SnowflakeType::from(report_id.to_owned()),
};
self.post_typed_with_reason("/admin/reports/resolve", &body, audit_log_reason)
.await
let mut body = serde_json::json!({"status": "resolved"});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
self.patch_with_reason(
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
Some(&body),
audit_log_reason,
)
.await
}
#[allow(clippy::too_many_arguments)]
@@ -73,48 +68,37 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
let body = generated_types::SearchReportsRequest {
category: nonempty_string(category),
guild_context_id: nonempty_snowflake(guild_context_id),
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
let status = status.map(report_status).transpose()?.unwrap_or_default();
let report_type = report_type
.map(report_type_name)
.transpose()?
.unwrap_or_default();
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
let limit = limit.to_string();
let offset = offset.to_string();
let query_params = [
("q", query.unwrap_or_default()),
("status", status),
("report_type", report_type),
("category", category.unwrap_or_default()),
("reporter_id", reporter_id.unwrap_or_default()),
("reported_user_id", reported_user_id.unwrap_or_default()),
("reported_guild_id", reported_guild_id.unwrap_or_default()),
(
"reported_channel_id",
reported_channel_id.unwrap_or_default(),
),
offset: Some(i64::from(offset)),
query: nonempty_string(query),
report_type: report_type
.map(generated_types::ReportType::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
reported_channel_id: nonempty_snowflake(reported_channel_id),
reported_guild_id: nonempty_snowflake(reported_guild_id),
reported_user_id: nonempty_snowflake(reported_user_id),
reporter_id: nonempty_snowflake(reporter_id),
resolved_by_admin_id: nonempty_snowflake(resolved_by_admin_id),
sort_by: sort_by
.map(generated_types::SearchReportsRequestSortBy::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
sort_order: sort_order
.map(generated_types::SearchReportsRequestSortOrder::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
status: status
.map(generated_types::ReportStatus::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.search_reports(&body)
.await
.map_err(|e| self.generated_error(e))?;
let response = response.into_inner();
Ok(SearchReportsResponse {
reports: self.generated_value(response.reports)?,
total: response.total as u64,
offset: response.offset as u64,
limit: response.limit as u64,
})
("guild_context_id", guild_context_id.unwrap_or_default()),
(
"resolved_by_admin_id",
resolved_by_admin_id.unwrap_or_default(),
),
("sort_by", sort_by),
("sort_order", sort_order.unwrap_or_default()),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
self.get("/admin/reports", Some(&query_params)).await
}
pub async fn search_reports_by_reporter(
@@ -168,12 +152,30 @@ impl AdminApiClient {
}
}
fn nonempty_string(value: Option<&str>) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
fn report_status(value: i32) -> ApiResult<&'static str> {
match value {
0 => Ok("pending"),
1 => Ok("resolved"),
other => Err(ApiError::Parse(format!("unknown report status: {other}"))),
}
}
fn nonempty_snowflake(value: Option<&str>) -> Option<generated_types::SnowflakeType> {
nonempty_string(value).map(generated_types::SnowflakeType::from)
fn report_type_name(value: i32) -> ApiResult<&'static str> {
match value {
0 => Ok("message"),
1 => Ok("user"),
2 => Ok("guild"),
other => Err(ApiError::Parse(format!("unknown report type: {other}"))),
}
}
fn report_sort_by(value: &str) -> ApiResult<&'static str> {
match value {
"created_at" | "createdAt" => Ok("created_at"),
"reported_at" | "reportedAt" => Ok("reported_at"),
"resolved_at" | "resolvedAt" => Ok("resolved_at"),
other => Err(ApiError::Parse(format!(
"unknown report sort field: {other}"
))),
}
}
+82 -8
View File
@@ -13,13 +13,11 @@ impl AdminApiClient {
) -> ApiResult<RefreshSearchIndexResponse> {
let body = generated_types::RefreshSearchIndexRequest {
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
index_type: generated_types::RefreshSearchIndexRequestIndexType::try_from(index_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_id: None,
};
let response = self
.generated()
.refresh_search_index(&body)
.create_admin_search_index_refresh(index_type, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -29,14 +27,90 @@ impl AdminApiClient {
&self,
job_id: &str,
) -> ApiResult<IndexRefreshStatusResponse> {
let body = generated_types::GetIndexRefreshStatusRequest {
job_id: job_id.to_owned(),
};
let response = self
.generated()
.get_search_index_refresh_status(&body)
.get_admin_search_index_refresh(job_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
index_refresh_status(response.into_inner())
}
}
fn index_refresh_status(
response: generated_types::IndexRefreshStatusResponse,
) -> ApiResult<IndexRefreshStatusResponse> {
match response {
generated_types::IndexRefreshStatusResponse::Variant0 { status } => {
Ok(IndexRefreshStatusResponse::NotFound {
status: status.to_string(),
})
}
generated_types::IndexRefreshStatusResponse::Variant1 {
status,
index_type,
total,
indexed,
started_at,
completed_at,
failed_at,
error,
} => Ok(IndexRefreshStatusResponse::Progress {
status: status.to_string(),
index_type: Some(index_type),
total: total
.map(|value| float_to_u64(value, "total"))
.transpose()?,
indexed: indexed
.map(|value| float_to_u64(value, "indexed"))
.transpose()?,
started_at,
completed_at,
failed_at,
error,
}),
}
}
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_a_running_refresh_to_progress() {
let json = r#"{"status":"in_progress","index_type":"users","total":50000,"indexed":1200,"started_at":"2026-09-06T00:00:00Z"}"#;
let response: generated_types::IndexRefreshStatusResponse =
serde_json::from_str(json).unwrap();
match index_refresh_status(response).unwrap() {
IndexRefreshStatusResponse::Progress {
status,
index_type,
total,
indexed,
started_at,
..
} => {
assert_eq!(status, "in_progress");
assert_eq!(index_type.as_deref(), Some("users"));
assert_eq!(total, Some(50_000));
assert_eq!(indexed, Some(1_200));
assert_eq!(started_at.as_deref(), Some("2026-09-06T00:00:00Z"));
}
other => panic!("expected a progress status, got {other:?}"),
}
}
#[test]
fn maps_a_missing_refresh_to_not_found() {
let json = r#"{"status":"not_found"}"#;
let response: generated_types::IndexRefreshStatusResponse =
serde_json::from_str(json).unwrap();
match index_refresh_status(response).unwrap() {
IndexRefreshStatusResponse::NotFound { status } => assert_eq!(status, "not_found"),
other => panic!("expected a not found status, got {other:?}"),
}
}
}
+6 -8
View File
@@ -2,7 +2,7 @@
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::client::{AdminApiClient, ApiResult};
use super::types::{
GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse,
ReloadAllGuildsResponse,
@@ -10,12 +10,10 @@ use super::types::{
impl AdminApiClient {
pub async fn get_guild_memory_stats(&self, limit: u32) -> ApiResult<GuildMemoryStatsResponse> {
let body = generated_types::GetProcessMemoryStatsRequest {
limit: Some(i32::try_from(limit).map_err(|e| ApiError::Parse(e.to_string()))?),
};
let limit = limit.to_string();
let response = self
.generated()
.get_guild_memory_statistics(&body)
.get_admin_gateway_memory_stats(Some(limit.as_str()))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -34,7 +32,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.reload_all_specified_guilds(&body)
.create_admin_gateway_reload(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -43,7 +41,7 @@ impl AdminApiClient {
pub async fn get_node_stats(&self) -> ApiResult<NodeStatsResponse> {
let response = self
.generated()
.get_gateway_node_statistics()
.get_admin_gateway_stats()
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -54,7 +52,7 @@ impl AdminApiClient {
) -> ApiResult<GatewayVoiceStateCountsResponse> {
let response = self
.generated()
.get_gateway_voice_state_counts()
.get_admin_gateway_voice_state_counts()
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+1 -1
View File
@@ -22,7 +22,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.send_system_dm(&body)
.create_admin_system_dm(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
-39
View File
@@ -1,39 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BillingOverview {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SubscriptionResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentMethodListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InvoiceListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RefundCancelResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
@@ -826,13 +826,3 @@ pub struct CreateRegistrationUrlResponse {
pub code: String,
pub url: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct RegistrationUrlActionRequest {
pub id: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct PendingRegistrationActionRequest {
pub user_id: String,
}
-2
View File
@@ -4,7 +4,6 @@ mod admin_api_keys;
mod applications;
mod archives;
mod audit;
mod billing;
mod bulk;
mod codes;
mod common;
@@ -24,7 +23,6 @@ pub use admin_api_keys::*;
pub use applications::*;
pub use archives::*;
pub use audit::*;
pub use billing::*;
pub use bulk::*;
pub use codes::*;
pub use common::*;
+111 -183
View File
@@ -17,18 +17,19 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchUsersResponse> {
let body = generated_types::SearchUsersRequest {
email: nonempty_string(email),
last_active_ip: nonempty_string(last_active_ip),
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
query: nonempty_string(query),
};
let limit = limit.to_string();
let offset = offset.to_string();
let response = self
.generated()
.search_users(&body)
.list_admin_users(
nonempty(email),
nonempty(last_active_ip),
Some(limit.as_str()),
Some(offset.as_str()),
nonempty(query),
None,
None,
)
.await
.map_err(|e| self.generated_error(e))?;
let response = response.into_inner();
@@ -39,10 +40,9 @@ impl AdminApiClient {
}
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
let body = generated_types::LookupUserRequest::Query(query.to_owned());
let response = self
.generated()
.lookup_user(&body)
.list_admin_users(None, None, None, None, None, Some(query), None)
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
@@ -53,27 +53,18 @@ impl AdminApiClient {
if user_ids.is_empty() {
return Ok(vec![]);
}
let body = generated_types::LookupUserRequest::UserIds(
user_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
);
let response = self
.generated()
.lookup_user(&body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
let query_params: Vec<(&str, &str)> = user_ids
.iter()
.map(|user_id| ("user_id", user_id.as_str()))
.collect();
let resp: LookupUserResponse = self.get("/admin/users", Some(&query_params)).await?;
Ok(resp.users)
}
pub async fn get_user_by_id(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::LookupUserRequest::Query(user_id.to_owned());
let response = self
.generated()
.lookup_user(&body)
.get_admin_user(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
@@ -89,7 +80,7 @@ impl AdminApiClient {
pub async fn get_current_admin(&self) -> ApiResult<AdminUser> {
let response = self
.generated()
.get_authenticated_admin_user()
.get_current_admin_user()
.await
.map_err(|e| self.generated_error(e))?;
let resp: AdminUserMeResponse = self.generated_value(response.into_inner())?;
@@ -102,14 +93,13 @@ impl AdminApiClient {
add_flags: &[String],
remove_flags: &[String],
) -> ApiResult<AdminUser> {
let body = generated_types::UpdateUserFlagsRequest {
let body = generated_types::AdminUserFlagsUpdateRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
user_id: snowflake(user_id),
};
let response = self
.generated()
.update_user_flags(&body)
.update_admin_user_flags(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -124,19 +114,19 @@ impl AdminApiClient {
after: Option<&str>,
with_counts: Option<bool>,
) -> ApiResult<Vec<GuildInfo>> {
let body = generated_types::ListUserGuildsRequest {
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
limit: Some(
crate::api::generated::nonzero_u32(limit.unwrap_or(200), "limit")
.map_err(ApiError::Parse)?,
),
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
with_counts: Some(with_counts.unwrap_or(true)),
};
let after = after.map(snowflake);
let before = before.map(snowflake);
let limit = limit.unwrap_or(200).to_string();
let with_counts = bool_param(with_counts.unwrap_or(true));
let response = self
.generated()
.list_user_guilds(&body)
.list_admin_user_guilds(
&snowflake(user_id),
after.as_ref(),
before.as_ref(),
Some(limit.as_str()),
Some(with_counts),
)
.await
.map_err(|e| self.generated_error(e))?;
let resp: ListUserGuildsResponse = self.generated_value(response.into_inner())?;
@@ -147,12 +137,9 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::ListUserSessionsResponse> {
let body = generated_types::ListUserSessionsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.list_user_sessions(&body)
.list_admin_user_sessions(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -162,12 +149,9 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<TerminateSessionsResponse> {
let body = generated_types::TerminateSessionsRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.terminate_user_sessions(&body)
.terminate_admin_user_sessions(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -177,12 +161,9 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::ListUserRelationshipsResponse> {
let body = generated_types::ListUserRelationshipsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.admin_list_user_relationships(&body)
.list_admin_user_relationships(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -195,18 +176,18 @@ impl AdminApiClient {
after: Option<&str>,
limit: Option<u32>,
) -> ApiResult<super::types::ListUserDmChannelsResponse> {
let body = generated_types::ListUserDmChannelsRequest {
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
limit: Some(
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
.map_err(ApiError::Parse)?,
),
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let after = after.map(snowflake);
let before = before.map(snowflake);
let limit = limit.unwrap_or(50).to_string();
let response = self
.generated()
.list_user_dm_channels(&body)
.list_admin_user_dm_channels(
&snowflake(user_id),
after.as_ref(),
before.as_ref(),
Some(limit.as_str()),
Some(generated_types::AdminUserDmChannelType::Dm),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -216,12 +197,15 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::ListUserGroupDmChannelsResponse> {
let body = generated_types::ListUserGroupDmChannelsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.list_user_group_dm_channels(&body)
.list_admin_user_dm_channels(
&snowflake(user_id),
None,
None,
None,
Some(generated_types::AdminUserDmChannelType::GroupDm),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -233,14 +217,13 @@ impl AdminApiClient {
add_flags: &[i32],
remove_flags: &[i32],
) -> ApiResult<AdminUser> {
let body = generated_types::UpdatePremiumFlagsRequest {
let body = generated_types::AdminUserPremiumFlagsUpdateRequest {
add_flags: premium_flags(add_flags),
remove_flags: premium_flags(remove_flags),
user_id: snowflake(user_id),
};
let response = self
.generated()
.update_user_premium_flags(&body)
.update_admin_user_premium_flags(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -248,13 +231,12 @@ impl AdminApiClient {
}
pub async fn update_suspicious_flags(&self, user_id: &str, flags: i32) -> ApiResult<AdminUser> {
let body = generated_types::UpdateSuspiciousActivityFlagsRequest {
let body = generated_types::AdminUserSuspiciousActivityFlagsRequest {
flags: generated_types::SuspiciousActivityFlags::from(flags),
user_id: snowflake(user_id),
};
let response = self
.generated()
.update_suspicious_activity_flags(&body)
.update_admin_user_suspicious_activity_flags(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -262,13 +244,12 @@ impl AdminApiClient {
}
pub async fn set_user_acls(&self, user_id: &str, acls: &[String]) -> ApiResult<AdminUser> {
let body = generated_types::SetUserAclsRequest {
acls: acls.to_vec(),
user_id: snowflake(user_id),
let body = generated_types::AdminUserAclsRequest {
acls: super::admin_api_keys::parse_acls(acls)?,
};
let response = self
.generated()
.set_user_acls(&body)
.set_admin_user_acls(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -276,13 +257,12 @@ impl AdminApiClient {
}
pub async fn set_user_traits(&self, user_id: &str, traits: &[String]) -> ApiResult<AdminUser> {
let body = generated_types::SetUserTraitsRequest {
let body = generated_types::AdminUserTraitsRequest {
traits: traits.to_vec(),
user_id: snowflake(user_id),
};
let response = self
.generated()
.set_user_traits(&body)
.set_admin_user_traits(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -290,34 +270,25 @@ impl AdminApiClient {
}
pub async fn disable_mfa(&self, user_id: &str) -> ApiResult<()> {
let body = generated_types::DisableMfaRequest {
user_id: snowflake(user_id),
};
self.generated()
.disable_user_mfa(&body)
.disable_admin_user_mfa(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn resend_verification_email(&self, user_id: &str) -> ApiResult<()> {
let body = generated_types::ResendVerificationEmailRequest {
user_id: snowflake(user_id),
};
self.generated()
.admin_resend_verification_email(&body)
.resend_admin_user_verification_email(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn verify_email(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::VerifyUserEmailRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.verify_user_email(&body)
.verify_admin_user_email(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -329,13 +300,10 @@ impl AdminApiClient {
user_id: &str,
has_verified_phone: bool,
) -> ApiResult<AdminUser> {
let body = generated_types::UpdateHasVerifiedPhoneRequest {
has_verified_phone,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserPhoneVerificationRequest { has_verified_phone };
let response = self
.generated()
.update_user_has_verified_phone(&body)
.update_admin_user_phone_verification(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -349,16 +317,15 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> {
let fields = fields
.iter()
.map(generated_types::ClearUserFieldsRequestFieldsItem::try_from)
.map(|field| {
generated_types::AdminUserClearFieldsRequestFieldsItem::try_from(field.as_str())
})
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::ClearUserFieldsRequest {
fields,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserClearFieldsRequest { fields };
let response = self
.generated()
.clear_user_fields(&body)
.clear_admin_user_profile_fields(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -366,13 +333,10 @@ impl AdminApiClient {
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
let body = generated_types::SetUserBotStatusRequest {
bot: is_bot,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
let response = self
.generated()
.set_user_bot_status(&body)
.set_admin_user_bot_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -380,13 +344,10 @@ impl AdminApiClient {
}
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
let body = generated_types::SetUserSystemStatusRequest {
system: is_system,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
let response = self
.generated()
.set_user_system_status(&body)
.set_admin_user_system_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -399,18 +360,17 @@ impl AdminApiClient {
username: &str,
discriminator: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::ChangeUsernameRequest {
let body = generated_types::AdminUserUsernameUpdateRequest {
discriminator: discriminator
.map(generated_types::DiscriminatorType::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_id: snowflake(user_id),
username: generated_types::UsernameType::try_from(username)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.change_user_username(&body)
.update_admin_user_username(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -418,13 +378,12 @@ impl AdminApiClient {
}
pub async fn change_email(&self, user_id: &str, email: &str) -> ApiResult<AdminUser> {
let body = generated_types::ChangeEmailRequest {
let body = generated_types::AdminUserEmailUpdateRequest {
email: generated_types::EmailType::from(email.to_owned()),
user_id: snowflake(user_id),
};
let response = self
.generated()
.change_user_email(&body)
.update_admin_user_email(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -438,25 +397,25 @@ impl AdminApiClient {
reason: Option<&str>,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::TempBanUserRequest {
let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?,
reason: reason.map(std::borrow::ToOwned::to_owned),
user_id: snowflake(user_id),
};
let resp: UserMutationResponse = self
.post_typed_with_reason("/admin/users/temp-ban", &body, private_reason)
.put_typed_with_reason(
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
&body,
private_reason,
)
.await?;
Ok(resp.user)
}
pub async fn unban_user(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::DisableMfaRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.unban_user(&body)
.unban_admin_user(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -470,18 +429,18 @@ impl AdminApiClient {
public_reason: Option<&str>,
days_until_deletion: u32,
) -> ApiResult<AdminUser> {
let body = generated_types::ScheduleAccountDeletionRequest {
let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code,
user_id: snowflake(user_id),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?,
};
let response = self
.generated()
.schedule_account_deletion(&body)
.schedule_admin_user_deletion(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -489,12 +448,9 @@ impl AdminApiClient {
}
pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::DisableMfaRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.cancel_account_deletion(&body)
.cancel_admin_user_deletion(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -502,13 +458,12 @@ impl AdminApiClient {
}
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
let body = generated_types::ChangeDobRequest {
let body = generated_types::AdminUserDobUpdateRequest {
date_of_birth: dob.to_owned(),
user_id: snowflake(user_id),
};
let response = self
.generated()
.change_user_dob(&body)
.update_admin_user_date_of_birth(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -516,11 +471,8 @@ impl AdminApiClient {
}
pub async fn send_password_reset(&self, user_id: &str) -> ApiResult<()> {
let body = generated_types::SendPasswordResetRequest {
user_id: snowflake(user_id),
};
self.generated()
.send_password_reset(&body)
.send_admin_user_password_reset(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -532,14 +484,10 @@ impl AdminApiClient {
target_id: &str,
category: &str,
) -> ApiResult<()> {
let body = generated_types::RemoveUserRelationshipRequest {
category: generated_types::RemoveUserRelationshipRequestCategory::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?,
target_user_id: snowflake(target_id),
user_id: snowflake(user_id),
};
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
self.generated()
.remove_user_relationship(&body)
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -550,16 +498,11 @@ impl AdminApiClient {
user_id: &str,
category: &str,
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
let body = generated_types::RemoveUserRelationshipsByCategoryRequest {
category: generated_types::RemoveUserRelationshipsByCategoryRequestCategory::try_from(
category,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_id: snowflake(user_id),
};
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.remove_user_relationships_by_category(&body)
.clear_admin_user_relationships(&snowflake(user_id), category)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -570,12 +513,8 @@ impl AdminApiClient {
user_id: &str,
credential_id: &str,
) -> ApiResult<()> {
let body = generated_types::DeleteWebAuthnCredentialRequest {
credential_id: credential_id.to_owned(),
user_id: snowflake(user_id),
};
self.generated()
.delete_user_webauthn_credential(&body)
.delete_admin_user_webauthn_credential(&snowflake(user_id), credential_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -586,17 +525,10 @@ impl AdminApiClient {
user_id: &str,
limit: Option<u32>,
) -> ApiResult<super::types::ListUserChangeLogResponse> {
let body = generated_types::ListUserChangeLogRequest {
limit: Some(
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
.map_err(ApiError::Parse)?,
),
page_token: None,
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let limit = limit.unwrap_or(50).to_string();
let response = self
.generated()
.get_user_change_log(&body)
.list_admin_user_change_log(&snowflake(user_id), Some(limit.as_str()), None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -606,24 +538,18 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::WebAuthnCredentialListResponse> {
let body = generated_types::ListWebAuthnCredentialsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.list_user_webauthn_credentials(&body)
.list_admin_user_webauthn_credentials(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn cancel_bulk_message_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::CancelBulkMessageDeletionRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.admin_cancel_bulk_message_deletion(&body)
.cancel_admin_user_message_deletion(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -631,10 +557,12 @@ impl AdminApiClient {
}
}
fn nonempty_string(value: Option<&str>) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
fn nonempty(value: Option<&str>) -> Option<&str> {
value.filter(|value| !value.is_empty())
}
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
+39 -29
View File
@@ -14,12 +14,9 @@ impl AdminApiClient {
&self,
include_servers: bool,
) -> ApiResult<ListVoiceRegionsResponse> {
let body = generated_types::ListVoiceRegionsRequest {
include_servers: Some(include_servers),
};
let response = self
.generated()
.list_voice_regions(&body)
.list_admin_voice_regions(Some(bool_param(include_servers)))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -30,13 +27,9 @@ impl AdminApiClient {
id: &str,
include_servers: bool,
) -> ApiResult<GetVoiceRegionResponse> {
let body = generated_types::GetVoiceRegionRequest {
id: id.to_owned(),
include_servers: Some(include_servers),
};
let response = self
.generated()
.get_voice_region(&body)
.get_admin_voice_region(id, Some(bool_param(include_servers)))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -51,7 +44,7 @@ impl AdminApiClient {
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.create_voice_region(&body)
.create_admin_voice_region(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -61,34 +54,31 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
let region_id = required_field(params, "id")?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_voice_region(&body)
.update_admin_voice_region(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
let body = generated_types::DeleteVoiceRegionRequest { id: id.to_owned() };
let response = self
.generated()
.delete_voice_region(&body)
.delete_admin_voice_region(id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn list_voice_servers(&self, region_id: &str) -> ApiResult<ListVoiceServersResponse> {
let body = generated_types::ListVoiceServersRequest {
region_id: region_id.to_owned(),
};
let response = self
.generated()
.list_voice_servers(&body)
.list_admin_voice_servers(region_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -99,13 +89,9 @@ impl AdminApiClient {
region_id: &str,
server_id: &str,
) -> ApiResult<GetVoiceServerResponse> {
let body = generated_types::GetVoiceServerRequest {
region_id: region_id.to_owned(),
server_id: server_id.to_owned(),
};
let response = self
.generated()
.get_voice_server(&body)
.get_admin_voice_server(region_id, server_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -115,12 +101,14 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<CreateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.create_voice_server(&body)
.create_admin_voice_server(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -130,12 +118,15 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_voice_server(&body)
.update_admin_voice_server(&region_id, &server_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -146,15 +137,34 @@ impl AdminApiClient {
region_id: &str,
server_id: &str,
) -> ApiResult<DeleteVoiceResponse> {
let body = generated_types::DeleteVoiceServerRequest {
region_id: region_id.to_owned(),
server_id: server_id.to_owned(),
};
let response = self
.generated()
.delete_voice_server(&body)
.delete_admin_voice_server(region_id, server_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
if has_coordinate("latitude") == has_coordinate("longitude") {
Ok(())
} else {
Err(ApiError::Parse(
"latitude and longitude must both be set or both be left empty".to_owned(),
))
}
}
fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String> {
params
.get(field)
.and_then(serde_json::Value::as_str)
.map(std::borrow::ToOwned::to_owned)
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
}
+131 -23
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::normalize_public_endpoint_from_env;
use std::env;
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
@@ -40,40 +41,47 @@ pub enum RuntimeEnv {
}
impl AdminConfig {
pub fn from_env() -> Self {
pub fn from_env() -> anyhow::Result<Self> {
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
let admin_endpoint = trim_trailing_slash(&read_env(
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_ADMIN_ENDPOINT",
"https://admin.fluxer.app",
));
let oauth_redirect_uri = read_env_preferred(
)));
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred(
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
&format!("{admin_endpoint}/oauth2_callback"),
));
let secret_key_base = read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "");
anyhow::ensure!(
!secret_key_base.trim().is_empty(),
"FLUXER_ADMIN_SECRET_KEY_BASE is required"
);
Self {
Ok(Self {
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
port: read_env("FLUXER_ADMIN_PORT", "3020")
.parse()
.unwrap_or(3020),
secret_key_base: read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "development-admin-secret"),
secret_key_base,
base_path,
api_endpoint: trim_trailing_slash(&read_env(
"FLUXER_API_ENDPOINT",
"https://api.fluxer.app",
)),
media_endpoint: trim_trailing_slash(&read_env(
media_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_MEDIA_ENDPOINT",
"https://media.fluxer.app",
))),
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
static_cdn_endpoint: trim_trailing_slash(&read_env("FLUXER_STATIC_CDN_ENDPOINT", "")),
admin_endpoint,
web_app_endpoint: trim_trailing_slash(&read_env(
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_APP_ENDPOINT",
"https://app.fluxer.app",
)),
))),
kv_url: read_env("FLUXER_KV_URL", ""),
oauth_client_id: read_env(
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
@@ -107,7 +115,7 @@ impl AdminConfig {
.trim()
.to_ascii_lowercase(),
},
}
})
}
pub fn is_dev(&self) -> bool {
@@ -117,6 +125,10 @@ impl AdminConfig {
pub fn is_production(&self) -> bool {
self.env == RuntimeEnv::Production
}
pub fn secure_cookies(&self) -> bool {
self.admin_endpoint.starts_with("https://")
}
}
impl RuntimeEnv {
@@ -166,6 +178,40 @@ pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 11] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
"FLUXER_APP_ENDPOINT",
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
let _guard = ENV_LOCK.lock().unwrap();
for name in MANAGED_ENV {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
for (name, value) in vars {
unsafe { env::set_var(name, value) };
}
let config = AdminConfig::from_env().expect("config loads with a secret");
for (name, _) in vars {
unsafe { env::remove_var(name) };
}
config
}
#[test]
fn normalize_base_path_strips_trailing_slashes() {
@@ -287,18 +333,7 @@ mod tests {
#[test]
fn from_env_uses_defaults() {
for var in &[
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
] {
unsafe { env::remove_var(var) };
}
let config = AdminConfig::from_env();
let config = config_from_env(&[]);
assert_eq!(config.env, RuntimeEnv::Development);
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3020);
@@ -308,4 +343,77 @@ mod tests {
"https://admin.fluxer.app/oauth2_callback"
);
}
#[test]
fn a_non_default_public_port_reaches_the_public_endpoints() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "http://fluxer.example:19080/admin");
assert_eq!(config.media_endpoint, "http://fluxer.example:19080/media");
assert_eq!(config.web_app_endpoint, "http://fluxer.example:19080");
assert_eq!(config.static_cdn_endpoint, "https://cdn.example.net");
assert_eq!(
config.oauth_redirect_uri,
format!("{}/oauth2_callback", config.admin_endpoint)
);
}
#[test]
fn a_default_public_port_leaves_the_public_endpoints_alone() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "443"),
("FLUXER_ADMIN_ENDPOINT", "https://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "https://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "https://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"https://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "https://fluxer.example/admin");
assert_eq!(config.media_endpoint, "https://fluxer.example/media");
assert_eq!(config.web_app_endpoint, "https://fluxer.example");
assert_eq!(config.static_cdn_endpoint, "https://fluxer.example");
assert_eq!(
config.oauth_redirect_uri,
"https://fluxer.example/admin/oauth2_callback"
);
}
#[test]
fn the_oauth_redirect_uri_matches_the_api_derived_admin_endpoint() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
let api_admin_endpoint = fluxer_common::config::normalize_public_endpoint(
"http://fluxer.example/admin",
"fluxer.example",
Some(19080),
);
assert_eq!(
config.oauth_redirect_uri,
format!("{api_admin_endpoint}/oauth2_callback")
);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ async fn main() -> anyhow::Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let config = AdminConfig::from_env();
let config = AdminConfig::from_env()?;
let addr = format!("{}:{}", config.host, config.port);
let router = build_router(config);
+1 -1
View File
@@ -135,7 +135,7 @@ async fn fetch_admin_user(
config: &crate::config::AdminConfig,
session: &Session,
) -> AdminFetchResult {
let url = format!("{}/admin/users/me", config.api_endpoint);
let url = format!("{}/admin/users/@me", config.api_endpoint);
let response =
match crate::api::client::with_proxy_client_ip_header(http_client.get(&url), config)
.header("Authorization", format!("Bearer {}", session.access_token))
+139 -54
View File
@@ -2,24 +2,43 @@
use axum::{
body::{Body, to_bytes},
extract::Request,
extract::{Request, State},
http::{HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use rand::RngExt;
use crate::middleware::auth::AuthContext;
use crate::session::{create_csrf_token, verify_csrf_token};
use crate::state::AppState;
const CSRF_COOKIE_NAME: &str = "csrf_token";
pub const CSRF_FORM_FIELD: &str = "_csrf";
const CSRF_HEADER_NAME: &str = "x-csrf-token";
const TOKEN_LENGTH: usize = 32;
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
let existing_token = extract_csrf_cookie(&request);
let token = existing_token.unwrap_or_else(generate_csrf_token);
pub async fn csrf_protection(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let secure_cookies = config.secure_cookies();
let user_id = request
.extensions()
.get::<AuthContext>()
.map(|ctx| ctx.session.user_id.clone())
.unwrap_or_default();
let token = extract_csrf_cookie(&request)
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
request.extensions_mut().insert(CsrfToken(token.clone()));
if matches!(
@@ -31,6 +50,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, &admin_endpoint) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
let query_token = extract_csrf_from_query(&request);
let mut submitted = query_token.or(header_token);
@@ -43,40 +65,58 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
request = restored_request;
submitted = body_token;
}
match submitted {
Some(ref submitted_token) if submitted_token == &token => {}
_ => {
return StatusCode::FORBIDDEN.into_response();
}
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
});
if !accepted {
return StatusCode::FORBIDDEN.into_response();
}
}
}
let mut response = next.run(request).await;
let cookie_value = format!(
"{}={}; Path=/; SameSite=Lax; HttpOnly",
CSRF_COOKIE_NAME, token
);
let cookie_name = if secure_cookies {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if secure_cookies { "; Secure" } else { "" };
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
if secure_cookies
&& let Ok(value) = HeaderValue::from_str(&format!(
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
))
{
response.headers_mut().append(header::SET_COOKIE, value);
}
response
}
fn extract_csrf_cookie(request: &Request) -> Option<String> {
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
let mut legacy = None;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("csrf_token=") {
if let Some(value) = pair.strip_prefix("__Host-csrf_token=") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
} else if let Some(value) = pair.strip_prefix("csrf_token=")
&& legacy.is_none()
{
let trimmed = value.trim();
if !trimmed.is_empty() {
legacy = Some(trimmed.to_owned());
}
}
}
None
legacy
}
fn extract_csrf_header(request: &Request) -> Option<String> {
@@ -127,18 +167,22 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn generate_csrf_token() -> String {
let mut rng = rand::rng();
let bytes: [u8; TOKEN_LENGTH] = rng.random();
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes {
s.push_str(&format!("{byte:02x}"));
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
{
return matches!(site, "same-origin" | "same-site" | "none");
}
match request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == admin_endpoint,
None => true,
}
s
}
#[derive(Clone, Debug)]
@@ -155,39 +199,80 @@ pub fn get_csrf_token(request: &Request) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
use crate::state::AppState;
use axum::{Router, middleware::from_fn_with_state, routing::get};
use tower::ServiceExt;
#[test]
fn generate_csrf_token_correct_length() {
let token = generate_csrf_token();
assert_eq!(
token.len(),
TOKEN_LENGTH * 2,
"token must be {} hex chars",
TOKEN_LENGTH * 2
);
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
AppState::new(AdminConfig {
env: RuntimeEnv::Production,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: String::new(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: String::new(),
kv_url: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
release_channel: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
})
}
#[test]
fn generate_csrf_token_is_valid_hex() {
let token = generate_csrf_token();
async fn csrf_cookies(admin_endpoint: &str) -> Vec<String> {
let state = state_with_admin_endpoint(admin_endpoint);
let app = Router::new()
.route("/", get(|| async { "ok" }))
.layer(from_fn_with_state(state, csrf_protection));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("router responds");
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.map(|value| value.to_owned())
.collect()
}
#[tokio::test]
async fn https_admin_endpoint_sets_a_host_prefixed_secure_cookie() {
let cookies = csrf_cookies("https://example.com/admin").await;
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must contain only hex chars: {token}"
cookies
.iter()
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
&& cookie.contains("; Secure")),
"expected a secure __Host- cookie, got {cookies:?}"
);
}
#[test]
fn generate_csrf_token_is_unique() {
let a = generate_csrf_token();
let b = generate_csrf_token();
assert_ne!(a, b, "consecutive tokens must differ");
}
#[test]
fn hex_encode_produces_correct_output() {
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
assert_eq!(hex_encode(&[]), "");
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
#[tokio::test]
async fn http_admin_endpoint_sets_a_plain_cookie_without_secure() {
let cookies = csrf_cookies("http://example.com/admin").await;
assert!(
cookies
.iter()
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("Secure")),
"expected a plain csrf_token cookie, got {cookies:?}"
);
assert!(
!cookies.iter().any(|cookie| cookie.contains("__Host-")),
"expected no __Host- cookie, got {cookies:?}"
);
}
#[test]
+2 -2
View File
@@ -92,9 +92,9 @@ pub fn clear_flash_cookie(response: &mut Response) {
}
}
pub fn redirect_with_flash(url: &str, flash: FlashData, is_production: bool) -> Response {
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
let encoded = serialize_flash(&flash);
let secure_flag = if is_production { "; Secure" } else { "" };
let secure_flag = if secure { "; Secure" } else { "" };
let cookie_value = format!(
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
);
+3 -3
View File
@@ -119,7 +119,7 @@ async fn admin_api_keys_post(
return flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
flash,
config.is_production(),
config.secure_cookies(),
);
}
}
@@ -128,7 +128,7 @@ async fn admin_api_keys_post(
flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
FlashData::success(format!("API key action '{action}' completed.")),
config.is_production(),
config.secure_cookies(),
)
}
@@ -143,7 +143,7 @@ fn admin_api_key_flash_response(
flash::redirect_with_flash(
&format!("{}/admin-api-keys", config.base_path),
flash_data,
config.is_production(),
config.secure_cookies(),
)
}
}
+2 -2
View File
@@ -151,7 +151,7 @@ async fn application_detail_post(
return flash::redirect_with_flash(
&format!("{base}/applications/{application_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -178,6 +178,6 @@ async fn application_detail_post(
flash::redirect_with_flash(
&format!("{base}/applications/{application_id}"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
+2 -2
View File
@@ -187,7 +187,7 @@ async fn oauth2_callback_finish(
let session_cookie_value =
session::create_session(&user.id, &token.access_token, &config.secret_key_base);
let secure = if config.is_production() {
let secure = if config.secure_cookies() {
"; Secure"
} else {
""
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
)
}
fn json_string(value: &str) -> String {
pub(crate) fn json_string(value: &str) -> String {
serde_json::to_string(value)
.expect("JSON string serialization cannot fail")
.replace('<', "\\u003c")
+4 -4
View File
@@ -82,7 +82,7 @@ async fn gift_codes_post(
return flash::redirect_with_flash(
&format!("{base}/gift-codes"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -99,14 +99,14 @@ async fn gift_codes_post(
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let is_prod = config.is_production();
let secure_cookies = config.secure_cookies();
match client.generate_gift_codes(count, dur_type, dur_qty).await {
Ok(result) => {
let codes = result.codes.join(",");
flash::redirect_with_flash(
&format!("{base}/gift-codes?codes={codes}"),
FlashData::success(format!("{} gift code(s) generated", result.codes.len())),
is_prod,
secure_cookies,
)
}
Err(error) => {
@@ -114,7 +114,7 @@ async fn gift_codes_post(
flash::redirect_with_flash(
&format!("{base}/gift-codes"),
FlashData::error("Failed to generate gift codes"),
is_prod,
secure_cookies,
)
}
}
+9 -9
View File
@@ -105,7 +105,7 @@ async fn discovery_approve(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -115,7 +115,7 @@ async fn discovery_approve(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -131,7 +131,7 @@ async fn discovery_approve(
flash::redirect_with_flash(
&format!("{base}/discovery?tab=pending"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
@@ -149,7 +149,7 @@ async fn discovery_reject(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -159,7 +159,7 @@ async fn discovery_reject(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -173,7 +173,7 @@ async fn discovery_reject(
flash::redirect_with_flash(
&format!("{base}/discovery?tab=pending"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
@@ -191,7 +191,7 @@ async fn discovery_remove(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -201,7 +201,7 @@ async fn discovery_remove(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -215,6 +215,6 @@ async fn discovery_remove(
flash::redirect_with_flash(
&format!("{base}/discovery?tab=listed"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
+1 -17
View File
@@ -150,22 +150,6 @@ pub async fn render(
},
))
}
"billing" => {
if config.self_hosted || !acl::has_permission(admin_acls, acl::BILLING_VIEW) {
return None;
}
let billing = client
.get_billing_overview(guild_id)
.await
.log_error("load guild billing overview")
.map(|b| b.data);
Some(tabs::billing::billing_tab(
config,
guild_id,
billing.as_ref(),
csrf_token,
))
}
"applications" => {
if !acl::has_any_permission(
admin_acls,
@@ -174,7 +158,7 @@ pub async fn render(
return None;
}
let apps = client
.list_user_applications(guild_id)
.list_guild_applications(guild_id)
.await
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications"))
.unwrap_or_default();
+4 -4
View File
@@ -191,7 +191,7 @@ async fn guild_detail_post(
return flash::redirect_with_flash(
&format!("{base}/guilds/{guild_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -203,7 +203,7 @@ async fn guild_detail_post(
} else {
format!("{base}/guilds/{guild_id}?tab={tab}")
};
flash::redirect_with_flash(&redirect, flash, config.is_production())
flash::redirect_with_flash(&redirect, flash, config.secure_cookies())
}
async fn dispatch_guild_action(
@@ -415,7 +415,7 @@ async fn dispatch_guild_action(
return FlashData::error("Emoji ID is required");
};
action_result(
client.purge_assets(&[emoji_id]).await,
client.purge_assets(guild_id, &[emoji_id]).await,
"Emoji deleted",
"Failed to delete emoji",
)
@@ -425,7 +425,7 @@ async fn dispatch_guild_action(
return FlashData::error("Sticker ID is required");
};
action_result(
client.purge_assets(&[sticker_id]).await,
client.purge_assets(guild_id, &[sticker_id]).await,
"Sticker deleted",
"Failed to delete sticker",
)
+2 -2
View File
@@ -187,7 +187,7 @@ async fn job_detail_post(
return flash::redirect_with_flash(
&format!("{base}/jobs/{job_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -214,7 +214,7 @@ async fn job_detail_post(
flash::redirect_with_flash(
&format!("{base}/jobs/{job_id}"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
+17 -13
View File
@@ -48,7 +48,7 @@ pub(crate) async fn messages_post(
return flash::redirect_with_flash(
&format!("{base}/messages"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -164,7 +164,7 @@ pub(crate) async fn system_dms_post(
return flash::redirect_with_flash(
&format!("{base}/system-dms"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -184,7 +184,11 @@ pub(crate) async fn system_dms_post(
} else {
FlashData::error("Recipients and content are required")
};
flash::redirect_with_flash(&format!("{base}/system-dms"), flash, config.is_production())
flash::redirect_with_flash(
&format!("{base}/system-dms"),
flash,
config.secure_cookies(),
)
}
pub(crate) async fn bulk_actions_post(
@@ -201,7 +205,7 @@ pub(crate) async fn bulk_actions_post(
return flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -247,7 +251,7 @@ pub(crate) async fn bulk_actions_post(
.bulk_add_guild_members(&guild_id, &user_ids, audit_log_reason.as_deref())
.await
}
"bulk-schedule-user-deletion" => {
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
let reason_code = form.parse_u32("reason_code").unwrap_or(2);
let days = form.parse_u32("days_until_deletion").unwrap_or(14);
@@ -262,17 +266,17 @@ pub(crate) async fn bulk_actions_post(
)
.await
}
"bulk_delete_users" => {
"bulk-delete-user-messages" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
client
.bulk_schedule_user_deletion(&user_ids, 0, 30, None, audit_log_reason.as_deref())
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
.await
}
_ => {
return flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Unknown bulk action"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -284,7 +288,7 @@ pub(crate) async fn bulk_actions_post(
flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::success("Bulk action submitted"),
config.is_production(),
config.secure_cookies(),
)
}
}
@@ -292,8 +296,8 @@ pub(crate) async fn bulk_actions_post(
tracing::warn!(%error, action, "admin API request failed: submit bulk action");
flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Failed to submit bulk action"),
config.is_production(),
FlashData::error(format!("Failed to submit bulk action: {error}")),
config.secure_cookies(),
)
}
}
@@ -399,14 +403,14 @@ pub(crate) async fn archives_download(
Ok(_) => flash::redirect_with_flash(
&format!("{base}/archives"),
FlashData::error("Archive download URL was empty"),
config.is_production(),
config.secure_cookies(),
),
Err(error) => {
tracing::warn!(%error, "admin API request failed: get archive download URL");
flash::redirect_with_flash(
&format!("{base}/archives"),
FlashData::error("Failed to create archive download URL"),
config.is_production(),
config.secure_cookies(),
)
}
}
+4 -1
View File
@@ -73,7 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn(middleware::csrf::csrf_protection))
.layer(from_fn_with_state(
state.clone(),
middleware::csrf::csrf_protection,
))
.layer(from_fn(middleware::self_hosted::self_hosted_override))
.layer(from_fn_with_state(
state.clone(),
+28 -5
View File
@@ -2,6 +2,7 @@
use crate::{
api::client::{AdminApiClient, ApiResultExt},
config::AdminConfig,
middleware::{
auth::AuthContext,
csrf,
@@ -22,6 +23,8 @@ use axum::{
};
use serde::Deserialize;
const MAX_REPORT_OFFSET: u32 = 10_000;
#[derive(Deserialize)]
struct ReportsQuery {
q: Option<String>,
@@ -70,6 +73,14 @@ async fn reports_list(
let page = query.page.unwrap_or(0);
let limit = query.limit.unwrap_or(25).clamp(1, 200);
let offset = page.saturating_mul(limit);
if offset > MAX_REPORT_OFFSET {
return reports_error_page(
config,
&auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
);
}
let search_query = query.q.as_deref().and_then(clean_string);
let (sort_by, sort_order) = decode_sort(query.sort.as_deref());
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let status = query.status.as_deref().and_then(|s| s.parse::<i32>().ok());
@@ -79,7 +90,7 @@ async fn reports_list(
.and_then(|s| s.parse::<i32>().ok());
let reports = client
.search_reports(
query.q.as_deref(),
search_query.as_deref(),
status,
report_type,
query.category.as_deref(),
@@ -102,7 +113,7 @@ async fn reports_list(
&auth.0,
reports.as_ref(),
&templates::pages::reports_list::ReportFilters {
query: query.q.as_deref(),
query: search_query.as_deref(),
status: query.status.as_deref(),
report_type: query.report_type.as_deref(),
category: query.category.as_deref(),
@@ -120,6 +131,18 @@ async fn reports_list(
Html(markup.into_string()).into_response()
}
fn reports_error_page(config: &AdminConfig, auth: &AuthContext, message: &str) -> Response {
let markup = templates::layout::admin_layout(
config,
auth,
"Reports",
"reports",
None,
templates::components::error_display::error_alert(message),
);
Html(markup.into_string()).into_response()
}
async fn report_detail(
State(state): State<AppState>,
headers: HeaderMap,
@@ -195,7 +218,7 @@ async fn report_resolve(
return flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -212,7 +235,7 @@ async fn report_resolve(
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::success("Report resolved"),
config.is_production(),
config.secure_cookies(),
)
}
Err(error) => {
@@ -223,7 +246,7 @@ async fn report_resolve(
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::error("Failed to resolve report"),
config.is_production(),
config.secure_cookies(),
)
}
}
+25 -25
View File
@@ -44,8 +44,8 @@ pub struct ActionQuery {
pub rule: Option<String>,
}
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, prod: bool) -> Response {
flash::redirect_with_flash(&format!("{base}{path}"), fd, prod)
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, secure: bool) -> Response {
flash::redirect_with_flash(&format!("{base}{path}"), fd, secure)
}
pub async fn gateway_post(
@@ -63,7 +63,7 @@ pub async fn gateway_post(
base,
"/gateway",
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -80,7 +80,7 @@ pub async fn gateway_post(
} else {
FlashData::error("Unknown gateway action")
};
redirect_back_with_flash(base, "/gateway", flash, config.is_production())
redirect_back_with_flash(base, "/gateway", flash, config.secure_cookies())
}
pub async fn search_index_post(
@@ -97,7 +97,7 @@ pub async fn search_index_post(
base,
"/search-index",
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -114,7 +114,7 @@ pub async fn search_index_post(
flash::redirect_with_flash(
&format!("{base}/search-index?job_id={job_id}"),
FlashData::success("Search index refresh started"),
config.is_production(),
config.secure_cookies(),
)
}
Err(error) => {
@@ -123,7 +123,7 @@ pub async fn search_index_post(
base,
"/search-index",
FlashData::error("Failed to start search index refresh"),
config.is_production(),
config.secure_cookies(),
)
}
};
@@ -132,7 +132,7 @@ pub async fn search_index_post(
base,
"/search-index",
FlashData::error("Index type is required"),
config.is_production(),
config.secure_cookies(),
)
}
@@ -157,7 +157,7 @@ pub async fn instance_config_post(
base,
"/instance-config",
flash,
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -320,7 +320,7 @@ pub async fn instance_config_post(
if htmx::is_htmx_request(&headers) {
return htmx::toast_response(&flash);
}
redirect_back_with_flash(base, "/instance-config", flash, config.is_production())
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
}
fn render_registration_url_list_response(
@@ -866,13 +866,13 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let action = aq.action.as_deref().unwrap_or("");
let is_prod = config.is_production();
let secure_cookies = config.secure_cookies();
let current = match client.get_limit_config().await {
Ok(current) => current,
Err(error) => {
@@ -881,7 +881,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Failed to fetch current limit configuration"),
is_prod,
secure_cookies,
);
}
};
@@ -895,7 +895,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
}
};
@@ -908,7 +908,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
};
let fallback = current
@@ -923,7 +923,7 @@ pub async fn limit_config_post(
"Limit configuration updated",
"Failed to update limit configuration",
);
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
"delete" => {
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
@@ -933,7 +933,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
}
};
@@ -942,7 +942,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("The default rule cannot be deleted"),
is_prod,
secure_cookies,
);
}
let old_len = limit_config.rules.len();
@@ -952,14 +952,14 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
}
let request = LimitConfigUpdateRequest { limit_config };
let result = client.update_limit_config(&request).await;
let flash =
limit_config_result(result, "Limit rule deleted", "Failed to delete limit rule");
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
"create" => {
let rule_id = match form.clean("rule_id") {
@@ -969,7 +969,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule ID is required"),
is_prod,
secure_cookies,
);
}
};
@@ -978,7 +978,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("The default rule ID is reserved"),
is_prod,
secure_cookies,
);
}
if limit_config.rules.iter().any(|rule| rule.id == rule_id) {
@@ -986,7 +986,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule ID already exists"),
is_prod,
secure_cookies,
);
}
let limits = current.defaults.get("default").cloned().unwrap_or_default();
@@ -1000,7 +1000,7 @@ pub async fn limit_config_post(
let result = client.update_limit_config(&request).await;
let flash =
limit_config_result(result, "Limit rule created", "Failed to create limit rule");
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
_ => {}
}
@@ -1008,7 +1008,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::success("Limit config updated"),
is_prod,
secure_cookies,
)
}
-49
View File
@@ -399,55 +399,6 @@ pub async fn dispatch(
"Bulk message deletion cancelled successfully",
"Failed to cancel bulk message deletion",
),
"refund_payment" => {
let Some(pi) = form.clean("payment_intent_id") else {
return DispatchOutcome::error("Payment intent ID is required");
};
let amt = form.parse_u64("amount_cents");
let reason = get("reason");
DispatchOutcome::from_result(
client
.issue_refund(user_id, &pi, amt, reason.as_deref())
.await,
"Refund issued successfully",
"Failed to issue refund",
)
}
"refund_policy_cancel_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.refund_policy_cancel_now(user_id, reason.as_deref())
.await,
"Refund policy cancellation completed successfully",
"Failed to apply refund policy cancellation",
)
}
"cancel_subscription" => DispatchOutcome::from_result(
client.cancel_subscription(user_id).await,
"Subscription cancelled successfully",
"Failed to cancel subscription",
),
"cancel_subscription_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.cancel_subscription_immediately(user_id, reason.as_deref())
.await,
"Subscription cancelled immediately",
"Failed to cancel subscription immediately",
)
}
"reactivate_subscription" => DispatchOutcome::from_result(
client.reactivate_subscription(user_id).await,
"Subscription reactivated successfully",
"Failed to reactivate subscription",
),
"end_premium_grace_period" => DispatchOutcome::from_result(
client.end_premium_grace_period(user_id).await,
"Premium grace period ended successfully",
"Failed to end premium grace period",
),
"message_shred" => {
let csv = form.first("csv_data").unwrap_or_default();
match parse_message_shred_csv(csv) {
-38
View File
@@ -155,44 +155,6 @@ pub async fn render(
csrf_token,
))
}
"billing" => {
if config.self_hosted
|| !acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
{
return None;
}
let can_view_billing = acl::has_permission(admin_acls, acl::BILLING_VIEW);
let b = if can_view_billing {
client
.get_billing_overview(user_id)
.await
.log_error("load user billing overview")
} else {
None
};
let invoices = if can_view_billing {
client
.get_user_invoices(user_id, 25, None)
.await
.log_error("load user invoices")
} else {
None
};
Some(tabs::billing::billing_tab(
config,
user_id,
b.as_ref().map(|v| &v.data),
invoices.as_ref().map(|v| &v.data),
csrf_token,
))
}
"guilds" => {
let g = client
.get_user_guilds(user_id, Some(200), None, None, Some(true))
+22 -11
View File
@@ -2,7 +2,10 @@
use crate::{
acl,
api::client::{AdminApiClient, ApiResultExt},
api::{
client::{AdminApiClient, ApiResult, ApiResultExt},
types::AdminUser,
},
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
state::AppState,
@@ -18,6 +21,8 @@ use axum::{
};
use serde::Deserialize;
const USER_ID_LOOKUP_BATCH: usize = 100;
#[derive(Deserialize)]
struct UserListQuery {
q: Option<String>,
@@ -55,7 +60,6 @@ pub fn router() -> Router<AppState> {
.route("/users", get(users_list))
.route("/users/{user_id}", get(user_detail).post(user_detail_post))
.route("/users/{user_id}/tabs/{tab}", get(user_tab))
.route("/users/{user_id}/peek", get(user_peek))
.route("/users/{user_id}/fragment", get(user_peek))
}
@@ -84,14 +88,10 @@ async fn users_list(
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let results = if params.has_id_lookup() {
let users = client
.lookup_users_by_ids(&params.requested_ids)
lookup_users_in_batches(&client, &params.requested_ids)
.await
.map_err(
|error| tracing::warn!(%error, "admin API request failed: lookup users by ids"),
)
.unwrap_or_default();
Some((users, false))
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = params.page.saturating_mul(params.limit);
client
@@ -125,6 +125,17 @@ async fn users_list(
Html(markup.into_string()).into_response()
}
async fn lookup_users_in_batches(
client: &AdminApiClient,
user_ids: &[String],
) -> ApiResult<Vec<AdminUser>> {
let mut users = Vec::new();
for batch in user_ids.chunks(USER_ID_LOOKUP_BATCH) {
users.extend(client.lookup_users_by_ids(batch).await?);
}
Ok(users)
}
async fn user_detail(
State(state): State<AppState>,
headers: HeaderMap,
@@ -189,7 +200,7 @@ async fn user_detail_post(
return flash::redirect_with_flash(
&format!("{base}/users/{user_id}"),
flash,
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -208,7 +219,7 @@ async fn user_detail_post(
{
return htmx::toast_response(&outcome.flash);
}
flash::redirect_with_flash(&redirect, outcome.flash, config.is_production())
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
}
async fn user_tab(
+2 -2
View File
@@ -160,7 +160,7 @@ pub(crate) async fn voice_regions_post(
flash::redirect_with_flash(
&format!("{base}/voice-regions"),
flash_from_level(level, &msg),
config.is_production(),
config.secure_cookies(),
)
}
@@ -232,7 +232,7 @@ pub(crate) async fn voice_servers_post(
flash::redirect_with_flash(
&redirect_url,
flash_from_level(level, &msg),
config.is_production(),
config.secure_cookies(),
)
}
+31
View File
@@ -2,6 +2,7 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use rand::RngExt;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -78,6 +79,36 @@ fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a st
if diff == 0 { Some(data) } else { None }
}
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
let mut rng = rand::rng();
let nonce: [u8; 16] = rng.random();
let payload = URL_SAFE_NO_PAD.encode(format!(
"{}:{}",
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
URL_SAFE_NO_PAD.encode(nonce)
));
sign_data(&payload, secret_key)
}
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
let Some(data) = verify_signature(token, secret_key) else {
return false;
};
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
return false;
};
let Ok(payload) = String::from_utf8(decoded) else {
return false;
};
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
return false;
};
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
Err(_) => false,
}
}
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_COOKIE_NAME: &str = "admin_session";
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
@@ -7,6 +7,7 @@ use super::media::user_avatar_url;
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
pub struct Attachment {
pub id: String,
@@ -309,7 +310,7 @@ pub fn message_list(
}
pub fn message_deletion_script(csrf_token: &str) -> Markup {
let csrf = serde_json::to_string(csrf_token).unwrap_or_else(|_| "\"\"".into());
let csrf = json_string(csrf_token);
let script = r#"(function() {
var csrf = __CSRF__;
function bp() {
@@ -30,12 +30,6 @@ pub fn user_profile_badges(
tooltip: "Fluxer Staff".into(),
});
}
if !is_self_hosted && flags & user_flag_bits::CTP_MEMBER != 0 {
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/ctp.svg"),
tooltip: "Fluxer Community Team".into(),
});
}
if !is_self_hosted && flags & user_flag_bits::PARTNER != 0 {
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/partner.svg"),
@@ -57,6 +57,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
acl::BULK_DELETE_USER_MESSAGES,
]
),
],
@@ -51,10 +51,6 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
name: "STAFF",
value: 1 << 0,
},
UserFlag {
name: "CTP_MEMBER",
value: 1 << 1,
},
UserFlag {
name: "PARTNER",
value: 1 << 2,
@@ -205,6 +201,9 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
(bulk_schedule_deletion_section(base, csrf_token))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
(bulk_delete_user_messages_section(base, csrf_token))
}
}
};
admin_layout(config, auth, "Bulk Actions", "bulk-actions", None, content)
@@ -388,3 +387,24 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
)
}
fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
section_card_simple(
"Bulk Delete User Messages",
html! {
form method="post" action={(base) "/bulk-actions?action=bulk-delete-user-messages"} {
(csrf_input(csrf_token))
div class="space-y-4" {
p class="text-neutral-500 text-sm" {
"Deletes every message authored by each user across all channels. This cannot be undone."
}
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(form_actions(html! {
(danger_button("Delete All Messages"))
}))
}
}
},
)
}
@@ -1,94 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
pub fn billing_tab(
config: &AdminConfig,
guild_id: &str,
billing: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this guild."
}
}))
}
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=refresh_billing"}
class="block" {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Refresh Billing Data"))
}))
}
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
input type="text" name="reason" placeholder="Reason (optional)"
class="block w-full rounded-md border border-neutral-300 px-3 \
py-2 text-sm shadow-sm focus:border-brand-primary \
focus:outline-none focus:ring-1 focus:ring-brand-primary";
(form_actions(html! {
(danger_button("Cancel Subscription"))
}))
}
}
}
}))
}
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("none");
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription Status", html! {
span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs \
font-medium bg-neutral-100 text-neutral-700" {
(sub_status)
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
@@ -3,7 +3,6 @@
pub mod applications;
pub mod archives;
pub mod audit_log;
pub mod billing;
pub mod emojis;
pub mod features;
pub mod members;
@@ -25,8 +25,8 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
(select_input("status", "Status", &[
("", "Any"), ("queued", "Queued"), ("running", "Running"),
("succeeded", "Succeeded"), ("failed", "Failed"),
("cancelled", "Cancelled"), ("deadletter", "Dead-letter"),
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
("deadletter", "Dead-letter"),
], p.status_filter))
div class="flex flex-col gap-2" {
label for="task_type" class=(FORM_LABEL_CLASS) { "Task type" }
@@ -22,7 +22,6 @@ use maud::{Markup, html};
pub const USER_TABS: &[(&str, &str)] = &[
("overview", "Overview"),
("account", "Account"),
("billing", "Billing"),
("guilds", "Guilds"),
("dm_history", "DM History"),
("group_dms", "Group DMs"),
@@ -164,21 +163,10 @@ fn render_user_detail(
}
}
fn user_tab_visible(config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
fn user_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
match tab_id {
"overview" | "account" | "guilds" | "dm_history" | "group_dms" | "reports"
| "moderation" => true,
"billing" => {
!config.self_hosted
&& acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
}
"relationships" => acl::has_permission(admin_acls, acl::USER_LIST_RELATIONSHIPS),
"applications" => acl::has_permission(admin_acls, acl::APPLICATION_LIST_BY_OWNER),
"archives" => acl::has_any_permission(
@@ -1,410 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
const INPUT_CLS: &str = "block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
focus:ring-brand-primary";
pub fn billing_tab(
config: &AdminConfig,
user_id: &str,
billing: Option<&serde_json::Value>,
invoices: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
(render_subscription(data))
(render_payment_methods(data))
(render_payments(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this user."
}
}))
}
@if let Some(data) = invoices {
(render_invoices(data))
}
(render_actions(base, user_id, csrf_token))
}
}
}
fn subscription_badge_variant(status: &str) -> BadgeVariant {
match status {
"active" | "trialing" => BadgeVariant::Success,
"past_due" | "unpaid" | "incomplete" => BadgeVariant::Warning,
"canceled" | "incomplete_expired" => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str());
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription", html! {
@if let Some(status) = sub_status {
(badge(status, subscription_badge_variant(status)))
} @else {
span class="text-sm text-neutral-900" { "none" }
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
fn render_subscription(data: &serde_json::Value) -> Markup {
let sub = match data.get("subscription") {
Some(s) if !s.is_null() => s,
_ => return html! {},
};
let status = sub
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let sub_id = sub.get("id").and_then(|v| v.as_str());
let plan_interval = sub.get("plan_interval").and_then(|v| v.as_str());
let period_start = sub.get("current_period_start").and_then(|v| v.as_str());
let period_end = sub.get("current_period_end").and_then(|v| v.as_str());
let cancel_at_period_end = sub
.get("cancel_at_period_end")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Subscription", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Status", html! {
(badge(status, subscription_badge_variant(status)))
}))
@if let Some(id) = sub_id {
(detail_row("ID", html! {
span class="text-xs" { (id) }
}))
}
@if let Some(interval) = plan_interval {
(detail_row("Plan Interval", html! { (interval) }))
}
@if let Some(start) = period_start {
(detail_row("Period Start", html! { (start) }))
}
@if let Some(end) = period_end {
(detail_row("Period End", html! { (end) }))
}
(detail_row("Cancel at Period End", html! {
@if cancel_at_period_end { "yes" } @else { "no" }
}))
}
}))
}
}
fn render_payment_methods(data: &serde_json::Value) -> Markup {
let methods = data.get("payment_methods").and_then(|v| v.as_array());
let empty = methods.is_none() || methods.is_some_and(|m| m.is_empty());
html! {
(card_with_header("Payment Methods", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payment methods on file." }
} @else if let Some(pms) = methods {
div class="space-y-3" {
@for pm in pms {
@let pm_type = pm.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
@let brand = pm.get("card_brand").and_then(|v| v.as_str());
@let last4 = pm.get("card_last4").and_then(|v| v.as_str());
@let pm_id = pm.get("id").and_then(|v| v.as_str()).unwrap_or("");
@let display = match (brand, last4) {
(Some(b), Some(l)) => format!("{b} **** {l}"),
_ => pm_type.to_string(),
};
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3" {
p class="text-sm text-neutral-900" { (display) }
p class="text-xs text-neutral-500" { (pm_id) }
}
}
}
}
}))
}
}
fn render_payments(data: &serde_json::Value) -> Markup {
let payments = data.get("payments").and_then(|v| v.as_array());
let empty = payments.is_none() || payments.is_some_and(|p| p.is_empty());
html! {
(card_with_header("Payments", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payments recorded." }
} @else if let Some(ps) = payments {
div class="space-y-3" {
@for p in ps { (payment_row(p)) }
}
}
}))
}
}
fn payment_row(p: &serde_json::Value) -> Markup {
let amount = p.get("amount_cents").and_then(|v| v.as_i64()).unwrap_or(0);
let currency = p.get("currency").and_then(|v| v.as_str()).unwrap_or("");
let status = p
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let created = p.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
let display_amount = format!("{:.2} {}", amount as f64 / 100.0, currency.to_uppercase());
let variant = match status {
"completed" | "succeeded" => BadgeVariant::Success,
"pending" | "processing" => BadgeVariant::Info,
"failed" | "canceled" => BadgeVariant::Danger,
"refunded" | "partially_refunded" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
};
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="flex items-center justify-between" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status, variant))
}
span class="text-xs text-neutral-500" { (created) }
}
}
}
}
fn invoice_badge_variant(status: Option<&str>) -> BadgeVariant {
match status {
Some("paid") => BadgeVariant::Success,
Some("open" | "draft") => BadgeVariant::Info,
Some("uncollectible" | "void") => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_invoices(data: &serde_json::Value) -> Markup {
let invoices = data.get("invoices").and_then(|v| v.as_array());
let empty = invoices.is_none() || invoices.is_some_and(|i| i.is_empty());
let has_more = data
.get("has_more")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Invoices", html! {
@if empty {
p class="text-sm text-neutral-500" { "No invoices on file." }
} @else if let Some(items) = invoices {
div class="space-y-3" {
@for invoice in items {
(invoice_row(invoice))
}
@if has_more {
p class="text-xs text-neutral-500" {
"More invoices exist beyond this list."
}
}
}
}
}))
}
}
fn invoice_row(invoice: &serde_json::Value) -> Markup {
let amount = invoice
.get("amount_paid")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let currency = invoice
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = invoice.get("status").and_then(|v| v.as_str());
let created = invoice
.get("created")
.and_then(|v| v.as_i64())
.map(format_unix_timestamp)
.unwrap_or_default();
let display_amount = format_amount(amount, currency);
let status_label = status.unwrap_or("unknown");
let billing_reason = invoice.get("billing_reason").and_then(|v| v.as_str());
let invoice_id = invoice.get("id").and_then(|v| v.as_str()).unwrap_or("");
let subscription_id = invoice.get("subscription_id").and_then(|v| v.as_str());
let payment_intent_id = invoice.get("payment_intent_id").and_then(|v| v.as_str());
let charge_id = invoice.get("charge_id").and_then(|v| v.as_str());
let hosted_invoice_url = invoice.get("hosted_invoice_url").and_then(|v| v.as_str());
let invoice_pdf = invoice.get("invoice_pdf").and_then(|v| v.as_str());
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="space-y-3" {
div class="flex items-center justify-between gap-3" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status_label, invoice_badge_variant(status)))
}
span class="text-xs text-neutral-500" { (created) }
}
@if let Some(reason) = billing_reason {
p class="text-sm text-neutral-500" { (reason) }
}
dl class="space-y-1" {
(compact_detail_row("id", invoice_id))
@if let Some(id) = subscription_id {
(compact_detail_row("subscription", id))
}
@if let Some(id) = payment_intent_id {
(compact_detail_row("payment_intent", id))
}
@if let Some(id) = charge_id {
(compact_detail_row("charge", id))
}
}
@if hosted_invoice_url.is_some() || invoice_pdf.is_some() {
div class="flex items-center gap-3 text-sm" {
@if let Some(url) = hosted_invoice_url {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"View"
}
}
@if let Some(url) = invoice_pdf {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"PDF"
}
}
}
}
}
}
}
}
fn compact_detail_row(label: &str, value: &str) -> Markup {
html! {
div class="grid grid-cols-1 gap-1 text-xs sm:grid-cols-3" {
dt class="text-neutral-500" { (label) }
dd class="break-all text-neutral-700 sm:col-span-2" { (value) }
}
}
}
fn format_amount(amount_minor: i64, currency: &str) -> String {
let code = currency.trim().to_uppercase();
if code.is_empty() {
format!("{:.2}", amount_minor as f64 / 100.0)
} else {
format!("{:.2} {code}", amount_minor as f64 / 100.0)
}
}
fn format_unix_timestamp(value: i64) -> String {
time::OffsetDateTime::from_unix_timestamp(value)
.ok()
.and_then(|ts| {
ts.format(&time::format_description::well_known::Rfc3339)
.ok()
})
.unwrap_or_else(|| value.to_string())
}
fn render_actions(base: &str, user_id: &str, csrf_token: &str) -> Markup {
html! {
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription_now"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel subscription immediately, no refund."
}
input type="text" name="reason" placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Cancel Now"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel at renewal (access until period end)."
}
(form_actions(html! {
(submit_button("Cancel at Renewal"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=refund_payment"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm font-medium text-neutral-700" {
"Manual Refund"
}
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
input type="text" name="payment_intent_id"
placeholder="pi_..." required
class=(INPUT_CLS);
input type="number" name="amount_cents" min="1"
placeholder="Amount cents (blank = full)"
class=(INPUT_CLS);
}
input type="text" name="reason"
placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Refund"))
}))
}
}
}
}))
}
}
@@ -5,7 +5,6 @@ use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
pub mod account;
pub mod applications;
pub mod archives;
pub mod billing;
pub mod dm_history;
pub mod group_dm;
pub mod guilds;
+267
View File
@@ -0,0 +1,267 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
Json, Router,
body::{Body, to_bytes},
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
const ADMIN_ORIGIN: &str = "https://admin.example.test";
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
struct TestApp {
router: Router,
session_cookie: String,
}
#[tokio::test]
async fn clean_browser_can_submit_an_action() {
let app = setup().await;
let cookie = app.session_cookie.clone();
let (cookie_token, page_token) = load_page(&app, &cookie).await;
assert_eq!(cookie_token, page_token);
let with_csrf = format!("{cookie}; __Host-csrf_token={cookie_token}");
let status = submit_action(&app, &with_csrf, &page_token).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn legacy_csrf_cookie_does_not_wedge_actions() {
let app = setup().await;
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
let (cookie_token, page_token) = load_page(&app, &stale).await;
assert_eq!(cookie_token, page_token);
let both = format!("{stale}; __Host-csrf_token={cookie_token}");
let status = submit_action(&app, &both, &page_token).await;
assert_eq!(
status,
StatusCode::OK,
"a leftover unsigned csrf_token cookie must not block actions"
);
}
#[tokio::test]
async fn production_responses_expire_the_legacy_csrf_cookie() {
let app = setup().await;
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
let headers = page_headers(&app, &stale).await;
let expiry = headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find(|value| value.starts_with("csrf_token=;"))
.unwrap_or_else(|| panic!("legacy cookie was not expired: {headers:?}"));
assert!(expiry.contains("Max-Age=0"), "{expiry}");
assert!(expiry.contains("Path=/"), "{expiry}");
}
async fn setup() -> TestApp {
let api_endpoint = spawn_mock_api().await;
let router = build_router(production_config(api_endpoint));
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
}
}
fn production_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Production,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: ADMIN_ORIGIN.to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
async fn page_headers(app: &TestApp, cookie: &str) -> HeaderMap {
app.router
.clone()
.oneshot(page_request(cookie))
.await
.unwrap()
.headers()
.clone()
}
async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
let response = app
.router
.clone()
.oneshot(page_request(cookie))
.await
.unwrap();
let status = response.status();
let headers = response.headers().clone();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(status, StatusCode::OK, "{text}");
let cookie_token = host_csrf_cookie(&headers)
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
(cookie_token, page_token)
}
fn page_request(cookie: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri("/admin-api-keys")
.header(header::COOKIE, cookie)
.header("sec-fetch-site", "same-origin")
.body(Body::empty())
.unwrap()
}
async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusCode {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/admin-api-keys?action=create")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.header(header::ORIGIN, ADMIN_ORIGIN)
.header("sec-fetch-site", "same-origin")
.header("HX-Request", "true")
.header("HX-Boosted", "true")
.header("HX-Target", "body")
.body(Body::from(format!(
"_csrf={form_token}&name=Legacy+Cookie+Key&acls=*"
)))
.unwrap(),
)
.await
.unwrap();
response.status()
}
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
value
.split(';')
.next()
.and_then(|pair| pair.trim().strip_prefix("__Host-csrf_token="))
.map(str::to_owned)
})
}
fn form_csrf_value(body: &str) -> Option<String> {
let marker = r#"name="_csrf" value=""#;
body.match_indices(marker)
.filter_map(|(index, _)| {
let rest = &body[index + marker.len()..];
let end = rest.find('"')?;
Some(rest[..end].to_owned())
})
.find(|value| !value.is_empty())
}
async fn spawn_mock_api() -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(mock_api))
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(method: Method, uri: Uri) -> Response {
match (method, uri.path()) {
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
(Method::POST, "/admin/api-keys") => Json(json!({
"key_id": "1900000000000000001",
"key": "fa_1900000000000000001_OneTimeSecretForTests",
"name": "Legacy Cookie Key",
"created_at": "2026-07-10T15:00:00.000Z",
"expires_at": null,
"acls": ["*"]
}))
.into_response(),
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
fn admin_user() -> Value {
json!({
"id": "1500000000000000000",
"username": "AdminUser",
"discriminator": 1,
"avatar": null,
"banner": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"global_name": "AdminUser",
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-US",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": false,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
+52 -28
View File
@@ -218,6 +218,16 @@ async fn user_fragment_alias_returns_drawer_fragment() {
assert!(fragment.contains("SearchedUser"), "{fragment}");
}
#[tokio::test]
async fn user_peek_alias_is_gone() {
let app = setup().await;
assert_eq!(
get_status(&app, "/users/1500000000000000001/peek").await,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn drawer_triggers_use_htmx_and_native_popover() {
let app = setup().await;
@@ -644,6 +654,23 @@ async fn get(app: &TestApp, uri: &str, headers: &[(&str, &str)]) -> String {
get_with_headers(app, uri, headers).await.1
}
async fn get_status(app: &TestApp, uri: &str) -> StatusCode {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(uri)
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response.status()
}
async fn get_with_headers(
app: &TestApp,
uri: &str,
@@ -708,11 +735,11 @@ fn csrf_cookie(headers: &HeaderMap) -> Option<String> {
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
value
.split(';')
.next()
.and_then(|pair| pair.strip_prefix("csrf_token="))
.map(str::to_owned)
let pair = value.split(';').next()?;
let token = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="))?;
(!token.is_empty()).then(|| token.to_owned())
})
}
@@ -752,8 +779,9 @@ async fn spawn_mock_api() -> String {
}
async fn mock_api(method: Method, uri: Uri) -> Response {
match (method, uri.path()) {
(Method::GET, "/admin/users/me") => json_response(json!({ "user": admin_user() })),
let path = uri.path().to_owned();
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
(Method::POST, "/admin/api-keys") => json_response(json!({
"key_id": "1900000000000000001",
@@ -763,60 +791,56 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
"expires_at": null,
"acls": ["*"]
})),
(Method::POST, "/admin/users/search") => {
(Method::GET, "/admin/users") => {
json_response(json!({ "users": [searched_user()], "total": 1 }))
}
(Method::POST, "/admin/users/lookup") => {
(Method::GET, "/admin/users/1500000000000000001") => {
json_response(json!({ "users": [searched_user()] }))
}
(Method::POST, "/admin/users/update-has-verified-phone") => {
(Method::PUT, "/admin/users/1500000000000000001/phone-verification") => {
json_response(json!({ "user": searched_user() }))
}
(Method::POST, "/admin/guilds/search") => {
(Method::GET, "/admin/guilds") => {
json_response(json!({ "guilds": [searched_guild()], "total": 1 }))
}
(Method::POST, "/admin/guilds/lookup") => {
(Method::GET, "/admin/guilds/1600000000000000001") => {
json_response(json!({ "guild": searched_guild_detail() }))
}
(Method::POST, "/admin/applications/lookup") => {
json_response(json!({ "application": searched_application() }))
}
(Method::POST, "/admin/applications/list-by-owner") => {
(Method::GET, "/admin/applications") => {
json_response(json!({ "applications": [searched_application()] }))
}
(Method::POST, "/admin/reports/search") => json_response(
(Method::GET, "/admin/reports") => json_response(
json!({ "reports": [searched_report()], "total": 1, "offset": 0, "limit": 25 }),
),
(Method::GET, "/admin/reports/1800000000000000001") => json_response(searched_report()),
(Method::GET, "/admin/reports/1800000000000000002") => {
json_response(searched_message_report())
}
(Method::POST, "/admin/reports/resolve") => json_response(json!({
(Method::PATCH, "/admin/reports/1800000000000000001") => json_response(json!({
"report_id": "1800000000000000001",
"status": 1,
"resolved_at": "2026-05-26T12:03:00.000Z",
"public_comment": "done"
})),
(Method::POST, "/admin/jobs/list") => {
(Method::GET, "/admin/jobs") => {
json_response(json!({ "jobs": [searched_job()], "next_cursor": null, "cursor": null }))
}
(Method::POST, "/admin/jobs/get") => json_response(json!({ "job": searched_job() })),
(Method::POST, "/admin/instance-config/get") => json_response(instance_config()),
(Method::POST, "/admin/instance-config/registration-urls/create") => json_response(json!({
(Method::GET, "/admin/jobs/1900000000000000001") => {
json_response(json!({ "job": searched_job() }))
}
(Method::GET, "/admin/instance/config") => json_response(instance_config()),
(Method::POST, "/admin/instance/registration-urls") => json_response(json!({
"registration_url": registration_url_fixture(),
"code": "11111111-1111-4111-8111-111111111111",
"url": "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111"
})),
(Method::POST, "/admin/instance-config/registration-urls/revoke") => {
(Method::DELETE, path) if path.starts_with("/admin/instance/registration-urls/") => {
json_response(instance_config_without_registration_urls())
}
(Method::POST, "/admin/instance-config/pending-registrations/approve") => {
(Method::PATCH, path) if path.starts_with("/admin/instance/pending-registrations/") => {
json_response(instance_config_without_pending_registrations())
}
(Method::POST, "/admin/instance-config/pending-registrations/reject") => {
json_response(instance_config_without_pending_registrations())
}
(Method::POST, "/admin/limit-config/get") => json_response(limit_config()),
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
@@ -2,7 +2,7 @@
"routes": [
{
"method": "GET",
"path": "/admin/users/me",
"path": "/admin/users/@me",
"body_file": "admin_user_me.json"
},
{
@@ -21,28 +21,28 @@
"body": "{}"
},
{
"method": "POST",
"path": "/admin/users/search",
"method": "GET",
"path": "/admin/users",
"body_file": "search_users.json"
},
{
"method": "POST",
"path": "/admin/users/lookup",
"method": "GET",
"path": "/admin/users/1508576042312688531",
"body_file": "lookup_user.json"
},
{
"method": "POST",
"path": "/admin/guilds/search",
"method": "GET",
"path": "/admin/guilds",
"body_file": "search_guilds.json"
},
{
"method": "POST",
"path": "/admin/guilds/lookup",
"method": "GET",
"path": "/admin/guilds/1600000000000000001",
"body_file": "lookup_guild.json"
},
{
"method": "POST",
"path": "/admin/reports/search",
"method": "GET",
"path": "/admin/reports",
"body_file": "search_reports.json"
},
{
+19 -3
View File
@@ -23,11 +23,26 @@ COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @fluxer/config run --if-present generate
RUN pnpm deploy --legacy --filter=fluxer_api --prod /out
RUN pnpm --filter fluxer_api run build
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
FROM node:24-bookworm-slim
ARG BUILD_VERSION
ARG SOURCE_SHA
ARG SOURCE_DATE
LABEL org.opencontainers.image.title="fluxer-api"
LABEL org.opencontainers.image.description="Fluxer HTTP API and background workers"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.vendor="Fluxer"
LABEL org.opencontainers.image.url="https://fluxer.app"
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/src/app/fluxer_api
@@ -48,6 +63,7 @@ RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/s
RUN corepack enable && corepack prepare [email protected] --activate
COPY --from=deploy /out ./
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
COPY --from=deploy /usr/src/app/tsconfigs /usr/src/app/tsconfigs
RUN rm -rf pkgs && \
@@ -57,7 +73,7 @@ RUN rm -rf pkgs && \
ENV HOME=/usr/src/app
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
ENV NODE_ENV=production
ENV NODE_OPTIONS="--max-old-space-size=2048"
ENV NODE_OPTIONS="--enable-source-maps"
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
ENV BUILD_VERSION=${BUILD_VERSION}
@@ -65,4 +81,4 @@ USER 65532:65532
EXPOSE 8080
CMD ["./node_modules/.bin/tsx", "src/AppEntrypoint.ts"]
CMD ["node", "dist/AppEntrypoint.js"]
+15 -2
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"test": "vitest run",
"typecheck": "tsgo --noEmit",
"dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts",
@@ -17,6 +18,7 @@
"@bluesky-social/jwk-jose": "catalog:",
"@bluesky-social/oauth-client-node": "catalog:",
"@bufbuild/protobuf": "^2.12.0",
"@elastic/elasticsearch": "catalog:",
"@fluxer/config": "workspace:*",
"@fluxer/constants": "workspace:*",
"@fluxer/date_utils": "workspace:*",
@@ -29,6 +31,9 @@
"@fluxer/logger": "workspace:*",
"@fluxer/schema": "workspace:*",
"@fluxer/snowflake": "workspace:*",
"@hono/node-server": "catalog:",
"@messageformat/core": "catalog:",
"@messageformat/parser": "catalog:",
"@pkgs/cache": "workspace:*",
"@pkgs/captcha": "workspace:*",
"@pkgs/cassandra": "workspace:*",
@@ -49,35 +54,43 @@
"@pkgs/worker": "workspace:*",
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
"cassandra-driver": "catalog:",
"emoji-regex": "catalog:",
"fast-xml-parser": "catalog:",
"hi-base32": "catalog:",
"hono": "catalog:",
"html-entities": "catalog:",
"idna-uts46-hx": "catalog:",
"ioredis": "catalog:",
"itty-time": "catalog:",
"jose": "catalog:",
"livekit-server-sdk": "catalog:",
"lodash": "catalog:",
"luxon": "catalog:",
"maxmind": "catalog:",
"mime": "catalog:",
"nats": "catalog:",
"nodemailer": "catalog:",
"pg": "catalog:",
"pino": "catalog:",
"sharp": "catalog:",
"stripe": "catalog:",
"tempy": "catalog:",
"transliteration": "catalog:",
"tsx": "catalog:",
"uint8array-extras": "catalog:",
"undici": "catalog:",
"validator": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/archiver": "catalog:",
"@types/lodash": "catalog:",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
+5 -1
View File
@@ -7,6 +7,8 @@
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
@@ -14,6 +16,8 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+9 -3
View File
@@ -1,20 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CacheLogger} from '@pkgs/cache/src/CacheProviderTypes';
import type {CacheLookupResult} from '@pkgs/cache/src/ICacheService';
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
export function parseCachedValue<T>(value: string, logger?: CacheLogger): CacheLookupResult<T> {
try {
return JSON.parse(value);
return {hit: true, value: JSON.parse(value)};
} catch (error) {
if (logger) {
const truncatedValue = value.length > 200 ? `${value.substring(0, 200)}...` : value;
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({errorMessage, value: truncatedValue}, '[CacheProvider] JSON parse error');
}
return null;
return {hit: false};
}
}
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
const parsed = parseCachedValue<T>(value, logger);
return parsed.hit ? parsed.value : null;
}
export function serializeValue<T>(value: T): string {
return JSON.stringify(value);
}
+160 -9
View File
@@ -1,17 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const CACHE_INFLIGHT_MAX_ENTRIES = 10000;
const CACHE_INFLIGHT_JOIN_RETRIES = 1;
const CACHE_PRODUCE_TIMEOUT_MS = 15000;
const CACHE_PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
interface CacheMSetEntry<T> {
key: string;
value: T;
ttlSeconds?: number;
}
interface CacheProduceTracking {
generation: number;
produces: number;
}
interface CacheProduceAbandonment {
abandoned: boolean;
}
export type CacheLookupResult<T> = {hit: true; value: T} | {hit: false};
type CacheTtlSeconds<T> = number | ((value: T) => number);
type CacheJoinResult<T> = {joined: true; value: T} | {joined: false; error: unknown};
export abstract class ICacheService {
abstract get<T>(key: string): Promise<T | null>;
private readonly inflightValues = new Map<string, Promise<unknown>>();
private readonly produceInvalidations = new Map<string, CacheProduceTracking>();
abstract getEntry<T>(key: string): Promise<CacheLookupResult<T>>;
abstract set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
abstract delete(key: string): Promise<void>;
protected abstract deleteEntry(key: string): Promise<void>;
async delete(key: string): Promise<void> {
const tracked = this.produceInvalidations.get(key);
if (tracked) {
tracked.generation += 1;
}
await this.deleteEntry(key);
}
abstract getAndDelete<T>(key: string): Promise<T | null>;
@@ -45,13 +76,133 @@ export abstract class ICacheService {
abstract sismember(key: string, member: string): Promise<boolean>;
async getOrSet<T>(key: string, valueFactory: () => Promise<T>, ttlSeconds?: number): Promise<T> {
const existingValue = await this.get<T>(key);
if (existingValue !== null) {
return existingValue;
async get<T>(key: string): Promise<T | null> {
const entry = await this.getEntry<T>(key);
return entry.hit ? entry.value : null;
}
async getOrSet<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds?: CacheTtlSeconds<T>,
produceTimeoutMs: number = CACHE_PRODUCE_TIMEOUT_MS,
): Promise<T> {
let generation = this.trackProduce(key);
try {
for (let attempt = 0; ; attempt++) {
const existing = await this.getEntry<T>(key);
if (existing.hit) {
return existing.value;
}
const inflight = this.inflightValues.get(key);
if (!inflight) {
return await this.produceSingleFlight(key, valueFactory, ttlSeconds, generation, produceTimeoutMs);
}
const joined = await this.joinInflight<T>(inflight);
if (joined.joined) {
return joined.value;
}
if (attempt >= CACHE_INFLIGHT_JOIN_RETRIES) {
throw joined.error;
}
generation = this.currentGeneration(key);
}
} finally {
this.releaseProduce(key);
}
const newValue = await valueFactory();
await this.set(key, newValue, ttlSeconds);
return newValue;
}
private trackProduce(key: string): number {
const tracked = this.produceInvalidations.get(key);
if (tracked) {
tracked.produces += 1;
return tracked.generation;
}
this.produceInvalidations.set(key, {generation: 0, produces: 1});
return 0;
}
private currentGeneration(key: string): number {
return this.produceInvalidations.get(key)?.generation ?? 0;
}
private releaseProduce(key: string): void {
const tracked = this.produceInvalidations.get(key);
if (!tracked) {
return;
}
tracked.produces -= 1;
if (tracked.produces <= 0) {
this.produceInvalidations.delete(key);
}
}
private async joinInflight<T>(inflight: Promise<unknown>): Promise<CacheJoinResult<T>> {
try {
return {joined: true, value: (await inflight) as T};
} catch (error) {
return {joined: false, error};
}
}
private async produceSingleFlight<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds: CacheTtlSeconds<T> | undefined,
generation: number,
produceTimeoutMs: number,
): Promise<T> {
const abandonment: CacheProduceAbandonment = {abandoned: false};
const produced = this.boundProduce(
this.produceAndStore(key, valueFactory, ttlSeconds, generation, abandonment),
abandonment,
produceTimeoutMs,
);
if (this.inflightValues.size >= CACHE_INFLIGHT_MAX_ENTRIES) {
return await produced;
}
const pending = produced.finally(() => {
this.inflightValues.delete(key);
});
this.inflightValues.set(key, pending);
return await pending;
}
private boundProduce<T>(
produced: Promise<T>,
abandonment: CacheProduceAbandonment,
produceTimeoutMs: number,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
abandonment.abandoned = true;
reject(new Error(CACHE_PRODUCE_TIMEOUT_MESSAGE));
}, produceTimeoutMs);
timer.unref?.();
produced.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
},
);
});
}
private async produceAndStore<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds: CacheTtlSeconds<T> | undefined,
generation: number,
abandonment: CacheProduceAbandonment,
): Promise<T> {
const value = await valueFactory();
if (!abandonment.abandoned && this.currentGeneration(key) === generation) {
await this.set(key, value, typeof ttlSeconds === 'function' ? ttlSeconds(value) : ttlSeconds);
}
return value;
}
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
function createRecordingProvider(): {
client: IKVProvider;
commands: Array<Array<string>>;
} {
const commands: Array<Array<string>> = [];
const client = {
set: async (key: string) => {
commands.push([key]);
return 'OK';
},
setex: async (key: string) => {
commands.push([key]);
},
isClustered: () => true,
pipeline: () => {
const keys: Array<string> = [];
commands.push(keys);
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => [],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return {client, commands};
}
describe('KVCacheProvider cluster hash slots', () => {
it('keeps a multi entry write off batched commands that span hash slots', async () => {
const {client, commands} = createRecordingProvider();
const provider = new KVCacheProvider({client});
expect(computeHashSlot('cache:alpha')).not.toBe(computeHashSlot('cache:beta'));
await provider.mset([
{key: 'cache:alpha', value: 1, ttlSeconds: 60},
{key: 'cache:beta', value: 2},
]);
expect(commands.flat().sort()).toEqual(['cache:alpha', 'cache:beta']);
expect(commands.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
const MAX_CONCURRENT_ROUND_TRIPS = 16;
interface RecordingProvider {
client: IKVProvider;
batches: Array<Array<string>>;
peakInFlight: number;
}
function createRecordingProvider(clustered: boolean): RecordingProvider {
const recorder: RecordingProvider = {
client: {} as IKVProvider,
batches: [],
peakInFlight: 0,
};
let inFlight = 0;
const trackRoundTrip = async (keys: Array<string>): Promise<void> => {
recorder.batches.push(keys);
inFlight += 1;
recorder.peakInFlight = Math.max(recorder.peakInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 0));
inFlight -= 1;
};
recorder.client = {
isClustered: () => clustered,
set: async (key: string) => {
await trackRoundTrip([key]);
return 'OK';
},
setex: async (key: string) => {
await trackRoundTrip([key]);
},
pipeline: () => {
const keys: Array<string> = [];
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => {
await trackRoundTrip(keys);
return [];
},
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return recorder;
}
function createEntries(count: number): Array<{key: string; value: number; ttlSeconds: number}> {
return Array.from({length: count}, (_unused, index) => ({
key: `cache:entry:${index}`,
value: index,
ttlSeconds: 60,
}));
}
describe('KVCacheProvider multi entry write fan out', () => {
it('writes every entry in one round trip outside cluster mode', async () => {
const recorder = createRecordingProvider(false);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.batches.map((keys) => keys.length)).toEqual([1000]);
expect(recorder.peakInFlight).toBe(1);
});
it('surfaces a failed command inside a batched write', async () => {
const client = {
isClustered: () => false,
pipeline: () => {
const batch = {
set: () => batch,
setex: () => batch,
exec: async () => [[new Error('write rejected'), null]],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
const provider = new KVCacheProvider({client});
await expect(provider.mset(createEntries(2))).rejects.toThrow('write rejected');
});
it('bounds concurrent round trips when entries span hash slots', async () => {
const recorder = createRecordingProvider(true);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
expect(recorder.batches.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
expect(recorder.batches.flat().length).toBe(1000);
});
});
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {describe, expect, it, vi} from 'vitest';
function createKVCacheProvider(): {
provider: KVCacheProvider;
store: Map<string, string>;
ttls: Array<[string, number]>;
} {
const store = new Map<string, string>();
const ttls: Array<[string, number]> = [];
const client = {
get: async (key: string) => store.get(key) ?? null,
set: async (key: string, value: string) => {
store.set(key, value);
return 'OK';
},
setex: async (key: string, ttlSeconds: number, value: string) => {
ttls.push([key, ttlSeconds]);
store.set(key, value);
},
} as unknown as IKVProvider;
return {provider: new KVCacheProvider({client}), store, ttls};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('ICacheService.getOrSet', () => {
it('runs the factory once for concurrent callers on the same key', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => {
await delay(10);
return 7;
});
const results = await Promise.all([
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
]);
expect(results).toEqual([7, 7, 7]);
expect(factory).toHaveBeenCalledTimes(1);
await expect(cache.get('key')).resolves.toBe(7);
});
it('does not coalesce concurrent callers on different keys', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => {
await delay(10);
return 1;
});
await Promise.all([cache.getOrSet('a', factory), cache.getOrSet('b', factory)]);
expect(factory).toHaveBeenCalledTimes(2);
});
it('caches a null factory result and serves it as a hit', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => null);
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(factory).toHaveBeenCalledTimes(1);
});
it('serves a stored json null from the kv provider as a hit', async () => {
const {provider, store} = createKVCacheProvider();
const factory = vi.fn(async () => null);
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(store.get('key')).toBe('null');
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(factory).toHaveBeenCalledTimes(1);
});
it('treats an unparseable stored value as a miss', async () => {
const {provider, store} = createKVCacheProvider();
store.set('key', '{not json');
const factory = vi.fn(async () => 3);
await expect(provider.getOrSet('key', factory, 60)).resolves.toBe(3);
expect(factory).toHaveBeenCalledTimes(1);
});
it('resolves the ttl from the produced value', async () => {
const {provider, store, ttls} = createKVCacheProvider();
const resolver = (value: number | null) => (value === null ? 5 : 30);
await expect(provider.getOrSet<number | null>('present', async () => 1, resolver)).resolves.toBe(1);
await expect(provider.getOrSet<number | null>('absent', async () => null, resolver)).resolves.toBeNull();
expect(ttls).toEqual([
['present', 30],
['absent', 5],
]);
expect(store.get('absent')).toBe('null');
});
it('rejects every waiter after a single coalesced retry when the factory keeps failing', async () => {
const cache = new InMemoryProvider();
const failing = vi.fn(async () => {
await delay(10);
throw new Error('factory failed');
});
const settled = await Promise.allSettled([
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
]);
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected', 'rejected', 'rejected']);
expect(failing).toHaveBeenCalledTimes(2);
await expect(cache.exists('key')).resolves.toBe(false);
const succeeding = vi.fn(async () => 11);
await expect(cache.getOrSet('key', succeeding)).resolves.toBe(11);
expect(succeeding).toHaveBeenCalledTimes(1);
});
it('does not fan a transient producer failure out to the callers that joined it', async () => {
const cache = new InMemoryProvider();
let calls = 0;
const factory = vi.fn(async () => {
calls += 1;
const attempt = calls;
await delay(10);
if (attempt === 1) {
throw new Error('transient failure');
}
return 11;
});
const settled = await Promise.allSettled([
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
]);
expect(settled.map((result) => result.status)).toEqual(['rejected', 'fulfilled', 'fulfilled', 'fulfilled']);
expect(settled.filter((result) => result.status === 'fulfilled').map((result) => result.value)).toEqual([
11, 11, 11,
]);
expect(factory).toHaveBeenCalledTimes(2);
await expect(cache.get('key')).resolves.toBe(11);
});
});
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {describe, expect, it, vi} from 'vitest';
const INFLIGHT_OVERFLOW_ENTRIES = 10000;
const PRODUCE_TIMEOUT_MS = 50;
const PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
function deferred<T>(): {promise: Promise<T>; resolve: (value: T) => void; reject: (error: Error) => void} {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function settleWithin<T>(pending: Promise<T>, ms: number): Promise<T | 'pinned' | 'rejected'> {
return Promise.race([
pending.then(
(value) => value,
() => 'rejected' as const,
),
new Promise<'pinned'>((resolve) => setTimeout(() => resolve('pinned'), ms)),
]);
}
function trackedProduceKeys(cache: InMemoryProvider): Array<string> {
return [...(cache as unknown as {produceInvalidations: Map<string, unknown>}).produceInvalidations.keys()];
}
describe('cache invalidation during an in-flight produce', () => {
it('does not resurrect a value deleted while the factory was running', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await cache.delete('session');
gate.resolve('revoked-session');
await expect(pending).resolves.toBe('revoked-session');
expect(await cache.get('session')).toBeNull();
});
it('still stores the value when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
gate.resolve('live-session');
await pending;
expect(await cache.get('session')).toBe('live-session');
});
it('does not resurrect a value deleted while a retried produce was running', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
expect(gates).toHaveLength(2);
await cache.delete('session');
gates[1].resolve('fresh-after-delete');
await expect(joiner).resolves.toBe('fresh-after-delete');
expect(await cache.get('session')).toBeNull();
});
it('stores the value a retried produce built when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
});
it('stores the value a retried produce built after the first produce was invalidated', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
await cache.delete('session');
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
expect(gates).toHaveLength(2);
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
});
it('does not resurrect a value deleted after a concurrent caller released its produce', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await flush();
await cache.set('session', 'served-from-cache', 30);
await expect(cache.getOrSet('session', async () => 'unused', 30)).resolves.toBe('served-from-cache');
await cache.delete('session');
gate.resolve('stale-produce');
await expect(pending).resolves.toBe('stale-produce');
expect(await cache.get('session')).toBeNull();
});
it('does not resurrect a value deleted while a second overflow produce was running', async () => {
const cache = new InMemoryProvider();
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
const gate = deferred<string>();
fillers.push(gate);
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
}
await flush();
const first = deferred<string>();
const second = deferred<string>();
const firstProduce = cache.getOrSet('session', async () => await first.promise, 30);
const secondProduce = cache.getOrSet('session', async () => await second.promise, 30);
await flush();
first.resolve('first-produce');
await expect(firstProduce).resolves.toBe('first-produce');
await cache.delete('session');
second.resolve('second-produce');
await expect(secondProduce).resolves.toBe('second-produce');
expect(await cache.get('session')).toBeNull();
for (const gate of fillers) {
gate.resolve('filler');
}
await Promise.all(filling);
});
it('drops produce tracking once the last produce for a key settles', async () => {
const cache = new InMemoryProvider();
for (let index = 0; index < 50; index++) {
const gate = deferred<string>();
const pending = cache.getOrSet(`session:${index}`, async () => await gate.promise, 30);
await cache.delete(`session:${index}`);
gate.resolve('value');
await pending;
}
const shared = deferred<string>();
const producer = cache.getOrSet('shared', async () => await shared.promise, 30);
const joiner = cache.getOrSet('shared', async () => 'unused', 30);
await flush();
await cache.delete('shared');
shared.resolve('shared-value');
await Promise.all([producer, joiner]);
const failing = cache.getOrSet(
'failing',
async () => {
throw new Error('produce failed');
},
30,
);
await expect(failing).rejects.toThrow('produce failed');
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('does not pin a key forever when the factory never settles', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pinned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(settleWithin(pinned, 500)).resolves.toBe('rejected');
await expect(pinned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
const recovered = cache.getOrSet('session', async () => 'recovered', 30, PRODUCE_TIMEOUT_MS);
await expect(settleWithin(recovered, 500)).resolves.toBe('recovered');
stuck.resolve('never-settled');
await flush();
expect(await cache.get('session')).toBe('recovered');
});
it('does not store a value produced by a factory that settled after the timeout', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
stuck.resolve('late-produce');
await flush();
expect(await cache.get('session')).toBeNull();
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('retries once for the joiners when the producer times out', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
const joiner = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
await expect(producer).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
await flush();
expect(gates).toHaveLength(2);
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
gates[0].resolve('abandoned-produce');
await flush();
expect(await cache.get('session')).toBe('retried-session');
});
it('releases produce tracking when the factory never settles', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
await flush();
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('stores a sibling produce that succeeded after an overflow produce timed out', async () => {
const cache = new InMemoryProvider();
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
const gate = deferred<string>();
fillers.push(gate);
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
}
await flush();
const stuck = deferred<string>();
const sibling = deferred<string>();
const abandoned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
const succeeding = cache.getOrSet('session', async () => await sibling.promise, 30, PRODUCE_TIMEOUT_MS * 100);
await expect(abandoned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
sibling.resolve('sibling-produce');
await expect(succeeding).resolves.toBe('sibling-produce');
expect(await cache.get('session')).toBe('sibling-produce');
for (const gate of fillers) {
gate.resolve('filler');
}
await Promise.all(filling);
});
it('does not hold the event loop open while a produce is in flight', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const timers: Array<NodeJS.Timeout> = [];
const scheduled = globalThis.setTimeout;
const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number) => {
const timer = scheduled(handler, ms);
if (ms === PRODUCE_TIMEOUT_MS) {
timers.push(timer);
}
return timer;
}) as typeof globalThis.setTimeout);
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await flush();
spy.mockRestore();
expect(timers).toHaveLength(1);
expect(timers[0].hasRef()).toBe(false);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
});
it('keeps a later produce cacheable after an earlier one was invalidated', async () => {
const cache = new InMemoryProvider();
const first = deferred<string>();
const pending = cache.getOrSet('session', async () => await first.promise, 30);
await cache.delete('session');
first.resolve('stale');
await pending;
expect(await cache.get('session')).toBeNull();
await cache.getOrSet('session', async () => 'fresh', 30);
expect(await cache.get('session')).toBe('fresh');
});
});

Some files were not shown because too many files have changed in this diff Show More