Compare commits

...
Author SHA1 Message Date
HampusandGitHub 38b7c63431 fix(admin): declare gateway cluster_metrics in node stats (#2728) 2026-09-12 22:48:22 +02:00
HampusandGitHub 57d08cd091 fix(api): keep stickers on messages sent to personal notes (#2727) 2026-09-12 20:20:26 +02:00
HampusandGitHub 91e2d31614 style(voice): format the room_finished webhook test 2026-09-12 16:05:26 +02:00
HampusandGitHub d375dc7946 fix(ci): stop a new component blocking every other image promote 2026-09-12 16:01:47 +02:00
HampusandGitHub 3fffce2a4a fix(voice): correct the room_finished test harness types (#2723) 2026-09-12 15:49:47 +02:00
HampusandGitHub 376c509083 docs(self-host): tighten the env example comments (#2722) 2026-09-12 15:39:24 +02:00
HampusandGitHub 156315fd5a docs: drop exact counts that go stale when inventory changes (#2721) 2026-09-12 15:39:07 +02:00
HampusandGitHub c7b9e9b9bd fix(voice): stop room_finished evicting a whole channel (#2720) 2026-09-12 15:38:50 +02:00
HampusandGitHub 12397032e3 feat(voice): add the recon service and remove the old worker (#2719) 2026-09-12 15:38:32 +02:00
HampusandGitHub 4a285cbb11 fix(docs): drop the orphaned voice command footnote (#2718) 2026-09-12 01:45:27 +02:00
HampusandGitHub 6d600990fe fix(app): derive unread from an ack timestamp floor (#2717) 2026-09-12 01:40:36 +02:00
HampusandGitHub e1bc6c2f7e refactor(voice): remove the unused voice state ack (#2716) 2026-09-12 01:37:45 +02:00
HampusandGitHub 3e79530389 fix(app): defer non-critical gateway dispatches (#2715) 2026-09-12 01:36:04 +02:00
HampusandGitHub d0c84b3d9b fix(voice): apply noise suppression in the mic test (#2714) 2026-09-12 01:14:36 +02:00
HampusandGitHub 3affd295e8 feat(guild): require opt-in for emoji and sticker cloning (#2712) 2026-09-12 00:33:23 +02:00
HampusandGitHub 7b15e5be0f fix(admin): reject synthetic user ids on mutating routes (#2711) 2026-09-12 00:23:04 +02:00
HampusandGitHub adab646d1e fix(schema): preserve WebAuthn payloads and align fixtures (#2710) 2026-09-12 00:19:44 +02:00
HampusandGitHub de1fd95a99 refactor(schema): simplify validation and OpenAPI generation (#2709) 2026-09-11 23:24:26 +02:00
HampusandGitHub 4bc5593f9f chore(i18n): translate the voice quality catalog additions (#2707) 2026-09-11 22:59:12 +02:00
HampusandGitHub 172791316b feat(voice): add noise suppression backends and rollout (#2706) 2026-09-11 22:24:05 +02:00
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
1354 changed files with 131141 additions and 77787 deletions
+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.
+3
View File
@@ -33,6 +33,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:recon:
- changed-files:
- any-glob-to-any-file: fluxer_recon/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
+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
+36
View File
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build recon
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: write
jobs:
approve:
name: approve build release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Build release approved."
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-recon
dockerfile: fluxer_recon/Dockerfile
build-version: ${{ inputs['build-version'] }}
+29 -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
Generated
+20
View File
@@ -1866,6 +1866,25 @@ dependencies = [
"url",
]
[[package]]
name = "fluxer-recon"
version = "0.0.0"
dependencies = [
"anyhow",
"axum",
"base64",
"fluxer-svc",
"hmac 0.13.0",
"reqwest",
"scylla",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "fluxer-snowflakes"
version = "0.1.0"
@@ -1940,6 +1959,7 @@ dependencies = [
"anyhow",
"chrono",
"fluxer-svc",
"fluxer_common",
"futures",
"moka",
"rmp-serde",
+1
View File
@@ -15,6 +15,7 @@ members = [
"fluxer_users",
"fluxer_unfurl",
"packages/markdown_parser/rust",
"fluxer_recon",
]
exclude = [
"fluxer_marketing",
+23 -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,
@@ -116,10 +127,21 @@
},
"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"}}},
+5 -1
View File
@@ -65,10 +65,14 @@ FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
FLUXER_RECON_MODE=observing
FLUXER_RECON_EXPECTED_ROOMS=64
FLUXER_RECON_WARMUP_SECONDS=15
FLUXER_RECON_MAX_HOT_ROOMS=8
FLUXER_API_PORT=8080
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
FLUXER_API_WORKER_MODE=all_lanes
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION=true
FLUXER_APP_DEV_PORT=3000
FLUXER_APP_PROXY_PORT=8773
FLUXER_STATIC_DIR=fluxer_app/dist
+160 -52
View File
@@ -1,18 +1,21 @@
# 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.
# Every variable docker-compose.yml reads is named here, uncommented when it has
# no default and commented with its default when it has one. A name absent from
# this file reaches a service only through a Compose override. Compose expands
# top to bottom, so a line using ${...} must sit below every name it reads.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
# The lines above are the address browsers use, and every advertised endpoint
# carries FLUXER_PUBLIC_PORT. They do not move what the host publishes.
# FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below do that, and a non-default port
# needs the matching one set as well. Complete recipes sit beside them.
# 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.
# Point DNS at this host.
#
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
@@ -33,50 +36,70 @@ FLUXER_PUBLIC_PORT=443
# address if it reaches Fluxer from a public IP.
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
# The public origin browsers use, without a trailing slash. Derived from the three
# values above and correct for the usual https-on-443 setup, so leave it alone
# unless you serve Fluxer on a non-default port, where the port must appear here.
# 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 Fluxer's edge listens on. Honoured in the default mode
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
# file win, so a value here is discarded under the proxy overlay with no warning.
# Set it only for an unusual default-mode layout, such as serving several
# hostnames or binding a non-default TLS port.
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
# 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.
# The old name for the value above, read only when FLUXER_EDGE_SITE_ADDRESS is
# unset, so an existing .env keeps the listener it already had.
#FLUXER_CADDY_SITE_ADDRESS=
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
# origin written with a default port never matches a browser Origin header.
# Serving on any other port means setting all three, plus the published port
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
# stay below the two values it reads. Above them it silently expands to a bare
# host with a trailing colon.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
#FLUXER_HTTP_PORT=19080
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
# the container, so change only these when something else already owns the
# standard ports or another proxy sits in front. Both take an optional bind
# address in front of the port, and 127.0.0.1 keeps the publish off every
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
# together, because HTTP/3 needs both on the same port.
# Host side of the edge's publishes, and the only 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.
@@ -88,6 +111,60 @@ 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
@@ -100,6 +177,12 @@ 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
@@ -147,9 +230,11 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
# Set it only when LiveKit is served from another host.
# 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
@@ -157,6 +242,16 @@ LIVEKIT_API_SECRET=CHANGE_ME
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# LiveKit finds the address browsers dial by asking a STUN server. A host that
# cannot reach one over UDP stops with "could not resolve external IP", and the
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the STUN entries at another
# server to keep the lookup and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
FLUXER_KLIPY_API_KEY=
FLUXER_EMAIL_ENABLED=false
@@ -178,18 +273,18 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. The 25 limits sum to 16.75 GiB, which is a sum of ceilings and
# Container memory. The 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.
# The reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from services whose death takes the instance down. They reserve
# nothing. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
#FLUXER_NATS_MEMORY_LIMIT=256mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=512mb
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
#FLUXER_API_MEMORY_LIMIT=2560mb
@@ -217,6 +312,14 @@ FLUXER_DISCOVERY_ENABLED=true
# 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
# outside the heap. Leave these unset unless you have a reason to pin the value.
@@ -255,11 +358,16 @@ 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. The Rust built-in defaults are 192
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
# In-flight request ceiling for the services Compose forwards it to: the users
# and messages routers and their shards. Leave it unset and each service uses its
# built-in default. Set it and the one value replaces that default on all of
# them, so size it for the busiest. 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
+11 -33
View File
@@ -13,73 +13,51 @@
}
handle_path /api/* {
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy api:8080
}
handle /gateway {
rewrite * /
reverse_proxy gateway:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy gateway:8080
}
handle_path /gateway/* {
reverse_proxy gateway:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy gateway:8080
}
handle_path /media/* {
reverse_proxy media-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy media-proxy:8080
}
handle_path /livekit/* {
reverse_proxy livekit:7880 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy livekit:7880
}
handle /admin {
rewrite * /
reverse_proxy admin:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy admin:8080
}
handle_path /admin/* {
reverse_proxy admin:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy admin:8080
}
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
handle @staticAssets {
reverse_proxy static-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy static-proxy:8080
}
handle /.well-known/fluxer {
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy api:8080
}
handle {
reverse_proxy app-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy app-proxy:8080
}
}
:8088 {
handle /.well-known/fluxer {
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
reverse_proxy api:8080
}
}
+54 -39
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
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: us-east-1
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,16 +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_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
FLUXER_STRIPE_ENABLED: "false"
FLUXER_NCMEC_ENABLED: "false"
FLUXER_CLAMAV_ENABLED: "false"
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}
@@ -131,7 +134,7 @@ services:
- "${FLUXER_HTTPS_PORT:-443}:443"
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
environment:
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN 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
@@ -258,9 +261,11 @@ 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
@@ -284,11 +289,16 @@ services:
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
@@ -332,10 +342,11 @@ services:
rtc:
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
use_external_ip: true
use_external_ip: ${FLUXER_LIVEKIT_USE_EXTERNAL_IP:-true}
node_ip: "${FLUXER_LIVEKIT_NODE_IP:-}"
stun_servers:
- stun.l.google.com:19302
- stun1.l.google.com:19302
- ${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:
@@ -490,6 +501,10 @@ services:
environment:
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
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
@@ -550,7 +565,7 @@ services:
<<: *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}
@@ -568,7 +583,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -618,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}
@@ -636,7 +651,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
+2
View File
@@ -2,3 +2,5 @@ services:
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',
},
},
];
+83 -1
View File
@@ -35,8 +35,9 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
}
let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json");
let spec: openapiv3::OpenAPI =
let mut spec: openapiv3::OpenAPI =
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
adapt_progenitor_throttled_errors(&mut spec);
let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional);
@@ -54,6 +55,87 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
fs::write(&output_path, content).expect("failed to write generated API code");
}
fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
let schemas = &spec
.components
.as_ref()
.expect("missing API components")
.schemas;
let error = serde_json::to_value(schemas.get("Error").expect("missing Error schema"))
.expect("failed to inspect Error schema");
let mut throttled = serde_json::to_value(
schemas
.get("ThrottledError")
.expect("missing ThrottledError schema"),
)
.expect("failed to inspect ThrottledError schema");
assert_eq!(
error["additionalProperties"],
serde_json::json!({}),
"Progenitor error adaptation requires Error to retain all additional fields"
);
let properties = throttled["properties"]
.as_object_mut()
.expect("ThrottledError must be an object schema");
assert_eq!(
properties
.remove("retry_after")
.expect("missing retry_after")["type"],
"number"
);
assert_eq!(
properties.remove("global").expect("missing global")["type"],
"boolean"
);
assert_eq!(
throttled, error,
"ThrottledError must extend the common Error schema"
);
for path in spec.paths.paths.values_mut() {
let openapiv3::ReferenceOr::Item(path) = path else {
panic!("Progenitor error adaptation requires inline API paths");
};
for operation in [
&mut path.get,
&mut path.put,
&mut path.post,
&mut path.delete,
&mut path.options,
&mut path.head,
&mut path.patch,
&mut path.trace,
]
.into_iter()
.flatten()
{
let Some(response) = operation
.responses
.responses
.get_mut(&openapiv3::StatusCode::Code(429))
else {
continue;
};
let openapiv3::ReferenceOr::Item(response) = response else {
panic!("Progenitor error adaptation requires inline 429 responses");
};
let schema = &mut response
.content
.get_mut("application/json")
.expect("429 responses must return JSON")
.schema;
assert_eq!(
schema,
&Some(openapiv3::ReferenceOr::ref_(
"#/components/schemas/ThrottledError"
)),
"Progenitor only supports one error type per operation"
);
*schema = Some(openapiv3::ReferenceOr::ref_("#/components/schemas/Error"));
}
}
}
struct Face {
css_family: String,
weight: u64,
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
@@ -35,7 +35,7 @@ impl AdminApiClient {
}
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
let key_id = snowflake(key_id);
self.generated()
.delete_admin_api_key(&key_id)
.await
+36 -19
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
impl AdminApiClient {
@@ -12,7 +12,7 @@ impl AdminApiClient {
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
include_attachments,
};
let response = self
.generated()
@@ -28,7 +28,7 @@ impl AdminApiClient {
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
include_attachments,
};
let response = self
.generated()
@@ -45,16 +45,31 @@ impl AdminApiClient {
include_expired: bool,
requested_by: Option<&str>,
) -> ApiResult<ListArchivesResponse> {
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" },
),
];
self.get("/admin/archives", Some(&query_params)).await
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 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(
@@ -63,15 +78,17 @@ impl AdminApiClient {
subject_id: &str,
archive_id: &str,
) -> ApiResult<ArchiveDownloadUrlResponse> {
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.get_admin_archive_download(subject_type, subject_id, archive_id)
.get_admin_archive_download(
subject_type,
&snowflake(subject_id),
&snowflake(archive_id),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
+2 -5
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult};
@@ -13,10 +13,7 @@ impl AdminApiClient {
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
let response = self
.generated()
.purge_admin_guild_assets(
&generated_types::SnowflakeType::from(guild_id.to_owned()),
&body,
)
.purge_admin_guild_assets(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+42 -63
View File
@@ -3,8 +3,6 @@
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
#[cfg(test)]
use super::types::AuditLogEntry;
use super::types::AuditLogsListResponse;
pub struct SearchAuditLogsParams {
@@ -38,67 +36,25 @@ impl AdminApiClient {
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(),
),
("q", params.query.as_deref().unwrap_or_default()),
(
"admin_user_id",
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
params.admin_user_id.as_deref().unwrap_or_default(),
),
(
"target_type",
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
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),
("target_id", params.target_id.as_deref().unwrap_or_default()),
("sort_by", sort_by.as_deref().unwrap_or_default()),
("sort_order", sort_order.as_deref().unwrap_or_default()),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
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
}
}
#[cfg(test)]
fn audit_logs_response(
response: generated_types::AuditLogsListResponseSchema,
) -> ApiResult<AuditLogsListResponse> {
Ok(AuditLogsListResponse {
logs: response.logs.into_iter().map(audit_log_entry).collect(),
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
})
}
#[cfg(test)]
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),
admin_user: None,
action: entry.action,
target_id: entry.target_id,
target_type: entry.target_type,
target_user: None,
target_guild: None,
target_channel: None,
related_users: Default::default(),
related_guilds: Default::default(),
related_channels: Default::default(),
audit_log_reason: entry.audit_log_reason,
metadata: entry.metadata,
created_at: entry.created_at,
}
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
let value = match value {
"created_at" => "createdAt",
@@ -113,12 +69,6 @@ fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLog
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn nonempty_string(value: Option<&str>) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -135,10 +85,39 @@ mod tests {
#[test]
fn rejects_lossy_audit_totals() {
let response = generated_types::AuditLogsListResponseSchema {
logs: Vec::new(),
total: 1.5,
};
assert!(audit_logs_response(response).is_err());
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
let response = serde_json::json!({"logs": [], "total": total});
assert!(serde_json::from_value::<AuditLogsListResponse>(response).is_err());
}
}
#[test]
fn deserializes_audit_fields_without_losing_generated_string_values() {
let json = serde_json::json!({
"logs": [{
"log_id": "123456789012345678",
"admin_user_id": "234567890123456789",
"admin_user": null,
"action": "USER_UPDATE",
"target_id": "345678901234567890",
"target_type": "user",
"target_user": null,
"target_guild": null,
"target_channel": null,
"related_users": {},
"related_guilds": {},
"related_channels": {},
"audit_log_reason": "Account review",
"metadata": {"field": "username"},
"created_at": "2026-09-11T12:00:00.000Z"
}],
"total": 1
});
let generated: generated_types::AuditLogsListResponseSchema =
serde_json::from_value(json.clone()).unwrap();
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), json);
}
}
+71 -46
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
@@ -9,10 +9,12 @@ impl AdminApiClient {
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"email",
generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_1: Some(generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}),
..Default::default()
},
)
.await
}
@@ -28,7 +30,10 @@ impl AdminApiClient {
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
..Default::default()
},
)
.await
}
@@ -44,7 +49,10 @@ impl AdminApiClient {
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST,
suspicious_email_domain_request(domain)?.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_2: Some(suspicious_email_domain_request(domain)?),
..Default::default()
},
)
.await
}
@@ -62,10 +70,12 @@ impl AdminApiClient {
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"phrase",
generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_3: Some(generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}),
..Default::default()
},
)
.await
}
@@ -81,14 +91,16 @@ impl AdminApiClient {
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
self.create_blocklist_entry(
"url",
generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}
.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_4: Some(generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}),
..Default::default()
},
)
.await
}
@@ -104,15 +116,17 @@ impl AdminApiClient {
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
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(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_5: Some(generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains,
notes: None,
severity: None,
source_url: None,
}),
..Default::default()
},
)
.await
}
@@ -131,16 +145,17 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanFileShaRequest {
let body = generated_types::AdminBlocklistEntryCreateRequest {
subtype_6: Some(generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
},
);
}),
..Default::default()
};
self.post_void_with_reason(
"/admin/blocklists/file-sha/entries",
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
@@ -186,15 +201,17 @@ impl AdminApiClient {
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
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(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_7: Some(generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
}),
..Default::default()
},
)
.await
}
@@ -213,10 +230,7 @@ impl AdminApiClient {
let body = generated_types::BanUserAvatarRequest::default();
let response = self
.generated()
.ban_admin_user_avatar(
&generated_types::SnowflakeType::from(user_id.to_owned()),
&body,
)
.ban_admin_user_avatar(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -225,7 +239,10 @@ impl AdminApiClient {
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
profile_substring_request(scope, substring)?.into(),
generated_types::AdminBlocklistEntryCreateRequest {
subtype_8: Some(profile_substring_request(scope, substring)?),
..Default::default()
},
)
.await
}
@@ -249,6 +266,7 @@ impl AdminApiClient {
list_type: &str,
body: generated_types::AdminBlocklistEntryCreateRequest,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
self.generated()
.create_admin_blocklist_entry(list_type, &body)
.await
@@ -262,6 +280,7 @@ impl AdminApiClient {
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_delete_scope).transpose()?;
self.generated()
.delete_admin_blocklist_entry(list_type, entry_value, scope)
@@ -276,6 +295,7 @@ impl AdminApiClient {
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<BanCheckResult> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_get_scope).transpose()?;
let response = self
.generated()
@@ -290,6 +310,11 @@ const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
generated_types::AdminBlocklistListType::try_from(list_type)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
format!(
"/admin/blocklists/{}/entries/{}",
+39 -66
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::BulkJobResponse;
@@ -13,15 +13,11 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
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),
},
);
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -33,14 +29,11 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
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),
},
);
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -52,14 +45,11 @@ impl AdminApiClient {
remove_features: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
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,
},
);
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -70,14 +60,10 @@ impl AdminApiClient {
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
guild_id: snowflake(guild_id),
task:
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
user_ids: snowflakes(user_ids),
},
);
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
guild_id: snowflake(guild_id),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -87,13 +73,9 @@ impl AdminApiClient {
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),
},
);
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -106,37 +88,28 @@ impl AdminApiClient {
public_reason: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
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),
},
);
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
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)?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
values
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect()
values.iter().map(|value| snowflake(value)).collect()
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
+109 -40
View File
@@ -120,7 +120,7 @@ impl AdminApiClient {
query_params: Option<&[(&str, &str)]>,
) -> ApiResult<T> {
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn post<T: DeserializeOwned>(
@@ -152,7 +152,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn post_with_reason<T: DeserializeOwned>(
@@ -164,7 +164,7 @@ impl AdminApiClient {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
Self::parse_response(response).await
}
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
@@ -200,7 +200,7 @@ impl AdminApiClient {
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
Self::parse_response(response).await
}
pub async fn patch_typed_with_reason<T, B>(
@@ -216,7 +216,7 @@ impl AdminApiClient {
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
Self::parse_response(response).await
}
pub async fn put_with_reason<T: DeserializeOwned>(
@@ -228,7 +228,7 @@ impl AdminApiClient {
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
Self::parse_response(response).await
}
pub async fn put_typed_with_reason<T, B>(
@@ -244,7 +244,7 @@ impl AdminApiClient {
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
Self::parse_response(response).await
}
pub async fn put_void_with_reason(
@@ -284,22 +284,22 @@ impl AdminApiClient {
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
Self::parse_response(response).await
}
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
Self::check_response_status(response).await.map(drop)
}
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
if response.status().is_success() {
Ok(())
} else {
let status = response.status().as_u16();
let text = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
Err(ApiError::Http {
status,
message: text,
})
return Ok(response);
}
let status = response.status().as_u16();
let message = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
Err(ApiError::Http { status, message })
}
pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient {
@@ -328,28 +328,16 @@ impl AdminApiClient {
}
}
async fn parse_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> ApiResult<T> {
let status = response.status();
if status.as_u16() == 204 {
return serde_json::from_value(serde_json::Value::Null)
.map_err(|e| ApiError::Parse(e.to_string()));
}
if !status.is_success() {
let text = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
return Err(ApiError::Http {
status: status.as_u16(),
message: text,
});
}
let text = response
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
let response = Self::check_response_status(response).await?;
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
String::new()
} else {
response
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?
};
if text.is_empty() {
return serde_json::from_value(serde_json::Value::Null)
.map_err(|e| ApiError::Parse(e.to_string()));
@@ -418,3 +406,84 @@ impl std::fmt::Display for ApiError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn response(status: u16, body: &'static str) -> reqwest::Response {
axum::http::Response::builder()
.status(status)
.body(body)
.expect("valid response")
.into()
}
#[tokio::test]
async fn parses_successful_json_and_empty_responses() {
for (status, body, expected) in [
(200, r#"{"value":1}"#, json!({"value": 1})),
(201, "[1,2]", json!([1, 2])),
(202, "null", Value::Null),
(200, "", Value::Null),
(204, "ignored body", Value::Null),
] {
let actual: Value = AdminApiClient::parse_response(response(status, body))
.await
.expect("valid response body");
assert_eq!(actual, expected, "HTTP {status}: {body}");
}
}
#[tokio::test]
async fn empty_responses_preserve_null_deserialization_errors() {
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
.expect_err("null is not a list")
.to_string();
for (status, body) in [(200, ""), (204, "ignored body")] {
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
.await
.expect_err("missing list");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn malformed_json_preserves_deserialization_errors() {
for body in [" ", "{", "not JSON"] {
let expected = serde_json::from_str::<Value>(body)
.expect_err("malformed JSON")
.to_string();
let error = AdminApiClient::parse_response::<Value>(response(200, body))
.await
.expect_err("malformed response");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn void_responses_do_not_parse_successful_bodies() {
for status in [200, 201, 202, 204] {
AdminApiClient::parse_void_response(response(status, "not JSON"))
.await
.expect("successful void response");
}
}
#[tokio::test]
async fn typed_and_void_responses_preserve_http_errors() {
for status in [302, 400, 403, 404, 500] {
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
.await
.map(drop);
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
for result in [typed, empty] {
let error = result.expect_err("unsuccessful response");
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
}
}
}
}
}
+7 -6
View File
@@ -13,16 +13,17 @@ impl AdminApiClient {
duration_quantity: u32,
) -> ApiResult<CodesResponse> {
let body = generated_types::GenerateGiftCodesRequest {
count: crate::api::generated::nonzero_u32(count, "count").map_err(ApiError::Parse)?,
count: crate::api::generated::nonzero_u32(count, "count")
.map_err(ApiError::Parse)?
.into(),
duration_quantity: crate::api::generated::nonzero_u32(
duration_quantity,
"duration_quantity",
)
.map_err(ApiError::Parse)?,
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from(
duration_type,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
.map_err(ApiError::Parse)?
.into(),
duration_type: generated_types::GiftCodeDurationTypeSchema::try_from(duration_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
+16 -28
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -16,8 +16,7 @@ impl AdminApiClient {
.list_admin_discovery_applications()
.await
.map_err(|e| self.generated_error(e))?;
response
.into_inner()
Vec::from(response.into_inner())
.into_iter()
.map(pending_discovery_application)
.collect()
@@ -29,8 +28,7 @@ impl AdminApiClient {
.list_admin_discovery_listings()
.await
.map_err(|e| self.generated_error(e))?;
response
.into_inner()
Vec::from(response.into_inner())
.into_iter()
.map(listed_guild)
.collect()
@@ -41,16 +39,13 @@ impl AdminApiClient {
guild_id: &str,
reason: Option<&str>,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
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 guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
reason: reason
.map(generated_types::DiscoveryReviewReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.update_admin_discovery_application(&guild_id, &body)
@@ -64,18 +59,11 @@ impl AdminApiClient {
guild_id: &str,
reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
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 guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
reason: generated_types::DiscoveryRejectionReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.update_admin_discovery_application(&guild_id, &body)
@@ -89,7 +77,7 @@ impl AdminApiClient {
guild_id: &str,
reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminRemoveRequest {
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
+6 -2
View File
@@ -16,6 +16,10 @@ pub use inner::types;
pub use inner::Client as GeneratedClient;
pub(crate) fn snowflake(value: &str) -> types::SnowflakeType {
types::SnowflakeType::Variant0(value.to_owned())
}
pub(crate) fn number_to_u64(value: f64, field: &str) -> Result<u64, String> {
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
@@ -126,10 +130,10 @@ mod tests {
let response: SearchGuildsResponse =
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
assert_eq!(response.total as i64, 1);
assert_eq!(response.total, 1.0);
assert_eq!(response.guilds.len(), 1);
assert_eq!(response.guilds[0].name, "Test Guild");
assert_eq!(response.guilds[0].member_count, 42);
assert_eq!(*response.guilds[0].member_count, 42);
}
#[test]
+3 -3
View File
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
impl AdminApiClient {
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let response = self
.generated()
.list_admin_guild_emojis(&guild_id)
@@ -20,7 +20,7 @@ impl AdminApiClient {
&self,
guild_id: &str,
) -> ApiResult<ListGuildStickersResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let response = self
.generated()
.list_admin_guild_stickers(&guild_id)
+23 -42
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use serde::Deserialize;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::reports::SearchReportsParams;
use super::types::{
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
@@ -28,13 +29,8 @@ impl AdminApiClient {
}
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
let response = self
.generated()
.get_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
resp.guild
self.lookup_guild(guild_id)
.await?
.map(GuildInfo::from)
.ok_or_else(|| super::client::ApiError::Http {
status: 404,
@@ -144,8 +140,7 @@ impl AdminApiClient {
let limit = limit
.map(i32::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?
.map(generated_types::Int32Type::from);
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.list_admin_guild_audit_logs(
@@ -153,7 +148,7 @@ impl AdminApiClient {
None,
None,
before.as_ref(),
limit.as_ref(),
limit,
None,
)
.await
@@ -259,37 +254,27 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
None,
None,
Some(guild_id),
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reported_guild_id: Some(guild_id),
limit,
offset,
)
..Default::default()
})
.await
}
}
#[derive(Deserialize)]
struct GuildSettingsPatch {
content_warning_level: Option<generated_types::ContentWarningLevel>,
content_warning_level: Option<generated_types::ContentWarningLevelInput>,
content_warning_text: Option<String>,
default_message_notifications: Option<generated_types::DefaultMessageNotifications>,
default_message_notifications: Option<generated_types::DefaultMessageNotificationsInput>,
disabled_operations: Option<generated_types::GuildOperations>,
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>,
mfa_level: Option<generated_types::GuildMfaLevel>,
explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
mfa_level: Option<generated_types::GuildMfaLevelInput>,
nsfw: Option<bool>,
nsfw_level: Option<generated_types::NsfwLevel>,
verification_level: Option<generated_types::GuildVerificationLevel>,
nsfw_level: Option<generated_types::NsfwLevelInput>,
verification_level: Option<generated_types::GuildVerificationLevelInput>,
}
fn search_guilds_response(
@@ -317,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
owner_global_name: response.owner_global_name,
owner_discriminator: response.owner_discriminator,
member_count: crate::api::generated::i64_to_u64(
i64::from(response.member_count),
i64::from(i32::from(response.member_count)),
"member_count",
)
.map_err(ApiError::Parse)?,
@@ -325,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
nsfw_level: response.nsfw_level.map(i32::from),
nsfw: response.nsfw,
content_warning_level: response.content_warning_level.map(i32::from),
content_warning_text: response.content_warning_text,
content_warning_text: response.content_warning_text.map(String::from),
description: None,
vanity_url_code: None,
})
@@ -338,9 +323,9 @@ fn guild_update_response(
Ok(GuildUpdateResponse {
guild: GuildInfo {
id: String::from(guild.id),
name: guild.name,
icon: guild.icon,
banner: guild.banner,
name: String::from(guild.name),
icon: guild.icon.map(String::from),
banner: guild.banner.map(String::from),
owner_id: String::from(guild.owner_id),
owner_username: None,
owner_global_name: None,
@@ -350,11 +335,11 @@ fn guild_update_response(
"member_count",
)
.map_err(ApiError::Parse)?,
features: guild.features,
features: guild.features.into_iter().map(String::from).collect(),
nsfw_level: guild.nsfw_level.map(i32::from),
nsfw: guild.nsfw,
content_warning_level: guild.content_warning_level.map(i32::from),
content_warning_text: guild.content_warning_text,
content_warning_text: guild.content_warning_text.map(String::from),
description: None,
vanity_url_code: None,
},
@@ -380,10 +365,6 @@ fn guild_settings_request(
})
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
values
.iter()
+3 -1
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
@@ -39,7 +41,7 @@ impl AdminApiClient {
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
let response = self
.generated()
.get_admin_job(job_id)
.get_admin_job(&snowflake(job_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+7 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -43,7 +43,8 @@ impl AdminApiClient {
attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id),
confirmed_viewed: true,
filename: filename.to_owned(),
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.to_string()))?,
message_id: snowflake(message_id),
reporter_full_name:
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
@@ -119,7 +120,7 @@ impl AdminApiClient {
) -> ApiResult<MessageShredStatusResponse> {
let response = self
.generated()
.get_admin_message_shred(job_id)
.get_admin_message_shred(&snowflake(job_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -133,13 +134,15 @@ impl AdminApiClient {
context_limit: u32,
) -> ApiResult<LookupMessageResponse> {
let context_limit = context_limit.to_string();
let filename = generated_types::SearchAdminMessagesFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.search_admin_messages(
Some(&snowflake(attachment_id)),
&snowflake(channel_id),
Some(context_limit.as_str()),
Some(filename),
Some(&filename),
None,
None,
None,
@@ -195,7 +198,3 @@ impl AdminApiClient {
self.generated_value(response.into_inner())
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
+120 -58
View File
@@ -1,10 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
};
#[derive(Default)]
pub struct SearchReportsParams<'a> {
pub query: Option<&'a str>,
pub status: Option<i32>,
pub report_type: Option<i32>,
pub category: Option<&'a str>,
pub reporter_id: Option<&'a str>,
pub reported_user_id: Option<&'a str>,
pub reported_guild_id: Option<&'a str>,
pub reported_channel_id: Option<&'a str>,
pub guild_context_id: Option<&'a str>,
pub resolved_by_admin_id: Option<&'a str>,
pub sort_by: Option<&'a str>,
pub sort_order: Option<&'a str>,
pub limit: u32,
pub offset: u32,
}
impl AdminApiClient {
pub async fn list_reports(
&self,
@@ -26,7 +46,7 @@ impl AdminApiClient {
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
let response = self
.generated()
.get_admin_report(report_id)
.get_admin_report(&snowflake(report_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -50,51 +70,55 @@ impl AdminApiClient {
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn search_reports(
&self,
query: Option<&str>,
status: Option<i32>,
report_type: Option<i32>,
category: Option<&str>,
reporter_id: Option<&str>,
reported_user_id: Option<&str>,
reported_guild_id: Option<&str>,
reported_channel_id: Option<&str>,
guild_context_id: Option<&str>,
resolved_by_admin_id: Option<&str>,
sort_by: Option<&str>,
sort_order: Option<&str>,
limit: u32,
offset: u32,
params: &SearchReportsParams<'_>,
) -> ApiResult<SearchReportsResponse> {
let status = status.map(report_status).transpose()?.unwrap_or_default();
let report_type = report_type
let status = params
.status
.map(report_status)
.transpose()?
.unwrap_or_default();
let report_type = params
.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 sort_by = params
.sort_by
.map(report_sort_by)
.transpose()?
.unwrap_or_default();
let limit = params.limit.to_string();
let offset = params.offset.to_string();
let query_params = [
("q", query.unwrap_or_default()),
("q", params.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()),
("category", params.category.unwrap_or_default()),
("reporter_id", params.reporter_id.unwrap_or_default()),
(
"reported_user_id",
params.reported_user_id.unwrap_or_default(),
),
(
"reported_guild_id",
params.reported_guild_id.unwrap_or_default(),
),
(
"reported_channel_id",
reported_channel_id.unwrap_or_default(),
params.reported_channel_id.unwrap_or_default(),
),
(
"guild_context_id",
params.guild_context_id.unwrap_or_default(),
),
("guild_context_id", guild_context_id.unwrap_or_default()),
(
"resolved_by_admin_id",
resolved_by_admin_id.unwrap_or_default(),
params.resolved_by_admin_id.unwrap_or_default(),
),
("sort_by", sort_by),
("sort_order", sort_order.unwrap_or_default()),
("sort_order", params.sort_order.unwrap_or_default()),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
@@ -107,22 +131,12 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
Some(reporter_id),
None,
None,
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reporter_id: Some(reporter_id),
limit,
offset,
)
..Default::default()
})
.await
}
@@ -132,22 +146,12 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
None,
Some(reported_user_id),
None,
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reported_user_id: Some(reported_user_id),
limit,
offset,
)
..Default::default()
})
.await
}
}
@@ -179,3 +183,61 @@ fn report_sort_by(value: &str) -> ApiResult<&'static str> {
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_statuses_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "pending"), (1, "resolved")] {
assert_eq!(report_status(value).expect("supported status"), expected);
}
for value in [-1, 2] {
assert_eq!(
report_status(value)
.expect_err("unknown status")
.to_string(),
format!("parse error: unknown report status: {value}")
);
}
}
#[test]
fn report_types_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "message"), (1, "user"), (2, "guild")] {
assert_eq!(report_type_name(value).expect("supported type"), expected);
}
for value in [-1, 3] {
assert_eq!(
report_type_name(value)
.expect_err("unknown type")
.to_string(),
format!("parse error: unknown report type: {value}")
);
}
}
#[test]
fn report_sort_fields_accept_only_the_existing_aliases() {
for (field, expected) in [
("createdAt", "created_at"),
("created_at", "created_at"),
("reportedAt", "reported_at"),
("reported_at", "reported_at"),
("resolvedAt", "resolved_at"),
("resolved_at", "resolved_at"),
] {
assert_eq!(
report_sort_by(field).expect("supported sort field"),
expected
);
}
assert_eq!(
report_sort_by("unknown")
.expect_err("unknown sort field")
.to_string(),
"parse error: unknown report sort field: unknown"
);
}
}
+86 -4
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
impl AdminApiClient {
@@ -11,8 +11,11 @@ impl AdminApiClient {
index_type: &str,
guild_id: Option<&str>,
) -> ApiResult<RefreshSearchIndexResponse> {
let index_type =
generated_types::CreateAdminSearchIndexRefreshIndexName::try_from(index_type)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::RefreshSearchIndexRequest {
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
guild_id: guild_id.map(snowflake),
user_id: None,
};
let response = self
@@ -32,6 +35,85 @@ impl AdminApiClient {
.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:?}"),
}
}
}
+2 -6
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult};
use super::types::{
@@ -24,11 +24,7 @@ impl AdminApiClient {
guild_ids: &[String],
) -> ApiResult<ReloadAllGuildsResponse> {
let body = generated_types::ReloadGuildsRequest {
guild_ids: guild_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
};
let response = self
.generated()
+2 -6
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::SendSystemDmResponse;
@@ -14,11 +14,7 @@ impl AdminApiClient {
let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
};
let response = self
.generated()
@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse {
pub sso: SsoConfigResponse,
@@ -18,6 +20,10 @@ pub struct InstanceConfigResponse {
pub integrations: InstanceIntegrationsResponse,
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -436,6 +442,125 @@ impl VoiceE2eeScope {
}
}
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
pub const ALL: [Self; 7] = [
Self::None,
Self::Standard,
Self::Gate,
Self::Speex,
Self::Rnnoise,
Self::Gtcrn,
Self::DeepFilter,
];
pub fn label(&self) -> &'static str {
match self {
Self::None => "None (pass-through)",
Self::Standard => "Standard (WebRTC)",
Self::Gate => "Noise gate",
Self::Speex => "Speex",
Self::Rnnoise => "RNNoise",
Self::Gtcrn => "GTCRN",
Self::DeepFilter => "DeepFilterNet",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VoiceNoiseSuppressionGuildOverride {
pub guild_id: String,
pub backend: NoiseSuppressionBackend,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceNoiseSuppressionConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub default_backend: NoiseSuppressionBackend,
pub enabled_backends: Vec<NoiseSuppressionBackend>,
pub allow_user_override: bool,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub stereo_enabled: bool,
pub suppression_strength: u32,
}
impl Default for VoiceNoiseSuppressionConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
default_backend: NoiseSuppressionBackend::Standard,
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
allow_user_override: true,
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
stereo_enabled: false,
suppression_strength: 80,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_backend: Option<NoiseSuppressionBackend>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_user_override: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
pub poll_interval_seconds: u64,
pub poll_jitter_percent: u32,
}
impl Default for ExperimentDeliveryConfigResponse {
fn default() -> Self {
Self {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ExperimentDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_jitter_percent: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceRegistrationResponse {
pub mode: RegistrationMode,
@@ -525,6 +650,10 @@ pub struct InstanceConfigUpdateRequest {
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<InstanceMediaUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -826,3 +955,89 @@ pub struct CreateRegistrationUrlResponse {
pub code: String,
pub url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
assert_eq!(
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
json!([
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
])
);
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
}
#[test]
fn default_instance_experiment_config_matches_the_published_contract() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../../openapi-admin.json"))
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
assert_eq!(
value, &schema["components"]["schemas"][name]["properties"][field]["default"],
"{name}.{field}"
);
}
}
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
enabled_backends: Some(Vec::new()),
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
guild_overrides: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
);
assert_eq!(
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+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>,
+14 -18
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -362,11 +362,8 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserUsernameUpdateRequest {
discriminator: discriminator
.map(generated_types::DiscriminatorType::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
username: generated_types::UsernameType::try_from(username)
.map_err(|e| ApiError::Parse(e.to_string()))?,
.map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
username: generated_types::UsernameType::from(username.to_owned()),
};
let response = self
.generated()
@@ -399,7 +396,8 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?,
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
reason: reason.map(std::borrow::ToOwned::to_owned),
};
let resp: UserMutationResponse = self
@@ -430,10 +428,12 @@ impl AdminApiClient {
days_until_deletion: u32,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?,
@@ -484,10 +484,10 @@ impl AdminApiClient {
target_id: &str,
category: &str,
) -> ApiResult<()> {
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
let category = generated_types::RelationshipCategoryEnum::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
self.generated()
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
.remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -498,7 +498,7 @@ impl AdminApiClient {
user_id: &str,
category: &str,
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
let category = generated_types::RelationshipCategoryEnum::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
@@ -565,10 +565,6 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
values
.iter()
+121 -21
View File
@@ -55,15 +55,14 @@ impl AdminApiClient {
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
let region_id = required_field(params, "id")?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_region(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let body = voice_request_body(params, &["id"])?;
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
self.patch_with_reason(
&format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
Some(&body),
None,
)
.await
}
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
@@ -102,9 +101,11 @@ impl AdminApiClient {
params: &serde_json::Value,
) -> ApiResult<CreateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?;
let body =
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
paired_coordinates(params)?;
let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
voice_request_body(params, &["region_id"])?,
)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.create_admin_voice_server(&region_id, &body)
@@ -119,15 +120,19 @@ impl AdminApiClient {
) -> ApiResult<UpdateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_admin_voice_server(&region_id, &server_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
paired_coordinates(params)?;
let body = voice_request_body(params, &["region_id", "server_id"])?;
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
self.patch_with_reason(
&format!(
"/admin/voice/regions/{}/servers/{}",
urlencoding::encode(&region_id),
urlencoding::encode(&server_id)
),
Some(&body),
None,
)
.await
}
pub async fn delete_voice_server(
@@ -148,6 +153,37 @@ 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 voice_request_body(
params: &serde_json::Value,
path_fields: &[&str],
) -> ApiResult<serde_json::Value> {
let mut body = params
.as_object()
.ok_or_else(|| ApiError::Parse("voice request body must be an object".to_owned()))?
.clone();
for field in path_fields {
body.remove(*field);
}
Ok(body.into())
}
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)
@@ -155,3 +191,67 @@ fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String>
.map(std::borrow::ToOwned::to_owned)
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn region_update_body_preserves_explicit_restriction_clears() {
let expected = json!({
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
});
let mut params = expected.clone();
params["id"] = json!("eu");
let body = voice_request_body(&params, &["id"]).expect("region body");
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)
.expect("valid region body");
assert_eq!(body, expected);
}
#[test]
fn server_update_body_preserves_clears_and_omitted_restrictions() {
for expected in [
json!({
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"soft_connection_limit": null,
"latitude": null,
"longitude": null,
}),
json!({"is_active": false}),
] {
let mut params = expected.clone();
params["region_id"] = json!("eu");
params["server_id"] = json!("primary");
let body =
voice_request_body(&params, &["region_id", "server_id"]).expect("server body");
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)
.expect("valid server body");
assert_eq!(body, expected);
}
}
#[test]
fn create_server_body_keeps_the_server_id_and_rejects_missing_fields() {
let expected = json!({
"server_id": "primary",
"endpoint": "wss://voice.example.com",
"api_key": "key",
"api_secret": "secret",
});
let mut params = expected.clone();
params["region_id"] = json!("eu");
let body = voice_request_body(&params, &["region_id"]).expect("server body");
validate_against::<generated_types::CreateVoiceServerRequestBody>(&body)
.expect("valid server body");
assert_eq!(body, expected);
assert!(
validate_against::<generated_types::CreateVoiceServerRequestBody>(&json!({})).is_err()
);
}
}
+7 -1
View File
@@ -129,6 +129,11 @@ impl AdminConfig {
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 {
@@ -202,6 +207,7 @@ 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) };
@@ -350,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"),
(
+95 -4
View File
@@ -27,7 +27,6 @@ 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 secure_cookies = config.secure_cookies();
let user_id = request
@@ -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);
@@ -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,
}
}
@@ -275,6 +274,98 @@ mod tests {
);
}
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() {
let exempt = IGNORED_PATH_SUFFIXES
+2 -8
View File
@@ -251,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);
@@ -272,12 +272,6 @@ pub(crate) async fn bulk_actions_post(
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
.await
}
"bulk_delete_users" => {
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())
.await
}
_ => {
return flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
@@ -302,7 +296,7 @@ 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"),
FlashData::error(format!("Failed to submit bulk action: {error}")),
config.secure_cookies(),
)
}
+40 -14
View File
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::{AdminApiClient, ApiResultExt},
api::{
client::{AdminApiClient, ApiResultExt},
reports::SearchReportsParams,
},
config::AdminConfig,
middleware::{
auth::AuthContext,
csrf,
@@ -22,6 +26,8 @@ use axum::{
};
use serde::Deserialize;
const MAX_REPORT_OFFSET: u32 = 10_000;
#[derive(Deserialize)]
struct ReportsQuery {
q: Option<String>,
@@ -70,6 +76,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());
@@ -78,22 +92,22 @@ async fn reports_list(
.as_deref()
.and_then(|s| s.parse::<i32>().ok());
let reports = client
.search_reports(
query.q.as_deref(),
.search_reports(&SearchReportsParams {
query: search_query.as_deref(),
status,
report_type,
query.category.as_deref(),
query.reporter_id.as_deref(),
query.reported_user_id.as_deref(),
query.reported_guild_id.as_deref(),
query.reported_channel_id.as_deref(),
query.guild_context_id.as_deref(),
query.resolved_by_admin_id.as_deref(),
Some(sort_by),
Some(sort_order),
category: query.category.as_deref(),
reporter_id: query.reporter_id.as_deref(),
reported_user_id: query.reported_user_id.as_deref(),
reported_guild_id: query.reported_guild_id.as_deref(),
reported_channel_id: query.reported_channel_id.as_deref(),
guild_context_id: query.guild_context_id.as_deref(),
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
sort_by: Some(sort_by),
sort_order: Some(sort_order),
limit,
offset,
)
})
.await
.log_error("search reports");
@@ -102,7 +116,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 +134,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,
+623 -63
View File
@@ -7,7 +7,8 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
@@ -16,8 +17,10 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
VoiceE2eeScope,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -200,6 +203,14 @@ pub async fn instance_config_post(
let update = build_media_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"test_smtp" => match build_smtp_test_request(&form) {
Ok(request) => match client.test_instance_smtp_config(&request).await {
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
@@ -401,12 +412,7 @@ fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
allowed_domains: Some(allowed),
redirect_uri: None,
}),
gateway_rollout: None,
registration: None,
app_public: None,
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
@@ -437,15 +443,216 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
.parse_u64("gateway_rollout_max_concurrent_guild_starts"),
voice_e2ee_scope,
}),
sso: None,
registration: None,
app_public: None,
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
fn parse_form_number<T>(
form: &MultiValueForm,
key: &str,
label: &str,
min: T,
max: T,
) -> Result<Option<T>, String>
where
T: std::str::FromStr + Ord + std::fmt::Display,
{
let Some(raw) = form.first(key) else {
return Ok(None);
};
let invalid = || format!("{label} must be a whole number between {min} and {max}");
let value = raw.trim().parse::<T>().map_err(|_| invalid())?;
if value < min || value > max {
return Err(invalid());
}
Ok(Some(value))
}
fn parse_voice_noise_suppression_rollout_salt(
form: &MultiValueForm,
) -> Result<Option<String>, String> {
let Some(raw) = form.first("voice_ns_rollout_salt") else {
return Ok(None);
};
let salt = raw.trim();
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
return Err(format!(
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
Ok(Some(salt.to_owned()))
}
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
&& value.bytes().all(|byte| byte.is_ascii_digit())
}
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
let mut ids: Vec<String> = Vec::new();
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
let candidate = candidate.trim();
if candidate.is_empty() {
continue;
}
if !is_voice_noise_suppression_snowflake(candidate) {
return Err(format!(
"{label} entry {} must contain 1 to 20 decimal digits",
index + 1
));
}
if ids.iter().any(|existing| existing == candidate) {
continue;
}
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
return Err(format!(
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
));
}
ids.push(candidate.to_owned());
}
Ok(ids)
}
fn parse_voice_noise_suppression_guild_overrides(
value: &str,
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
for (index, line) in value.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_number = index + 1;
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
format!("Guild overrides line {line_number} must use guild_id=backend")
})?;
let guild_id = guild_id.trim();
if !is_voice_noise_suppression_snowflake(guild_id) {
return Err(format!(
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
));
}
let backend = backend.trim().parse().map_err(|_| {
format!("Guild overrides line {line_number} must name a supported backend")
})?;
if let Some(existing) = overrides
.iter()
.find(|existing| existing.guild_id == guild_id)
{
if existing.backend != backend {
return Err(format!(
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
));
}
continue;
}
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
return Err(format!(
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
));
}
overrides.push(VoiceNoiseSuppressionGuildOverride {
guild_id: guild_id.to_owned(),
backend,
});
}
Ok(overrides)
}
fn build_voice_noise_suppression_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let selected: Vec<NoiseSuppressionBackend> = form
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
.into_iter()
.map(|value| {
value.parse().map_err(|_| {
"Enabled backends must name supported noise suppression backends".to_owned()
})
})
.collect::<Result<_, _>>()?;
let enabled_backends = NoiseSuppressionBackend::ALL
.into_iter()
.filter(|backend| selected.contains(backend))
.collect();
Ok(InstanceConfigUpdateRequest {
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
enabled: Some(form.bool_value("voice_ns_enabled")),
default_backend: form
.first("voice_ns_default_backend")
.map(|value| {
value.parse().map_err(|_| {
"Default backend must name a supported noise suppression backend".to_owned()
})
})
.transpose()?,
enabled_backends: Some(enabled_backends),
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
rollout_basis_points: parse_form_number(
form,
"voice_ns_rollout_basis_points",
"Rollout basis points",
0,
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
"Excluded user IDs",
)?),
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
form.first("voice_ns_guild_overrides").unwrap_or_default(),
)?),
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
suppression_strength: parse_form_number(
form,
"voice_ns_suppression_strength",
"Suppression strength",
0,
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
)?,
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
experiment_delivery: Some(ExperimentDeliveryConfigUpdateRequest {
poll_interval_seconds: parse_form_number(
form,
"experiment_delivery_poll_interval_seconds",
"Poll interval",
EXPERIMENT_MIN_POLL_INTERVAL_SECONDS,
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
)?,
poll_jitter_percent: parse_form_number(
form,
"experiment_delivery_poll_jitter_percent",
"Poll jitter",
0,
EXPERIMENT_MAX_POLL_JITTER_PERCENT,
)?,
}),
..Default::default()
})
}
fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let mode = match form.first("registration_mode") {
Some("approval") => Some(RegistrationMode::Approval),
@@ -454,27 +661,19 @@ fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
_ => None,
};
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: Some(InstanceRegistrationConfigUpdateRequest {
mode,
admin_registration_urls_enabled: Some(
form.bool_value("admin_registration_urls_enabled"),
),
}),
sso: None,
app_public: None,
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let optional = |key: &str| Some(form.clean(key));
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: Some(AppPublicConfigUpdateRequest {
branding: Some(AppBrandingConfigUpdateRequest {
product_name: form.clean("app_product_name"),
@@ -491,18 +690,13 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
legal: None,
registration: None,
}),
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let optional = |key: &str| Some(form.clean(key));
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: Some(AppPublicConfigUpdateRequest {
branding: None,
setup: None,
@@ -512,17 +706,12 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
}),
registration: None,
}),
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: Some(AppPublicConfigUpdateRequest {
branding: None,
setup: None,
@@ -531,9 +720,7 @@ fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateR
collect_date_of_birth: Some(form.bool_value("app_collect_date_of_birth")),
}),
}),
policy: None,
integrations: None,
media: None,
..Default::default()
}
}
@@ -549,10 +736,6 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let services = build_services_update(form);
let deferred_phone_gate = build_deferred_phone_gate_update(form);
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: None,
single_community_name: None,
@@ -561,8 +744,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
services,
deferred_phone_gate,
}),
integrations: None,
media: None,
..Default::default()
}
}
@@ -626,11 +808,6 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
_ => None,
};
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: None,
integrations: Some(InstanceIntegrationsUpdateRequest {
gif: Some(InstanceGifIntegrationUpdateRequest {
klipy_api_key: clean("integration_klipy_api_key"),
@@ -671,7 +848,7 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
keys: bluesky_keys,
}),
}),
media: None,
..Default::default()
}
}
@@ -681,12 +858,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
.and_then(|value| value.trim().parse::<f64>().ok())
};
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: None,
integrations: None,
media: Some(InstanceMediaUpdateRequest {
attachment_decay: Some(InstanceAttachmentDecayUpdateRequest {
enabled: Some(form.bool_value("media_attachment_decay_enabled")),
@@ -700,6 +871,7 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
renew_window_days: form.parse_u32("media_attachment_decay_renew_window_days"),
}),
}),
..Default::default()
}
}
@@ -728,10 +900,6 @@ fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTes
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(enabled),
single_community_name: None,
@@ -740,8 +908,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
services: None,
deferred_phone_gate: None,
}),
integrations: None,
media: None,
..Default::default()
}
}
@@ -1066,6 +1233,399 @@ mod tests {
);
}
#[test]
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
let form = MultiValueForm::parse(
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
);
let request = build_voice_noise_suppression_update(&form).expect("valid form");
let update = request
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.allow_user_override, Some(true));
assert_eq!(update.stereo_enabled, Some(false));
assert_eq!(
update.default_backend,
Some(NoiseSuppressionBackend::Rnnoise)
);
assert_eq!(
update.enabled_backends,
Some(vec![
NoiseSuppressionBackend::None,
NoiseSuppressionBackend::DeepFilter
])
);
assert_eq!(update.rollout_basis_points, Some(10_000));
assert_eq!(update.suppression_strength, Some(100));
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
}
#[test]
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_voice_noise_suppression_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"voice_noise_suppression": {
"enabled": false,
"allow_user_override": false,
"stereo_enabled": false,
"enabled_backends": [],
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
}})
);
}
#[test]
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
let form = MultiValueForm::parse(
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(
update.included_user_ids,
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
assert_eq!(
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
.expect("valid IDs"),
vec![
"1".to_owned(),
"2".to_owned(),
"3".to_owned(),
"4".to_owned(),
"5".to_owned()
]
);
}
#[test]
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
assert_eq!(
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
.expect("valid IDs"),
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
);
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
for value in [
"abc",
"12a",
"-1",
"1.0",
"999999999999999999999",
"<script>",
] {
assert_eq!(
parse_voice_noise_suppression_user_ids(
&format!("123,{value}"),
"Included user IDs"
)
.expect_err("invalid ID"),
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
"{value}"
);
}
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids =
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
.expect_err("too many IDs"),
"Included user IDs must contain at most 1000 unique IDs"
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
for (line, message) in [
("456", "Guild overrides line 3 must use guild_id=backend"),
(
"=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"not-a-guild=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"999999999999999999999=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"456=unknown_backend",
"Guild overrides line 3 must name a supported backend",
),
(
"456=",
"Guild overrides line 3 must name a supported backend",
),
(
"123=gate",
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
),
] {
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
.expect_err("invalid guild rule"),
message,
"{line}"
);
}
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
for (key, message, above_max) in [
(
"voice_ns_rollout_basis_points",
"Rollout basis points must be a whole number between 0 and 10000",
"10001",
),
(
"voice_ns_suppression_strength",
"Suppression strength must be a whole number between 0 and 100",
"101",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_basis_points, Some(250));
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
for salt in [
String::new(),
" ".to_owned(),
"é".repeat(65),
"🎲".repeat(33),
] {
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
"Rollout salt must be between 1 and 64 characters"
);
}
}
#[test]
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
let form =
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_salt, Some(salt));
}
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
let overrides = parse_voice_noise_suppression_guild_overrides(
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
).expect("valid guild rules");
assert_eq!(
overrides,
vec![
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
},
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000002".to_owned(),
backend: NoiseSuppressionBackend::Speex,
},
]
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
.map(|index| format!("{index}=gate"))
.collect::<Vec<_>>()
.join("\n");
let overrides =
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
.expect("valid guild rules at cap");
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
assert_eq!(
overrides.last().map(|entry| entry.guild_id.as_str()),
Some("199")
);
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
.expect_err("too many guild rules"),
"Guild overrides must contain at most 200 unique guilds"
);
}
#[test]
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
for (form, message) in [
(
"voice_ns_default_backend=unknown",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_default_backend=",
"Default backend must name a supported noise suppression backend",
),
(
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
"Enabled backends must name supported noise suppression backends",
),
(
"voice_ns_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_experiment_delivery_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"experiment_delivery": {}})
);
}
#[test]
fn build_experiment_delivery_update_rejects_invalid_numbers() {
for (key, message, below_min, above_max) in [
(
"experiment_delivery_poll_interval_seconds",
"Poll interval must be a whole number between 60 and 86400",
"59",
"86401",
),
(
"experiment_delivery_poll_jitter_percent",
"Poll jitter must be a whole number between 0 and 50",
"-1",
"51",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
below_min,
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_experiment_delivery_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_experiment_delivery_update_accepts_inclusive_bounds() {
for (interval, jitter) in [(60, 0), (86_400, 50)] {
let form = MultiValueForm::parse(format!("experiment_delivery_poll_interval_seconds={interval}&experiment_delivery_poll_jitter_percent={jitter}").as_bytes());
let request = build_experiment_delivery_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
);
}
}
#[test]
fn build_experiment_delivery_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
);
let update = build_experiment_delivery_update(&form)
.expect("valid form")
.experiment_delivery
.expect("experiment delivery update");
assert_eq!(update.poll_interval_seconds, Some(900));
assert_eq!(update.poll_jitter_percent, Some(25));
}
#[test]
fn update_limit_rule_values_reads_checked_limit_keys() {
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
+20 -8
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>,
@@ -83,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
@@ -124,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,
+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()
}
+109 -20
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)
}
@@ -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(
@@ -0,0 +1,236 @@
use std::cmp::Ordering;
use serde_json::Value;
#[derive(Debug, PartialEq)]
pub struct Attachment {
pub id: String,
pub url: String,
pub filename: String,
pub nsfw: Option<bool>,
pub content_type: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
#[derive(Debug, PartialEq)]
pub struct Message {
pub id: String,
pub content: String,
pub timestamp: String,
pub author_id: String,
pub author_username: String,
pub author_global_name: Option<String>,
pub author_discriminator: String,
pub author_avatar: Option<String>,
pub channel_id: String,
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
}
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value["attachments"]
.as_array()
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value_id(&value["id"]).unwrap_or_default(),
content: value["content"].as_str().unwrap_or("").to_owned(),
timestamp: value["timestamp"].as_str().unwrap_or("").to_owned(),
author_id: value_id(&value["author_id"]).unwrap_or_default(),
author_username: value["author_username"]
.as_str()
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value["author_global_name"].as_str().map(ToOwned::to_owned),
author_discriminator: value_id(&value["author_discriminator"])
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
channel_nsfw: value["channel_nsfw"].as_bool(),
channel_content_warning_level: value["channel_content_warning_level"]
.as_i64()
.map(|n| n as i32),
channel_content_warning_text: value["channel_content_warning_text"]
.as_str()
.map(ToOwned::to_owned),
guild_nsfw: value["guild_nsfw"].as_bool(),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value_id(&value["id"]).unwrap_or_default(),
url: value["url"].as_str().unwrap_or("").to_owned(),
filename: value["filename"].as_str().unwrap_or("").to_owned(),
nsfw: value["nsfw"].as_bool(),
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
width: value["width"].as_u64().map(|n| n as u32),
height: value["height"].as_u64().map(|n| n as u32),
size: value["size"].as_u64(),
ncmec_status: value["ncmec_status"]
.as_str()
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
ncmec_failure_reason: value["ncmec_failure_reason"]
.as_str()
.map(ToOwned::to_owned),
}
}
pub(crate) fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_message_and_attachment_fields() {
let value = json!({
"id": "9007199254740993",
"content": "Message content",
"timestamp": "2026-09-11T12:00:00Z",
"author_id": 42,
"author_username": "alice",
"author_global_name": "Alice",
"author_discriminator": 1234,
"author_avatar": "avatar-hash",
"channel_id": "100",
"channel_nsfw": false,
"channel_content_warning_level": 2,
"channel_content_warning_text": "Content warning",
"guild_nsfw": true,
"attachments": [{
"id": 9007199254740993_u64,
"url": "https://cdn.example.com/image.png",
"filename": "image.png",
"nsfw": true,
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096,
"ncmec_status": "submitted",
"ncmec_report_id": "report-id",
"ncmec_failure_reason": "previous failure"
}]
});
assert_eq!(
message_from_value(&value),
Message {
id: "9007199254740993".into(),
content: "Message content".into(),
timestamp: "2026-09-11T12:00:00Z".into(),
author_id: "42".into(),
author_username: "alice".into(),
author_global_name: Some("Alice".into()),
author_discriminator: "1234".into(),
author_avatar: Some("avatar-hash".into()),
channel_id: "100".into(),
channel_nsfw: Some(false),
channel_content_warning_level: Some(2),
channel_content_warning_text: Some("Content warning".into()),
guild_nsfw: Some(true),
attachments: vec![Attachment {
id: "9007199254740993".into(),
url: "https://cdn.example.com/image.png".into(),
filename: "image.png".into(),
nsfw: Some(true),
content_type: Some("image/png".into()),
width: Some(640),
height: Some(480),
size: Some(4096),
ncmec_status: "submitted".into(),
ncmec_report_id: Some("report-id".into()),
ncmec_failure_reason: Some("previous failure".into()),
}],
}
);
}
#[test]
fn retains_display_defaults_for_incomplete_snapshots() {
let message = message_from_value(&json!({"attachments": [{}]}));
assert_eq!(message.author_username, "Unknown");
assert_eq!(message.author_discriminator, "0000");
assert_eq!(message.content, "");
assert_eq!(message.author_global_name, None);
assert_eq!(message.channel_nsfw, None);
assert_eq!(
message.attachments,
vec![Attachment {
id: String::new(),
url: String::new(),
filename: String::new(),
nsfw: None,
content_type: None,
width: None,
height: None,
size: None,
ncmec_status: "not_submitted".into(),
ncmec_report_id: None,
ncmec_failure_reason: None,
}]
);
}
#[test]
fn orders_string_and_numeric_snowflakes_without_rounding() {
let values = vec![
json!({"id": "9007199254740993"}),
json!({"id": "10"}),
json!({"id": 2}),
json!({"id": 9007199254740992_u64}),
];
let messages = ordered_messages(&values);
let ids: Vec<&str> = messages.iter().map(|message| message.id.as_str()).collect();
assert_eq!(ids, ["2", "10", "9007199254740992", "9007199254740993"]);
assert_eq!(values[0]["id"], "9007199254740993");
assert!(ordered_messages(&[]).is_empty());
}
#[test]
fn preserves_message_order_when_ids_are_equal() {
let values = [
json!({"id": "10", "content": "first"}),
json!({"id": 10, "content": "second"}),
];
let messages = ordered_messages(&values);
assert_eq!(messages[0].content, "first");
assert_eq!(messages[1].content, "second");
}
}
@@ -9,36 +9,7 @@ use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
pub struct Attachment {
pub id: String,
pub url: String,
pub filename: String,
pub nsfw: Option<bool>,
pub content_type: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
pub struct Message {
pub id: String,
pub content: String,
pub timestamp: String,
pub author_id: String,
pub author_username: String,
pub author_global_name: Option<String>,
pub author_discriminator: String,
pub author_avatar: Option<String>,
pub channel_id: String,
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
}
use super::message_data::{Attachment, Message};
fn is_image(att: &Attachment) -> bool {
att.content_type
@@ -13,6 +13,7 @@ pub mod error_display;
pub mod form;
pub mod icons;
pub mod media;
pub mod message_data;
pub mod message_list;
pub mod nsfw_indicators;
pub mod page_container;
@@ -147,9 +147,14 @@ 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_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
@@ -175,6 +180,16 @@ const GUILD_FEATURES: &[&str] = &[
"VERY_LARGE_GUILD",
];
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn guild_feature_label(feature: &str) -> String {
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
format!("{feature} (deprecated, removal only)")
} else {
feature.to_owned()
}
}
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
let base = &config.base_path;
let admin_acls = auth
@@ -219,6 +234,18 @@ fn flag_checkbox_grid(prefix: &str, flags: &[&str]) -> Markup {
}
}
fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup {
html! {
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
@for feature in GUILD_FEATURES {
@if include_deprecated || !DEPRECATED_GUILD_FEATURES.contains(feature) {
(checkbox(prefix, feature, &guild_feature_label(feature), false, true))
}
}
}
}
}
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
html! {
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
@@ -301,13 +328,13 @@ fn bulk_update_guild_features_section(base: &str, csrf_token: &str) -> Markup {
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
"Features to Add"
}
(flag_checkbox_grid("add_features[]", GUILD_FEATURES))
(guild_feature_checkbox_grid("add_features[]", false))
}
div {
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
"Features to Remove"
}
(flag_checkbox_grid("remove_features[]", GUILD_FEATURES))
(guild_feature_checkbox_grid("remove_features[]", true))
}
(form_field_group("Custom features to add", "custom_add_features", false, None,
Some("Comma-separated list of custom features not in the standard set."),
@@ -408,3 +435,26 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_grid_offers_only_the_opt_in_clone_features() {
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, removal only)"));
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
}
}
@@ -14,9 +14,14 @@ 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_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER",
"INVITE_SPLASH",
"INVITES_DISABLED",
@@ -44,6 +49,16 @@ const GUILD_FEATURES: &[&str] = &[
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
const DEPRECATED_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn feature_label(feature: &str) -> String {
if DEPRECATED_FEATURES.contains(&feature) {
format!("{feature} (deprecated, no longer enforced)")
} else {
feature.to_owned()
}
}
pub fn features_tab(
config: &AdminConfig,
guild: &GuildInfo,
@@ -85,7 +100,7 @@ pub fn features_tab(
(checkbox(
"features[]",
feature,
feature,
&feature_label(feature),
guild.features.iter().any(|f| f == feature),
true,
))
@@ -139,7 +154,7 @@ fn features_tab_readonly(guild: &GuildInfo, features_list: &[&str]) -> Markup {
@for feature in &enabled {
span class="inline-flex items-center rounded-full bg-green-100 \
px-2.5 py-0.5 text-xs font-medium text-green-800" {
(feature)
(feature_label(feature))
}
}
@for feature in &custom {
@@ -166,3 +181,56 @@ fn filtered_features() -> Vec<&'static str> {
.copied()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn guild_with_features(features: &[&str]) -> GuildInfo {
serde_json::from_value(serde_json::json!({
"id": "1600000000000000001",
"name": "Test Guild",
"icon": null,
"banner": null,
"owner_id": "1500000000000000001",
"owner_username": null,
"owner_global_name": null,
"owner_discriminator": null,
"features": features,
"nsfw_level": null,
"nsfw": null,
"content_warning_level": null,
"content_warning_text": null,
"description": null,
"vanity_url_code": null,
}))
.expect("guild fixture")
}
#[test]
fn editor_offers_the_opt_in_clone_features() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
}
#[test]
fn editor_keeps_the_deprecated_clone_features_clearable() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_DISABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_DISABLED"));
assert_eq!(
feature_label("CLONE_EMOJI_DISABLED"),
"CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"
);
assert_eq!(feature_label("CLONE_EMOJI_ENABLED"), "CLONE_EMOJI_ENABLED");
}
#[test]
fn readonly_view_marks_a_stale_flag_as_deprecated() {
let guild = guild_with_features(&["CLONE_EMOJI_DISABLED", "CLONE_STICKER_ENABLED"]);
let markup = features_tab_readonly(&guild, GUILD_FEATURES).into_string();
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"));
assert!(markup.contains("CLONE_STICKER_ENABLED"));
}
}
@@ -2,10 +2,12 @@
use crate::{
api::types::{
AppPublicConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
RegistrationUrlResponse, SsoConfigResponse,
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -42,6 +44,17 @@ fn format_decimal(value: f64) -> String {
}
}
fn entry_count_hint(count: usize, cap: usize) -> Markup {
html! {
p class="text-xs text-neutral-500" {
(count) " of " (cap) " stored"
@if count >= cap {
" (at the cap; remove an entry before adding another)"
}
}
}
}
fn number_field(
name: &str,
label: &str,
@@ -134,6 +147,8 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.",
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
} @else {
@@ -953,6 +968,254 @@ fn gateway_rollout_section(
)
}
fn voice_noise_suppression_section(
base: &str,
csrf_token: &str,
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> Markup {
let status = if voice_noise_suppression.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let backend_labels =
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
let backend_options = backend_labels
.iter()
.map(|(value, label)| (value.as_str(), *label))
.collect::<Vec<_>>();
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
let guild_overrides = voice_noise_suppression
.guild_overrides
.iter()
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
.collect::<Vec<_>>()
.join("\n");
section_card_with_description(
"Voice Noise Suppression",
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
of them are targeted. While the master switch below is off nothing on this form reaches \
any client: every user keeps the audio pipeline they have today, whatever the rest of \
these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (voice_noise_suppression.config_version)
}
}
(checkbox(
"voice_ns_enabled",
"true",
"Serve noise suppression assignments to clients",
voice_noise_suppression.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
feature is inert and keeps its current behavior, so the rollout, targeting \
and override fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
(select_input(
"voice_ns_default_backend",
"Default Backend",
&backend_options,
&voice_noise_suppression.default_backend.to_string(),
))
p class="text-xs text-neutral-500" {
"The backend assigned by always-on user rules and the canary. A default \
that is not ticked below is unavailable, but per-guild overrides can \
still target users."
}
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for backend in NoiseSuppressionBackend::ALL {
(checkbox(
"voice_ns_enabled_backends[]",
&backend.to_string(),
backend.label(),
voice_noise_suppression.enabled_backends.contains(&backend),
true,
))
}
}
p class="text-xs text-neutral-500" {
"Backends clients are allowed to load. Unticking one withdraws it from \
every user, including anyone who picked it themselves."
}
(checkbox(
"voice_ns_allow_user_override",
"true",
"Let users pick their own backend from the ticked list",
voice_noise_suppression.allow_user_override,
true,
))
p class="text-xs text-neutral-500" {
"Applies only to users who are already targeted. It never pulls anyone \
into the rollout."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"voice_ns_rollout_basis_points",
"Rollout (basis points)",
&voice_noise_suppression.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"voice_ns_rollout_salt",
"Rollout Salt",
&voice_noise_suppression.rollout_salt,
"voice-ns-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save; blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_guild_overrides",
"Guild Overrides",
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
&guild_overrides,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.guild_overrides.len(),
VOICE_NS_MAX_GUILD_OVERRIDES,
))
p class="text-xs text-neutral-500" {
"One per line as guild_id=backend. A guild \
rule targets callers even outside the canary. Always-on user rules \
take precedence, and excluded users stay off. Invalid lines and \
conflicting rules for the same guild prevent the save. \
Unticked backends stay stored but are inactive."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
(checkbox(
"voice_ns_stereo_enabled",
"true",
"Process stereo input instead of downmixing to mono",
voice_noise_suppression.stereo_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Costs more CPU on the client. Leave off unless you are testing stereo \
capture."
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
"Suppression Strength",
&voice_noise_suppression.suppression_strength.to_string(),
Some(0), Some(100), "1",
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
))
}
(form_actions(html! {
(submit_button("Save Voice Noise Suppression Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
experiment_delivery: &ExperimentDeliveryConfigResponse,
) -> Markup {
section_card_with_description(
"Experiment Delivery",
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the one above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
html! {
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"experiment_delivery_poll_interval_seconds",
"Assignment Poll Interval (s)",
&experiment_delivery.poll_interval_seconds.to_string(),
Some(60), Some(86400), "1",
Some("How often a client re-reads its assignments, 60 to 86400 seconds. Lower values pick up changes sooner at the cost of more requests."),
))
(number_field(
"experiment_delivery_poll_jitter_percent",
"Assignment Poll Jitter (%)",
&experiment_delivery.poll_jitter_percent.to_string(),
Some(0), Some(50), "1",
Some("How far each client spreads its poll around the interval, 0 to 50 percent. Raise it to break up a fleet that polls on the same tick, set it to 0 for an exact interval."),
))
}
(form_actions(html! {
(submit_button("Save Experiment Delivery Configuration"))
}))
}
}
},
)
}
fn registration_config_section(
config: &AdminConfig,
csrf_token: &str,
@@ -1522,3 +1785,49 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
fn rendered_voice_noise_suppression_section(
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> String {
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
}
#[test]
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
}],
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(markup.contains("1 of 200 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect(),
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1000 of 1000 stored"));
assert!(markup.contains("at the cap"));
}
}
+12 -130
View File
@@ -10,7 +10,8 @@ use crate::{
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
submit_button,
},
message_list::{Attachment, Message, message_deletion_script, message_list},
message_data::{Message, ordered_messages, value_id},
message_list::{message_deletion_script, message_list},
page_container::{card, page_header},
},
layout::LayoutOptions,
@@ -19,7 +20,6 @@ use crate::{
};
use maud::{Markup, html};
use serde_json::Value;
use std::cmp::Ordering;
const MESSAGE_BROWSE_SCRIPT: &str = r#"
(function () {
@@ -263,7 +263,7 @@ pub fn browse_messages_fragment(
show_delete: bool,
highlight_message_id: Option<&str>,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let has_more = result
.get("has_more")
.and_then(Value::as_bool)
@@ -295,7 +295,7 @@ fn browse_result_card(
csrf_token: &str,
context_limit: u32,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let has_more = result
.get("has_more")
.and_then(Value::as_bool)
@@ -345,7 +345,7 @@ fn search_result_card(
query_text: &str,
show_delete: bool,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let total = result
.get("total")
.and_then(Value::as_u64)
@@ -382,7 +382,7 @@ fn lookup_result_card(
show_delete: bool,
context_limit: u32,
) -> Markup {
let messages = ordered_messages(result);
let messages = response_messages(result);
let channel_id = messages
.first()
.map(|m| m.channel_id.as_str())
@@ -508,130 +508,12 @@ fn empty_state(text: &str) -> Markup {
}
}
fn ordered_messages(result: &Value) -> Vec<Message> {
let mut messages: Vec<Message> = result
.get("messages")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(message_from_value)
.collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value
.get("attachments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value.get("id").and_then(value_id).unwrap_or_default(),
content: value
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
author_id: value
.get("author_id")
.and_then(value_id)
.unwrap_or_default(),
author_username: value
.get("author_username")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value
.get("author_global_name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author_discriminator: value
.get("author_discriminator")
.and_then(value_id)
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value
.get("author_avatar")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
channel_id: value
.get("channel_id")
.and_then(value_id)
.unwrap_or_default(),
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
channel_content_warning_level: value
.get("channel_content_warning_level")
.and_then(Value::as_i64)
.map(|n| n as i32),
channel_content_warning_text: value
.get("channel_content_warning_text")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value.get("id").and_then(value_id).unwrap_or_default(),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
filename: value
.get("filename")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
nsfw: value.get("nsfw").and_then(Value::as_bool),
content_type: value
.get("content_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
height: value
.get("height")
.and_then(Value::as_u64)
.map(|n| n as u32),
size: value.get("size").and_then(Value::as_u64),
ncmec_status: value
.get("ncmec_status")
.and_then(Value::as_str)
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value
.get("ncmec_report_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
ncmec_failure_reason: value
.get("ncmec_failure_reason")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
}
}
fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
fn response_messages(result: &Value) -> Vec<Message> {
let values = result["messages"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default();
ordered_messages(values)
}
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::cmp::Ordering;
use crate::{
acl,
api::types::ReportEntry,
@@ -13,7 +11,8 @@ use crate::{
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
form::csrf_input,
media::{guild_icon_url, initials, user_avatar_url},
message_list::{Attachment, Message, message_deletion_script, message_list},
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
nsfw_indicators::{
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
},
@@ -26,7 +25,6 @@ use crate::{
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
use serde_json::Value;
fn status_badge(status: i32) -> Markup {
let (label, variant) = match status {
@@ -534,123 +532,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
}))
}
}
fn ordered_messages(values: &[Value]) -> Vec<Message> {
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value
.get("attachments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value.get("id").and_then(value_id).unwrap_or_default(),
content: value
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
author_id: value
.get("author_id")
.and_then(value_id)
.unwrap_or_default(),
author_username: value
.get("author_username")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value
.get("author_global_name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author_discriminator: value
.get("author_discriminator")
.and_then(value_id)
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value
.get("author_avatar")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
channel_id: value
.get("channel_id")
.and_then(value_id)
.unwrap_or_default(),
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
channel_content_warning_level: value
.get("channel_content_warning_level")
.and_then(Value::as_i64)
.map(|n| n as i32),
channel_content_warning_text: value
.get("channel_content_warning_text")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value.get("id").and_then(value_id).unwrap_or_default(),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
filename: value
.get("filename")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
nsfw: value.get("nsfw").and_then(Value::as_bool),
content_type: value
.get("content_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
height: value
.get("height")
.and_then(Value::as_u64)
.map(|n| n as u32),
size: value.get("size").and_then(Value::as_u64),
ncmec_status: value
.get("ncmec_status")
.and_then(Value::as_str)
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value
.get("ncmec_report_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
ncmec_failure_reason: value
.get("ncmec_failure_reason")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
}
}
fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
}
@@ -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,
+55 -13
View File
@@ -7,6 +7,7 @@ use axum::{
response::{IntoResponse, Response},
};
use fluxer_admin::{
api::{generated::types as generated_types, types::LookupGuildResponse},
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
@@ -430,6 +431,8 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
&[
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_experiment_delivery",
][..],
),
];
@@ -903,8 +906,8 @@ fn user(id: &str, username: &str) -> Value {
})
}
fn searched_guild() -> Value {
json!({
fn searched_guild() -> generated_types::GuildAdminResponse {
serde_json::from_value(json!({
"id": "1600000000000000001",
"name": "Searched Guild",
"icon": null,
@@ -917,15 +920,14 @@ fn searched_guild() -> Value {
"features": ["COMMUNITY"],
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_text": null,
"description": "Guild used by HTMX acceptance tests.",
"vanity_url_code": null
})
"content_warning_level": 0,
"content_warning_text": null
}))
.expect("guild search fixture must match the generated response contract")
}
fn searched_guild_detail() -> Value {
json!({
fn searched_guild_detail() -> generated_types::LookupGuildResponseGuild {
serde_json::from_value(json!({
"id": "1600000000000000001",
"owner_id": "1500000000000000001",
"owner_username": "SearchedUser",
@@ -942,7 +944,7 @@ fn searched_guild_detail() -> Value {
"mfa_level": 0,
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_level": 0,
"content_warning_text": null,
"explicit_content_filter": 0,
"default_message_notifications": 0,
@@ -954,9 +956,23 @@ fn searched_guild_detail() -> Value {
"disabled_operations": 0,
"member_count": 12,
"channels": [],
"roles": [],
"description": "Guild used by HTMX acceptance tests."
})
"roles": []
}))
.expect("guild detail fixture must match the generated response contract")
}
#[test]
fn guild_fixtures_match_generated_response_contracts() {
let search = searched_guild();
assert_eq!(search.name, "Searched Guild");
assert_eq!(*search.member_count, 12);
let response: LookupGuildResponse =
serde_json::from_value(json!({"guild": searched_guild_detail()})).unwrap();
let detail = response.guild.unwrap();
assert_eq!(detail.name, "Searched Guild");
assert_eq!(detail.id, "1600000000000000001");
assert_eq!(detail.member_count, 12);
}
fn searched_application() -> Value {
@@ -1081,6 +1097,32 @@ fn instance_config() -> Value {
"max_concurrent_guild_starts": 16,
"voice_e2ee_scope": "guild_feature_only"
},
"voice_noise_suppression": {
"enabled": false,
"config_version": 0,
"default_backend": "standard",
"enabled_backends": [
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
],
"allow_user_override": true,
"rollout_basis_points": 0,
"rollout_salt": "voice-ns-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
},
"registration": registration_config(),
"self_hosted": false
})
+26
View File
@@ -3,6 +3,7 @@
#[path = "parity/mod.rs"]
mod parity_support;
use fluxer_admin::api::generated::types::{LookupGuildResponse, SearchGuildsResponse};
use parity_support::{
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
html_normalizer, rust_server,
@@ -48,6 +49,31 @@ fn html_normalizer_allows_intentional_rust_markup_fixes() {
);
}
#[test]
fn guild_search_fixture_matches_the_generated_response_contract() {
let response: SearchGuildsResponse =
serde_json::from_str(include_str!("parity/fixtures/api/search_guilds.json"))
.expect("guild search fixture must match the generated response contract");
assert_eq!(response.guilds.len(), 1);
let guild = &response.guilds[0];
assert_eq!(guild.name, "Parity Guild");
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
}
#[test]
fn guild_lookup_fixture_matches_the_generated_response_contract() {
let response: LookupGuildResponse =
serde_json::from_str(include_str!("parity/fixtures/api/lookup_guild.json"))
.expect("guild lookup fixture must match the generated response contract");
let guild = response
.guild
.expect("guild lookup fixture must contain a guild");
assert_eq!(String::from(guild.name), "Parity Guild");
assert_eq!(guild.content_warning_level.as_deref(), Some(&0));
assert_eq!(guild.channels.len(), 1);
assert_eq!(guild.channels[0].content_warning_level.as_deref(), Some(&0));
}
#[tokio::test(flavor = "multi_thread")]
async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<(), Box<dyn Error>> {
let api_server = api_fixtures::ApiFixtureServer::start_default()
@@ -16,7 +16,7 @@
"mfa_level": 0,
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_level": 0,
"content_warning_text": null,
"explicit_content_filter": 2,
"default_message_notifications": 1,
@@ -36,7 +36,7 @@
"parent_id": null,
"nsfw": false,
"nsfw_override": null,
"content_warning_level": null,
"content_warning_level": 0,
"content_warning_text": null,
"url": null
}
@@ -51,7 +51,6 @@
"hoist": false,
"mentionable": false
}
],
"description": "Guild used by admin parity fixtures."
]
}
}
@@ -13,10 +13,8 @@
"features": ["COMMUNITY", "DISCOVERABLE"],
"nsfw_level": 0,
"nsfw": false,
"content_warning_level": null,
"content_warning_text": null,
"description": "Guild used by admin parity fixtures.",
"vanity_url_code": "parity"
"content_warning_level": 0,
"content_warning_text": null
}
],
"total": 1
@@ -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(),
},
}
}
-1
View File
@@ -22,7 +22,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @fluxer/config run --if-present generate
RUN pnpm --filter fluxer_api run build
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
-1
View File
@@ -92,7 +92,6 @@
"@typescript/native-preview": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
},
"packageManager": "[email protected]"
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -21,7 +21,6 @@
"@types/node": "catalog:",
"@types/nodemailer": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -2
View File
@@ -1,11 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
export default defineConfig({
root: process.cwd(),
plugins: [tsconfigPaths()],
resolve: {tsconfigPaths: true},
cacheDir: './node_modules/.vitest',
test: {
globals: true,
+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);
},
};
}
-1
View File
@@ -18,7 +18,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
@@ -215,7 +215,7 @@ function createBlockedRequestError(url: URL, context: RequestUrlValidationContex
}
async function defaultLookupHost(hostname: string): Promise<Array<string>> {
const addresses = await dns.promises.lookup(hostname, {all: true, verbatim: true});
const addresses = await dns.promises.lookup(hostname, {all: true, order: 'verbatim'});
return addresses.map((addressEntry) => addressEntry.address);
}
@@ -234,7 +234,7 @@ function deduplicateAddresses(addresses: Array<string>): Array<string> {
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
const lookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, {...options, all: true, verbatim: true}, (error, addresses) => {
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
if (error) {
callback(error, []);
return;
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -19,7 +19,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
@@ -38,7 +38,6 @@
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "catalog:"
"vitest": "catalog:"
}
}
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -17,7 +17,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
-1
View File
@@ -19,7 +19,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+1 -10
View File
@@ -1,18 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
+55
View File
@@ -81,6 +81,22 @@ function withUploadRelaySecret(master: MasterConfig, secretBase64: string): Mast
};
}
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 () => {
@@ -111,3 +127,42 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
);
});
});
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();
});
});
+13 -9
View File
@@ -372,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,
@@ -395,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,
@@ -518,15 +531,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
laneName: apiWorkerConfig?.lane,
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
voiceReconciliation: {
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
},
laneConcurrencyOverrides: {
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
+26 -75
View File
@@ -1,15 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
InputValidationError,
type LocalizedValidationError,
} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationError';
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
import type {ZodError, ZodTypeAny} from 'zod';
import {type core, type input, type output, ZodObject, ZodOptional, type ZodSafeParseResult, type ZodType} from 'zod';
import {requireRequestJsonBody} from './utils/RequestJsonBody';
import {initializeFluxerErrorMap} from './ZodErrorMap';
@@ -19,12 +20,6 @@ function isEmptyObject(obj: object): boolean {
return Object.keys(obj).length === 0;
}
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
function isValidationErrorCode(value: string): value is ValidationErrorCode {
return validationErrorCodeSet.has(value);
}
function getValidationErrorCode(message: string): ValidationErrorCode {
if (isValidationErrorCode(message)) {
return message;
@@ -32,72 +27,37 @@ function getValidationErrorCode(message: string): ValidationErrorCode {
return ValidationErrorCodes.INVALID_FORMAT;
}
interface ZodTooSmallIssue {
code: 'too_small';
minimum: number | bigint;
type: string;
}
interface ZodTooBigIssue {
code: 'too_big';
maximum: number | bigint;
type: string;
}
function isTooSmallIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooSmallIssue {
return issue.code === 'too_small' && 'minimum' in issue && 'type' in issue;
}
function isTooBigIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodTooBigIssue {
return issue.code === 'too_big' && 'maximum' in issue && 'type' in issue;
}
interface ZodInvalidTypeIssue {
code: 'invalid_type';
expected: string;
received: string;
}
interface ZodCustomIssue {
code: 'custom';
params?: Record<string, unknown>;
}
function isInvalidTypeIssue(
issue: ZodError['issues'][number],
): issue is ZodError['issues'][number] & ZodInvalidTypeIssue {
return issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue;
}
function isCustomIssue(issue: ZodError['issues'][number]): issue is ZodError['issues'][number] & ZodCustomIssue {
return issue.code === 'custom';
}
function extractVariablesFromIssue(issue: ZodError['issues'][number]): Record<string, unknown> | undefined {
function extractVariablesFromIssue(issue: core.$ZodIssue): Record<string, unknown> {
const path = issue.path;
const fieldName = path.length > 0 ? String(path[path.length - 1]) : 'field';
if (isTooSmallIssue(issue)) {
if (issue.code === 'too_small') {
return {name: fieldName, min: issue.minimum, minValue: issue.minimum};
}
if (isTooBigIssue(issue)) {
if (issue.code === 'too_big') {
return {name: fieldName, max: issue.maximum, maxLength: issue.maximum, maxValue: issue.maximum};
}
if (isInvalidTypeIssue(issue)) {
return {name: fieldName, expected: issue.expected, received: issue.received};
if (issue.code === 'invalid_type') {
return {name: fieldName, expected: issue.expected};
}
if (isCustomIssue(issue) && issue.params) {
if (issue.code === 'custom' && issue.params) {
return {name: fieldName, ...issue.params};
}
return {name: fieldName};
}
function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
function convertEmptyValuesToNull(obj: unknown, schema?: core.$ZodType, isRoot = true): unknown {
while (schema instanceof ZodOptional) schema = schema.unwrap();
if (schema && schemaMetadata.get(schema)?.preserveEmptyValues) return obj;
if (typeof obj === 'string' && obj === '') return null;
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, false));
if (Array.isArray(obj)) return obj.map((item) => convertEmptyValuesToNull(item, undefined, false));
if (obj !== null && typeof obj === 'object') {
if (isEmptyObject(obj) && !isRoot) return null;
const shape = schema instanceof ZodObject ? schema.shape : undefined;
const processed = Object.fromEntries(
Object.entries(obj).map(([key, value]) => [key, convertEmptyValuesToNull(value, false)]),
Object.entries(obj).map(([key, value]) => [
key,
convertEmptyValuesToNull(value, shape && Object.hasOwn(shape, key) ? shape[key] : undefined, false),
]),
);
if (!isRoot && Object.values(processed).every((value) => value === null)) return null;
return processed;
@@ -106,24 +66,15 @@ function convertEmptyValuesToNull(obj: unknown, isRoot = true): unknown {
}
type HasUndefined<T> = undefined extends T ? true : false;
type SafeParseResult<T extends ZodTypeAny> =
| {
success: true;
data: T['_output'];
}
| {
success: false;
error: ZodError<T['_input']>;
};
type Hook<
T extends ZodTypeAny,
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets = keyof ValidationTargets,
V extends Input = Input,
O = Record<string, unknown>,
> = (
result: SafeParseResult<T> & {
result: ZodSafeParseResult<output<T>> & {
target: Target;
},
c: Context<E, P, V>,
@@ -134,7 +85,7 @@ type PreHook<E extends Env, P extends string, Target extends keyof ValidationTar
target: Target,
) => unknown | Promise<unknown>;
type ValidatorOptions<
T extends ZodTypeAny,
T extends ZodType,
E extends Env,
P extends string,
Target extends keyof ValidationTargets,
@@ -144,7 +95,7 @@ type ValidatorOptions<
post?: Hook<T, E, P, Target, V>;
};
export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): InputValidationError {
export function inputValidationErrorFromZodIssues(issues: Array<core.$ZodIssue>): InputValidationError {
const errors: Array<ValidationError> = [];
const localizedErrors: Array<LocalizedValidationError> = [];
const seen = new Set<string>();
@@ -162,12 +113,12 @@ export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): I
}
export const Validator = <
T extends ZodTypeAny,
T extends ZodType,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = T['_input'],
Out = T['_output'],
In = input<T>,
Out = output<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
@@ -248,7 +199,7 @@ export const Validator = <
if (options.pre) {
value = await options.pre(value, c, target);
}
const transformedValue = convertEmptyValuesToNull(value);
const transformedValue = convertEmptyValuesToNull(value, schema);
const result = await schema.safeParseAsync(transformedValue);
if (options.post) {
const hookResult = await options.post({...result, target}, c);
+7 -37
View File
@@ -1,37 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValidationErrorCode} from '@fluxer/constants/src/ValidationErrorCodes';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {isValidationErrorCode, ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {z} from 'zod';
const validationErrorCodeSet = new Set<string>(Object.values(ValidationErrorCodes));
type FluxerZodErrorMapIssue = z.core.$ZodRawIssue;
type FluxerZodErrorMapResult =
| {
message: string;
}
| string
| undefined
| null;
function isValidationErrorCode(value: string): value is ValidationErrorCode {
return validationErrorCodeSet.has(value);
}
function getParamsProperty(obj: object): Record<string, unknown> | undefined {
if ('params' in obj) {
const value = (
obj as {
params?: unknown;
}
).params;
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
}
return undefined;
}
function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResult {
const fluxerZodErrorMap: z.core.$ZodErrorMap = (issue) => {
if (issue.message && isValidationErrorCode(issue.message)) {
return {message: issue.message};
}
@@ -58,8 +31,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'too_big': {
const origin = 'origin' in issue ? String(issue.origin) : undefined;
if (origin === 'string') {
if (issue.origin === 'string') {
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
@@ -67,10 +39,9 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'invalid_format': {
const format = 'format' in issue ? String(issue.format) : undefined;
if (format === 'email') {
if (issue.format === 'email') {
errorCode = ValidationErrorCodes.INVALID_EMAIL_ADDRESS;
} else if (format === 'uuid') {
} else if (issue.format === 'uuid') {
errorCode = ValidationErrorCodes.INVALID_SNOWFLAKE;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
@@ -82,8 +53,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
break;
}
case 'custom': {
const params = getParamsProperty(issue);
const customErrorCode = params?.['error_code'];
const customErrorCode = issue.params?.['error_code'];
errorCode =
typeof customErrorCode === 'string' && isValidationErrorCode(customErrorCode)
? customErrorCode
@@ -101,7 +71,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
}
}
return {message: errorCode};
}
};
export function initializeFluxerErrorMap(): void {
z.config({customError: fluxerZodErrorMap});
@@ -2,6 +2,7 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
AdminApiKeyListResponse,
CreateAdminApiKeyRequest,
CreateAdminApiKeyResponse,
type CreateAdminApiKeyResponse as CreateAdminApiKeyResponseType,
@@ -11,7 +12,7 @@ import {
UpdateAdminApiKeyRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {z} from 'zod';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -72,7 +73,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
OpenAPI({
operationId: 'list_admin_api_keys',
summary: 'List admin API keys',
responseSchema: z.array(ListAdminApiKeyResponse),
responseSchema: AdminApiKeyListResponse,
statusCode: 200,
security: ['adminApiKey'],
tags: ['Admin'],
@@ -3,8 +3,11 @@
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
import {
AdminArchiveCreateRequest,
AdminArchiveResponseSchema,
type ArchiveSubjectType,
} from '@fluxer/schema/src/domains/admin/AdminArchiveSchemas';
import {
AdminArchiveCreateRequest,
DownloadUrlResponseSchema,
GetArchiveResponseSchema,
ListArchivesQuery,
@@ -19,7 +22,7 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
function canViewArchive(adminAcls: Set<string>, subjectType: 'user' | 'guild'): boolean {
function canViewArchive(adminAcls: Set<string>, subjectType: ArchiveSubjectType): boolean {
if (adminAcls.has(AdminACLs.WILDCARD) || adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL)) return true;
if (subjectType === 'user') return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_USER);
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
@@ -30,6 +33,19 @@ function requireArchiveSubjectAccess(adminAcls: Set<string>, subjectType: 'user'
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/users/:user_id/archives',
@@ -104,18 +120,8 @@ export function ArchiveAdminController(app: HonoApp) {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const query = ctx.req.valid('query');
if (
query.subject_type === 'all' &&
!adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL) &&
!adminAcls.has(AdminACLs.WILDCARD)
) {
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
}
if (query.subject_type !== 'all') {
requireArchiveSubjectAccess(adminAcls, query.subject_type);
}
const result = await adminArchiveService.listArchives({
subjectType: query.subject_type,
subjectType: resolveListSubjectType(adminAcls, query.subject_type),
subjectId: query.subject_id ?? undefined,
requestedBy: query.requested_by ?? undefined,
limit: query.limit,
@@ -39,7 +39,7 @@ import {
SuspiciousEmailDomainRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import type {ZodTypeAny, z} from 'zod';
import type {ZodType} from 'zod';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
@@ -221,7 +221,7 @@ function requireProfileSubstringScope(scope: ProfileSubstringScope | undefined):
return scope;
}
async function parseBlocklistBody<T extends ZodTypeAny>(schema: T, value: unknown): Promise<z.infer<T>> {
async function parseBlocklistBody<T>(schema: ZodType<T>, value: unknown): Promise<T> {
const result = await schema.safeParseAsync(value);
if (!result.success) {
throw inputValidationErrorFromZodIssues(result.error.issues);
@@ -6,17 +6,17 @@ import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas
import {
DiscoveryAdminApplicationUpdateRequest,
DiscoveryAdminCategoryListingQuery,
DiscoveryAdminListedGuildResponse,
DiscoveryAdminListedGuildListResponse,
DiscoveryAdminListingBulkCategoryRequest,
DiscoveryAdminListingBulkCategoryResponse,
DiscoveryAdminPendingApplicationResponse,
DiscoveryAdminPendingApplicationListResponse,
DiscoveryAdminRemoveRequest,
DiscoveryApplicationPatchRequest,
DiscoveryApplicationResponse,
DiscoveryCategoryIdParam,
DiscoveryCategoryListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import {z} from 'zod';
import {createGuildID} from '../../BrandedTypes';
import type {GuildDiscoveryRow} from '../../database/types/GuildDiscoveryTypes';
import {mapGuildFeatures} from '../../guild/GuildFeatureUtils';
@@ -138,7 +138,7 @@ export function DiscoveryAdminController(app: HonoApp) {
summary: 'List discovery applications',
description:
'Returns every pending discovery application, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
responseSchema: z.array(DiscoveryAdminPendingApplicationResponse),
responseSchema: DiscoveryAdminPendingApplicationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -214,7 +214,7 @@ export function DiscoveryAdminController(app: HonoApp) {
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),
responseSchema: DiscoveryAdminListedGuildListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -249,7 +249,7 @@ export function DiscoveryAdminController(app: HonoApp) {
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),
responseSchema: DiscoveryAdminListedGuildListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -17,9 +17,8 @@ import {
ListGuildStickersResponse,
LookupGuildResponse,
SearchGuildsResponse,
SuccessResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GuildIdParam, GuildIdUserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildIdParam, GuildIdUserIdParam, SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
import {createGuildID} from '../../BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
@@ -72,7 +71,10 @@ function selectGuildUpdateACLs(body: UpdateGuildRequest): Array<string> {
if (body.new_owner_id !== undefined) {
required.push(AdminACLs.GUILD_TRANSFER_OWNERSHIP);
}
return required.length > 0 ? required : [AdminACLs.WILDCARD];
if (required.length > 0) {
return required;
}
return Object.keys(body).length === 0 ? [] : [AdminACLs.WILDCARD];
}
function requireAllAdminACLs(granted: ReadonlySet<string>, required: ReadonlyArray<string>): void {
@@ -148,7 +150,7 @@ export function GuildAdminController(app: HonoApp) {
operationId: 'update_admin_guild',
summary: 'Update guild',
description:
'Partially updates a guild. The permissions required are selected by the fields present in the body and are evaluated with all-of semantics: name requires GUILD_UPDATE_NAME, vanity_url_code requires GUILD_UPDATE_VANITY, new_owner_id requires GUILD_TRANSFER_OWNERSHIP, add_features and remove_features require GUILD_UPDATE_FEATURES, and fields together with every other setting requires GUILD_UPDATE_SETTINGS. A body carrying no field requires the wildcard permission. Every applied change is logged to the audit log.',
'Partially updates a guild. The permissions required are selected by the fields present in the body and are evaluated with all-of semantics: name requires GUILD_UPDATE_NAME, vanity_url_code requires GUILD_UPDATE_VANITY, new_owner_id requires GUILD_TRANSFER_OWNERSHIP, add_features and remove_features require GUILD_UPDATE_FEATURES, and fields together with every other setting requires GUILD_UPDATE_SETTINGS. A body with no fields applies no change. Every applied change is logged to the audit log.',
responseSchema: GuildUpdateResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -14,14 +14,16 @@ import {
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
import {createUserID} from '../../BrandedTypes';
import {Config} from '../../Config';
import {
type InstanceBrandingConfig,
type InstancePolicyConfig,
REGISTRATION_PENDING_APPROVAL_TRAIT,
REGISTRATION_REJECTED_TRAIT,
@@ -51,9 +53,19 @@ function omitUndefinedFields<T extends object>(value: T): Partial<T> {
async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
const instanceConfigRepository = getInstanceConfigRepository();
const [ssoConfig, gatewayRollout, registrationConfig, registrationUrls, pendingRegistrations] = await Promise.all([
const [
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
experimentDelivery,
registrationConfig,
registrationUrls,
pendingRegistrations,
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
@@ -83,6 +95,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
urls: registrationUrls,
@@ -214,6 +228,26 @@ export function InstanceConfigAdminController(app: HonoApp) {
await instanceConfigRepository.setGatewayRolloutConfig(validated);
await getGatewayRolloutConfigPublisher().publish(validated);
}
if (data.voice_noise_suppression) {
const patch = omitUndefinedFields(data.voice_noise_suppression);
if (Object.keys(patch).length > 0) {
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
const validated = VoiceNoiseSuppressionConfigSchema.parse({
...currentNoiseSuppression,
...patch,
config_version: currentNoiseSuppression.config_version + 1,
});
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
}
}
if (data.experiment_delivery) {
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
const validated = ExperimentDeliveryConfigSchema.parse({
...currentExperimentDelivery,
...data.experiment_delivery,
});
await instanceConfigRepository.setExperimentDeliveryConfig(validated);
}
if (data.sso) {
const sso = data.sso;
const current = await instanceConfigRepository.getSsoConfig({includeSecret: true});
@@ -404,7 +438,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
base64Image: image ?? null,
errorPath: 'image',
});
const brandingPatch: Partial<InstanceBrandingConfig> = {[`${kind}_url`]: prepared.newCdnUrl};
const brandingPatch: Partial<InstanceBranding> = {[`${kind}_url`]: prepared.newCdnUrl};
await instanceConfigRepository.setAppPublicConfig({branding: brandingPatch});
return ctx.json(await buildInstanceConfigResponse());
},
@@ -88,12 +88,12 @@ export function ReportAdminController(app: HonoApp) {
const adminService = ctx.get('adminService');
const adminUserAcls = ctx.get('adminUserAcls');
const query = ctx.req.valid('query');
if (usesReportSearchIndex(query)) {
if (query.status === undefined || usesReportSearchIndex(query)) {
return ctx.json(
await adminService.reportServiceAggregate.searchReports(toSearchReportsRequest(query), adminUserAcls),
);
}
const status = query.status === undefined ? 0 : REPORT_STATUS_BY_FILTER[query.status];
const status = REPORT_STATUS_BY_FILTER[query.status];
return ctx.json(
await adminService.reportServiceAggregate.listReports(status, adminUserAcls, query.limit, query.offset),
);
@@ -23,6 +23,7 @@ export function SystemAdminController(app: HonoApp) {
description:
'Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.',
responseSchema: HeapSnapshotResponse,
responseContentType: 'application/octet-stream',
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -5,6 +5,7 @@ import {
CreateVoiceRegionRequest,
CreateVoiceRegionResponse,
CreateVoiceServerRequest,
CreateVoiceServerRequestBody,
CreateVoiceServerResponse,
DeleteVoiceResponse,
GetVoiceRegionQuery,
@@ -14,8 +15,10 @@ import {
ListVoiceRegionsResponse,
ListVoiceServersResponse,
UpdateVoiceRegionRequest,
UpdateVoiceRegionRequestBody,
UpdateVoiceRegionResponse,
UpdateVoiceServerRequest,
UpdateVoiceServerRequestBody,
UpdateVoiceServerResponse,
VoiceRegionIdParam,
VoiceServerIdParam,
@@ -117,6 +120,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'update_admin_voice_region',
summary: 'Update voice region',
requestSchema: UpdateVoiceRegionRequestBody,
responseSchema: UpdateVoiceRegionResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -195,6 +199,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'create_admin_voice_server',
summary: 'Create voice server',
requestSchema: CreateVoiceServerRequestBody,
responseSchema: CreateVoiceServerResponse,
statusCode: 200,
security: 'adminApiKey',
@@ -247,6 +252,7 @@ export function VoiceAdminController(app: HonoApp) {
OpenAPI({
operationId: 'update_admin_voice_server',
summary: 'Update voice server',
requestSchema: UpdateVoiceServerRequestBody,
responseSchema: UpdateVoiceServerResponse,
statusCode: 200,
security: 'adminApiKey',

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