Compare commits

...
Author SHA1 Message Date
HampusandGitHub b30a4f5d14 fix(admin): omit synthetic accounts from user lookup and search (#2705) 2026-09-11 22:06:29 +02:00
HampusandGitHub f254ed679b fix(app): never lower the read-state unread watermark (#2704) 2026-09-11 22:02:24 +02:00
HampusandGitHub 258fe6f742 feat(voice): add audio bitrate guild features and 96 kbps cap (#2703) 2026-09-11 22:00:15 +02:00
HampusandGitHub cfa20b7093 fix(admin): let voice restriction lists be cleared (#2701) 2026-09-11 21:28:26 +02:00
HampusandGitHub 569146c5bc fix(app): pick favorite GIF preview kind from content type (#2696) 2026-09-11 21:06:00 +02:00
HampusandGitHub 1b1d48b05e feat(voice): soft connection limits for voice servers (#2694) 2026-09-11 20:05:14 +02:00
HampusandGitHub cadca2c18e test(voice): build watch attempt keys from the shared builder (#2693) 2026-09-11 19:44:34 +02:00
HampusandGitHub 2e3f78b3c6 fix(app): stop restarting the read-state ack batch window (#2689) 2026-09-11 16:26:34 +02:00
HampusandGitHub b57545b1a4 refactor(api): replace stripe mock currency ternary chains (#2688) 2026-09-11 16:02:09 +02:00
HampusandGitHub e490be2f35 feat(app): prompt to delete when clearing a message edit (#2687) 2026-09-11 15:56:05 +02:00
fluxer-ci[bot]andGitHub ab07fd23cf chore(i18n): update public marketing catalogs (#2686) 2026-09-11 15:50:16 +02:00
fluxer-ci[bot]andGitHub c1fd2234b8 chore(marketing): advance pointer 7867cf8 → 23cd1c9 (#2685) 2026-09-11 15:50:05 +02:00
HampusandGitHub 3af43b3366 feat(api): add SEK, DKK and NOK as localized currencies (#2684) 2026-09-11 15:48:56 +02:00
HampusandGitHub 0e470f532e test(voice): rename the watch failure deadline test file (#2683) 2026-09-11 15:18:08 +02:00
HampusandGitHub c541b86c00 fix(voice): show buffering while screen share recovery runs (#2682) 2026-09-11 15:03:21 +02:00
HampusandGitHub 53b3fa2f4a fix(voice): key watch attempts by published track (#2681) 2026-09-11 15:01:14 +02:00
HampusandGitHub 67e01be34a fix(voice): judge H.264 hardware support by negotiated format (#2680) 2026-09-11 14:59:04 +02:00
HampusandGitHub 81fd8c9aad fix(gateway): skip empty dm partner registration casts (#2677) 2026-09-11 13:49:03 +02:00
HampusandGitHub bcef7b3123 feat(app): edit blockquote lines in the composer (#2676) 2026-09-11 13:27:50 +02:00
HampusandGitHub a98d8ef679 fix(app): wrap multiline selections in code blocks (#2675) 2026-09-11 13:22:03 +02:00
HampusandGitHub a5af857564 fix(app): insert a newline on Enter inside code blocks (#2674) 2026-09-11 13:20:26 +02:00
HampusandGitHub 2830221949 fix(desktop): download the version a linux update prompt names (#2673) 2026-09-11 13:19:25 +02:00
HampusandGitHub 84aa8880f5 fix(app): format typed @everyone and @here in the composer (#2672) 2026-09-11 13:18:48 +02:00
HampusandGitHub 395ec1d60f fix(ci): publish desktop update feeds only after the release (#2671) 2026-09-11 13:18:15 +02:00
HampusandGitHub e6ee3b8059 fix(api): only offer desktop builds whose release is published (#2670) 2026-09-11 13:17:41 +02:00
HampusandGitHub 61a13e1c1a fix(app): download the version a linux update prompt names (#2669) 2026-09-11 13:16:27 +02:00
HampusandGitHub fc0e2628a4 fix(app): honour @silent in the message composer (#2668) 2026-09-11 13:16:13 +02:00
HampusandGitHub 87fdfd9c34 fix(app): show DMs opened by an incoming message as unread (#2667) 2026-09-11 13:14:06 +02:00
HampusandGitHub 88a5ff9c45 feat(voice): record watch failures and decode counters (#2666) 2026-09-11 13:05:34 +02:00
HampusandGitHub 320949a79d fix(gateway): drop dead clauses in dm partner visibility (#2665) 2026-09-11 13:00:23 +02:00
HampusandGitHub 3a862f1484 fix(voice): record why a screen share stopped (#2664) 2026-09-11 12:59:51 +02:00
HampusandGitHub 5da256df12 fix(voice): poll the current video element for a first frame (#2663) 2026-09-11 12:57:52 +02:00
HampusandGitHub baf2cbf3fd fix(voice): rebind codec negotiation after a region hot swap (#2662) 2026-09-11 12:55:50 +02:00
HampusandGitHub 74782dc4f2 fix(voice): confirm a decode stall before withdrawing a codec (#2661) 2026-09-11 12:53:29 +02:00
HampusandGitHub 7d8778495f chore(desktop): drop Chromium switches that no longer exist (#2660) 2026-09-11 12:50:54 +02:00
HampusandGitHub 53399ffb44 fix(gateway): track dm partner presence in mutual guilds (#2658) 2026-09-11 04:23:20 +02:00
HampusandGitHub 35d73eae76 fix(voice): stop asking for camera and mic access on page load (#2657) 2026-09-11 02:00:48 +02:00
HampusandGitHub 54128e049a test(api): restore the stripe webhook secret after mocking it (#2656) 2026-09-11 01:36:26 +02:00
HampusandGitHub 7d8d0ff804 fix(ci): retry release publish after transient GitHub failures (#2655) 2026-09-11 01:35:24 +02:00
HampusandGitHub 2e8f381efc fix(gateway): keep ets tids opaque in the permission cache (#2654) 2026-09-11 01:31:58 +02:00
HampusandGitHub b29da84282 perf(gateway): trim large guild connect snapshots by default (#2653) 2026-09-11 01:03:22 +02:00
HampusandGitHub 2988c846c8 perf(gateway): read cached members from the guild member table (#2652) 2026-09-11 00:58:17 +02:00
fluxer-ci[bot]andGitHub 8e91c1412b chore(marketing): advance pointer 5908507 → 7867cf8 (#2650) 2026-09-11 00:51:33 +02:00
fluxer-ci[bot]andGitHub 5b2099c777 chore(i18n): update public marketing catalogs (#2651) 2026-09-11 00:51:25 +02:00
HampusandGitHub f97841a58f fix(installer): resolve the compose file name Compose loads (#2649) 2026-09-11 00:38:09 +02:00
HampusandGitHub 0421c86039 fix(api): price gifts in the base currency everywhere (#2648) 2026-09-11 00:28:14 +02:00
HampusandGitHub d17f320bd7 fix(app): tidy the Plutonium billing and pricing layout (#2647) 2026-09-10 23:06:18 +02:00
HampusandGitHub f708586c59 feat(api)!: always use localized pricing where it is offered (#2646) 2026-09-10 21:22:32 +02:00
HampusandGitHub 905af5dd5a fix(app): shrink stored favorite gifs and raise their budget (#2643) 2026-09-10 18:43:42 +02:00
HampusandGitHub 5fea319f4e fix(app): stop other youtube embeds when one starts playing (#2642) 2026-09-10 18:42:30 +02:00
HampusandGitHub 01fd11fea9 fix(api): unexport the search lookup result type (#2641) 2026-09-10 18:24:16 +02:00
HampusandGitHub d028679b90 fix(api): batch the message lookups behind message search (#2640) 2026-09-10 18:18:49 +02:00
HampusandGitHub 4a93b677af feat(api): retire prices safely and add a self-serve switch (#2637) 2026-09-10 17:28:16 +02:00
HampusandGitHub d79cd99050 refactor(api): tidy message helper internals (#2636) 2026-09-10 02:07:03 +02:00
HampusandGitHub 167862a8a6 perf(api): harvest messages a page at a time (#2633) 2026-09-09 20:59:38 +02:00
HampusandGitHub 48b569b9d4 fix(api): harvest every authored message, not the first 100000 (#2631) 2026-09-09 11:52:55 +02:00
HampusandGitHub 75be6aa492 fix(gateway): deliver mention updates to passive sessions (#2632) 2026-09-09 11:31:17 +02:00
HampusandGitHub 9fe65d5036 fix(api): honour the configured S3 addressing on uploads (#2626) 2026-09-09 11:26:30 +02:00
HampusandGitHub bfa9bf221d docs(api): tidy up the reference prose (#2628) 2026-09-09 02:11:38 +02:00
HampusandGitHub 184eeb0846 fix(gateway): keep dispatch ordered under broadcaster load (#2627) 2026-09-09 02:06:36 +02:00
HampusandGitHub 0eff26a1c9 test(config): match the configurable client-IP trust defaults (#2625) 2026-09-09 01:32:36 +02:00
HampusandGitHub d729f641ff fix(app): do not crash when the browser translates the page (#2624) 2026-09-09 01:24:14 +02:00
HampusandGitHub 044a2c101d feat(self-hosting): make the bundled services configurable (#2621) 2026-09-09 01:17:58 +02:00
HampusandGitHub 38e2c8db3e fix(admin): keep server traits when an operator saves traits (#2622) 2026-09-09 00:13:07 +02:00
HampusandGitHub 1b22d14f3d feat(self-hosting): run postgres or the object store outside (#2620) 2026-09-08 23:36:52 +02:00
HampusandGitHub 98cceae59d fix(i18n): point static catalog translation at weblate (#2619) 2026-09-08 23:10:10 +02:00
HampusandGitHub 3e32414849 test(config): expand the shipped stack on another port (#2612) 2026-09-08 23:10:00 +02:00
HampusandGitHub 7707b9531c chore(i18n): translate the new setup and email domain strings (#2613) 2026-09-08 22:57:07 +02:00
HampusandHampus Kraft 86745e01e9 docs(operator): cover serving on a non-default port (#2611) 2026-09-08 22:18:19 +02:00
HampusandHampus Kraft 098830a95a fix(admin): compare the request origin against an origin (#2610) 2026-09-08 22:18:10 +02:00
HampusandHampus Kraft cf83f66911 fix(app): keep the new admin when setup meets one 401 (#2609) 2026-09-08 22:18:02 +02:00
HampusandHampus Kraft c577b97f35 fix(api): reject a mail-less email domain by its own code (#2608) 2026-09-08 22:17:53 +02:00
HampusandGitHub 8cc485cf81 fix(self-host): tie the public address to a single origin (#2605) 2026-09-08 22:17:39 +02:00
HampusandGitHub 6c36d934f7 fix(api): widen guild IP ban guard to shared-access networks (#2607) 2026-09-08 22:09:39 +02:00
HampusandGitHub 63e3be5750 fix(media-proxy): tone map HDR video instead of refusing it (#2606) 2026-09-08 21:18:55 +02:00
HampusandGitHub cdecda7f78 ci: exclude the gateway build output from the rebar3 cache (#2604) 2026-09-08 19:47:44 +02:00
HampusandGitHub 4ad2858773 test(api): isolate the instance policy test files (#2603) 2026-09-08 19:46:07 +02:00
HampusandGitHub fda41bb57a style(gateway): apply erlfmt to the voice disconnect modules (#2602) 2026-09-08 19:29:38 +02:00
HampusandGitHub ce08f82a92 refactor(gateway): remove voice reconciliation v3 (#2601) 2026-09-08 19:17:48 +02:00
HampusandGitHub ef067f36c6 fix(voice): report real state in voice diagnostics (#2600) 2026-09-08 19:16:22 +02:00
HampusandGitHub fc2b6b5299 fix(app): correct shortcuts, nagbar, stream menu, share audio (#2599) 2026-09-08 19:09:59 +02:00
HampusandGitHub 55846b24ea fix(api): respect age gating in search and stabilise discovery (#2598) 2026-09-08 19:07:59 +02:00
HampusandGitHub 20a15ac11d fix(voice): scope disconnects, correct VAD and stream lifecycle (#2597) 2026-09-08 19:06:42 +02:00
HampusandGitHub 667ac7da8e fix(voice): rank h264 baseline first and gate opus stereo (#2596) 2026-09-08 19:04:24 +02:00
HampusandGitHub 1b81c14c48 fix(api): stop treating a LiveKit 404 as an empty room (#2595) 2026-09-08 19:02:43 +02:00
HampusandGitHub ceec183d38 docs: remove duplicated statements from the reference (#2594) 2026-09-08 17:55:55 +02:00
HampusandGitHub 69ddc07ebb docs(operator): tighten the get started guide (#2593) 2026-09-08 17:38:29 +02:00
HampusandGitHub 2f008b8653 docs: rewrite reference prose and correct field code citations (#2592) 2026-09-08 17:13:37 +02:00
HampusandGitHub 3d38d3f694 fix(self-host): add FLUXER_NATS_AUTH_TOKEN to .env.example (#2590) 2026-09-08 16:32:29 +02:00
HampusandGitHub ad86a04e67 feat(app): describe every role and channel permission toggle (#2589) 2026-09-08 16:20:37 +02:00
HampusandGitHub 600c15e17d chore(github): drop mobile build hint from the bug report form (#2588) 2026-09-08 15:37:26 +02:00
HampusandGitHub 08c9fe9886 docs: correct misreadable and factually wrong reference prose (#2587) 2026-09-08 15:36:50 +02:00
HampusandGitHub 43924e3ac5 chore(github): link mobile bug reports, drop security duplicate (#2586) 2026-09-08 15:34:34 +02:00
HampusandGitHub 824b5c86c9 fix(api): dedupe and budget ipinfo lookups across api pods (#2584) 2026-09-08 15:13:32 +02:00
HampusandGitHub a2a68847fd fix(api): let channel managers edit a mature channel (#2583) 2026-09-08 14:51:47 +02:00
HampusandGitHub 2019909a5e fix(api): skip the mature gate when no birth date is collected (#2582) 2026-09-08 14:51:41 +02:00
HampusandGitHub d46c8d49c6 fix(svc): authenticate to nats with the configured token (#2581) 2026-09-08 14:51:34 +02:00
HampusandGitHub 45530ebbf5 docs(operator): drop the redundant caddy forwarded-for setter (#2580) 2026-09-08 14:51:29 +02:00
HampusandGitHub 9cdad046b1 fix(self-host): keep seaweedfs inside its memory ceiling (#2579) 2026-09-08 14:51:24 +02:00
HampusandGitHub b6c6928073 fix(self-host): strip the caddy file capability in fluxer-static (#2578) 2026-09-08 14:51:19 +02:00
HampusandGitHub dd1ee999a4 fix(docs): drop visible pipe escapes from union notation prose (#2577) 2026-09-08 14:27:41 +02:00
HampusandGitHub c506d6d5e3 fix(webhook): stop gating webhook file uploads on creator perms (#2576) 2026-09-08 14:25:59 +02:00
HampusandGitHub 8a65832a65 feat(theme): default new accounts to the dark theme (#2575) 2026-09-08 14:05:10 +02:00
HampusandGitHub fd6ae4abd7 fix(app): distrust windows loaded across a connection gap (#2574) 2026-09-08 13:06:53 +02:00
HampusandGitHub 24b84c419c docs(http-api): reword the supplementary members paragraph (#2573) 2026-09-08 12:53:43 +02:00
HampusandGitHub 746a75187a fix(api): snapshot the new message id when opening a closed DM (#2569) 2026-09-07 11:00:03 +02:00
HampusandGitHub 10ba2ca896 fix(app): show the format toolbar on double-click selections (#2566) 2026-09-07 00:13:30 +02:00
HampusandGitHub 977b6767cd fix(app): refetch the tail when a channel window falls behind (#2565) 2026-09-06 23:51:08 +02:00
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
2294 changed files with 186554 additions and 83753 deletions
-2
View File
@@ -243,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
@@ -351,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
+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
+1 -2
View File
@@ -36,8 +36,7 @@ body:
label: Build information
description: >-
Open User Settings, scroll to the bottom of the left sidebar, and select
the build information. Fluxer copies it to the clipboard. On mobile,
select the build information at the bottom of the settings list.
the build information. Fluxer copies it to the clipboard.
validations:
required: true
+3 -3
View File
@@ -1,15 +1,15 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
blank_issues_enabled: false
contact_links:
- name: Mobile client bugs
url: https://github.com/fluxerapp/flutter_client#bug-reporting
about: Read the reporting instructions for the Fluxer mobile client.
- name: Account and billing support
url: https://fluxer.app/help
about: Find account help and support contact details.
- name: Feature proposals
url: https://github.com/orgs/fluxerapp/discussions
about: Propose a feature in a discussion.
- name: Security vulnerabilities
url: https://github.com/fluxerapp/fluxer/security/advisories/new
about: Submit a private vulnerability report.
- name: Translations
url: https://weblate.fluxer.tools
about: Improve an existing locale or start a new one.
+8
View File
@@ -525,6 +525,7 @@ jobs:
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 }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ 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
@@ -602,7 +603,9 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ 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 }}
@@ -660,3 +663,8 @@ jobs:
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
-1
View File
@@ -33,5 +33,4 @@ jobs:
with:
image: fluxer-docs
dockerfile: fluxer_docs/Dockerfile
context: fluxer_docs
build-version: ${{ inputs['build-version'] }}
+54 -4
View File
@@ -314,8 +314,8 @@ jobs:
path: |
~/.cache/rebar3
fluxer_gateway/_build
!fluxer_gateway/_build/default/lib/fluxer_gateway
!fluxer_gateway/_build/test/lib/fluxer_gateway
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >-
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
@@ -345,8 +345,8 @@ jobs:
path: |
~/.cache/rebar3
fluxer_gateway/_build
!fluxer_gateway/_build/default/lib/fluxer_gateway
!fluxer_gateway/_build/test/lib/fluxer_gateway
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >-
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
@@ -426,6 +426,31 @@ jobs:
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
lint:
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
- name: Check formatting and lint
run: pnpm exec biome ci .
- name: Lint JSX for browser-translation safety
run: pnpm exec eslint . --max-warnings 0
i18n:
runs-on: ubuntu-24.04
timeout-minutes: 25
@@ -460,6 +485,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
-3
View File
@@ -1990,13 +1990,10 @@ dependencies = [
"anyhow",
"axum",
"base64",
"fluxer-svc",
"fluxer_common",
"hex",
"moka",
"rand 0.10.1",
"reqwest",
"scylla",
"serde",
"serde_json",
"tokio",
+34 -1
View File
@@ -84,7 +84,18 @@
},
"useConst": "error",
"noNonNullAssertion": "off",
"noParameterAssign": "off"
"noParameterAssign": "off",
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"@lingui/react": {
"importNames": ["I18nProvider"],
"message": "Use AppI18nProvider from @app/features/i18n/components/AppI18nProvider so <Trans> output stays safe under page translation."
}
}
}
}
},
"a11y": {
"recommended": true,
@@ -115,6 +126,28 @@
}
},
"assist": {"actions": {"source": {"organizeImports": "on"}}},
"overrides": [
{
"includes": ["fluxer_app/src/**/*.tsx"],
"plugins": ["./tools/lint/no-adjacent-jsx-text.grit"]
},
{
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
},
{
"includes": [
"fluxer_app/src/features/i18n/components/AppI18nProvider.tsx",
"fluxer_app/src/features/i18n/components/AppI18nProvider.test.tsx"
],
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
},
{
"includes": ["**/*.astro"],
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
"assist": {"actions": {"source": {"organizeImports": "off"}}}
}
],
"vcs": {
"enabled": true,
"clientKind": "git",
+2 -3
View File
@@ -89,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
@@ -99,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
+259 -45
View File
@@ -1,36 +1,111 @@
# 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. Compose expands this file from
# top to bottom, so a line written with ${...} has to sit below every name it
# reads.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
# 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_CADDY_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
# The three lines above are the address browsers use, and every endpoint the
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
# do that, and a non-default port needs the matching one set as well. Both
# complete recipes are written out beside them.
# 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.
# 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 origin browsers see, without a trailing slash. Leave it unset and each
# service builds one from the three values at the top of this file. Set it and it
# wins: every service reads the host, the scheme and the port out of it and
# ignores those three names. Use it when browsers reach the instance on a host
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
# and an optional port and nothing after them, or the services refuse to start.
# It does not move the edge listener or the published ports either, so set the
# publish below to the port written here.
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
# Overrides the address the edge listens on inside its container. Compose builds
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
# site by host and ignores the port in the Host header, so a request arriving on
# a non-default published port still lands on this site. Put a port in this value
# only if you also publish that same container port below, or nothing will be
# listening where the publish points. Honoured in the default mode only:
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
# literal :80, and Compose lets the last file win, so a value here is discarded
# under either overlay with no warning. Set it for an unusual default-mode
# layout, such as serving several hostnames. Write the scheme into it: a bare
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
#FLUXER_EDGE_SITE_ADDRESS=https://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=
# Host side of the edge's publishes, and the only two names that decide which
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
# scheme, and the site itself under an http one. Container 443 carries the TLS
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
# HTTP/3 needs both on the same port. Both take an optional bind address in front
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
# them different host ports: the same host port on both is two publishes of one
# port and the edge refuses to start.
#FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80
#FLUXER_HTTPS_PORT=127.0.0.1:443
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
# certificate is issued if a router in front forwards public 80 to this host and
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
# cannot.
#FLUXER_PUBLIC_PORT=8443
#FLUXER_HTTPS_PORT=8443
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
# binds host 80. Under an http scheme nothing listens on container 443, so the
# last line parks that publish on loopback for a host that wants 443 for
# something else. Drop it and 443 is published and idle, which is what earlier
# releases did.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_HTTP_PORT=19080
#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
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
# at plain HTTP on that publish so it stops redirecting to https. 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.
@@ -42,20 +117,84 @@ FLUXER_IMAGE_TAG=v1
POSTGRES_PASSWORD=CHANGE_ME
MEILI_MASTER_KEY=CHANGE_ME
# The stack ships its own Postgres and its own object store, and points at both
# by service name. Set these to run either one outside the stack. Leave them
# unset and the bundled services are used. Taking a service out of the stack
# means an upgrade skips the backup step that reaches into it, and backing that
# store up belongs to whoever runs it.
#FLUXER_POSTGRES_HOST=db.example.com
#FLUXER_POSTGRES_PORT=5432
#FLUXER_POSTGRES_DATABASE=fluxer
#FLUXER_POSTGRES_USERNAME=fluxer
#FLUXER_POSTGRES_SSL=true
#FLUXER_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
#FLUXER_S3_REGION=eu-central-1
#FLUXER_S3_FORCE_PATH_STYLE=false
# Bucket names. The bundled object store creates whichever names these hold, so
# the two stay in step. An object store outside the stack needs the buckets to
# exist already.
#FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
# The rest of the bundled services, pointed somewhere else the same way. Leave a
# line unset and the service in the stack is used. Taking a service out of the
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
# replaces docker-compose.yml.
#FLUXER_KV_URL=redis://cache.example.com:6379/0
#FLUXER_NATS_URL=nats://mq.example.com:4222
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
#FLUXER_SEARCH_URL=https://search.example.com
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
# Voice off. The livekit service still runs until an override file takes it out.
#FLUXER_LIVEKIT_ENABLED=false
# Optional systems, each off unless the instance is configured for it.
#FLUXER_SMS_ENABLED=false
#FLUXER_STRIPE_ENABLED=false
#FLUXER_NCMEC_ENABLED=false
#FLUXER_CLAMAV_ENABLED=false
# The client address. Set the header name a proxy in front actually writes, and
# turn the trust off when nothing sits in front, because a trusted header an
# attacker can set is a spoofed client address.
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
#FLUXER_TRUST_CLIENT_IP_HEADER=true
# How much the services write. trace, debug, info, warn, error or fatal. Every
# service names the object storage endpoint and its addressing at info on start,
# so a bucket that answers 404 is visible without raising this.
#LOG_LEVEL=debug
FLUXER_S3_ACCESS_KEY=fluxer
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
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
# The token every service sends to NATS. The bundled NATS runs without
# authentication, so this stays empty unless a Compose override points the stack
# at an external NATS that requires a token. Compose forwards the name to every
# container that connects.
#FLUXER_NATS_AUTH_TOKEN=
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
@@ -68,10 +207,22 @@ [email protected]
# 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
@@ -80,22 +231,32 @@ [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
# Ports LiveKit publishes on the host for voice and video media. They take the
# same optional bind address as the Caddy ports above. This media does not pass
# through Caddy or through a tunnel, so it needs these ports reachable from
# clients. LiveKit advertises the port numbers from livekit.yaml, so publishing
# them on different host ports means changing that file too.
# The URL browsers use for voice signalling. Compose builds it from
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
# leading http to ws itself. 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
# The voice server URL clients connect to. It defaults to FLUXER_PUBLIC_ORIGIN
# plus /livekit, which the bundled Caddy proxies to the LiveKit container. Set
# it only when LiveKit lives on its own host, and add that origin to
# FLUXER_CSP_EXTRA_CONNECT_SRC when you do.
#FLUXER_LIVEKIT_URL=wss://voice.example.com
# 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=
@@ -112,20 +273,58 @@ 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. Every service limit and reservation below has a default that
# assumes a host with at least 16 GB of RAM. Limits are per-container ceilings, so
# their sum may exceed host RAM; the reservations are what protect the services
# whose death takes the whole instance down. Lower these on a smaller host.
# Container memory. The 25 limits sum to 18.25 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=2gb
#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_MEILISEARCH_MEMORY_LIMIT=768mb
#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
# SeaweedFS heap ceiling. Go collects against this value instead of against the
# container limit, which it cannot see, so without it an upload burst grows the
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
# together: the peak is the parts of one upload in flight at once, which is 25 MB
# times 20 for a 500 MB attachment.
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
# 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
@@ -140,18 +339,21 @@ FLUXER_DISCOVERY_ENABLED=true
# 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, app-proxy and shards.
# 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, the account deletion queue and every distributed lock, none of
# which carry an expiry. 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. Only change the policy if you have moved that
# durable state elsewhere.
# 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
@@ -162,6 +364,18 @@ FLUXER_DISCOVERY_ENABLED=true
#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. Leave it unset and each service
# uses its own built-in default, which is what the numbers below describe. Set it
# and the one value replaces the built-in default on all four, so size it for the
# busiest of them rather than for the smallest. The built-in defaults are 192 for
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to. A router holds a slot for the whole round
# trip to its shard, so this is a ceiling on requests in flight at once and not a
# rate: too low a value does not slow requests down, it rejects them, and the api
# turns that rejection into a 503.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
# 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
+8 -3
View File
@@ -1,12 +1,17 @@
{
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
}
@@ -52,7 +57,7 @@
}
:8088 {
handle_path /api/* {
handle /.well-known/fluxer {
reverse_proxy api:8080
}
}
@@ -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"
+102 -58
View File
@@ -2,60 +2,63 @@ 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_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_POSTGRES_SSL: "${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
LOG_LEVEL: ${LOG_LEVEL:-info}
FLUXER_SELF_HOSTED: "true"
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
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
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
FLUXER_SVC_NATS_URL: nats://nats:4222
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
FLUXER_SVC_SHARD_COUNT: "1"
FLUXER_SEARCH_ENGINE: meilisearch
FLUXER_SEARCH_URL: http://meilisearch:7700
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
FLUXER_S3_ENDPOINT: http://seaweedfs:8333
FLUXER_S3_PUBLIC_ENDPOINT: http://seaweedfs:8333
FLUXER_S3_REGION: us-east-1
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_FORCE_PATH_STYLE: "true"
FLUXER_S3_BUCKET_CDN: fluxer
FLUXER_S3_BUCKET_UPLOADS: fluxer-uploads
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_DEFAULT_REGION: us-east-1
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
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: ${FLUXER_S3_REGION:-us-east-1}
AWS_EC2_METADATA_DISABLED: "true"
FLUXER_LIVEKIT_ENABLED: "true"
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
FLUXER_LIVEKIT_INTERNAL_URL: ${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_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
@@ -70,12 +73,16 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
FLUXER_SMS_ENABLED: "false"
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
FLUXER_STRIPE_ENABLED: "false"
FLUXER_NCMEC_ENABLED: "false"
FLUXER_CLAMAV_ENABLED: "false"
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: "${FLUXER_STRIPE_ENABLED:-false}"
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
@@ -114,7 +121,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
start_interval: 1s
services:
caddy:
edge:
image: caddy:2.10-alpine
deploy:
resources:
@@ -127,11 +134,12 @@ services:
- "${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_PUBLIC_SCHEME:-https}://${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
- edge-data:/data
- edge-config:/config
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 10s
@@ -160,8 +168,8 @@ services:
-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=256MB
-c autovacuum_work_mem=128MB
-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
@@ -238,7 +246,7 @@ services:
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_MAX_INDEXING_MEMORY: 384mb
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
@@ -253,17 +261,20 @@ services:
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-512mb}
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
restart: unless-stopped
networks: [fluxer]
environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
command: ["server", "-s3", "-dir=/data"]
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/"]
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
@@ -275,11 +286,19 @@ services:
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}
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
entrypoint:
- /bin/sh
- -c
- >
buckets="fluxer fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests";
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
missing="$$buckets";
for attempt in $$(seq 1 60); do
if ! nc -z seaweedfs 9333 2>/dev/null; then
@@ -292,6 +311,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;
@@ -311,14 +334,26 @@ services:
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
@@ -372,7 +407,7 @@ services:
reservations:
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
working_dir: /usr/src/app/fluxer_api
command: ["node", "dist/WorkerEntrypoint.js"]
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}
@@ -411,6 +446,9 @@ services:
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
@@ -435,6 +473,7 @@ services:
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
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_healthy}
@@ -460,10 +499,13 @@ services:
limits:
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-postgres-env
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
PUBLIC_BOOTSTRAP_API_ENDPOINT: /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:-}
@@ -477,11 +519,9 @@ services:
FLUXER_CSP_EXTRA_WORKER_SRC: ${FLUXER_CSP_EXTRA_WORKER_SRC:-}
FLUXER_CSP_EXTRA_MANIFEST_SRC: ${FLUXER_CSP_EXTRA_MANIFEST_SRC:-}
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
FLUXER_POSTGRES_MAX_CONNECTIONS: "5"
depends_on:
api: {condition: service_healthy}
caddy: {condition: service_healthy}
postgres: {condition: service_healthy}
edge: {condition: service_healthy}
snowflakes:
<<: *fluxer-service
@@ -523,8 +563,9 @@ services:
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}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -538,10 +579,11 @@ services:
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: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -591,7 +633,7 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -609,7 +651,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -624,6 +666,7 @@ services:
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}}
@@ -640,6 +683,7 @@ services:
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
@@ -681,8 +725,8 @@ networks:
driver: bridge
volumes:
caddy-data:
caddy-config:
edge-data:
edge-config:
postgres-data:
valkey-data:
nats-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
+3 -1
View File
@@ -1,4 +1,6 @@
services:
caddy:
edge:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":80"
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import reactGoogleTranslate from 'eslint-plugin-react-google-translate';
import tseslint from 'typescript-eslint';
export default [
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/coverage/**',
'**/*.generated.*',
'fluxer_app/src/features/i18n/locales/*/messages.mjs',
],
},
{
files: ['fluxer_app/src/**/*.tsx'],
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: './fluxer_app/tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {'react-google-translate': reactGoogleTranslate},
rules: {
'react-google-translate/no-conditional-text-nodes-with-siblings': [
'error',
{ignoreParents: ['Trans', 'Plural', 'Select', 'SelectOrdinal']},
],
'react-google-translate/no-return-text-nodes': 'error',
},
},
];
File diff suppressed because it is too large Load Diff
+3
View File
@@ -43,6 +43,7 @@ pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
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";
@@ -121,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,
@@ -154,6 +156,7 @@ pub const ALL_ACLS: &[&str] = &[
BAN_PROFILE_SUBSTRING_REMOVE,
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> {
+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())
+8
View File
@@ -32,6 +32,14 @@ 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 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)]
mod tests {
use super::{number_to_u64, types::*};
+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 -134
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,44 +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,
delete_message_seconds: 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(())
@@ -166,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())
@@ -189,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(())
@@ -208,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())
@@ -222,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())
@@ -239,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())
@@ -280,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())
@@ -298,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
}
}
@@ -417,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()
})
}
@@ -459,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())
+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())
@@ -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,
}
+1
View File
@@ -28,6 +28,7 @@ pub struct VoiceServer {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub is_active: Option<bool>,
pub soft_connection_limit: Option<i64>,
pub vip_only: Option<bool>,
#[serde(default)]
pub required_guild_features: Vec<String>,
+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 {
+61 -45
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,29 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
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)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let region_id = required_field(params, "id")?;
validate_against::<generated_types::UpdateVoiceRegionRequest>(params)?;
self.patch_with_reason(
&format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
Some(params),
None,
)
.await
}
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 +87,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 +99,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,15 +116,20 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceServerResponse> {
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)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?;
validate_against::<generated_types::UpdateVoiceServerRequest>(params)?;
self.patch_with_reason(
&format!(
"/admin/voice/regions/{}/servers/{}",
urlencoding::encode(&region_id),
urlencoding::encode(&server_id)
),
Some(params),
None,
)
.await
}
pub async fn delete_voice_server(
@@ -146,15 +137,40 @@ 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 validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
serde_json::from_value::<T>(params.clone())
.map(drop)
.map_err(|e| ApiError::Parse(e.to_string()))
}
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")))
}
+22 -6
View File
@@ -41,7 +41,7 @@ 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 = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_ADMIN_ENDPOINT",
@@ -51,14 +51,19 @@ impl AdminConfig {
&["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",
@@ -110,7 +115,7 @@ impl AdminConfig {
.trim()
.to_ascii_lowercase(),
},
}
})
}
pub fn is_dev(&self) -> bool {
@@ -120,6 +125,15 @@ impl AdminConfig {
pub fn is_production(&self) -> bool {
self.env == RuntimeEnv::Production
}
pub fn secure_cookies(&self) -> bool {
self.admin_endpoint.starts_with("https://")
}
pub fn admin_origin(&self) -> Option<String> {
let origin = url::Url::parse(&self.admin_endpoint).ok()?.origin();
origin.is_tuple().then(|| origin.ascii_serialization())
}
}
impl RuntimeEnv {
@@ -193,10 +207,12 @@ mod tests {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
unsafe { env::remove_var("FLUXER_PUBLIC_ORIGIN") };
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();
let config = AdminConfig::from_env().expect("config loads with a secret");
for (name, _) in vars {
unsafe { env::remove_var(name) };
}
@@ -340,7 +356,7 @@ mod tests {
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
(
+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))
+174 -8
View File
@@ -27,8 +27,7 @@ pub async fn csrf_protection(
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let is_production = config.is_production();
let secure_cookies = config.secure_cookies();
let user_id = request
.extensions()
@@ -50,7 +49,7 @@ pub async fn csrf_protection(
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, &admin_endpoint) {
if !is_same_site_request(&request, config.admin_origin().as_deref()) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
@@ -76,17 +75,17 @@ pub async fn csrf_protection(
let mut response = next.run(request).await;
let cookie_name = if is_production {
let cookie_name = if secure_cookies {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if is_production { "; Secure" } else { "" };
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 is_production
if secure_cookies
&& let Ok(value) = HeaderValue::from_str(&format!(
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
))
@@ -167,7 +166,7 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
fn is_same_site_request(request: &Request, admin_origin: Option<&str>) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
@@ -180,7 +179,7 @@ fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == admin_endpoint,
Some(origin) => admin_origin.is_some_and(|expected| origin == expected),
None => true,
}
}
@@ -199,6 +198,173 @@ 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;
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(),
},
})
}
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!(
cookies
.iter()
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
&& cookie.contains("; Secure")),
"expected a secure __Host- cookie, got {cookies:?}"
);
}
#[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:?}"
);
}
async fn action_status(admin_endpoint: &str, origin: &str) -> StatusCode {
let state = state_with_admin_endpoint(admin_endpoint);
let app = Router::new()
.route("/", get(|| async { "ok" }).post(|| async { "ok" }))
.layer(from_fn_with_state(state, csrf_protection));
let issued = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("router responds");
let cookie = issued
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.filter_map(|value| value.split(';').next())
.find(|pair| pair.contains("csrf_token=") && !pair.ends_with('='))
.expect("a csrf cookie is issued")
.to_owned();
let token = cookie.split_once('=').expect("a cookie value").1.to_owned();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/")
.header(header::COOKIE, cookie.as_str())
.header(header::ORIGIN, origin)
.header(CSRF_HEADER_NAME, token.as_str())
.body(Body::empty())
.unwrap(),
)
.await
.expect("router responds");
response.status()
}
#[tokio::test]
async fn a_matching_origin_passes_the_same_site_check() {
let status = action_status(
"https://admin.example.test/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_matching_origin_on_a_non_default_port_passes_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://admin.example.test:19080",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_foreign_origin_fails_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://evil.example.test:19080",
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn another_port_on_the_admin_host_fails_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_unparseable_admin_endpoint_fails_closed() {
let status = action_status("not-an-endpoint", "https://admin.example.test").await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_explicit_default_port_matches_a_portless_origin() {
let status = action_status(
"https://admin.example.test:443/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[test]
fn oauth2_callback_is_exempt() {
+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(),
)
}
+1 -1
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 {
""
+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 -1
View File
@@ -158,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(),
)
}
}
+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,
)
}
+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(
+48 -16
View File
@@ -4,7 +4,7 @@ use crate::{
api::client::AdminApiClient,
middleware::{auth::AuthContext, csrf},
state::AppState,
templates,
templates::{self, pages::voice_servers::VoiceServersPageParams},
};
use axum::{
Router,
@@ -13,12 +13,34 @@ use axum::{
routing::get,
};
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
struct VoiceServersQuery {
region_id: Option<String>,
}
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
let response = match client.get_gateway_voice_state_counts().await {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "admin API request failed: load voice state counts");
return HashMap::new();
}
};
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
return HashMap::new();
};
servers
.iter()
.filter_map(|entry| {
let server_id = entry.get("server_id")?.as_str()?.to_owned();
let count = entry.get("voice_state_count")?.as_i64()?;
Some((server_id, count))
})
.collect()
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
@@ -79,17 +101,21 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
None,
None,
None,
None,
&csrf_token,
&VoiceServersPageParams {
region_id: None,
region_name: None,
servers: None,
connection_counts: &HashMap::new(),
error: None,
csrf_token: &csrf_token,
},
);
return Html(markup.into_string()).into_response();
}
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let connection_counts = load_server_connection_counts(&client).await;
let region_name = match client.get_voice_region(region_id, false).await {
Ok(resp) => resp
@@ -107,11 +133,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
Some(region_id),
Some(&region_name),
Some(&resp.servers),
None,
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_name),
servers: Some(&resp.servers),
connection_counts: &connection_counts,
error: None,
csrf_token: &csrf_token,
},
);
Html(markup.into_string()).into_response()
}
@@ -120,11 +149,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page(
config,
&auth.0,
Some(region_id),
Some(&region_name),
None,
Some(&msg),
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_name),
servers: None,
connection_counts: &connection_counts,
error: Some(&msg),
csrf_token: &csrf_token,
},
);
Html(markup.into_string()).into_response()
}
+111 -22
View File
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
}
body.insert("is_default".into(), form.bool_value("is_default").into());
body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert(
"required_guild_features".into(),
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
insert_submitted_list(&mut body, form, "required_guild_features");
insert_submitted_list(&mut body, form, "allowed_guild_ids");
serde_json::Value::Object(body)
}
fn insert_submitted_list(
body: &mut serde_json::Map<String, serde_json::Value>,
form: &MultiValueForm,
field: &str,
) {
let repeated = format!("{field}[]");
if !form.contains_key(&repeated) && !form.contains_key(field) {
return;
}
body.insert(
field.to_owned(),
form.list_values_any(&[repeated.as_str(), field]).into(),
);
}
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
let mut body = serde_json::Map::new();
if let Some(v) = form.clean("region_id") {
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
body.insert("longitude".into(), lng.into());
}
body.insert("is_active".into(), form.bool_value("is_active").into());
if let Some(raw) = form.first("soft_connection_limit") {
let trimmed = raw.trim();
if trimmed.is_empty() {
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
} else if let Ok(limit) = trimmed.parse::<i64>()
&& limit > 0
{
body.insert("soft_connection_limit".into(), limit.into());
}
}
body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert(
"required_guild_features".into(),
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
insert_submitted_list(&mut body, form, "required_guild_features");
insert_submitted_list(&mut body, form, "allowed_guild_ids");
serde_json::Value::Object(body)
}
@@ -160,7 +169,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 +241,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(),
)
}
@@ -255,6 +264,86 @@ mod tests {
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
}
#[test]
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
);
let body = build_server_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
);
let body = build_server_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], serde_json::json!(false));
}
#[test]
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
let body = build_region_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
let body = build_region_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
}
#[test]
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
let form =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
}
#[test]
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
}
#[test]
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
assert!(
!build_server_body(&absent)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let invalid =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
assert!(
!build_server_body(&invalid)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
assert!(
!build_server_body(&zero)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
}
#[test]
fn build_server_body_preserves_single_and_repeated_values() {
let form = MultiValueForm::parse(
@@ -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,
@@ -151,6 +147,9 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_STICKER_DISABLED",
@@ -205,6 +204,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 +390,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"))
}))
}
}
},
)
}
@@ -14,6 +14,9 @@ use maud::{Markup, html};
const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON",
"ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER",
"CLONE_EMOJI_DISABLED",
"CLONE_STICKER_DISABLED",
@@ -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" }
@@ -560,6 +560,8 @@ fn traits_form(
}
}
const DERIVED_TRAITS: [&str; 1] = ["premium"];
fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> {
limit_config
.map(|response| {
@@ -569,6 +571,7 @@ fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&s
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.filter(|value| !DERIVED_TRAITS.contains(value))
.collect()
})
.unwrap_or_default()
@@ -579,5 +582,6 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
.iter()
.map(String::as_str)
.filter(|trait_name| !trait_definitions.contains(trait_name))
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
.collect()
}
@@ -18,19 +18,33 @@ use crate::{
},
};
use maud::{Markup, html};
use std::collections::HashMap;
use super::voice_servers_forms::{create_server_form, edit_server_form};
pub struct VoiceServersPageParams<'a> {
pub region_id: Option<&'a str>,
pub region_name: Option<&'a str>,
pub servers: Option<&'a [VoiceServer]>,
pub connection_counts: &'a HashMap<String, i64>,
pub error: Option<&'a str>,
pub csrf_token: &'a str,
}
pub fn voice_servers_page(
config: &AdminConfig,
auth: &AuthContext,
region_id: Option<&str>,
region_name: Option<&str>,
servers: Option<&[VoiceServer]>,
error: Option<&str>,
csrf_token: &str,
p: &VoiceServersPageParams<'_>,
) -> Markup {
let base = &config.base_path;
let VoiceServersPageParams {
region_id,
region_name,
servers,
connection_counts,
error,
csrf_token,
} = *p;
let options = LayoutOptions {
csrf_token,
inspected_voice_region_id: region_id,
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
html! {},
))
@if let Some(servers) = servers {
(servers_list(config, rid, servers, csrf_token))
(servers_list(config, rid, servers, connection_counts, csrf_token))
}
div id="create" class="mt-8" {
(create_server_form(config, rid, csrf_token))
@@ -109,6 +123,7 @@ fn servers_list(
config: &AdminConfig,
region_id: &str,
servers: &[VoiceServer],
connection_counts: &HashMap<String, i64>,
csrf_token: &str,
) -> Markup {
if servers.is_empty() {
@@ -121,7 +136,7 @@ fn servers_list(
html! {
div class="space-y-4" {
@for server in servers {
(server_card(config, region_id, server, csrf_token))
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
}
}
}
@@ -131,6 +146,7 @@ fn server_card(
config: &AdminConfig,
region_id: &str,
server: &VoiceServer,
connection_count: Option<i64>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
@@ -145,6 +161,15 @@ fn server_card(
let lng_str = server
.longitude
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
let soft_limit_str = server
.soft_connection_limit
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
let connections_str =
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
let at_soft_limit = matches!(
(server.soft_connection_limit, connection_count),
(Some(limit), Some(count)) if limit > 0 && count >= limit
);
card(html! {
div class="mb-4 flex flex-col gap-1" {
@@ -155,6 +180,9 @@ fn server_card(
} @else {
(badge("INACTIVE", BadgeVariant::Default))
}
@if at_soft_limit {
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
}
(voice_status_badges(vip_only, has_features, has_guild_ids))
}
p class="text-sm text-neutral-500" { (endpoint) }
@@ -164,6 +192,8 @@ fn server_card(
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
(data_field_text("Latitude", &lat_str))
(data_field_text("Longitude", &lng_str))
(data_field_text("Soft connection limit", &soft_limit_str))
(data_field_text("Live connections", &connections_str))
}
(voice_features_list(&server.required_guild_features))
(voice_guild_ids_list(&server.allowed_guild_ids))
@@ -26,6 +26,9 @@ pub fn edit_server_form(
let lat_val = server.latitude.map_or_else(String::new, |v| v.to_string());
let lng_val = server.longitude.map_or_else(String::new, |v| v.to_string());
let is_active = server.is_active.unwrap_or(false);
let soft_limit_val = server
.soft_connection_limit
.map_or_else(String::new, |v| v.to_string());
let vip_only = server.vip_only.unwrap_or(false);
let features_csv = server.required_guild_features.join(", ");
let guild_ids_csv = server.allowed_guild_ids.join(", ");
@@ -57,6 +60,15 @@ pub fn edit_server_form(
"Optional per-server coordinate override",
))
}
(form_field_with_helper(
"Soft Connection Limit",
&format!("{id_prefix}-soft-connection-limit"),
"soft_connection_limit",
"number",
&soft_limit_val,
"Leave empty for no limit",
"Placement prefers another server once this server holds this many connections",
))
(form_field_with_helper(
"API Key",
&format!("{id_prefix}-api-key"),
@@ -105,6 +117,15 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
(form_field_with_id("API Secret", "new-server-api-secret", "api_secret", "password", "", "LiveKit API secret", true))
(form_field_with_id("Latitude (optional)", "new-server-latitude", "latitude", "number", "", "40.7128", false))
(form_field_with_id("Longitude (optional)", "new-server-longitude", "longitude", "number", "", "-74.0060", false))
(form_field_with_helper(
"Soft Connection Limit (optional)",
"new-server-soft-connection-limit",
"soft_connection_limit",
"number",
"",
"Leave empty for no limit",
"Placement prefers another server once this server holds this many connections",
))
}
div class="space-y-3" {
(checkbox("is_active", "true", "Server is active", true, true))
+17 -3
View File
@@ -18,6 +18,7 @@ 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";
const CREATED_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForTests";
struct TestApp {
router: Router,
@@ -128,6 +129,10 @@ async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
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}");
assert!(
text.contains("AdminUser"),
"the page did not render the admin the mock API returns"
);
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");
@@ -166,7 +171,16 @@ async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusC
)
.await
.unwrap();
response.status()
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
if status == StatusCode::OK {
assert!(
text.contains(CREATED_KEY_SECRET),
"the action did not render the key the mock API creates"
);
}
status
}
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
@@ -207,11 +221,11 @@ 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(json!({ "user": admin_user() })).into_response(),
(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",
"key": CREATED_KEY_SECRET,
"name": "Legacy Cookie Key",
"created_at": "2026-07-10T15:00:00.000Z",
"expires_at": 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"
},
{
@@ -0,0 +1,336 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
Json, Router,
body::{Body, to_bytes},
extract::State,
http::{Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "voice-restriction-writes-test-secret";
const REGION_ID: &str = "europe-north";
const SERVER_ID: &str = "europe-north-server-1";
type CapturedBodies = Arc<Mutex<Vec<(String, Value)>>>;
#[tokio::test]
async fn clearing_the_restriction_fields_reaches_the_api_as_empty_lists() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-servers?action=update",
&format!(
"_csrf={csrf_token}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=true\
&required_guild_features=&allowed_guild_ids=&soft_connection_limit="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
assert_eq!(body["soft_connection_limit"], Value::Null);
assert_eq!(body["vip_only"], json!(false));
}
#[tokio::test]
async fn activating_a_server_leaves_the_restriction_fields_untouched() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-servers?action=update",
&format!(
"_csrf={csrf_token}&region_id={REGION_ID}&server_id={SERVER_ID}\
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=false&vip_only=true"
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(
&app,
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
);
let object = body.as_object().expect("object body");
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], json!(false));
assert_eq!(body["vip_only"], json!(true));
}
#[tokio::test]
async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists() {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(
&app,
"/voice-regions?action=update",
&format!(
"_csrf={csrf_token}&id={REGION_ID}&name=Northern%20Europe&emoji=%F0%9F%87%B8%F0%9F%87%AA\
&latitude=59.33&longitude=18.06&required_guild_features=&allowed_guild_ids="
),
)
.await;
assert_eq!(status, StatusCode::SEE_OTHER);
let body = captured_body(&app, "PATCH /admin/voice/regions/europe-north");
assert_eq!(body["required_guild_features"], json!([]));
assert_eq!(body["allowed_guild_ids"], json!([]));
}
struct TestApp {
router: Router,
session_cookie: String,
captured: CapturedBodies,
}
async fn setup() -> TestApp {
let captured: CapturedBodies = Arc::new(Mutex::new(Vec::new()));
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
let router = build_router(test_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),
captured,
}
}
fn captured_body(app: &TestApp, route: &str) -> Value {
let captured = app.captured.lock().expect("captured bodies");
captured
.iter()
.find(|(seen, _)| seen == route)
.map(|(_, body)| body.clone())
.unwrap_or_else(|| {
panic!(
"no request captured for {route}, saw {:?}",
captured.iter().map(|(seen, _)| seen).collect::<Vec<_>>()
)
})
}
async fn csrf_token(app: &TestApp) -> String {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/voice-regions")
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
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())
})
.expect("csrf_token cookie")
}
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(
header::COOKIE,
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
)
.body(Body::from(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
response.status()
}
async fn spawn_mock_api(captured: CapturedBodies) -> 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).with_state(captured),
)
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(
State(captured): State<CapturedBodies>,
method: Method,
uri: Uri,
request: Request<Body>,
) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH {
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
captured
.lock()
.expect("captured bodies")
.push((format!("PATCH {path}"), body));
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::PATCH, "/admin/voice/regions/europe-north") => {
Json(json!({ "region": region() })).into_response()
}
(Method::PATCH, "/admin/voice/regions/europe-north/servers/europe-north-server-1") => {
Json(json!({ "server": server() })).into_response()
}
(Method::GET, "/admin/voice/regions") => {
Json(json!({ "regions": [region()] })).into_response()
}
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn region() -> Value {
json!({
"id": REGION_ID,
"name": "Northern Europe",
"emoji": "flag",
"latitude": 59.33,
"longitude": 18.06,
"is_default": true,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn server() -> Value {
json!({
"region_id": REGION_ID,
"server_id": SERVER_ID,
"endpoint": "wss://voice.example.com",
"latitude": null,
"longitude": null,
"is_active": true,
"soft_connection_limit": null,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
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
})
}
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
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: "https://admin.example.test".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(),
},
}
}
+125 -1
View File
@@ -2,7 +2,7 @@
import {getRegionDisplayName} from '@fluxer/geo_utils/src/RegionFormatting';
import {getSameIpDecisionKey, isValidIp, normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
import maxmind, {type CityResponse, type Reader} from 'maxmind';
import maxmind, {type AsnResponse, type CityResponse, type Reader} from 'maxmind';
export const UNKNOWN_LOCATION = 'Unknown Location';
@@ -15,6 +15,15 @@ export interface GeoipResult {
countryName: string | null;
latitude?: number | null;
longitude?: number | null;
accuracyRadiusKm?: number | null;
timeZone?: string | null;
}
export interface GeoipAsnResult {
normalizedIp: string | null;
asn: number | null;
asnOrg: string | null;
available: boolean;
}
type CacheEntry = {
@@ -22,12 +31,21 @@ type CacheEntry = {
expiresAt: number;
};
type AsnCacheEntry = {
result: GeoipAsnResult;
expiresAt: number;
};
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 10_000;
const geoipCache = new Map<string, CacheEntry>();
const asnCache = new Map<string, AsnCacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
let maxmindAsnReader: Reader<AsnResponse> | null = null;
let maxmindAsnReaderPromise: Promise<Reader<AsnResponse>> | null = null;
let maxmindAsnUnavailable = false;
function buildFallbackResult(normalizedIp: string): GeoipResult {
return {
@@ -39,6 +57,17 @@ function buildFallbackResult(normalizedIp: string): GeoipResult {
countryName: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
timeZone: null,
};
}
function buildAsnFallbackResult(normalizedIp: string | null): GeoipAsnResult {
return {
normalizedIp: normalizedIp || null,
asn: null,
asnOrg: null,
available: false,
};
}
@@ -59,6 +88,24 @@ async function ensureReader(dbPath: string): Promise<Reader<CityResponse>> {
return maxmindReaderPromise;
}
async function ensureAsnReader(dbPath: string): Promise<Reader<AsnResponse>> {
if (maxmindAsnReader) return maxmindAsnReader;
if (!maxmindAsnReaderPromise) {
maxmindAsnReaderPromise = maxmind
.open<AsnResponse>(dbPath, {watchForUpdates: true, watchForUpdatesNonPersistent: true})
.then((reader) => {
maxmindAsnReader = reader;
return reader;
})
.catch((error) => {
maxmindAsnReaderPromise = null;
maxmindAsnUnavailable = true;
throw error;
});
}
return maxmindAsnReaderPromise;
}
function stateLabel(record?: CityResponse): string | null {
const subdivision = record?.subdivisions?.[0];
if (!subdivision) return null;
@@ -112,6 +159,31 @@ function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
function getCachedAsnResult(cacheKey: string, normalizedIp: string): GeoipAsnResult | null {
const cached = asnCache.get(cacheKey);
if (!cached) {
return null;
}
if (Date.now() >= cached.expiresAt) {
asnCache.delete(cacheKey);
return null;
}
asnCache.delete(cacheKey);
asnCache.set(cacheKey, cached);
return {...cached.result, normalizedIp};
}
function setCachedAsnResult(cacheKey: string, result: GeoipAsnResult): void {
asnCache.delete(cacheKey);
if (asnCache.size >= CACHE_MAX_ENTRIES) {
const oldestKey = asnCache.keys().next().value;
if (oldestKey !== undefined) {
asnCache.delete(oldestKey);
}
}
asnCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
try {
const reader = await ensureReader(dbPath);
@@ -128,12 +200,32 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null,
latitude: record.location?.latitude ?? null,
longitude: record.location?.longitude ?? null,
accuracyRadiusKm: record.location?.accuracy_radius ?? null,
timeZone: record.location?.time_zone ?? null,
};
} catch {
return buildFallbackResult(clean);
}
}
async function lookupMaxmindAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
try {
const reader = await ensureAsnReader(dbPath);
const record = reader.get(clean);
if (!record) {
return {normalizedIp: clean, asn: null, asnOrg: null, available: true};
}
return {
normalizedIp: clean,
asn: record.autonomous_system_number ?? null,
asnOrg: record.autonomous_system_organization ?? null,
available: true,
};
} catch {
return buildAsnFallbackResult(clean);
}
}
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = getCachedGeoipResult(cacheKey, clean);
@@ -145,6 +237,17 @@ async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult>
return result;
}
async function resolveAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = getCachedAsnResult(cacheKey, clean);
if (cached) {
return cached;
}
const result = await lookupMaxmindAsn(clean, dbPath);
setCachedAsnResult(cacheKey, result);
return result;
}
export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): Promise<GeoipResult> {
if (!dbPath) {
return buildFallbackResult(ip);
@@ -156,6 +259,27 @@ export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): P
return resolveGeoip(clean, dbPath);
}
export async function lookupAsnByIp(ip: string, asnDbPath: string | undefined): Promise<GeoipAsnResult> {
if (!asnDbPath || maxmindAsnUnavailable) {
return buildAsnFallbackResult(null);
}
const clean = normalizeIpString(ip);
if (!isValidIp(clean)) {
return buildAsnFallbackResult(clean);
}
return resolveAsn(clean, asnDbPath);
}
export function resetGeoipReadersForTesting(): void {
maxmindReader = null;
maxmindReaderPromise = null;
maxmindAsnReader = null;
maxmindAsnReaderPromise = null;
maxmindAsnUnavailable = false;
geoipCache.clear();
asnCache.clear();
}
export function formatGeoipLocation(result: GeoipResult): string | null {
const parts: Array<string> = [];
if (result.city) parts.push(result.city);
+4 -1
View File
@@ -12,6 +12,7 @@ const GEOIP_DOWNLOAD_PATH_QUERY_PARAM = 'download_path';
const GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM = 'asn_download_path';
const GEOIP_ASN_KEY_QUERY_PARAM = 'asn_key';
const DEFAULT_GEOIP_TEMPORARY_DIRECTORY = '/tmp/fluxer/geoip';
const DEFAULT_GEOIP_ASN_DB_BASENAME = 'GeoLite2-ASN.mmdb';
type GeoipSourceMode = 'filesystem' | 's3';
@@ -167,9 +168,11 @@ async function downloadS3Object(
}
function createGeoipFilesystemSourceConfig(rawValue: string | undefined): GeoipFilesystemSourceConfig {
const maxmindDbPath = rawValue === '' ? undefined : rawValue;
return {
mode: 'filesystem',
maxmindDbPath: rawValue === '' ? undefined : rawValue,
maxmindDbPath,
maxmindAsnDbPath: maxmindDbPath ? path.join(path.dirname(maxmindDbPath), DEFAULT_GEOIP_ASN_DB_BASENAME) : undefined,
};
}
+77 -6
View File
@@ -9,6 +9,11 @@ const CACHE_KEY_PREFIX = 'ipinfo:max:';
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
const FAILURE_TTL_QUOTA_SECONDS = 900;
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
const FAILURE_TTL_BACKGROUND_CAP_SECONDS = 120;
export interface IpInfoGeoBlock {
countryCode: string | null;
@@ -73,6 +78,41 @@ export interface IpInfoCache {
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}
export type IpInfoLookupPriority = 'critical' | 'standard' | 'background';
export interface IpInfoLookupBudget {
tryConsume(priority: IpInfoLookupPriority): Promise<boolean>;
}
export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedFailure: true;
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
failureHttpStatus: number | null;
cachedAtMs: number;
}
export function resolveIpInfoLookupPriority(source: string | undefined): IpInfoLookupPriority {
if (source === 'admin.ip_ban' || source === 'admin.scheduled_deletion_suspicious_ip') return 'critical';
if (source === 'AbusiveIpAutoBanner') return 'background';
return 'standard';
}
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
}
function failureCacheTtlSeconds(
outcome: CachedIpInfoFailure['failureOutcome'],
httpStatus: number | null,
priority: IpInfoLookupPriority,
): number {
let ttl = FAILURE_TTL_HTTP_ERROR_SECONDS;
if (outcome === 'request_failed') ttl = FAILURE_TTL_REQUEST_FAILED_SECONDS;
else if (outcome === 'schema_mismatch') ttl = FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
else if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) ttl = FAILURE_TTL_QUOTA_SECONDS;
return priority === 'background' ? Math.min(ttl, FAILURE_TTL_BACKGROUND_CAP_SECONDS) : ttl;
}
export interface IpInfoLookupContext {
source?: string;
reason?: string;
@@ -86,7 +126,7 @@ export interface IpInfoRequestAuditEvent {
source: string;
reason: string | null;
metadata?: Record<string, string | number | boolean | null>;
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch' | 'budget_shed';
httpStatus: number | null;
available: boolean;
riskNote: string;
@@ -110,6 +150,7 @@ interface IpInfoServiceContext {
apiKey: string;
cache: IpInfoCache;
auditLogger?: IpInfoRequestAuditLogger;
budget?: IpInfoLookupBudget;
}
export interface IpInfoService {
@@ -177,8 +218,12 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
return {
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
const priority = resolveIpInfoLookupPriority(context?.source);
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
if (cached !== null) {
if (isCachedIpInfoFailure(cached)) {
return unavailable(ip, cached.riskNote);
}
return {...cached, ip};
}
const existing = inflight.get(cacheKey);
@@ -222,6 +267,30 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
return params.result;
};
const performLookup = async (): Promise<IpInfoLookupResult> => {
if (ctx.budget && !(await ctx.budget.tryConsume(priority))) {
return finalize({
result: unavailable(ip, `IPInfo lookup shed (budget exhausted, priority: ${priority})`),
outcome: 'budget_shed',
httpStatus: null,
});
}
const finalizeFailure = async (params: {
result: IpInfoLookupResult;
outcome: CachedIpInfoFailure['failureOutcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
const entry: CachedIpInfoFailure = {
...params.result,
cachedFailure: true,
failureOutcome: params.outcome,
failureHttpStatus: params.httpStatus,
cachedAtMs: Date.now(),
};
await ctx.cache
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus, priority))
.catch(() => {});
return finalize(params);
};
let payload: unknown;
try {
const res = await fetch(fetchUrl, {
@@ -229,7 +298,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
headers: {Accept: 'application/json'},
});
if (!res.ok) {
return finalize({
return finalizeFailure({
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
outcome: 'http_error',
httpStatus: res.status,
@@ -238,7 +307,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
payload = await res.json();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return finalize({
return finalizeFailure({
result: unavailable(ip, `IPInfo request failed: ${detail}`),
outcome: 'request_failed',
httpStatus: null,
@@ -246,7 +315,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
}
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
if (!parsedResponse.success) {
return finalize({
return finalizeFailure({
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
outcome: 'schema_mismatch',
httpStatus: 200,
@@ -261,8 +330,10 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
httpStatus: 200,
});
};
const promise = performLookup().finally(() => {
inflight.delete(cacheKey);
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
if (inflight.get(cacheKey) === promise) {
inflight.delete(cacheKey);
}
});
inflight.set(cacheKey, promise);
return promise;
@@ -8,6 +8,7 @@ interface TieredIpInfoCacheOptions {
hot: IpInfoCache;
cold: IpInfoCache;
hotTtlSeconds?: number;
skipColdWrite?: (value: unknown) => boolean;
}
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
@@ -18,14 +19,17 @@ export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoC
if (hit !== null) return hit;
const cold = await opts.cold.get<T>(key).catch(() => null);
if (cold === null) return null;
if (opts.skipColdWrite?.(cold) === true) return cold;
void opts.hot.set(key, cold, hotTtl).catch(() => {});
return cold;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
await Promise.all([
opts.hot.set(key, value, hotTtl).catch(() => {}),
opts.cold.set(key, value, ttlSeconds).catch(() => {}),
]);
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
if (opts.skipColdWrite?.(value) !== true) {
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
}
await Promise.all(writes);
},
};
}
@@ -62,4 +62,5 @@ export interface WorkerJobOptions {
requestedByUserId?: bigint | undefined;
auditLogReason?: string | undefined;
skipLedger?: boolean | undefined;
requireLedger?: boolean | undefined;
}
+104 -1
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {createServer} from '@fluxer/hono/src/Server';
import {Hono} from 'hono';
import {afterAll, afterEach, describe, expect, test, vi} from 'vitest';
import {afterAll, afterEach, beforeAll, describe, expect, it, test, vi} from 'vitest';
import {buildAPIConfigFromMaster, buildAPIServerOptions} from './Config';
interface ListeningServer {
@@ -63,3 +64,105 @@ describe('buildAPIServerOptions', () => {
expect(server.headersTimeout).toBe(45_000);
});
});
function withUploadRelaySecret(master: MasterConfig, secretBase64: string): MasterConfig {
return {
...master,
services: {
...master.services,
media_proxy: {
...master.services.media_proxy,
upload_relay: {
...master.services.media_proxy.upload_relay,
secret_base64: secretBase64,
},
},
},
};
}
function withStripeLegacyPrices(
master: MasterConfig,
legacyPrices: Record<string, Array<string> | undefined> | undefined,
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
stripe: {
...master.integrations.stripe,
legacy_prices: legacyPrices,
},
},
};
}
describe('buildAPIConfigFromMaster upload relay secret', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('refuses to build without FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', () => {
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, ''))).toThrow(
/FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64/,
);
});
it('refuses a secret that decodes to fewer than 32 bytes', () => {
const secret = Buffer.alloc(16, 7).toString('base64');
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, secret))).toThrow(/at least 32 bytes/);
});
it('accepts a secret that decodes to 32 bytes', () => {
const secret = Buffer.alloc(32, 7).toString('base64');
expect(
buildAPIConfigFromMaster(withUploadRelaySecret(master, secret)).mediaProxy.uploadRelay.relaySecretBase64,
).toBe(secret);
});
it('reads the relay secret from the loaded config rather than the environment', () => {
expect(buildAPIConfigFromMaster(master).mediaProxy.uploadRelay.relaySecretBase64).toBe(
master.services.media_proxy.upload_relay.secret_base64,
);
});
});
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('carries the retired stripe price map from master config onto the api config', () => {
const legacyPrices = {
monthly_brl: ['price_retired_monthly_brl'],
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up'],
};
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, legacyPrices)).stripe.legacyPrices).toEqual(
legacyPrices,
);
});
it('carries the retired price map even when no live prices are configured', () => {
const withoutPrices: MasterConfig = {
...master,
integrations: {
...master.integrations,
stripe: {
...master.integrations.stripe,
prices: undefined,
legacy_prices: {monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']},
},
},
};
const config = buildAPIConfigFromMaster(withoutPrices);
expect(config.stripe.prices).toBeUndefined();
expect(config.stripe.legacyPrices).toEqual({monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']});
});
it('leaves the retired price map undefined when master config does not set one', () => {
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
});
});
+33 -15
View File
@@ -113,17 +113,18 @@ function mapPushProviderApps(
project_id?: string;
}>
| undefined,
configName: string,
): APIConfig['push']['apns']['apps'] {
return (apps ?? []).flatMap((app) => {
if (!app.app_id) return [];
return [
{
appId: app.app_id,
topic: app.topic,
environment: app.environment,
projectId: app.project_id,
},
];
return (apps ?? []).map((app) => {
if (!app.app_id) {
throw new Error(`${configName} contains an entry with no app_id`);
}
return {
appId: app.app_id,
topic: app.topic,
environment: app.environment,
projectId: app.project_id,
};
});
}
@@ -139,7 +140,13 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
serviceName: 'api',
});
const uploadRelayConfig = master.services.media_proxy.upload_relay;
const uploadRelaySecretBase64 = process.env.FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 ?? '';
const uploadRelaySecretBase64 = uploadRelayConfig.secret_base64;
if (uploadRelaySecretBase64.length === 0) {
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API');
}
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
}
if (!s3Config) {
throw new Error('S3 configuration is required for the API');
}
@@ -149,7 +156,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
downloads: '',
reports: '',
harvests: '',
static: '',
};
if (master.database.backend === 'cassandra' && !cassandraSource) {
throw new Error('Cassandra configuration is required.');
@@ -366,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
monthlyEur: master.integrations.stripe.prices.monthly_eur,
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
monthlyInr: master.integrations.stripe.prices.monthly_inr,
monthlyNok: master.integrations.stripe.prices.monthly_nok,
monthlyPln: master.integrations.stripe.prices.monthly_pln,
monthlySek: master.integrations.stripe.prices.monthly_sek,
monthlyTry: master.integrations.stripe.prices.monthly_try,
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
yearlyEur: master.integrations.stripe.prices.yearly_eur,
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
yearlyInr: master.integrations.stripe.prices.yearly_inr,
yearlyNok: master.integrations.stripe.prices.yearly_nok,
yearlyPln: master.integrations.stripe.prices.yearly_pln,
yearlySek: master.integrations.stripe.prices.yearly_sek,
yearlyTry: master.integrations.stripe.prices.yearly_try,
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
@@ -389,6 +407,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
gift1YearTry: master.integrations.stripe.prices.gift_1_year_try,
}
: undefined,
legacyPrices: master.integrations.stripe.legacy_prices,
},
bunny: {
purgeEnabled: master.integrations.bunny.purge_enabled,
@@ -428,7 +447,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
bluesky: master.auth.bluesky as BlueskyOAuthConfig,
},
cookie: master.cookie,
klipy: {
apiKey: master.integrations.klipy.api_key,
},
@@ -495,7 +513,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
privateKey: master.integrations.push.apns.private_key,
privateKeyPath: master.integrations.push.apns.private_key_path,
defaultEnvironment: master.integrations.push.apns.default_environment ?? 'production',
apps: mapPushProviderApps(master.integrations.push.apns.apps),
apps: mapPushProviderApps(master.integrations.push.apns.apps, 'FLUXER_PUSH_APNS_APPS'),
},
fcm: {
enabled: master.integrations.push.fcm.enabled,
@@ -505,7 +523,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
privateKeyPath: master.integrations.push.fcm.private_key_path,
serviceAccountJsonPath: master.integrations.push.fcm.service_account_json_path,
tokenUri: master.integrations.push.fcm.token_uri ?? 'https://oauth2.googleapis.com/token',
apps: mapPushProviderApps(master.integrations.push.fcm.apps),
apps: mapPushProviderApps(master.integrations.push.fcm.apps, 'FLUXER_PUSH_FCM_APPS'),
},
},
worker: {
+2 -2
View File
@@ -10,7 +10,7 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
import type {ZodError, ZodTypeAny} from 'zod';
import {readRequestJsonBody} from './utils/RequestJsonBody';
import {requireRequestJsonBody} from './utils/RequestJsonBody';
import {initializeFluxerErrorMap} from './ZodErrorMap';
initializeFluxerErrorMap();
@@ -200,7 +200,7 @@ export const Validator = <
let value: unknown;
switch (target) {
case 'json':
value = (await readRequestJsonBody(c.req)).value;
value = await requireRequestJsonBody(c.req);
break;
case 'form': {
const formData = await c.req.formData();
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {beforeAll, describe, expect, it} from 'vitest';
import {z} from 'zod';
import {initializeFluxerErrorMap} from './ZodErrorMap';
function firstIssueMessage(schema: z.ZodType, value: unknown): string | undefined {
const result = schema.safeParse(value);
return result.success ? undefined : result.error.issues[0]?.message;
}
describe('ZodErrorMap', () => {
beforeAll(() => {
initializeFluxerErrorMap();
});
it('maps a date below its minimum to INVALID_FORMAT', () => {
expect(
firstIssueMessage(z.date().min(new Date('2000-01-01T00:00:00.000Z')), new Date('1999-12-31T00:00:00.000Z')),
).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps a date above its maximum to INVALID_FORMAT', () => {
expect(
firstIssueMessage(z.date().max(new Date('2000-01-01T00:00:00.000Z')), new Date('2000-01-02T00:00:00.000Z')),
).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps both numeric bounds to INVALID_FORMAT', () => {
expect(firstIssueMessage(z.number().min(1), 0)).toBe(ValidationErrorCodes.INVALID_FORMAT);
expect(firstIssueMessage(z.number().max(1), 2)).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
it('maps a string longer than its maximum to CONTENT_EXCEEDS_MAX_LENGTH', () => {
expect(firstIssueMessage(z.string().max(1), 'ab')).toBe(ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH);
});
it('maps a string shorter than its minimum to INVALID_FORMAT', () => {
expect(firstIssueMessage(z.string().min(2), 'a')).toBe(ValidationErrorCodes.INVALID_FORMAT);
});
});
+1 -6
View File
@@ -54,12 +54,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'too_small': {
const origin = 'origin' in issue ? String(issue.origin) : undefined;
if (origin === 'date') {
errorCode = ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
}
errorCode = ValidationErrorCodes.INVALID_FORMAT;
break;
}
case 'too_big': {
@@ -42,9 +42,11 @@ const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select();
const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
where: BannedEmails.where.eq('email_lower'),
});
const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select();
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
where: SuspiciousEmailDomains.where.eq('domain'),
});
const LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY = SuspiciousEmailDomains.select();
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
where: DisposableEmailDomains.where.eq('domain'),
});
@@ -246,6 +248,13 @@ export class AdminRepository implements IAdminRepository {
await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower}));
}
async loadAllBannedEmails(): Promise<Array<string>> {
const rows = await fetchMany<{
email_lower: string;
}>(LOAD_ALL_BANNED_EMAILS_QUERY.bind({}));
return rows.map((row) => row.email_lower);
}
async isEmailDomainSuspicious(domain: string): Promise<boolean> {
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
@@ -265,6 +274,13 @@ export class AdminRepository implements IAdminRepository {
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
}
async loadAllSuspiciousEmailDomains(): Promise<Array<string>> {
const rows = await fetchMany<{
domain: string;
}>(LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY.bind({}));
return rows.map((row) => row.domain);
}
async isEmailDomainDisposable(domain: string): Promise<boolean> {
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
+8 -4
View File
@@ -188,10 +188,14 @@ export class AdminService {
adminUserId: UserID,
auditLogReason: string | null,
): Promise<SendSystemDmResponse> {
await this.apiContext.services.worker.addJob('sendSystemDm', {
content: data.content,
user_ids: data.userIds,
});
await this.apiContext.services.worker.addJob(
'sendSystemDm',
{
content: data.content,
user_ids: data.userIds,
},
{requireLedger: true},
);
const metadata = new Map<string, string>([
['recipient_count', data.userIds.length.toString()],
['content_length', data.content.length.toString()],
@@ -57,12 +57,16 @@ export abstract class IAdminRepository {
abstract unbanEmail(email: string): Promise<void>;
abstract loadAllBannedEmails(): Promise<Array<string>>;
abstract isEmailDomainSuspicious(domain: string): Promise<boolean>;
abstract addSuspiciousEmailDomain(domain: string): Promise<void>;
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
abstract loadAllSuspiciousEmailDomains(): Promise<Array<string>>;
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
abstract addDisposableEmailDomain(domain: string): Promise<void>;
@@ -8,6 +8,7 @@ import {
DeleteApiKeyResponse,
ListAdminApiKeyResponse,
type ListAdminApiKeyResponse as ListAdminApiKeyResponseType,
UpdateAdminApiKeyRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {z} from 'zod';
@@ -17,6 +18,19 @@ import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
import type {AdminApiKeyView} from '../services/AdminApiKeyService';
function toApiKeyResponse(key: AdminApiKeyView): ListAdminApiKeyResponseType {
return {
key_id: key.keyId,
name: key.name,
created_at: key.createdAt.toISOString(),
last_used_at: key.lastUsedAt?.toISOString() ?? null,
expires_at: key.expiresAt?.toISOString() ?? null,
created_by_user_id: String(key.createdById),
acls: Array.from(key.acls),
};
}
export function AdminApiKeyAdminController(app: HonoApp) {
app.post(
@@ -53,6 +67,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
);
app.get(
'/admin/api-keys',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
OpenAPI({
operationId: 'list_admin_api_keys',
@@ -68,26 +83,66 @@ export function AdminApiKeyAdminController(app: HonoApp) {
const adminApiKeyService = ctx.get('adminApiKeyService');
const user = ctx.get('user');
const keys = await adminApiKeyService.listKeys(user.id);
const response: Array<ListAdminApiKeyResponseType> = keys.map((key) => ({
key_id: key.keyId,
name: key.name,
created_at: key.createdAt.toISOString(),
last_used_at: key.lastUsedAt?.toISOString() ?? null,
expires_at: key.expiresAt?.toISOString() ?? null,
created_by_user_id: String(key.createdById),
acls: Array.from(key.acls),
}));
const response: Array<ListAdminApiKeyResponseType> = keys.map(toApiKeyResponse);
return ctx.json(response);
},
);
app.get(
'/admin/api-keys/:key_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
Validator('param', KeyIdParam),
OpenAPI({
operationId: 'get_admin_api_key',
summary: 'Get admin API key',
responseSchema: ListAdminApiKeyResponse,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Retrieves a single API key created by the authenticated admin. Returns metadata including creation time, last used time, and assigned permissions. The actual key material is never returned.',
}),
async (ctx) => {
const adminApiKeyService = ctx.get('adminApiKeyService');
const user = ctx.get('user');
const keyId = ctx.req.valid('param').key_id;
const key = await adminApiKeyService.getKey(keyId, user.id);
return ctx.json(toApiKeyResponse(key));
},
);
app.patch(
'/admin/api-keys/:key_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
Validator('param', KeyIdParam),
Validator('json', UpdateAdminApiKeyRequest),
OpenAPI({
operationId: 'update_admin_api_key',
summary: 'Update admin API key',
responseSchema: ListAdminApiKeyResponse,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
}),
async (ctx) => {
const adminApiKeyService = ctx.get('adminApiKeyService');
const user = ctx.get('user');
const adminUserAcls = ctx.get('adminUserAcls');
const keyId = ctx.req.valid('param').key_id;
const key = await adminApiKeyService.updateKey(keyId, user.id, ctx.req.valid('json'), adminUserAcls);
return ctx.json(toApiKeyResponse(key));
},
);
app.delete(
'/admin/api-keys/:keyId',
'/admin/api-keys/:key_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
Validator('param', KeyIdParam),
OpenAPI({
operationId: 'delete_admin_api_key',
summary: 'Delete admin API key',
summary: 'Revoke admin API key',
responseSchema: DeleteApiKeyResponse,
statusCode: 200,
security: ['adminApiKey'],
@@ -98,7 +153,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
async (ctx) => {
const adminApiKeyService = ctx.get('adminApiKeyService');
const user = ctx.get('user');
const keyId = ctx.req.valid('param').keyId;
const keyId = ctx.req.valid('param').key_id;
await adminApiKeyService.revokeKey(keyId, user.id);
return ctx.json({success: true}, 200);
},
@@ -1,16 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
import {
AdminApplicationIdParam,
ApplicationUpdateResponse,
ListGuildApplicationsRequest,
ListGuildApplicationsResponse,
ListUserApplicationsRequest,
ListUserApplicationsResponse,
LookupApplicationRequest,
ListApplicationsQuery,
ListApplicationsResponse,
LookupApplicationResponse,
TransferApplicationOwnershipRequest,
} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {createApplicationID, createGuildID, createUserID} from '../../BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -18,15 +20,73 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
function requireRequestAdminACL(granted: ReadonlySet<string>, required: string): void {
if (!granted.has(required) && !granted.has(AdminACLs.WILDCARD)) {
throw new MissingACLError(required);
}
}
export function ApplicationAdminController(app: HonoApp) {
app.post(
'/admin/applications/lookup',
app.get(
'/admin/applications',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.APPLICATION_LOOKUP, AdminACLs.APPLICATION_LIST_BY_OWNER]),
Validator('query', ListApplicationsQuery),
OpenAPI({
operationId: 'list_admin_applications',
summary: 'List applications',
description:
'Lists OAuth2 applications and bots. Pass owner_id to list the applications a user owns, or guild_id to list the applications whose bot users are members of a guild. Exactly one of the two is required. owner_id requires APPLICATION_LIST_BY_OWNER permission, guild_id requires APPLICATION_LOOKUP permission.',
responseSchema: ListApplicationsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const {owner_id: ownerId, guild_id: guildId} = ctx.req.valid('query');
if (guildId != null && ownerId != null) {
throw InputValidationError.create('guild_id', 'Only one of owner_id and guild_id may be supplied');
}
if (guildId != null) {
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LOOKUP);
return ctx.json(await adminService.applicationService.listGuildApplications(createGuildID(guildId)));
}
if (ownerId != null) {
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LIST_BY_OWNER);
return ctx.json(await adminService.applicationService.listUserApplications(createUserID(ownerId)));
}
throw InputValidationError.create('owner_id', 'One of owner_id and guild_id is required');
},
);
app.get(
'/admin/users/:user_id/applications',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.APPLICATION_LIST_BY_OWNER),
Validator('param', UserIdParam),
OpenAPI({
operationId: 'list_admin_user_applications',
summary: 'List user applications',
description: 'Lists the OAuth2 applications and bots a user owns. Requires APPLICATION_LIST_BY_OWNER permission.',
responseSchema: ListApplicationsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const userId = createUserID(ctx.req.valid('param').user_id);
return ctx.json(await adminService.applicationService.listUserApplications(userId));
},
);
app.get(
'/admin/applications/:application_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.APPLICATION_LOOKUP),
Validator('json', LookupApplicationRequest),
Validator('param', AdminApplicationIdParam),
OpenAPI({
operationId: 'lookup_application',
summary: 'Look up application',
operationId: 'get_admin_application',
summary: 'Get application',
description:
'Retrieves complete application details including ownership, bot user, OAuth2 redirect URIs, and credential status. Requires APPLICATION_LOOKUP permission.',
responseSchema: LookupApplicationResponse,
@@ -36,59 +96,21 @@ export function ApplicationAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.applicationService.lookupApplication(ctx.req.valid('json')));
const applicationId = createApplicationID(ctx.req.valid('param').application_id);
return ctx.json(await adminService.applicationService.lookupApplication(applicationId));
},
);
app.post(
'/admin/applications/list-by-owner',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.APPLICATION_LIST_BY_OWNER),
Validator('json', ListUserApplicationsRequest),
OpenAPI({
operationId: 'admin_list_user_applications',
summary: 'List applications owned by a user',
description:
'Lists all applications (OAuth2 clients and bots) owned by a specific user. Requires APPLICATION_LIST_BY_OWNER permission.',
responseSchema: ListUserApplicationsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.applicationService.listUserApplications(ctx.req.valid('json')));
},
);
app.post(
'/admin/applications/list-by-guild',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.APPLICATION_LOOKUP, AdminACLs.APPLICATION_LIST_BY_OWNER]),
Validator('json', ListGuildApplicationsRequest),
OpenAPI({
operationId: 'admin_list_guild_applications',
summary: 'List applications installed in a guild',
description:
'Lists OAuth2 applications whose bot users are members of a guild. Requires APPLICATION_LOOKUP or APPLICATION_LIST_BY_OWNER permission.',
responseSchema: ListGuildApplicationsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.applicationService.listGuildApplications(ctx.req.valid('json')));
},
);
app.post(
'/admin/applications/transfer-ownership',
app.patch(
'/admin/applications/:application_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.APPLICATION_TRANSFER_OWNERSHIP),
Validator('param', AdminApplicationIdParam),
Validator('json', TransferApplicationOwnershipRequest),
OpenAPI({
operationId: 'transfer_application_ownership',
summary: 'Transfer application ownership',
operationId: 'update_admin_application',
summary: 'Update application',
description:
'Transfers application ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.',
'Updates an application. Transfers ownership to the user given by new_owner_id, which is used when the owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.',
responseSchema: ApplicationUpdateResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -98,8 +120,10 @@ export function ApplicationAdminController(app: HonoApp) {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const applicationId = createApplicationID(ctx.req.valid('param').application_id);
return ctx.json(
await adminService.applicationService.transferApplicationOwnership(
applicationId,
ctx.req.valid('json'),
adminUserId,
auditLogReason,
@@ -3,15 +3,14 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
import {
AdminArchiveCreateRequest,
AdminArchiveResponseSchema,
DownloadUrlResponseSchema,
GetArchiveResponseSchema,
ListArchivesRequest,
ListArchivesQuery,
ListArchivesResponseSchema,
TriggerGuildArchiveRequest,
TriggerUserArchiveRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {ArchivePathParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ArchivePathParam, GuildIdParam, UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {createGuildID, createUserID} from '../../BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
@@ -26,15 +25,34 @@ function canViewArchive(adminAcls: Set<string>, subjectType: 'user' | 'guild'):
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
}
function requireArchiveSubjectAccess(adminAcls: Set<string>, subjectType: 'user' | 'guild'): void {
if (canViewArchive(adminAcls, subjectType) || adminAcls.has(AdminACLs.WILDCARD)) return;
throw new MissingACLError(subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD);
}
function resolveListSubjectType(adminAcls: Set<string>, requested: 'all' | 'user' | 'guild'): 'all' | 'user' | 'guild' {
if (requested !== 'all') {
requireArchiveSubjectAccess(adminAcls, requested);
return requested;
}
const viewUser = canViewArchive(adminAcls, 'user');
const viewGuild = canViewArchive(adminAcls, 'guild');
if (viewUser && viewGuild) return 'all';
if (viewUser) return 'user';
if (viewGuild) return 'guild';
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
}
export function ArchiveAdminController(app: HonoApp) {
app.post(
'/admin/archives/user',
'/admin/users/:user_id/archives',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER),
Validator('json', TriggerUserArchiveRequest),
Validator('param', UserIdParam),
Validator('json', AdminArchiveCreateRequest),
OpenAPI({
operationId: 'trigger_user_archive',
summary: 'Trigger user archive',
operationId: 'create_admin_user_archive',
summary: 'Create user archive',
responseSchema: AdminArchiveResponseSchema,
statusCode: 200,
security: ['adminApiKey'],
@@ -45,23 +63,23 @@ export function ArchiveAdminController(app: HonoApp) {
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminUserId = ctx.get('adminUserId');
const body = ctx.req.valid('json');
const result = await adminArchiveService.triggerUserArchive(
createUserID(body.user_id),
createUserID(ctx.req.valid('param').user_id),
adminUserId,
body.include_attachments,
ctx.req.valid('json').include_attachments,
);
return ctx.json(result, 200);
},
);
app.post(
'/admin/archives/guild',
'/admin/guilds/:guild_id/archives',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_GUILD),
Validator('json', TriggerGuildArchiveRequest),
Validator('param', GuildIdParam),
Validator('json', AdminArchiveCreateRequest),
OpenAPI({
operationId: 'trigger_guild_archive',
summary: 'Trigger guild archive',
operationId: 'create_admin_guild_archive',
summary: 'Create guild archive',
responseSchema: AdminArchiveResponseSchema,
statusCode: 200,
security: ['adminApiKey'],
@@ -72,22 +90,21 @@ export function ArchiveAdminController(app: HonoApp) {
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminUserId = ctx.get('adminUserId');
const body = ctx.req.valid('json');
const result = await adminArchiveService.triggerGuildArchive(
createGuildID(body.guild_id),
createGuildID(ctx.req.valid('param').guild_id),
adminUserId,
body.include_attachments,
ctx.req.valid('json').include_attachments,
);
return ctx.json(result, 200);
},
);
app.post(
'/admin/archives/list',
app.get(
'/admin/archives',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
Validator('json', ListArchivesRequest),
Validator('query', ListArchivesQuery),
OpenAPI({
operationId: 'list_archives',
operationId: 'list_admin_archives',
summary: 'List archives',
responseSchema: ListArchivesResponseSchema,
statusCode: 200,
@@ -99,40 +116,24 @@ export function ArchiveAdminController(app: HonoApp) {
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const body = ctx.req.valid('json') as ListArchivesRequest;
if (
body.subject_type === 'all' &&
!adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL) &&
!adminAcls.has(AdminACLs.WILDCARD)
) {
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
}
if (
body.subject_type !== 'all' &&
!canViewArchive(adminAcls, body.subject_type) &&
!adminAcls.has(AdminACLs.WILDCARD)
) {
throw new MissingACLError(
body.subject_type === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
);
}
const query = ctx.req.valid('query');
const result = await adminArchiveService.listArchives({
subjectType: body.subject_type as 'user' | 'guild' | 'all',
subjectId: body.subject_id ?? undefined,
requestedBy: body.requested_by ?? undefined,
limit: body.limit,
includeExpired: body.include_expired,
subjectType: resolveListSubjectType(adminAcls, query.subject_type),
subjectId: query.subject_id ?? undefined,
requestedBy: query.requested_by ?? undefined,
limit: query.limit,
includeExpired: query.include_expired,
});
return ctx.json({archives: result}, 200);
},
);
app.get(
'/admin/archives/:subjectType/:subjectId/:archiveId',
'/admin/archives/:subject_type/:subject_id/:archive_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
Validator('param', ArchivePathParam),
OpenAPI({
operationId: 'get_archive_details',
operationId: 'get_admin_archive',
summary: 'Get archive details',
responseSchema: GetArchiveResponseSchema,
statusCode: 200,
@@ -145,25 +146,18 @@ export function ArchiveAdminController(app: HonoApp) {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const params = ctx.req.valid('param');
const subjectType = params.subjectType;
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
throw new MissingACLError(
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
);
}
const subjectId = params.subjectId;
const archiveId = params.archiveId;
const archive = await adminArchiveService.getArchive(subjectType, subjectId, archiveId);
requireArchiveSubjectAccess(adminAcls, params.subject_type);
const archive = await adminArchiveService.getArchive(params.subject_type, params.subject_id, params.archive_id);
return ctx.json({archive}, 200);
},
);
app.get(
'/admin/archives/:subjectType/:subjectId/:archiveId/download',
'/admin/archives/:subject_type/:subject_id/:archive_id/download',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
Validator('param', ArchivePathParam),
OpenAPI({
operationId: 'get_archive_download_url',
operationId: 'get_admin_archive_download',
summary: 'Get archive download URL',
responseSchema: DownloadUrlResponseSchema,
statusCode: 200,
@@ -176,15 +170,12 @@ export function ArchiveAdminController(app: HonoApp) {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const params = ctx.req.valid('param');
const subjectType = params.subjectType;
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
throw new MissingACLError(
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
);
}
const subjectId = params.subjectId;
const archiveId = params.archiveId;
const result = await adminArchiveService.getDownloadUrl(subjectType, subjectId, archiveId);
requireArchiveSubjectAccess(adminAcls, params.subject_type);
const result = await adminArchiveService.getDownloadUrl(
params.subject_type,
params.subject_id,
params.archive_id,
);
return ctx.json(result, 200);
},
);
@@ -2,6 +2,8 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {PurgeGuildAssetsRequest, PurgeGuildAssetsResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {createGuildID} from '../../BrandedTypes';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -10,20 +12,21 @@ import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function AssetAdminController(app: HonoApp) {
app.post(
'/admin/assets/purge',
app.delete(
'/admin/guilds/:guild_id/assets',
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
requireAdminACL(AdminACLs.ASSET_PURGE),
Validator('param', GuildIdParam),
Validator('json', PurgeGuildAssetsRequest),
OpenAPI({
operationId: 'purge_guild_assets',
operationId: 'purge_admin_guild_assets',
summary: 'Purge guild assets',
responseSchema: PurgeGuildAssetsResponseSchema,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Delete and clean up all assets belonging to a guild, including icons, banners, and other media. This is a destructive operation used for cleanup during guild management or compliance actions.',
'Delete and clean up emoji and sticker assets belonging to a guild, including their stored media. An ID owned by another guild is reported in errors and left untouched, and an ID with no record still queues its media for removal. This is a destructive operation used for cleanup during guild management or compliance actions.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
@@ -32,6 +35,7 @@ export function AssetAdminController(app: HonoApp) {
const data = ctx.req.valid('json');
return ctx.json(
await adminService.assetPurgeService.purgeGuildAssets({
guildId: createGuildID(ctx.req.valid('param').guild_id),
ids: data.ids,
adminUserId,
auditLogReason,
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
import {
AdminAuditLogResponseSchema,
AuditLogIdParam,
AuditLogsListResponseSchema,
ListAuditLogsRequest,
SearchAuditLogsRequest,
ListAdminAuditLogsQuery,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
@@ -14,44 +17,66 @@ import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function AuditLogAdminController(app: HonoApp) {
app.post(
app.get(
'/admin/audit-logs',
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
Validator('json', ListAuditLogsRequest),
Validator('query', ListAdminAuditLogsQuery),
OpenAPI({
operationId: 'list_audit_logs',
summary: 'List audit logs',
operationId: 'list_admin_audit_logs',
summary: 'List admin audit logs',
responseSchema: AuditLogsListResponseSchema,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Retrieve a paginated list of audit logs with optional filtering by date range, action type, or actor. Used for tracking administrative operations and compliance auditing.',
'Retrieve a paginated page of audit logs with optional filtering by acting admin, target type, or target ID. Passing q runs a full-text search across the audit log index instead of paging through the log in order, and sort_by with sort_order then order the matches. Used for tracking administrative operations, compliance auditing, and incident response.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.auditService.listAuditLogs(ctx.req.valid('json')));
const {q, admin_user_id, target_type, target_id, sort_by, sort_order, limit, offset} = ctx.req.valid('query');
if (q === undefined) {
return ctx.json(
await adminService.auditService.listAuditLogs({admin_user_id, target_type, target_id, limit, offset}),
);
}
return ctx.json(
await adminService.auditService.searchAuditLogs({
query: q,
admin_user_id,
target_type,
target_id,
sort_by,
sort_order,
limit,
offset,
}),
);
},
);
app.post(
'/admin/audit-logs/search',
app.get(
'/admin/audit-logs/:log_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
Validator('json', SearchAuditLogsRequest),
Validator('param', AuditLogIdParam),
OpenAPI({
operationId: 'search_audit_logs',
summary: 'Search audit logs',
responseSchema: AuditLogsListResponseSchema,
operationId: 'get_admin_audit_log',
summary: 'Get admin audit log entry',
responseSchema: AdminAuditLogResponseSchema,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
description:
'Perform a full-text search across audit logs for specific events or changes. Allows targeted queries for compliance investigations or incident response.',
'Retrieve a single admin audit log entry by ID, with the same resolved user, guild, and channel summaries the listing returns. Used to inspect one administrative operation during compliance investigations or incident response.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.auditService.searchAuditLogs(ctx.req.valid('json')));
const {log_id} = ctx.req.valid('param');
const log = await adminService.auditService.getAuditLog(log_id);
if (!log) {
throw new NotFoundError({code: APIErrorCodes.NOT_FOUND});
}
return ctx.json(log);
},
);
}
File diff suppressed because it is too large Load Diff
@@ -1,17 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
BulkAddGuildMembersRequest,
BulkUpdateGuildFeaturesRequest,
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
import {AdminBulkJobCreateRequest, AdminBulkTaskType} from '@fluxer/schema/src/domains/admin/AdminBulkSchemas';
import {BulkJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
BulkScheduleUserDeletionRequest,
BulkUpdateSuspiciousActivityFlagsRequest,
BulkUpdateUserFlagsRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import type {UserID} from '../../BrandedTypes';
import {requireAnyAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {getWorkerService} from '../../middleware/ServiceRegistry';
@@ -19,27 +13,25 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function BulkAdminController(app: HonoApp) {
app.post(
'/admin/bulk/update-user-flags',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_UPDATE_USER_FLAGS),
Validator('json', BulkUpdateUserFlagsRequest),
OpenAPI({
operationId: 'bulk_update_user_flags',
summary: 'Bulk update user flags',
description:
'Enqueue a background job that modifies user flags (e.g., verified, bot, system) for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
responseSchema: BulkJobResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const jobId = await getWorkerService().addJob(
const BULK_TASK_ACLS: Record<AdminBulkTaskType, string> = {
[AdminBulkTaskType.UPDATE_USER_FLAGS]: AdminACLs.BULK_UPDATE_USER_FLAGS,
[AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS]: AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY,
[AdminBulkTaskType.UPDATE_GUILD_FEATURES]: AdminACLs.BULK_UPDATE_GUILD_FEATURES,
[AdminBulkTaskType.ADD_GUILD_MEMBERS]: AdminACLs.BULK_ADD_GUILD_MEMBERS,
[AdminBulkTaskType.SCHEDULE_USER_DELETION]: AdminACLs.BULK_DELETE_USERS,
[AdminBulkTaskType.DELETE_USER_MESSAGES]: AdminACLs.BULK_DELETE_USER_MESSAGES,
};
async function queueBulkJob(
body: AdminBulkJobCreateRequest,
adminUserId: UserID,
auditLogReason: string | null,
): Promise<bigint> {
const workerService = getWorkerService();
const options = {requestedByUserId: adminUserId, requireLedger: true, ...(auditLogReason && {auditLogReason})};
switch (body.task) {
case AdminBulkTaskType.UPDATE_USER_FLAGS:
return await workerService.addJob(
'bulkUpdateUserFlags',
{
user_ids: body.user_ids.map((id) => id.toString()),
@@ -48,31 +40,10 @@ export function BulkAdminController(app: HonoApp) {
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
options,
);
return ctx.json({job_id: jobId.toString()});
},
);
app.post(
'/admin/bulk/update-suspicious-activity-flags',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY),
Validator('json', BulkUpdateSuspiciousActivityFlagsRequest),
OpenAPI({
operationId: 'bulk_update_suspicious_activity_flags',
summary: 'Bulk update suspicious activity flags',
description:
'Enqueue a background job that modifies suspicious activity flags for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
responseSchema: BulkJobResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const jobId = await getWorkerService().addJob(
case AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS:
return await workerService.addJob(
'bulkUpdateSuspiciousActivityFlags',
{
user_ids: body.user_ids.map((id) => id.toString()),
@@ -81,31 +52,10 @@ export function BulkAdminController(app: HonoApp) {
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
options,
);
return ctx.json({job_id: jobId.toString()});
},
);
app.post(
'/admin/bulk/update-guild-features',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_UPDATE_GUILD_FEATURES),
Validator('json', BulkUpdateGuildFeaturesRequest),
OpenAPI({
operationId: 'bulk_update_guild_features',
summary: 'Bulk update guild features',
description:
'Enqueue a background job that modifies guild features across multiple servers. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
responseSchema: BulkJobResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const jobId = await getWorkerService().addJob(
case AdminBulkTaskType.UPDATE_GUILD_FEATURES:
return await workerService.addJob(
'bulkUpdateGuildFeatures',
{
guild_ids: body.guild_ids.map((id) => id.toString()),
@@ -114,31 +64,10 @@ export function BulkAdminController(app: HonoApp) {
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
options,
);
return ctx.json({job_id: jobId.toString()});
},
);
app.post(
'/admin/bulk/add-guild-members',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_ADD_GUILD_MEMBERS),
Validator('json', BulkAddGuildMembersRequest),
OpenAPI({
operationId: 'bulk_add_guild_members',
summary: 'Bulk add guild members',
description:
'Enqueue a background job that adds multiple users to a guild. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
responseSchema: BulkJobResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const jobId = await getWorkerService().addJob(
case AdminBulkTaskType.ADD_GUILD_MEMBERS:
return await workerService.addJob(
'bulkAddGuildMembers',
{
guild_id: body.guild_id.toString(),
@@ -146,31 +75,10 @@ export function BulkAdminController(app: HonoApp) {
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
options,
);
return ctx.json({job_id: jobId.toString()});
},
);
app.post(
'/admin/bulk/schedule-user-deletion',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
requireAdminACL(AdminACLs.BULK_DELETE_USERS),
Validator('json', BulkScheduleUserDeletionRequest),
OpenAPI({
operationId: 'schedule_bulk_user_deletion',
summary: 'Schedule bulk user deletion',
description:
'Enqueue a background job that schedules account deletions for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the worker version skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.',
responseSchema: BulkJobResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const jobId = await getWorkerService().addJob(
case AdminBulkTaskType.SCHEDULE_USER_DELETION:
return await workerService.addJob(
'bulkScheduleUserDeletion',
{
user_ids: body.user_ids.map((id) => id.toString()),
@@ -180,8 +88,54 @@ export function BulkAdminController(app: HonoApp) {
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
options,
);
case AdminBulkTaskType.DELETE_USER_MESSAGES:
return await workerService.addJob(
'bulkDeleteMessagesForUsers',
{
user_ids: body.user_ids.map((id) => id.toString()),
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
options,
);
}
}
export function BulkAdminController(app: HonoApp) {
app.post(
'/admin/bulk-jobs',
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
Validator('json', AdminBulkJobCreateRequest),
requireAnyAdminACL([
AdminACLs.BULK_UPDATE_USER_FLAGS,
AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY,
AdminACLs.BULK_UPDATE_GUILD_FEATURES,
AdminACLs.BULK_ADD_GUILD_MEMBERS,
AdminACLs.BULK_DELETE_USERS,
AdminACLs.BULK_DELETE_USER_MESSAGES,
]),
OpenAPI({
operationId: 'create_admin_bulk_job',
summary: 'Queue a bulk job',
description:
'Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the schedule_user_deletion worker skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.',
responseSchema: BulkJobResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminUserId = ctx.get('adminUserId');
const adminAcls = ctx.get('adminUserAcls');
const auditLogReason = ctx.get('auditLogReason');
const body = ctx.req.valid('json');
const requiredAcl = BULK_TASK_ACLS[body.task];
if (!adminAcls.has(requiredAcl) && !adminAcls.has(AdminACLs.WILDCARD)) {
throw new MissingACLError(requiredAcl);
}
const jobId = await queueBulkJob(body, adminUserId, auditLogReason);
return ctx.json({job_id: jobId.toString()});
},
);
@@ -17,15 +17,15 @@ function trimTrailingSlash(value: string): string {
export function CodesAdminController(app: HonoApp) {
app.post(
'/admin/codes/gift',
'/admin/gift-codes',
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
requireAdminACL(AdminACLs.GIFT_CODES_GENERATE),
Validator('json', GenerateGiftCodesRequest),
OpenAPI({
operationId: 'generate_gift_codes',
summary: 'Generate gift codes',
operationId: 'create_admin_gift_codes',
summary: 'Issue gift codes',
description:
'Create one-use Plutonium gift codes with an explicit positive duration. Lifetime gifts are not supported.',
'Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.',
responseSchema: CodesResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -1,15 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {
DiscoveryAdminApplicationUpdateRequest,
DiscoveryAdminCategoryListingQuery,
DiscoveryAdminListedGuildResponse,
DiscoveryAdminListingBulkCategoryRequest,
DiscoveryAdminListingBulkCategoryResponse,
DiscoveryAdminPendingApplicationResponse,
DiscoveryAdminRejectRequest,
DiscoveryAdminRemoveRequest,
DiscoveryAdminReviewRequest,
DiscoveryApplicationPatchRequest,
DiscoveryApplicationResponse,
DiscoveryCategoryIdParam,
DiscoveryCategoryListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {z} from 'zod';
import {createGuildID} from '../../BrandedTypes';
@@ -41,8 +46,6 @@ function mapRowToApplicationResponse(row: GuildDiscoveryRow) {
};
}
const ADMIN_LIST_HARD_CAP = 1000;
interface GuildEnrichment {
name: string;
icon: string | null;
@@ -131,8 +134,8 @@ export function DiscoveryAdminController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
OpenAPI({
operationId: 'list_pending_discovery_applications',
summary: 'List all pending discovery applications',
operationId: 'list_admin_discovery_applications',
summary: 'List discovery applications',
description:
'Returns every pending discovery application, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminPendingApplicationResponse),
@@ -144,21 +147,106 @@ export function DiscoveryAdminController(app: HonoApp) {
const discoveryService = ctx.get('discoveryService');
const guildService = ctx.get('guildService');
const userRepository = ctx.get('userRepository');
const rows = await discoveryService.listByStatus({
status: DiscoveryApplicationStatus.PENDING,
limit: ADMIN_LIST_HARD_CAP,
});
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.PENDING});
const enrichment = await enrichGuilds(rows, guildService, userRepository);
return ctx.json(rows.map((row) => mapPendingResponse(row, enrichment.get(row.guild_id.toString()))));
},
);
app.patch(
'/admin/discovery/applications/:guild_id',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
Validator('param', GuildIdParam),
Validator('json', DiscoveryAdminApplicationUpdateRequest),
OpenAPI({
operationId: 'update_admin_discovery_application',
summary: 'Review discovery application',
description: 'Approve or reject a pending discovery application. Requires DISCOVERY_REVIEW permission.',
responseSchema: DiscoveryApplicationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
const data = ctx.req.valid('json');
const adminUserId = ctx.get('adminUserId');
const discoveryService = ctx.get('discoveryService');
const row =
data.status === DiscoveryApplicationStatus.APPROVED
? await discoveryService.approve({guildId, adminUserId, reason: data.reason})
: await discoveryService.reject({guildId, adminUserId, reason: data.reason});
return ctx.json(mapRowToApplicationResponse(row));
},
);
app.get(
'/admin/discovery/listed',
'/admin/discovery/categories',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
OpenAPI({
operationId: 'list_discovery_listed_guilds',
summary: 'List all guilds currently listed in discovery',
operationId: 'list_admin_discovery_categories',
summary: 'List discovery categories',
description:
'Returns every discovery category a listing can be filed under. Requires DISCOVERY_REVIEW permission.',
responseSchema: DiscoveryCategoryListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
return ctx.json(
Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({
id: Number(id),
name,
})),
);
},
);
app.get(
'/admin/discovery/categories/:category_id/listings',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
Validator('param', DiscoveryCategoryIdParam),
Validator('query', DiscoveryAdminCategoryListingQuery),
OpenAPI({
operationId: 'list_admin_discovery_category_listings',
summary: 'List guilds in a discovery category',
description:
'Returns an offset page of the guilds listed under one discovery category, most members first, enriched with guild metadata. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const {category_id} = ctx.req.valid('param');
const {limit, offset} = ctx.req.valid('query');
const discoveryService = ctx.get('discoveryService');
const guildService = ctx.get('guildService');
const userRepository = ctx.get('userRepository');
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED});
const inCategory = rows.filter((row) => row.category_type === category_id);
const enrichment = await enrichGuilds(inCategory, guildService, userRepository);
const sorted = [...inCategory].sort(
(left, right) =>
(enrichment.get(right.guild_id.toString())?.member_count ?? 0) -
(enrichment.get(left.guild_id.toString())?.member_count ?? 0),
);
return ctx.json(
sorted
.slice(offset, offset + limit)
.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))),
);
},
);
app.get(
'/admin/discovery/listings',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
OpenAPI({
operationId: 'list_admin_discovery_listings',
summary: 'List discovery listings',
description:
'Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
@@ -170,24 +258,59 @@ export function DiscoveryAdminController(app: HonoApp) {
const discoveryService = ctx.get('discoveryService');
const guildService = ctx.get('guildService');
const userRepository = ctx.get('userRepository');
const rows = await discoveryService.listByStatus({
status: DiscoveryApplicationStatus.APPROVED,
limit: ADMIN_LIST_HARD_CAP,
});
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED});
const enrichment = await enrichGuilds(rows, guildService, userRepository);
return ctx.json(rows.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))));
},
);
app.post(
'/admin/discovery/applications/:guild_id/approve',
app.patch(
'/admin/discovery/listings',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
Validator('json', DiscoveryAdminListingBulkCategoryRequest),
OpenAPI({
operationId: 'bulk_update_admin_discovery_listing_category',
summary: 'Move discovery listings to a category',
description:
'Files every named discovery listing under one category. Every guild is attempted and the ones that could not be moved are reported. Requires DISCOVERY_REVIEW permission.',
responseSchema: DiscoveryAdminListingBulkCategoryResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const data = ctx.req.valid('json');
const adminUserId = ctx.get('adminUserId');
const discoveryService = ctx.get('discoveryService');
const guildIds = [...new Set(data.guild_ids)];
const failed: Array<string> = [];
let updated = 0;
for (const rawGuildId of guildIds) {
try {
await discoveryService.editApplication({
guildId: createGuildID(rawGuildId),
userId: adminUserId,
data: {category_type: data.category_type},
});
updated += 1;
} catch {
failed.push(rawGuildId.toString());
}
}
return ctx.json({updated, failed_guild_ids: failed});
},
);
app.patch(
'/admin/discovery/listings/:guild_id',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
Validator('param', GuildIdParam),
Validator('json', DiscoveryAdminReviewRequest),
Validator('json', DiscoveryApplicationPatchRequest),
OpenAPI({
operationId: 'approve_discovery_application',
summary: 'Approve discovery application',
description: 'Approve a pending discovery application. Requires DISCOVERY_REVIEW permission.',
operationId: 'update_admin_discovery_listing',
summary: 'Update discovery listing',
description:
'Edit the description, category, language, or tags of a discovery listing without delisting the guild. Requires DISCOVERY_REVIEW permission.',
responseSchema: DiscoveryApplicationResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -199,44 +322,19 @@ export function DiscoveryAdminController(app: HonoApp) {
const data = ctx.req.valid('json');
const adminUserId = ctx.get('adminUserId');
const discoveryService = ctx.get('discoveryService');
const row = await discoveryService.approve({guildId, adminUserId, reason: data.reason});
const row = await discoveryService.editApplication({guildId, userId: adminUserId, data});
return ctx.json(mapRowToApplicationResponse(row));
},
);
app.post(
'/admin/discovery/applications/:guild_id/reject',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
Validator('param', GuildIdParam),
Validator('json', DiscoveryAdminRejectRequest),
OpenAPI({
operationId: 'reject_discovery_application',
summary: 'Reject discovery application',
description: 'Reject a pending discovery application. Requires DISCOVERY_REVIEW permission.',
responseSchema: DiscoveryApplicationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const {guild_id} = ctx.req.valid('param');
const guildId = createGuildID(guild_id);
const data = ctx.req.valid('json');
const adminUserId = ctx.get('adminUserId');
const discoveryService = ctx.get('discoveryService');
const row = await discoveryService.reject({guildId, adminUserId, reason: data.reason});
return ctx.json(mapRowToApplicationResponse(row));
},
);
app.post(
'/admin/discovery/guilds/:guild_id/remove',
app.delete(
'/admin/discovery/listings/:guild_id',
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
requireAdminACL(AdminACLs.DISCOVERY_REMOVE),
Validator('param', GuildIdParam),
Validator('json', DiscoveryAdminRemoveRequest),
OpenAPI({
operationId: 'remove_from_discovery',
summary: 'Remove guild from discovery',
operationId: 'delete_admin_discovery_listing',
summary: 'Remove discovery listing',
description: 'Remove an approved guild from discovery. Requires DISCOVERY_REMOVE permission.',
responseSchema: DiscoveryApplicationResponse,
statusCode: 200,
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {GetProcessMemoryStatsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import {GetProcessMemoryStatsQuery} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import {
GatewayVoiceStateCountsResponse,
GuildMemoryStatsResponse,
@@ -18,54 +18,12 @@ import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function GatewayAdminController(app: HonoApp) {
app.post(
'/admin/gateway/memory-stats',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
Validator('json', GetProcessMemoryStatsRequest),
OpenAPI({
operationId: 'get_guild_memory_statistics',
summary: 'Get guild memory statistics',
description: 'Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.',
responseSchema: GuildMemoryStatsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(body.limit));
},
);
app.post(
'/admin/gateway/reload-all',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD),
requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL),
Validator('json', ReloadGuildsRequest),
OpenAPI({
operationId: 'reload_all_specified_guilds',
summary: 'Reload specified guilds',
description:
'Reconnects to the database and re-syncs guild state. Used for recovery after data inconsistencies. Requires GATEWAY_RELOAD_ALL permission.',
responseSchema: ReloadAllGuildsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
const guildIds = body.guild_ids.map((id) => createGuildID(id));
return ctx.json(await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds));
},
);
app.get(
'/admin/gateway/stats',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
OpenAPI({
operationId: 'get_gateway_node_statistics',
operationId: 'get_admin_gateway_stats',
summary: 'Get gateway node statistics',
description:
'Returns uptime, process memory, and guild count. Used to monitor gateway health and performance. Requires GATEWAY_MEMORY_STATS permission.',
@@ -79,12 +37,32 @@ export function GatewayAdminController(app: HonoApp) {
return ctx.json(await adminService.guildServiceAggregate.managementService.getNodeStats());
},
);
app.get(
'/admin/gateway/memory-stats',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
Validator('query', GetProcessMemoryStatsQuery),
OpenAPI({
operationId: 'get_admin_gateway_memory_stats',
summary: 'Get guild memory statistics',
description: 'Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.',
responseSchema: GuildMemoryStatsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const {limit} = ctx.req.valid('query');
return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(limit));
},
);
app.get(
'/admin/gateway/voice-state-counts',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
OpenAPI({
operationId: 'get_gateway_voice_state_counts',
operationId: 'get_admin_gateway_voice_state_counts',
summary: 'Get gateway voice state counts',
description:
'Returns active voice state counts grouped by voice region and voice server. Requires GATEWAY_MEMORY_STATS permission.',
@@ -98,4 +76,26 @@ export function GatewayAdminController(app: HonoApp) {
return ctx.json(await adminService.guildServiceAggregate.managementService.getVoiceStateCounts());
},
);
app.post(
'/admin/gateway/reloads',
RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD),
requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL),
Validator('json', ReloadGuildsRequest),
OpenAPI({
operationId: 'create_admin_gateway_reload',
summary: 'Reload gateway guilds',
description:
'Reconnects to the database and re-syncs guild state. Used for recovery after data inconsistencies. Requires GATEWAY_RELOAD_ALL permission.',
responseSchema: ReloadAllGuildsResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const body = ctx.req.valid('json');
const guildIds = body.guild_ids.map((id) => createGuildID(id));
return ctx.json(await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds));
},
);
}

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