Compare commits

..
Author SHA1 Message Date
HampusandGitHub 73d3a4f843 fix(app): widen the custom status modal (#2551) 2026-09-06 20:19:28 +02:00
HampusandGitHub 091755fe78 fix(self-hosting): adapt the upgrade to existing instances (#2550) 2026-09-06 19:40:32 +02:00
HampusandGitHub 1fb2790bb9 fix(api): stop bounding the pin listing by the wall clock (#2549) 2026-09-06 19:03:15 +02:00
HampusandGitHub 798e64b224 refactor(app): remove the report modal path selection step (#2548) 2026-09-06 18:49:35 +02:00
HampusandGitHub a2d6477b42 fix(admin): route the bulk user deletion action correctly (#2545) 2026-09-06 18:42:37 +02:00
HampusandGitHub a2ca24eeb4 fix(admin): search archives across both subject types (#2542) 2026-09-06 18:42:33 +02:00
HampusandGitHub 8dcd00a8fe fix(admin): batch user id lookups on the users page (#2547) 2026-09-06 18:41:46 +02:00
HampusandGitHub be8a52c823 fix(admin): bound the reports page offset (#2546) 2026-09-06 18:41:18 +02:00
HampusandGitHub 43e420b0ab fix(admin): require paired voice server coordinates (#2544) 2026-09-06 18:40:50 +02:00
HampusandGitHub f8947adf62 fix(admin): map the index refresh status response union (#2543) 2026-09-06 18:40:20 +02:00
HampusandGitHub 81fccaf0ab docs(media-proxy): stop documenting literal response bodies (#2541) 2026-09-06 18:39:50 +02:00
HampusandGitHub 7a42291baf fix(api): search all reports when no status filter is given (#2540) 2026-09-06 18:39:18 +02:00
HampusandGitHub d9f983b08e fix(api): return the terminated count from terminate sessions (#2539) 2026-09-06 18:38:46 +02:00
HampusandGitHub 2f159852a7 fix(api): make an empty admin guild patch apply no change (#2538) 2026-09-06 18:38:13 +02:00
HampusandGitHub 5ef402b8ee fix(api): apply the nsfw and content warning guild settings (#2537) 2026-09-06 18:37:38 +02:00
HampusandGitHub a8d6e5ab73 refactor(api): remove premium-based voice track muting (#2536) 2026-09-06 18:37:01 +02:00
HampusandGitHub e805a3797f fix(api): stop entrance sound play probing channel existence (#2535) 2026-09-06 18:36:24 +02:00
HampusandGitHub 8f4fa82a9e fix(api): always return the page total when listing reports (#2534) 2026-09-06 17:38:21 +02:00
HampusandGitHub d2438b2fdd docs(operator): note the upload relay secret an upgrade now needs (#2533) 2026-09-06 17:21:06 +02:00
HampusandGitHub 133640ef2b fix(docs): allow unused pnpm patches in the docs image deploy (#2531) 2026-09-06 16:19:46 +02:00
HampusandGitHub 8e0516a8c3 feat(api): drop explicit media classification on asset uploads (#2530) 2026-09-06 16:12:25 +02:00
HampusandGitHub d784c0692e fix(app): set the jsx runtime in tsconfig so vitest parses tsx (#2529) 2026-09-06 15:51:18 +02:00
HampusandGitHub fffa265117 chore(i18n): refresh the client message catalogues (#2528) 2026-09-06 15:41:52 +02:00
HampusandGitHub 5367c0ab42 docs: move the reference site to astro starlight (#2527) 2026-09-06 15:40:22 +02:00
HampusandGitHub 7f8f09ee51 feat(admin)!: move the admin api to rest and fix its defects (#2515) 2026-09-06 15:36:41 +02:00
HampusandGitHub a70924d4b0 fix(admin): require the admin secret key base at boot (#2514) 2026-09-06 15:36:08 +02:00
HampusandGitHub 1a5925f9cb chore(app): remove message scheduling and a dead descriptor (#2513) 2026-09-06 15:35:36 +02:00
HampusandGitHub 43c778aae4 fix(api): guard the rpc session init test harness route (#2512) 2026-09-06 15:34:57 +02:00
HampusandGitHub 34cf8f821f refactor(api)!: drop unused helpers, parameters and a route (#2511) 2026-09-06 15:34:25 +02:00
HampusandGitHub 226cfd062e fix(worker): rebuild the deletion queue and cancel system dms (#2510) 2026-09-06 15:33:53 +02:00
HampusandGitHub e8f4e35c32 fix(api): gate stream keys by channel type and cover previews (#2509) 2026-09-06 15:33:20 +02:00
HampusandGitHub ef559f3d8c fix(api): handle bad manifests, unfurl errors and the apns key (#2508) 2026-09-06 15:32:47 +02:00
HampusandGitHub ee7206ac66 fix(api): batch connection reorders, dispatch on failed recheck (#2507) 2026-09-06 15:32:16 +02:00
HampusandGitHub 1ba9592308 fix(api): correct webhook dedupe and the instatus transforms (#2506) 2026-09-06 15:31:43 +02:00
HampusandGitHub d07f520b13 fix(api)!: correct pagination and locking, drop toggle routes (#2505) 2026-09-06 15:31:13 +02:00
HampusandGitHub 632f4c7b6c fix(api): correct report targets and ticket handling (#2504) 2026-09-06 15:30:41 +02:00
HampusandGitHub 1f627c9cc5 fix(api): correct user content, read state and harvest paths (#2501) 2026-09-06 15:30:08 +02:00
HampusandGitHub cc110b9f5a fix(api): raise coded errors for prerequisites and bounds (#2502) 2026-09-06 15:29:36 +02:00
HampusandGitHub f06d65db54 fix(api): reject unparsable bodies and screen non-form ones (#2503) 2026-09-06 15:29:04 +02:00
HampusandGitHub f8a04b8985 fix(api)!: enforce declared rate limits and correct route auth (#2500) 2026-09-06 15:28:32 +02:00
HampusandGitHub 908e1b8bd4 fix(api): correct guild permission and mfa checks (#2499) 2026-09-06 15:28:01 +02:00
HampusandGitHub f392636857 fix(auth): correct mfa errors, sudo methods and birth dates (#2498) 2026-09-06 15:27:30 +02:00
HampusandGitHub 150115cc0c fix(media-proxy): bound the relay body and drop the unread ttl (#2496) 2026-09-06 15:26:57 +02:00
HampusandGitHub 710a6f1c5d fix(media-proxy): correct route errors and test the ip gate (#2495) 2026-09-06 15:26:33 +02:00
HampusandGitHub 7c3e722085 chore(gateway): delete modules with no callers (#2494) 2026-09-06 15:26:08 +02:00
HampusandGitHub d8f2aa3184 feat(gateway): add an undrain endpoint and sweep orphan tables (#2493) 2026-09-06 15:25:43 +02:00
HampusandGitHub e828398e06 fix(gateway): close oversized bot identify with code 4011 (#2497) 2026-09-06 15:25:19 +02:00
HampusandGitHub 31d7cb81d6 fix(gateway): return precise rpc errors and bound snowflakes (#2491) 2026-09-06 15:24:54 +02:00
HampusandGitHub 214d19d45a fix(gateway): resync permissions and validate voice leaves (#2492) 2026-09-06 15:24:23 +02:00
HampusandGitHub d3170fc320 fix(gateway): repair the session lifecycle, limits and dead code (#2490) 2026-09-06 15:23:59 +02:00
HampusandGitHub 9a229c1b73 fix(api): answer 403 when the client ip header is unparsable (#2483) 2026-09-06 15:23:35 +02:00
HampusandGitHub a036d9a2e1 fix(api): resolve missing user rows for webhooks and sessions (#2487) 2026-09-06 15:23:03 +02:00
HampusandGitHub d5bfa5a73d fix(api)!: align error codes with throw sites and image bounds (#2488) 2026-09-06 15:21:38 +02:00
HampusandGitHub 4534822355 fix(config): derive the VAPID public point to verify the pair (#2521) 2026-09-06 15:13:32 +02:00
HampusandGitHub bff29d8f07 fix(api)!: always send Retry-After and reclassify two limits (#2489) 2026-09-06 15:07:00 +02:00
HampusandGitHub bec34ea147 fix(api)!: correct declared bounds and hide public bot mfa (#2485) 2026-09-06 15:06:15 +02:00
Hampus Kraft 3be4171256 feat(self-host)!: rework the compose stack and demand secrets (#2486) 2026-09-06 15:02:20 +02:00
Hampus Kraft 5bcaa7cfac fix(config)!: validate and derive config, drop the unread keys (#2482) 2026-09-06 15:02:20 +02:00
HampusandGitHub ea93ef5352 fix(api): find every live route when generating openapi.json (#2484) 2026-09-06 14:54:21 +02:00
HampusandGitHub 8734956d86 fix(auth): explain why a phone number was rejected (#2480) 2026-09-06 03:09:26 +02:00
HampusandGitHub 519b3a6127 fix(i18n): translate shipped English, repair broken catalogs (#2479) 2026-09-06 02:58:56 +02:00
HampusandGitHub 9b3773c1e6 feat(auth): let phone-gated accounts set the check aside (#2478) 2026-09-06 01:11:24 +02:00
HampusandGitHub e12b60078a fix(self-host): probe the seaweedfs s3 health endpoint (#2476) 2026-09-06 00:24:31 +02:00
HampusandGitHub 7cb8f9f4ae fix(app): pick default channel when guild channels arrive late (#2475) 2026-09-06 00:04:43 +02:00
HampusandGitHub 622bd124b9 fix(fonts): stop SC and TC from claiming kana (#2473) 2026-09-05 22:25:44 +02:00
HampusandGitHub fed8b2d089 feat(admin): add bulk delete user messages tool (#2472) 2026-09-05 22:20:14 +02:00
HampusandGitHub 12718eabbc refactor(api): drop cookie support for sudo mode (#2466) 2026-09-05 01:29:34 +02:00
HampusandGitHub ab68b61653 refactor: remove the CTP_MEMBER user flag (#2465) 2026-09-05 01:13:34 +02:00
HampusandGitHub 639ade3802 refactor(app-proxy): drop invite metadata and database access (#2464) 2026-09-05 00:13:04 +02:00
HampusandGitHub 3f1f899b23 fix(api): keep a deprecated nsfw field for older clients (#2463) 2026-09-04 23:08:34 +02:00
HampusandGitHub 51cb750502 feat(api): drop NSFW classification for emojis and stickers (#2462) 2026-09-04 22:55:58 +02:00
HampusandGitHub 1e6c332eae fix(app): show empty categories when hiding muted channels (#2460) 2026-09-04 21:04:34 +02:00
HampusandGitHub 18ae2e563e fix(worker): fit the job streams to the jetstream budget (#2458) 2026-09-04 19:31:20 +02:00
HampusandGitHub a4d039c910 fix(app-proxy): publish source maps with the asset tree (#2457) 2026-09-04 19:10:30 +02:00
HampusandGitHub 6163fd5644 fix(message): turn mentions red in failed messages (#2456) 2026-09-04 19:03:30 +02:00
HampusandGitHub 3e2ddaca4f fix(message): turn links red in failed messages (#2455) 2026-09-04 18:21:27 +02:00
HampusandGitHub 054a59e622 fix(message): keep reply previews on one line after a mention (#2454) 2026-09-04 18:20:14 +02:00
HampusandGitHub 84d7290ed9 fix(api): tighten guild emoji and sticker mutation limits (#2453) 2026-09-04 17:58:08 +02:00
HampusandGitHub f4c1254d91 fix(media-proxy): stop serving animated originals as stills (#2452) 2026-09-04 16:56:58 +02:00
HampusandGitHub c01d22dc05 fix(self-host): unfurl media hosted by the instance itself (#2451) 2026-09-04 16:56:35 +02:00
HampusandGitHub 4c0f02d8a5 chore(i18n): refresh catalogs for the window share audio scope (#2450) 2026-09-04 16:41:53 +02:00
HampusandGitHub 2770482baf perf(app-proxy): hold the frozen snapshot by reference (#2449) 2026-09-04 16:00:23 +02:00
HampusandGitHub 8e39a00e34 perf(app-proxy): run on jemalloc to curb arena growth (#2448) 2026-09-04 15:57:47 +02:00
HampusandGitHub 7bd0d3a962 fix(app-proxy): remove the SPA document render reservation (#2447) 2026-09-04 15:55:59 +02:00
HampusandGitHub bc7f701e87 feat(voice): give window shares their own audio scope (#2446) 2026-09-04 15:48:37 +02:00
HampusandGitHub 0d8116d73e fix(workspace): restore the devcontainer compose project name (#2445) 2026-09-04 15:41:38 +02:00
HampusandGitHub 255cbc1248 perf(app-proxy): drop dynamic brotli compression (#2444) 2026-09-04 14:47:49 +02:00
HampusandGitHub 098aeef412 fix(media-proxy): stop lifting bt709 video thumbnails (#2443) 2026-09-04 13:17:30 +02:00
HampusandGitHub baa18aed5b fix(media-proxy): link source-built native libs first (#2442) 2026-09-04 03:42:56 +02:00
HampusandGitHub b7c8dab019 fix(media-proxy): pin builder libheif, fix the image build (#2441) 2026-09-04 02:06:02 +02:00
HampusandGitHub 587324fa38 fix(voice): reuse the Linux audio capture across routing changes (#2440) 2026-09-04 01:00:32 +02:00
HampusandGitHub cc3a9c8613 fix(app): jitter gateway reconnects and recover status nagbar (#2439) 2026-09-03 23:21:32 +02:00
HampusandGitHub b0645300ec fix(i18n): reaction tooltip word order and plural agreement (#2438) 2026-09-03 22:11:59 +02:00
HampusandGitHub d7d4e8da03 refactor(media-proxy): split into modules and harden streaming (#2437) 2026-09-03 22:04:00 +02:00
HampusandGitHub 16ae98e189 fix(auth): regenerate backup codes with the emailed challenge (#2436) 2026-09-03 21:29:16 +02:00
HampusandGitHub add0a3dfc6 fix(voice): start bitrate for non-SVC screen share codecs (#2434) 2026-09-03 21:07:42 +02:00
HampusandGitHub 90c349392b fix(auth): email code to view backup codes, fix login matching (#2433) 2026-09-03 21:06:30 +02:00
HampusandGitHub eb4562b6a6 feat(voice): add screen share subscription debug helper (#2432) 2026-09-03 20:22:59 +02:00
HampusandGitHub 6c634b686f feat(voice): rework screen share audio source selection (#2431) 2026-09-03 20:01:28 +02:00
HampusandGitHub 48e03edccc chore(desktop): upgrade Electron to 44.1.1 (#2430) 2026-09-03 18:53:23 +02:00
HampusandGitHub cef6f11fd0 fix(app): correct the connection nagbar button styling (#2428) 2026-09-03 18:17:06 +02:00
HampusandGitHub 2757659989 fix(gateway): satisfy dialyzer after the hotpatch reconcile (#2427) 2026-09-03 17:26:51 +02:00
HampusandGitHub f090395c21 fix(voice): republish screen share when its codec goes stale (#2426) 2026-09-03 17:09:48 +02:00
HampusandGitHub dd1d554cc6 fix(gateway): reconcile hotpatched member-list and push fixes (#2425) 2026-09-03 17:04:32 +02:00
HampusandGitHub 9c1b38aeaf fix(api): always allow opening a dm channel (#2423) 2026-09-03 16:35:01 +02:00
HampusandGitHub 902dd60ff9 fix(voice): keep published codecs inside the opt-in policy (#2422) 2026-09-03 15:06:48 +02:00
HampusandGitHub 2426a5769d feat(voice): drive screen share quality from viewer demand (#2421) 2026-09-03 04:49:29 +02:00
HampusandGitHub 66517c925b feat(voice): show a passive badge when a stream underperforms (#2420) 2026-09-03 04:49:07 +02:00
HampusandGitHub 5f90e7d535 fix(api): downgrade oversized video instead of ending the call (#2419) 2026-09-03 04:47:02 +02:00
HampusandGitHub 9d63eb15a9 fix(voice): request a real camera frame rate at capture (#2418) 2026-09-03 04:46:32 +02:00
HampusandGitHub c97bc53342 refactor(voice): remove screen share codec renegotiation (#2417) 2026-09-03 04:46:11 +02:00
HampusandGitHub f5f60c66e7 refactor(voice): remove adaptive screen share quality system (#2416) 2026-09-03 04:41:04 +02:00
HampusandGitHub 3e15b97c8c fix(voice): stop clamping stored video quality preferences (#2415) 2026-09-03 04:36:04 +02:00
HampusandGitHub 6c08813f9b feat(voice): scale screen share bitrate to the selected rung (#2414) 2026-09-03 04:35:37 +02:00
HampusandGitHub 8158d44732 fix(voice): keep screen share resolution under constraint (#2413) 2026-09-03 04:35:12 +02:00
HampusandGitHub 9bd0019759 fix(api): declare undici for the bundled http client (#2410) 2026-09-02 19:55:11 +02:00
HampusandGitHub a74f1b0e7b fix(ci): clear knip, refresh openapi, close kv schema race (#2409) 2026-09-02 18:12:48 +02:00
HampusandGitHub 2b12f5db6c feat(voice): enable web camera background effects (#2408) 2026-09-02 17:40:13 +02:00
HampusandGitHub af4a52173c fix(voice): dispatch sourceLifecycle.removed on unbind (#2407) 2026-09-02 17:40:00 +02:00
HampusandGitHub 5bc1f21d46 fix(voice): drive call tiles from gateway voice state (#2406) 2026-09-02 17:39:48 +02:00
HampusandGitHub 917e437939 feat(voice-menus): add call controls to private call user menus (#2405) 2026-09-02 17:39:25 +02:00
HampusandGitHub 7ee4fb37d6 feat(voice): add a live input level meter to voice menus (#2404) 2026-09-02 17:39:01 +02:00
HampusandGitHub 6155eec804 feat(voice): flatten voice menus, gate ptt on a bound key (#2403) 2026-09-02 17:38:37 +02:00
HampusandGitHub 43d8637153 fix(voice): gate the camera preview and update effects in place (#2402) 2026-09-02 17:38:14 +02:00
HampusandGitHub 250db2fdab fix(voice): hide stream volume without remote share audio (#2401) 2026-09-02 17:37:52 +02:00
HampusandGitHub 7bd021cd5c feat(voice): open voice popouts in the browser (#2400) 2026-09-02 17:37:28 +02:00
HampusandGitHub adb9a689f0 feat(voice): share the voice room across popout trees (#2399) 2026-09-02 17:37:04 +02:00
HampusandGitHub d8e0c2ec20 fix(settings): stop auto-requesting devices, warn when none (#2398) 2026-09-02 17:36:49 +02:00
HampusandGitHub f98a74170a feat(settings): move macos permission review into desktop tab (#2397) 2026-09-02 17:36:26 +02:00
HampusandGitHub 17b9821879 fix(voice-menus): drive stream actions from the published source (#2396) 2026-09-02 17:36:05 +02:00
HampusandGitHub 4b5bdefcb9 fix(voice-menus): sentence-case participant menu labels (#2395) 2026-09-02 17:35:49 +02:00
HampusandGitHub 45cbabd94d fix(voice): abort screen share when its audio cannot start (#2394) 2026-09-02 17:35:26 +02:00
HampusandGitHub 8402eb53c8 feat(voice): skip the screen-share picker modal on web (#2393) 2026-09-02 17:35:15 +02:00
HampusandGitHub d04ace5789 feat(voice): redesign the screen-share source picker (#2392) 2026-09-02 17:35:01 +02:00
HampusandGitHub e94f587535 feat(voice): sequence join chimes ahead of entrance sounds (#2391) 2026-09-02 17:34:36 +02:00
HampusandGitHub e73285060e fix(voice): honour the codec preference in fallback selection (#2390) 2026-09-02 17:34:24 +02:00
HampusandGitHub f1734704ef fix(voice): guard stale screen-share negotiation and probes (#2389) 2026-09-02 17:34:10 +02:00
HampusandGitHub 59f6217267 refactor(voice): bound the screen-share codec wire formats (#2388) 2026-09-02 17:33:57 +02:00
HampusandGitHub 90f4a222b7 refactor(voice): request mic and camera permission separately (#2387) 2026-09-02 17:33:45 +02:00
HampusandGitHub 749d2091eb fix(voice): log local voice state hydration failures (#2386) 2026-09-02 17:33:32 +02:00
HampusandGitHub 8d34c6bcaa refactor(voice): make screen-share source swaps atomic (#2385) 2026-09-02 17:33:18 +02:00
HampusandGitHub 62577b25bb feat(voice): request web share audio via the browser picker (#2384) 2026-09-02 17:32:55 +02:00
HampusandGitHub 475f5a7dae fix(voice): refresh camera capture when the device changes (#2383) 2026-09-02 17:32:43 +02:00
HampusandGitHub 1772b3aad0 fix(voice): polish the stream settings menu (#2382) 2026-09-02 17:32:26 +02:00
HampusandGitHub 7263b21a06 refactor(voice): pin screen share to a fixed 7 Mbps bitrate (#2381) 2026-09-02 17:32:01 +02:00
HampusandGitHub 501adff13d refactor(voice): clamp premium video quality at read time (#2380) 2026-09-02 17:31:39 +02:00
HampusandGitHub 12c6fb7b7f feat(voice): add screen-share audio and rollback error handling (#2379) 2026-09-02 17:31:27 +02:00
HampusandGitHub 376168c922 feat(voice): add the camera background effects pipeline (#2378) 2026-09-02 17:31:04 +02:00
HampusandGitHub cadab239a2 feat(backgrounds): accept webm custom call backgrounds (#2377) 2026-09-02 17:30:50 +02:00
HampusandGitHub f57dc77c6d vendor(livekit): make local track swaps transactional (#2376) 2026-09-02 17:30:35 +02:00
HampusandGitHub 497a494c37 vendor(livekit): await setParameters in setDegradationPreference (#2375) 2026-09-02 17:30:22 +02:00
HampusandGitHub 02069e8e5d feat(voice-engine): add a sourceLifecycle.removed event (#2374) 2026-09-02 17:30:08 +02:00
HampusandGitHub 626293392c fix(ui): let callers style the audio level meter (#2373) 2026-09-02 17:29:55 +02:00
HampusandGitHub dfe42ae3e3 feat(sound): play one-shot sounds immediately and abortably (#2372) 2026-09-02 17:29:43 +02:00
HampusandGitHub 453abfa145 fix(ui): keep context menus clear of the native titlebar (#2371) 2026-09-02 17:29:29 +02:00
HampusandGitHub 8ebc9400ce fix(permissions): gate role hierarchy on known membership (#2370) 2026-09-02 17:29:06 +02:00
HampusandGitHub 1598f48edd fix(guild): invalidate member sidebar on role changes (#2369) 2026-09-02 17:28:44 +02:00
HampusandGitHub cc92f37f0c test(app): stop reading gl calls as react hooks (#2368) 2026-09-02 17:28:32 +02:00
HampusandGitHub 9322aca6cb fix(channel): restore composer draft and message focus (#2367) 2026-09-02 17:28:19 +02:00
HampusandGitHub ee8fbd6f4f fix(ui): keep the focus ring stable across refocus (#2366) 2026-09-02 17:27:57 +02:00
HampusandGitHub 1acd61a112 fix(ui): hand tooltips over between adjacent triggers (#2365) 2026-09-02 17:27:46 +02:00
HampusandGitHub e836686a71 fix(permissions): map not-determined media status to prompt (#2364) 2026-09-02 17:27:35 +02:00
HampusandGitHub ea6c417378 fix(discovery): keep category counts when a search resolves (#2363) 2026-09-02 17:27:22 +02:00
HampusandGitHub 16cc9a9e69 fix(app): clamp persisted accessibility values (#2362) 2026-09-02 17:27:10 +02:00
HampusandGitHub 6b5316fa84 fix(desktop): return a generic clipboard copy error (#2361) 2026-09-02 17:26:57 +02:00
HampusandGitHub a53f5d1289 fix(desktop): verify privileged ipc senders (#2360) 2026-09-02 17:26:45 +02:00
HampusandGitHub de2ea99928 fix(desktop): pin outbound fetches to a validated address (#2359) 2026-09-02 17:26:32 +02:00
HampusandGitHub 25f4332b9e fix(auth): guard stale submissions and rework form error mapping (#2358) 2026-09-02 17:26:18 +02:00
HampusandGitHub f703969e80 fix(auth): require a hashed poll secret for desktop handoff (#2357) 2026-09-02 17:25:54 +02:00
HampusandGitHub b82681b77a fix(auth): revoke the parsed token on logout (#2356) 2026-09-02 17:25:33 +02:00
HampusandGitHub ef248a8515 fix(platform): parse api error responses in one place (#2355) 2026-09-02 17:25:21 +02:00
HampusandGitHub 73a2345c26 fix(app-proxy): validate config, csp sources, cap resource use (#2354) 2026-09-02 17:25:10 +02:00
HampusandGitHub 9f33177eab fix(gateway): rate limit resume like identify (#2353) 2026-09-02 17:24:58 +02:00
HampusandGitHub d5a752c338 fix(gateway): only trust the client ip header when enabled (#2352) 2026-09-02 17:24:46 +02:00
HampusandGitHub 4021a2d697 fix(gateway): bound the zstd decompression window (#2351) 2026-09-02 17:24:35 +02:00
HampusandGitHub bea4a6dcbc fix(read-state): keep a failed ack dispatch from failing acks (#2350) 2026-09-02 17:24:24 +02:00
HampusandGitHub 4f48e04cad feat(api): serve well-known discovery with etag and 304 (#2349) 2026-09-02 17:24:11 +02:00
HampusandGitHub 5719dfe8a3 fix(auth): bucket phone attempt risk by v4 and v6 subnet (#2348) 2026-09-02 17:23:58 +02:00
HampusandGitHub 4fb14e85e8 fix(auth): harden login rate keys and totp reuse (#2347) 2026-09-02 17:23:47 +02:00
HampusandGitHub 1d84689b45 fix(api): validate push and domain verification targets (#2346) 2026-09-02 17:23:34 +02:00
HampusandGitHub 693aec2b4d fix(api): trust recorded upload types and bound edit sizes (#2345) 2026-09-02 17:23:23 +02:00
HampusandGitHub c79c0ee138 fix(api): bound storage listings, ranges and search paging (#2344) 2026-09-02 17:23:12 +02:00
HampusandGitHub e86e24a2db fix(captcha): drop the body-email contact policy exemption (#2343) 2026-09-02 17:23:02 +02:00
HampusandGitHub 0f7ad484ce fix(constants): add captcha, cache and feature headers (#2342) 2026-09-02 17:22:51 +02:00
HampusandGitHub bcd95b2af9 fix(http-client): validate public addresses at connect time (#2341) 2026-09-02 17:22:37 +02:00
HampusandGitHub 32dcd5ed1c chore(i18n): resync message catalogs with source (#2340) 2026-09-02 17:22:21 +02:00
HampusandGitHub 5119febb5b fix(api): send stickers through webhooks (#2338) 2026-09-02 13:55:54 +02:00
2413 changed files with 236706 additions and 130717 deletions
+28
View File
@@ -32,6 +32,7 @@ RUN apt-get update \
jq \
libasound2 \
libatk-bridge2.0-0 \
libaom-dev \
libavcodec-dev \
libavfilter-dev \
libavformat-dev \
@@ -51,9 +52,15 @@ RUN apt-get update \
libcurl4-openssl-dev \
libswresample-dev \
libswscale-dev \
libdav1d-dev \
libde265-dev \
liblcms2-dev \
libvips-dev \
libyuv-dev \
libwayland-dev \
libwebp-dev \
nasm \
yasm \
libssl-dev \
libx11-xcb1 \
libxcb-dri3-0 \
@@ -78,6 +85,7 @@ RUN apt-get update \
unzip \
xz-utils \
xdg-utils \
zlib1g-dev \
zstd \
&& rm -rf /var/lib/apt/lists/*
@@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh
RUN /tmp/fluxer-install-native-deps.sh /usr/local \
&& rm /tmp/fluxer-install-native-deps.sh
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig
ENV LD_LIBRARY_PATH=/usr/local/lib
RUN printf '%s\n' \
'#include <libheif/heif.h>' \
'#include <string.h>' \
'#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \
'#error the source-built libheif headers must win the include search' \
'#endif' \
'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \
>/tmp/fluxer-heif-probe.c \
&& cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \
&& /tmp/fluxer-heif-probe \
&& [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \
&& rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
&& rm /tmp/fluxer-fonts-requirements.txt \
+2 -2
View File
@@ -1,3 +1,5 @@
name: fluxer-dev
services:
workspace:
build:
@@ -241,7 +243,6 @@ services:
volume:
nocopy: true
- pnpm-store:/home/vscode/.local/share/pnpm/store
- docs-venv:/workspaces/fluxer/fluxer_docs/.venv
- cargo-registry:/home/vscode/.cargo/registry
- cargo-git:/home/vscode/.cargo/git
- rust-target:/workspaces/fluxer/target
@@ -349,7 +350,6 @@ services:
volumes:
pnpm-store:
docs-venv:
root-node-modules:
fluxer-api-node-modules:
fluxer-app-node-modules:
+1 -2
View File
@@ -28,8 +28,7 @@ for path in \
/home/vscode/.cargo/registry \
/home/vscode/.cargo/git \
/home/vscode/.local \
/home/vscode/.local/share/pnpm/store \
/workspaces/fluxer/fluxer_docs/.venv; do
/home/vscode/.local/share/pnpm/store; do
repair_tree "$path"
done
+2 -1
View File
@@ -29,7 +29,8 @@
**/node_modules/
**/target/
**/test-results.json
/fluxer_docs/site/
/fluxer_docs/dist/
/fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
-1
View File
@@ -33,5 +33,4 @@ jobs:
with:
image: fluxer-docs
dockerfile: fluxer_docs/Dockerfile
context: fluxer_docs
build-version: ${{ inputs['build-version'] }}
+63 -6
View File
@@ -152,8 +152,8 @@ jobs:
'packages/markdown_parser/rust/src/**') }}
rust:
runs-on: ubuntu-24.04
timeout-minutes: 60
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -174,11 +174,15 @@ jobs:
cache: 'pnpm'
- name: Cache cargo
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
workspaces: |
. -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.19.6 --locked
@@ -189,6 +193,12 @@ jobs:
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
- name: Cache native media dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: /opt/fluxer-native
key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}
- name: Install native dependencies
run: |
sudo apt-get update
@@ -197,21 +207,34 @@ jobs:
binutils \
clang \
cmake \
curl \
libaom-dev \
libavfilter-dev \
libclang-dev \
libcurl4-openssl-dev \
libdav1d-dev \
libde265-dev \
libfido2-dev \
libheif-dev \
liblcms2-dev \
libpipewire-0.3-dev \
libspa-0.2-dev \
libssl-dev \
libudev-dev \
libvips-dev \
libwebp-dev \
libyuv-dev \
meson \
nasm \
ninja-build \
pkg-config \
xz-utils \
yasm \
zlib1g-dev
sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native
echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV"
echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH"
- name: Install Node.js dependencies
run: pnpm --filter fluxer_admin install
@@ -228,7 +251,16 @@ jobs:
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
run: tools/ci/check-desktop-native-workspaces.sh clippy
- name: Verify the source-built ffmpeg CLI is on PATH
run: |
set -euo pipefail
command -v ffmpeg
test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg
ffmpeg -hide_banner -version
- name: Run tests
env:
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
run: cargo test --workspace --all-features --locked
- name: Run desktop native workspace tests on Linux
@@ -428,6 +460,31 @@ jobs:
exit 1
fi
docs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: '24'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile --filter fluxer_docs...
- name: Verify documentation matches the live API
run: pnpm --filter fluxer_docs verify
- name: Build documentation
run: pnpm --filter fluxer_docs build
fonts:
runs-on: ubuntu-24.04
timeout-minutes: 10
Generated
+1 -3
View File
@@ -1809,6 +1809,7 @@ dependencies = [
"clap",
"criterion",
"fluxer_common",
"futures-util",
"hex",
"hmac 0.13.0",
"http 1.4.2",
@@ -1989,13 +1990,10 @@ dependencies = [
"anyhow",
"axum",
"base64",
"fluxer-svc",
"fluxer_common",
"hex",
"moka",
"rand 0.10.1",
"reqwest",
"scylla",
"serde",
"serde_json",
"tokio",
+12 -1
View File
@@ -20,7 +20,7 @@
"bracketSpacing": false,
"bracketSameLine": false
},
"globals": ["React"]
"globals": ["React", "__webpack_base_uri__"]
},
"json": {
"formatter": {
@@ -115,6 +115,17 @@
}
},
"assist": {"actions": {"source": {"organizeImports": "on"}}},
"overrides": [
{
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
},
{
"includes": ["**/*.astro"],
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
"assist": {"actions": {"source": {"organizeImports": "off"}}}
}
],
"vcs": {
"enabled": true,
"clientKind": "git",
+2 -3
View File
@@ -89,7 +89,6 @@ FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
FLUXER_ADMIN_PORT=3020
FLUXER_ADMIN_BASE_PATH=/admin
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
FLUXER_ADMIN_OAUTH_CLIENT_ID=1234567890123456789
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
FLUXER_MARKETING_PORT=3010
@@ -99,8 +98,8 @@ FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
FLUXER_VAPID_PUBLIC_KEY=dev-vapid-public-key
FLUXER_VAPID_PRIVATE_KEY=dev-vapid-private-key
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
FLUXER_VAPID_EMAIL=dev@localhost
FLUXER_PASSKEY_RP_NAME='Fluxer Dev'
FLUXER_PASSKEY_RP_ID=localhost
+137 -28
View File
@@ -1,14 +1,60 @@
# Every variable docker-compose.yml reads from this file is named here:
# uncommented when it has no default, commented with its default when it has one.
# A name absent from this file is one Compose does not forward, and it reaches a
# service only through a Compose override file that adds it to that service's
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
# a Compose edit forgets the matching line here.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
# How browsers reach this instance.
#
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
# Point DNS at this host and there is nothing else to configure.
#
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
# proxy serves, not this local port.
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
# is on another machine, and firewall the port to that machine.
#FLUXER_EDGE_BIND=127.0.0.1:8080
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
# this to the real client address, so IP bans, rate limits and abuse detection
# see the caller rather than the proxy. The default covers proxies on private or
# loopback addresses, which is every same-host setup. Set it to your proxy's
# address if it reaches Fluxer from a public IP.
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
# The public origin browsers use, without a trailing slash. Derived from the three
# values above and correct for the usual https-on-443 setup, so leave it alone
# unless you serve Fluxer on a non-default port, where the port must appear here.
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
# file win, so a value here is discarded under the proxy overlay with no warning.
# Set it only for an unusual default-mode layout, such as serving several
# hostnames or binding a non-default TLS port.
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
# The old name for the value above. It is read only when
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
#FLUXER_CADDY_SITE_ADDRESS=
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
# origin written with a default port never matches a browser Origin header.
# Serving on any other port means setting all three, plus the published port
# below, and pointing FLUXER_CADDY_SITE_ADDRESS at the same scheme and host.
# 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.
@@ -48,6 +94,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
@@ -55,7 +102,10 @@ FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
FLUXER_VAPID_EMAIL=[email protected]
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
# only if that mailbox does not exist.
#[email protected]
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
@@ -68,10 +118,22 @@ [email protected]
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
# only when a browser must reach an origin the defaults do not cover, such as a
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
# sources with spaces or commas.
# sources with spaces or commas. Every one of them is empty by default, and the
# three carrying a value below are illustrations, not defaults.
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
#FLUXER_CSP_EXTRA_MEDIA_SRC=
#FLUXER_CSP_EXTRA_FONT_SRC=
#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com
#FLUXER_CSP_EXTRA_STYLE_SRC=
#FLUXER_CSP_EXTRA_FRAME_SRC=
#FLUXER_CSP_EXTRA_WORKER_SRC=
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
# directive off the header.
#FLUXER_CSP_REPORT_URI=
# Allow the SSO identity provider to resolve to a private or internal address.
# Off by default: the API refuses to call non-public addresses so a misconfigured
@@ -80,23 +142,21 @@ [email protected]
# identity provider, and only when you trust everyone who can configure SSO.
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# Ports LiveKit publishes on the host for voice and video media. They take the
# same optional bind address as the Caddy ports above. This media does not pass
# through Caddy or through a tunnel, so it needs these ports reachable from
# clients. LiveKit advertises the port numbers from livekit.yaml, so publishing
# them on different host ports means changing that file too.
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
# Set it only when LiveKit is served from another host.
#FLUXER_LIVEKIT_URL=
# Media ports. LiveKit advertises these in ICE candidates, so the host must
# forward the same numbers.
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# The voice server URL clients connect to. It defaults to FLUXER_PUBLIC_ORIGIN
# plus /livekit, which the bundled Caddy proxies to the LiveKit container. Set
# it only when LiveKit lives on its own host, and add that origin to
# FLUXER_CSP_EXTRA_CONNECT_SRC when you do.
#FLUXER_LIVEKIT_URL=wss://voice.example.com
FLUXER_KLIPY_API_KEY=
FLUXER_EMAIL_ENABLED=false
@@ -112,20 +172,60 @@ FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. Every service limit and reservation below has a default that
# assumes a host with at least 16 GB of RAM. Limits are per-container ceilings, so
# their sum may exceed host RAM; the reservations are what protect the services
# whose death takes the whole instance down. Lower these on a smaller host.
# Container memory. The 25 limits sum to 16.75 GiB, which is a sum of ceilings and
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
# The four reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from the services whose death takes the whole instance down. They do
# not reserve anything. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
#FLUXER_NATS_MEMORY_LIMIT=256mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
# 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 turn the
# lookup off. Point the two STUN entries at another server to keep the lookup
# and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=true
#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_SEAWEEDFS_MEMORY_LIMIT=512mb
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
#FLUXER_API_MEMORY_LIMIT=2560mb
#FLUXER_API_MEMORY_RESERVATION=1gb
#FLUXER_WORKER_MEMORY_LIMIT=2560mb
#FLUXER_WORKER_MEMORY_RESERVATION=1gb
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
#FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT=256mb
#FLUXER_USERS_MEMORY_LIMIT=128mb
#FLUXER_USERS_SHARD_MEMORY_LIMIT=256mb
#FLUXER_GIFS_MEMORY_LIMIT=128mb
#FLUXER_GIFS_SHARD_MEMORY_LIMIT=256mb
#FLUXER_MESSAGES_MEMORY_LIMIT=128mb
#FLUXER_MESSAGES_SHARD_MEMORY_LIMIT=256mb
#FLUXER_UNFURL_MEMORY_LIMIT=128mb
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
# which is the container ceiling the indexer shares with the search process.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
# Node sizes its own heap from the container memory limit by default, at roughly
# 55 percent of it, which always leaves room for the buffers and stacks that live
@@ -140,18 +240,21 @@ FLUXER_DISCOVERY_ENABLED=true
# budget roughly shared_buffers + (server max_connections x 12 MB) +
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
# pool sizes used by the api, worker, app-proxy and shards.
# pool sizes used by the api, worker and shards.
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
#FLUXER_POSTGRES_WORK_MEM=8MB
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
# The bundled Valkey holds durable state as well as cache: the bulk message
# deletion queue, the account deletion queue and every distributed lock, none of
# which carry an expiry. It therefore runs with an append-only file on a named
# volume and with noeviction, so an over-limit write fails loudly instead of
# silently deleting queued work. Only change the policy if you have moved that
# durable state elsewhere.
# The bundled Valkey holds durable state as well as cache. The bulk message
# deletion queue and the account deletion queue are sorted sets with no expiry,
# and nothing else stores the first of the two. It therefore runs with an
# append-only file on a named volume and with noeviction, so an over-limit write
# fails loudly instead of silently deleting queued work. Distributed locks all
# carry a TTL and are not what the durability is for. Only change the policy if
# you have moved that durable state elsewhere.
#FLUXER_VALKEY_MAXMEMORY=192mb
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
@@ -162,6 +265,12 @@ 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
# The api and the Rust services name their fixed Postgres statement shapes so the
# server can reuse their plans. Named prepared statements require a session that
# outlives the transaction, so set this to false if you put a transaction-pooling
+41 -14
View File
@@ -1,58 +1,85 @@
{
servers {
trusted_proxies static private_ranges
trusted_proxies static {$FLUXER_EDGE_TRUSTED_PROXIES:private_ranges}
trusted_proxies_strict
}
}
{$FLUXER_CADDY_SITE_ADDRESS} {
{$FLUXER_EDGE_SITE_ADDRESS} {
encode zstd gzip
handle /_health {
respond "OK" 200
}
handle_path /api/* {
reverse_proxy api:8080
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle /gateway {
rewrite * /
reverse_proxy gateway:8080
reverse_proxy gateway:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /gateway/* {
reverse_proxy gateway:8080
reverse_proxy gateway:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /media/* {
reverse_proxy media-proxy:8080
reverse_proxy media-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /livekit/* {
reverse_proxy livekit:7880
reverse_proxy livekit:7880 {
header_up X-Forwarded-For {client_ip}
}
}
handle /admin {
rewrite * /
reverse_proxy admin:8080
reverse_proxy admin:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle_path /admin/* {
reverse_proxy admin:8080
reverse_proxy admin:8080 {
header_up X-Forwarded-For {client_ip}
}
}
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
handle @staticAssets {
reverse_proxy static-proxy:8080
reverse_proxy static-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle /.well-known/fluxer {
reverse_proxy api:8080
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
}
handle {
reverse_proxy app-proxy:8080
reverse_proxy app-proxy:8080 {
header_up X-Forwarded-For {client_ip}
}
}
}
:8088 {
handle_path /api/* {
reverse_proxy api:8080
handle /.well-known/fluxer {
reverse_proxy api:8080 {
header_up X-Forwarded-For {client_ip}
}
}
}
@@ -0,0 +1,17 @@
# Overlay for running Fluxer behind your own reverse proxy.
#
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
#
# Or set this once in .env and keep using plain `docker compose up -d`:
#
# COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
#
# Fluxer stops binding 80 and 443 and serves plain HTTP on one port instead.
# That port already does all internal routing, so the proxy in front needs a
# single rule: send everything to it. Terminate TLS there.
services:
edge:
ports: !override
- "${FLUXER_EDGE_BIND:-127.0.0.1:8080}:8080"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":8080"
+56 -25
View File
@@ -44,8 +44,8 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?}
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: "true"
@@ -73,6 +73,10 @@ x-fluxer-env: &fluxer-env
FLUXER_SMS_ENABLED: "false"
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
FLUXER_STRIPE_ENABLED: "false"
FLUXER_NCMEC_ENABLED: "false"
FLUXER_CLAMAV_ENABLED: "false"
@@ -114,7 +118,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
start_interval: 1s
services:
caddy:
edge:
image: caddy:2.10-alpine
deploy:
resources:
@@ -127,11 +131,12 @@ services:
- "${FLUXER_HTTPS_PORT:-443}:443"
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
environment:
FLUXER_CADDY_SITE_ADDRESS: ${FLUXER_CADDY_SITE_ADDRESS:?set FLUXER_CADDY_SITE_ADDRESS in .env}
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
- edge-data:/data
- edge-config:/config
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 10s
@@ -160,8 +165,8 @@ services:
-c shared_buffers=${FLUXER_POSTGRES_SHARED_BUFFERS:-512MB}
-c effective_cache_size=${FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE:-2GB}
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
-c maintenance_work_mem=256MB
-c autovacuum_work_mem=128MB
-c maintenance_work_mem=${FLUXER_POSTGRES_MAINTENANCE_WORK_MEM:-256MB}
-c autovacuum_work_mem=${FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM:-128MB}
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c default_statistics_target=200
@@ -238,7 +243,7 @@ services:
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_MAX_INDEXING_MEMORY: 384mb
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
- meilisearch-data:/meili_data
@@ -260,10 +265,11 @@ services:
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/"]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
seaweedfs-init:
image: chrislusf/seaweedfs:4.34
@@ -275,6 +281,9 @@ services:
depends_on:
seaweedfs: {condition: service_healthy}
restart: "no"
environment:
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
entrypoint:
- /bin/sh
- -c
@@ -292,6 +301,10 @@ services:
echo "$$listed" | grep -q "^[[:space:]]*$$b[[:space:]]" || missing="$${missing:+$$missing }$$b";
done;
if [ -z "$$missing" ]; then
if ! echo "s3.configure -user=fluxer -access_key=$$FLUXER_S3_ACCESS_KEY -secret_key=$$FLUXER_S3_SECRET_KEY -actions=Admin,Read,Write,List,Tagging -apply" | timeout 10 weed shell -master=seaweedfs:9333 >/dev/null 2>&1; then
echo "seaweedfs-init could not configure the S3 identity" >&2;
exit 1;
fi;
echo "buckets ready";
exit 0;
fi;
@@ -311,14 +324,26 @@ services:
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
command: ["--config", "/etc/livekit.yaml"]
environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}"
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
LIVEKIT_CONFIG: |
port: 7880
log_level: info
rtc:
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
use_external_ip: ${FLUXER_LIVEKIT_USE_EXTERNAL_IP:-true}
node_ip: "${FLUXER_LIVEKIT_NODE_IP:-}"
stun_servers:
- ${FLUXER_LIVEKIT_STUN_PRIMARY:-stun.l.google.com:19302}
- ${FLUXER_LIVEKIT_STUN_SECONDARY:-stun1.l.google.com:19302}
webhook:
api_key: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
urls:
- http://api:8080/webhooks/livekit
ports:
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:7881"
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:7882/udp"
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:${FLUXER_LIVEKIT_TCP_PORT:-7881}"
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:${FLUXER_LIVEKIT_UDP_PORT:-7882}/udp"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
interval: 10s
@@ -372,7 +397,7 @@ services:
reservations:
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
working_dir: /usr/src/app/fluxer_api
command: ["node", "dist/WorkerEntrypoint.js"]
command: ["sh", "-c", "if [ -f dist/WorkerEntrypoint.js ]; then exec node dist/WorkerEntrypoint.js; else exec ./node_modules/.bin/tsx src/WorkerEntrypoint.ts; fi"]
environment:
<<: *fluxer-env
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
@@ -411,6 +436,9 @@ services:
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_GATEWAY_LOGGER_LEVEL: info
FLUXER_ERLANG_COOKIE: ${FLUXER_ERLANG_COOKIE:?set FLUXER_ERLANG_COOKIE in .env}
FLUXER_ERLANG_SCHEDULERS_MIN: "${FLUXER_ERLANG_SCHEDULERS_MIN:-2}"
FLUXER_ERLANG_SCHEDULERS_MAX: "${FLUXER_ERLANG_SCHEDULERS_MAX:-16}"
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
interval: 10s
@@ -434,6 +462,8 @@ services:
FLUXER_MEDIA_PROXY_PORT: "8080"
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_S3_READ_SIGNED: "true"
depends_on:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_healthy}
@@ -459,10 +489,9 @@ services:
limits:
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-postgres-env
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
@@ -476,11 +505,9 @@ services:
FLUXER_CSP_EXTRA_WORKER_SRC: ${FLUXER_CSP_EXTRA_WORKER_SRC:-}
FLUXER_CSP_EXTRA_MANIFEST_SRC: ${FLUXER_CSP_EXTRA_MANIFEST_SRC:-}
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
FLUXER_POSTGRES_MAX_CONNECTIONS: "5"
depends_on:
api: {condition: service_healthy}
caddy: {condition: service_healthy}
postgres: {condition: service_healthy}
edge: {condition: service_healthy}
snowflakes:
<<: *fluxer-service
@@ -522,6 +549,7 @@ services:
memory: ${FLUXER_USERS_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
@@ -537,10 +565,11 @@ services:
memory: ${FLUXER_USERS_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -608,7 +637,7 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -623,6 +652,7 @@ services:
memory: ${FLUXER_UNFURL_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
@@ -639,6 +669,7 @@ services:
memory: ${FLUXER_UNFURL_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
@@ -680,8 +711,8 @@ networks:
driver: bridge
volumes:
caddy-data:
caddy-config:
edge-data:
edge-config:
postgres-data:
valkey-data:
nats-data:
-15
View File
@@ -1,15 +0,0 @@
port: 7880
log_level: info
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
stun_servers:
- stun.l.google.com:19302
- stun1.l.google.com:19302
webhook:
api_key: fluxer
urls:
- http://api:8080/webhooks/livekit
+1 -1
View File
@@ -1,4 +1,4 @@
services:
caddy:
edge:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
File diff suppressed because it is too large Load Diff
+3
View File
@@ -43,6 +43,7 @@ pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
pub const BULK_UPDATE_SUSPICIOUS_ACTIVITY: &str = "bulk:update:suspicious_activity";
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
@@ -121,6 +122,7 @@ pub const ALL_ACLS: &[&str] = &[
ARCHIVE_TRIGGER_GUILD,
ARCHIVE_TRIGGER_USER,
ARCHIVE_VIEW_ALL,
ASSET_PURGE,
AUDIT_LOG_VIEW,
AUTHENTICATE,
JOBS_VIEW,
@@ -154,6 +156,7 @@ pub const ALL_ACLS: &[&str] = &[
BAN_PROFILE_SUBSTRING_REMOVE,
BULK_ADD_GUILD_MEMBERS,
BULK_DELETE_USERS,
BULK_DELETE_USER_MESSAGES,
BULK_UPDATE_GUILD_FEATURES,
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
BULK_UPDATE_USER_FLAGS,
-5
View File
@@ -12,7 +12,6 @@ pub struct I32Flag {
pub mod user_flag_bits {
pub const STAFF: u64 = 1 << 0;
pub const CTP_MEMBER: u64 = 1 << 1;
pub const PARTNER: u64 = 1 << 2;
pub const BUG_HUNTER: u64 = 1 << 3;
pub const FRIENDLY_BOT: u64 = 1 << 4;
@@ -40,10 +39,6 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "STAFF",
value: user_flag_bits::STAFF,
},
U64Flag {
name: "CTP_MEMBER",
value: user_flag_bits::CTP_MEMBER,
},
U64Flag {
name: "PARTNER",
value: user_flag_bits::PARTNER,
+12 -2
View File
@@ -12,7 +12,7 @@ impl AdminApiClient {
acls: &[String],
) -> ApiResult<CreateAdminApiKeyResponse> {
let body = generated_types::CreateAdminApiKeyRequest {
acls: acls.to_vec(),
acls: parse_acls(acls)?,
expires_in_days: None,
name: generated_types::CreateAdminApiKeyRequestName::try_from(name)
.map_err(|e| ApiError::Parse(e.to_string()))?,
@@ -35,10 +35,20 @@ impl AdminApiClient {
}
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
self.generated()
.delete_admin_api_key(key_id)
.delete_admin_api_key(&key_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
pub(super) fn parse_acls(acls: &[String]) -> ApiResult<Vec<generated_types::AdminAclType>> {
acls.iter()
.map(|acl| {
generated_types::AdminAclType::try_from(acl.as_str())
.map_err(|e| ApiError::Parse(e.to_string()))
})
.collect()
}
+29 -24
View File
@@ -4,35 +4,36 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse};
use serde::Serialize;
#[derive(Serialize)]
struct LookupApplicationRequest<'a> {
application_id: &'a str,
}
#[derive(Serialize)]
struct ListUserApplicationsRequest<'a> {
user_id: &'a str,
}
#[derive(Serialize)]
struct TransferApplicationOwnershipRequest<'a> {
application_id: &'a str,
new_owner_id: &'a str,
}
impl AdminApiClient {
pub async fn lookup_application(&self, application_id: &str) -> ApiResult<Option<Application>> {
let body = LookupApplicationRequest { application_id };
let resp: LookupApplicationResponse =
self.post_typed("/admin/applications/lookup", &body).await?;
let resp: LookupApplicationResponse = self
.get(
&format!(
"/admin/applications/{}",
urlencoding::encode(application_id)
),
None,
)
.await?;
Ok(resp.application)
}
pub async fn list_user_applications(&self, user_id: &str) -> ApiResult<Vec<Application>> {
let body = ListUserApplicationsRequest { user_id };
let resp: super::types::ListUserApplicationsResponse = self
.post_typed("/admin/applications/list-by-owner", &body)
.await?;
let query_params = [("owner_id", user_id)];
let resp: super::types::ListUserApplicationsResponse =
self.get("/admin/applications", Some(&query_params)).await?;
Ok(resp.applications)
}
pub async fn list_guild_applications(&self, guild_id: &str) -> ApiResult<Vec<Application>> {
let query_params = [("guild_id", guild_id)];
let resp: super::types::ListUserApplicationsResponse =
self.get("/admin/applications", Some(&query_params)).await?;
Ok(resp.applications)
}
@@ -41,11 +42,15 @@ impl AdminApiClient {
application_id: &str,
new_owner_id: &str,
) -> ApiResult<ApplicationUpdateResponse> {
let body = TransferApplicationOwnershipRequest {
application_id,
new_owner_id,
};
self.post_typed("/admin/applications/transfer-ownership", &body)
.await
let body = TransferApplicationOwnershipRequest { new_owner_id };
self.patch_typed_with_reason(
&format!(
"/admin/applications/{}",
urlencoding::encode(application_id)
),
&body,
None,
)
.await
}
}
+29 -22
View File
@@ -11,13 +11,12 @@ impl AdminApiClient {
user_id: &str,
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::TriggerUserArchiveRequest {
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
user_id: snowflake(user_id),
};
let response = self
.generated()
.trigger_user_archive(&body)
.create_admin_user_archive(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -28,13 +27,12 @@ impl AdminApiClient {
guild_id: &str,
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::TriggerGuildArchiveRequest {
guild_id: snowflake(guild_id),
let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true),
};
let response = self
.generated()
.trigger_guild_archive(&body)
.create_admin_guild_archive(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -47,22 +45,31 @@ impl AdminApiClient {
include_expired: bool,
requested_by: Option<&str>,
) -> ApiResult<ListArchivesResponse> {
let body = generated_types::ListArchivesRequest {
include_expired: Some(include_expired),
limit: None,
requested_by: requested_by.map(snowflake),
subject_id: subject_id.map(snowflake),
subject_type: Some(
generated_types::ListArchivesRequestSubjectType::try_from(subject_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
),
let subject_id = subject_id.filter(|id| !id.is_empty());
let search_every_subject_type = subject_type == "all" && subject_id.is_some();
let subject_types: &[&str] = if search_every_subject_type {
&["user", "guild"]
} else {
std::slice::from_ref(&subject_type)
};
let response = self
.generated()
.list_archives(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let mut archives = Vec::new();
for &subject_type in subject_types {
let query_params = [
("subject_type", subject_type),
("subject_id", subject_id.unwrap_or_default()),
("requested_by", requested_by.unwrap_or_default()),
(
"include_expired",
if include_expired { "true" } else { "false" },
),
];
match self.get("/admin/archives", Some(&query_params)).await {
Ok(ListArchivesResponse { archives: page }) => archives.extend(page),
Err(ApiError::Http { status: 403, .. }) if search_every_subject_type => {}
Err(error) => return Err(error),
}
}
Ok(ListArchivesResponse { archives })
}
pub async fn get_archive_download_url(
@@ -73,7 +80,7 @@ impl AdminApiClient {
) -> ApiResult<ArchiveDownloadUrlResponse> {
let response = self
.generated()
.get_archive_download_url(subject_type, subject_id, archive_id)
.get_admin_archive_download(subject_type, subject_id, archive_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+9 -2
View File
@@ -5,11 +5,18 @@ use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiResult};
impl AdminApiClient {
pub async fn purge_assets(&self, ids: &[String]) -> ApiResult<serde_json::Value> {
pub async fn purge_assets(
&self,
guild_id: &str,
ids: &[String],
) -> ApiResult<serde_json::Value> {
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
let response = self
.generated()
.purge_guild_assets(&body)
.purge_admin_guild_assets(
&generated_types::SnowflakeType::from(guild_id.to_owned()),
&body,
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+45 -24
View File
@@ -23,26 +23,47 @@ impl AdminApiClient {
&self,
params: &SearchAuditLogsParams,
) -> ApiResult<AuditLogsListResponse> {
let body = generated_types::SearchAuditLogsRequest {
admin_user_id: nonempty_string(params.admin_user_id.as_deref())
.map(generated_types::SnowflakeType::from),
limit: Some(
crate::api::generated::nonzero_u32(params.limit, "limit")
.map_err(ApiError::Parse)?,
let sort_by = params
.sort_by
.as_deref()
.map(audit_sort_by)
.transpose()?
.map(|value| value.to_string());
let sort_order = params
.sort_order
.as_deref()
.map(audit_sort_order)
.transpose()?
.map(|value| value.to_string());
let limit = params.limit.to_string();
let offset = params.offset.to_string();
let query_params = [
(
"q",
nonempty_string(params.query.as_deref()).unwrap_or_default(),
),
offset: Some(i64::from(params.offset)),
query: nonempty_string(params.query.as_deref()),
sort_by: params.sort_by.as_deref().map(audit_sort_by).transpose()?,
sort_order: params
.sort_order
.as_deref()
.map(audit_sort_order)
.transpose()?,
target_id: nonempty_string(params.target_id.as_deref()),
target_type: nonempty_string(params.target_type.as_deref()),
};
let body = serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?;
self.post("/admin/audit-logs/search", Some(&body)).await
(
"admin_user_id",
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
),
(
"target_type",
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
),
(
"target_id",
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
),
("sort_by", sort_by.unwrap_or_default()),
("sort_order", sort_order.unwrap_or_default()),
("limit", limit),
("offset", offset),
];
let query_params: Vec<(&str, &str)> = query_params
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect();
self.get("/admin/audit-logs", Some(&query_params)).await
}
}
@@ -58,7 +79,7 @@ fn audit_logs_response(
}
#[cfg(test)]
fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem) -> AuditLogEntry {
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
AuditLogEntry {
log_id: String::from(entry.log_id),
admin_user_id: String::from(entry.admin_user_id),
@@ -78,17 +99,17 @@ fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem)
}
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::SearchAuditLogsRequestSortBy> {
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
let value = match value {
"created_at" => "createdAt",
value => value,
};
generated_types::SearchAuditLogsRequestSortBy::try_from(value)
generated_types::ListAdminAuditLogsSortBy::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn audit_sort_order(value: &str) -> ApiResult<generated_types::SearchAuditLogsRequestSortOrder> {
generated_types::SearchAuditLogsRequestSortOrder::try_from(value)
fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortOrder> {
generated_types::ListAdminAuditLogsSortOrder::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
+176 -216
View File
@@ -7,209 +7,123 @@ use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
impl AdminApiClient {
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
let body = generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
};
self.generated()
.add_email_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"email",
generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}
.into(),
)
.await
}
pub async fn unban_email(&self, email: &str) -> ApiResult<()> {
let body = generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
};
self.generated()
.remove_email_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("email", email, None).await
}
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
};
let response = self
.generated()
.check_email_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("email", email, None).await
}
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
self.generated()
.add_ip_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"ip",
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
)
.await
}
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> {
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
self.generated()
.remove_ip_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("ip", ip, None).await
}
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
let response = self
.generated()
.check_ip_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("ip", ip, None).await
}
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
let body = suspicious_email_domain_request(domain)?;
self.generated()
.add_suspicious_email_domain(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST,
suspicious_email_domain_request(domain)?.into(),
)
.await
}
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
let body = suspicious_email_domain_request(domain)?;
self.generated()
.remove_suspicious_email_domain(&body)
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult<BanCheckResult> {
let body = suspicious_email_domain_request(domain)?;
let response = self
.generated()
.check_suspicious_email_domain(&body)
self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
let body = generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
};
self.generated()
.add_phrase_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"phrase",
generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}
.into(),
)
.await
}
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> {
let body = generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
};
self.generated()
.remove_phrase_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("phrase", phrase, None).await
}
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
};
let response = self
.generated()
.check_phrase_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("phrase", phrase, None).await
}
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
let body = generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
};
self.generated()
.add_url_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"url",
generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}
.into(),
)
.await
}
pub async fn unban_url(&self, url: &str) -> ApiResult<()> {
let body = generated_types::UnbanUrlRequest {
url: url.to_owned(),
};
self.generated()
.remove_url_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.delete_blocklist_entry("url", url, None).await
}
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::CheckUrlBlocklistRequest {
url: url.to_owned(),
};
let response = self
.generated()
.check_url_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("url", url, None).await
}
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
let body = generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains: Some(match_subdomains),
notes: None,
severity: None,
source_url: None,
};
self.generated()
.add_url_domain_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"url-domain",
generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains: Some(match_subdomains),
notes: None,
severity: None,
source_url: None,
}
.into(),
)
.await
}
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> {
let body = generated_types::UnbanUrlDomainRequest {
domain: domain.to_owned(),
};
self.generated()
.remove_url_domain_ban(&body)
self.delete_blocklist_entry("url-domain", domain, None)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_url_domain_ban(&self, domain: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains: None,
notes: None,
severity: None,
source_url: None,
};
let response = self
.generated()
.check_url_domain_ban_status(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
self.check_blocklist_entry("url-domain", domain, None).await
}
pub async fn ban_file_sha(
@@ -217,16 +131,22 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
};
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/add", &body, audit_log_reason)
.await
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
},
);
self.post_void_with_reason(
"/admin/blocklists/file-sha/entries",
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
audit_log_reason,
)
.await
}
pub async fn unban_file_sha(
@@ -234,23 +154,17 @@ impl AdminApiClient {
sha256_hex: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::UnbanFileShaRequest {
sha256_hex: sha256_hex.to_owned(),
};
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/remove", &body, audit_log_reason)
.await
self.delete_void_with_reason(
&blocklist_entry_path("file-sha", sha256_hex),
None,
audit_log_reason,
)
.await
}
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::CheckFileShaRequest {
sha256_hex: sha256_hex.to_owned(),
};
let response = self
.generated()
.check_file_sha_ban_status(&body)
self.check_blocklist_entry("file-sha", sha256_hex, None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn bulk_ban_file_shas(
@@ -261,54 +175,45 @@ impl AdminApiClient {
let body = generated_types::BulkBanFileShasRequest {
sha256_list: sha256_list.to_vec(),
};
self.post_typed_with_reason("/admin/bans/file-sha/bulk-add", &body, audit_log_reason)
.await
self.put_typed_with_reason(
"/admin/blocklists/file-sha/entries",
&body,
audit_log_reason,
)
.await
}
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
let body = generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
};
self.generated()
.add_avatar_hash_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
"avatar-hash",
generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
}
.into(),
)
.await
}
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
let body = generated_types::CheckAvatarHashRequest {
hashes: vec![hash_short.to_owned()],
};
self.generated()
.remove_avatar_hash_ban(&body)
self.delete_blocklist_entry("avatar-hash", hash_short, None)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_avatar_hash_ban(&self, hash_short: &str) -> ApiResult<BanCheckResult> {
let body = generated_types::CheckAvatarHashRequest {
hashes: vec![hash_short.to_owned()],
};
let response = self
.generated()
.check_avatar_hash_ban_status(&body)
self.check_blocklist_entry("avatar-hash", hash_short, None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
let body = generated_types::BanUserAvatarRequest::default();
let response = self
.generated()
.ban_user_avatar(
.ban_admin_user_avatar(
&generated_types::SnowflakeType::from(user_id.to_owned()),
&body,
)
@@ -318,21 +223,16 @@ impl AdminApiClient {
}
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
let body = profile_substring_request(scope, substring)?;
self.generated()
.add_profile_substring_ban(&body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
profile_substring_request(scope, substring)?.into(),
)
.await
}
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
let body = profile_substring_request(scope, substring)?;
self.generated()
.remove_profile_substring_ban(&body)
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn check_profile_substring_ban(
@@ -340,16 +240,76 @@ impl AdminApiClient {
scope: &str,
substring: &str,
) -> ApiResult<BanCheckResult> {
let body = profile_substring_request(scope, substring)?;
self.check_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
.await
}
async fn create_blocklist_entry(
&self,
list_type: &str,
body: generated_types::AdminBlocklistEntryCreateRequest,
) -> ApiResult<()> {
self.generated()
.create_admin_blocklist_entry(list_type, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
async fn delete_blocklist_entry(
&self,
list_type: &str,
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<()> {
let scope = scope.map(blocklist_delete_scope).transpose()?;
self.generated()
.delete_admin_blocklist_entry(list_type, entry_value, scope)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
async fn check_blocklist_entry(
&self,
list_type: &str,
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<BanCheckResult> {
let scope = scope.map(blocklist_get_scope).transpose()?;
let response = self
.generated()
.check_profile_substring_ban_status(&body)
.get_admin_blocklist_entry(list_type, entry_value, scope)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
format!(
"/admin/blocklists/{}/entries/{}",
urlencoding::encode(list_type),
urlencoding::encode(entry_value)
)
}
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
generated_types::GetAdminBlocklistEntryScope::try_from(scope)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn blocklist_delete_scope(
scope: &str,
) -> ApiResult<generated_types::DeleteAdminBlocklistEntryScope> {
generated_types::DeleteAdminBlocklistEntryScope::try_from(scope)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn suspicious_email_domain_request(
domain: &str,
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
+72 -43
View File
@@ -13,12 +13,16 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateUserFlagsRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk/update-user-flags", &body, audit_log_reason)
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
task:
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -29,17 +33,16 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateSuspiciousActivityFlagsRequest {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason(
"/admin/bulk/update-suspicious-activity-flags",
&body,
audit_log_reason,
)
.await
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
pub async fn bulk_update_guild_features(
@@ -49,12 +52,15 @@ impl AdminApiClient {
remove_features: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateGuildFeaturesRequest {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
};
self.post_typed_with_reason("/admin/bulk/update-guild-features", &body, audit_log_reason)
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -64,11 +70,31 @@ impl AdminApiClient {
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkAddGuildMembersRequest {
guild_id: snowflake(guild_id),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk/add-guild-members", &body, audit_log_reason)
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
guild_id: snowflake(guild_id),
task:
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
pub async fn bulk_delete_user_messages(
&self,
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
task:
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -80,21 +106,24 @@ impl AdminApiClient {
public_reason: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkScheduleUserDeletionRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason(
"/admin/bulk/schedule-user-deletion",
&body,
audit_log_reason,
)
.await
let body = generated_types::AdminBulkJobCreateRequest::from(
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
"reason_code",
)
.map_err(ApiError::Parse)?,
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
}
+93 -5
View File
@@ -188,17 +188,105 @@ impl AdminApiClient {
path: &str,
body: Option<&serde_json::Value>,
) -> ApiResult<T> {
let builder = Self::with_json_body(self.request(Method::PATCH, path, None), body);
let response = Self::send_request(builder).await?;
self.patch_with_reason(path, body, None).await
}
pub async fn patch_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
}
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
let builder = Self::with_json_body(self.request(Method::DELETE, path, None), body);
let response = Self::send_request(builder).await?;
pub async fn patch_typed_with_reason<T, B>(
&self,
path: &str,
body: &B,
audit_log_reason: Option<&str>,
) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
}
pub async fn put_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
}
pub async fn put_typed_with_reason<T, B>(
&self,
path: &str,
body: &B,
audit_log_reason: Option<&str>,
) -> ApiResult<T>
where
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
}
pub async fn put_void_with_reason(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
Self::parse_void_response(response).await
}
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
self.delete_void_with_reason(path, body, None).await
}
pub async fn delete_void_with_reason(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
Self::parse_void_response(response).await
}
pub async fn delete_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
}
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
if response.status().is_success() {
Ok(())
+1 -1
View File
@@ -26,7 +26,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.generate_gift_codes(&body)
.create_admin_gift_codes(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+25 -15
View File
@@ -13,7 +13,7 @@ impl AdminApiClient {
) -> ApiResult<Vec<DiscoveryPendingApplication>> {
let response = self
.generated()
.list_pending_discovery_applications()
.list_admin_discovery_applications()
.await
.map_err(|e| self.generated_error(e))?;
response
@@ -26,7 +26,7 @@ impl AdminApiClient {
pub async fn list_discovery_listed_guilds(&self) -> ApiResult<Vec<DiscoveryListedGuild>> {
let response = self
.generated()
.list_discovery_listed_guilds()
.list_admin_discovery_listings()
.await
.map_err(|e| self.generated_error(e))?;
response
@@ -42,15 +42,18 @@ impl AdminApiClient {
reason: Option<&str>,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let body = generated_types::DiscoveryAdminReviewRequest {
reason: reason
.map(generated_types::DiscoveryAdminReviewRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
reason: reason
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
},
);
let response = self
.generated()
.approve_discovery_application(&guild_id, &body)
.update_admin_discovery_application(&guild_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -62,13 +65,20 @@ impl AdminApiClient {
reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let body = generated_types::DiscoveryAdminRejectRequest {
reason: generated_types::DiscoveryAdminRejectRequestReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
reason:
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
reason,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
status:
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
},
);
let response = self
.generated()
.reject_discovery_application(&guild_id, &body)
.update_admin_discovery_application(&guild_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -86,7 +96,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.remove_from_discovery(&guild_id, &body)
.delete_admin_discovery_listing(&guild_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+8
View File
@@ -32,6 +32,14 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
pub(crate) fn deletion_reason_code(
value: i32,
field: &str,
) -> Result<types::DeletionReasonCode, String> {
types::DeletionReasonCode::try_from(value)
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
}
#[cfg(test)]
mod tests {
use super::{number_to_u64, types::*};
+2 -2
View File
@@ -10,7 +10,7 @@ impl AdminApiClient {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let response = self
.generated()
.admin_list_guild_emojis(&guild_id)
.list_admin_guild_emojis(&guild_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -23,7 +23,7 @@ impl AdminApiClient {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let response = self
.generated()
.admin_list_guild_stickers(&guild_id)
.list_admin_guild_stickers(&guild_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+77 -134
View File
@@ -17,28 +17,20 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchGuildsResponse> {
let body = generated_types::SearchGuildsRequest {
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
query: Some(query.to_owned()),
};
let limit = limit.to_string();
let offset = offset.to_string();
let response = self
.generated()
.search_guilds(&body)
.list_admin_guilds(Some(limit.as_str()), Some(offset.as_str()), Some(query))
.await
.map_err(|e| self.generated_error(e))?;
search_guilds_response(response.into_inner())
}
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
let body = generated_types::LookupGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.lookup_guild(&body)
.get_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
@@ -51,12 +43,9 @@ impl AdminApiClient {
}
pub async fn lookup_guild(&self, guild_id: &str) -> ApiResult<Option<GuildDetailInfo>> {
let body = generated_types::LookupGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.lookup_guild(&body)
.get_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
@@ -69,26 +58,23 @@ impl AdminApiClient {
add_features: &[String],
remove_features: &[String],
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildFeaturesRequest {
let body = generated_types::UpdateGuildRequest {
add_features: guild_features(add_features),
guild_id: snowflake(guild_id),
remove_features: guild_features(remove_features),
..Default::default()
};
let response = self
.generated()
.update_guild_features(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::DeleteGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.admin_delete_guild(&body)
.delete_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -99,13 +85,13 @@ impl AdminApiClient {
guild_id: &str,
new_owner_id: &str,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::TransferGuildOwnershipRequest {
guild_id: snowflake(guild_id),
new_owner_id: snowflake(new_owner_id),
let body = generated_types::UpdateGuildRequest {
new_owner_id: Some(snowflake(new_owner_id)),
..Default::default()
};
let response = self
.generated()
.admin_transfer_guild_ownership(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -117,44 +103,32 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<ListGuildMembersResponse> {
let body = generated_types::ListGuildMembersRequest {
guild_id: snowflake(guild_id),
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
};
let limit = limit.to_string();
let offset = offset.to_string();
let response = self
.generated()
.admin_list_guild_members(&body)
.list_admin_guild_members(
&snowflake(guild_id),
Some(limit.as_str()),
Some(offset.as_str()),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
let body = generated_types::BanGuildMemberRequest {
ban_duration_seconds: None,
delete_message_days: None,
delete_message_seconds: None,
guild_id: snowflake(guild_id),
reason: None,
user_id: snowflake(user_id),
};
let body = generated_types::BanGuildMemberBody::default();
self.generated()
.admin_ban_guild_member(&body)
.ban_admin_guild_member(&snowflake(guild_id), &snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn kick_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
let body = generated_types::KickGuildMemberRequest {
guild_id: snowflake(guild_id),
user_id: snowflake(user_id),
};
self.generated()
.kick_guild_member(&body)
.kick_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -166,21 +140,22 @@ impl AdminApiClient {
limit: Option<u32>,
before: Option<&str>,
) -> ApiResult<GuildAuditLogResponse> {
let body = generated_types::ListGuildAuditLogsRequest {
action_type: None,
after: None,
before: before.map(snowflake),
guild_id: snowflake(guild_id),
limit: limit
.map(i32::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?
.map(generated_types::Int32Type::from),
user_id: None,
};
let before = before.map(snowflake);
let limit = limit
.map(i32::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?
.map(generated_types::Int32Type::from);
let response = self
.generated()
.list_guild_audit_logs_admin(&body)
.list_admin_guild_audit_logs(
&snowflake(guild_id),
None,
None,
before.as_ref(),
limit.as_ref(),
None,
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -189,15 +164,15 @@ impl AdminApiClient {
pub async fn clear_guild_fields(&self, guild_id: &str, fields: &[String]) -> ApiResult<()> {
let fields = fields
.iter()
.map(generated_types::ClearGuildFieldsRequestFieldsItem::try_from)
.map(|field| generated_types::UpdateGuildRequestFieldsItem::try_from(field.as_str()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::ClearGuildFieldsRequest {
let body = generated_types::UpdateGuildRequest {
fields,
guild_id: snowflake(guild_id),
..Default::default()
};
self.generated()
.clear_guild_fields(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -208,10 +183,10 @@ impl AdminApiClient {
guild_id: &str,
settings: &serde_json::Value,
) -> ApiResult<GuildUpdateResponse> {
let body = guild_settings_request(guild_id, settings)?;
let body = guild_settings_request(settings)?;
let response = self
.generated()
.update_guild_settings(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
guild_update_response(response.into_inner())
@@ -222,13 +197,13 @@ impl AdminApiClient {
guild_id: &str,
name: &str,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildNameRequest {
guild_id: snowflake(guild_id),
name: name.to_owned(),
let body = generated_types::UpdateGuildRequest {
name: Some(name.to_owned()),
..Default::default()
};
let response = self
.generated()
.update_guild_name(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -239,37 +214,27 @@ impl AdminApiClient {
guild_id: &str,
vanity: Option<&str>,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildVanityRequest {
guild_id: snowflake(guild_id),
vanity_url_code: vanity.map(std::borrow::ToOwned::to_owned),
};
let response = self
.generated()
.update_guild_vanity(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let body = serde_json::json!({"vanity_url_code": vanity});
self.patch(
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
Some(&body),
)
.await
}
pub async fn reload_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::ReloadGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.reload_guild(&body)
.create_admin_guild_reload(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn shutdown_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::ShutdownGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.shutdown_guild(&body)
.create_admin_guild_shutdown(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -280,13 +245,9 @@ impl AdminApiClient {
user_id: &str,
guild_id: &str,
) -> ApiResult<SuccessResponse> {
let body = generated_types::ForceAddUserToGuildRequest {
guild_id: snowflake(guild_id),
user_id: snowflake(user_id),
};
let response = self
.generated()
.force_add_user_to_guild(&body)
.add_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -298,39 +259,23 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
let body = generated_types::SearchReportsRequest {
category: None,
guild_context_id: None,
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
query: None,
report_type: None,
reported_channel_id: None,
reported_guild_id: Some(snowflake(guild_id)),
reported_user_id: None,
reporter_id: None,
resolved_by_admin_id: None,
sort_by: None,
sort_order: None,
status: None,
};
let response = self
.generated()
.search_reports(&body)
.await
.map_err(|e| self.generated_error(e))?;
let response = response.into_inner();
Ok(SearchReportsResponse {
reports: self.generated_value(response.reports)?,
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
offset: crate::api::generated::number_to_u64(response.offset, "offset")
.map_err(ApiError::Parse)?,
limit: crate::api::generated::number_to_u64(response.limit, "limit")
.map_err(ApiError::Parse)?,
})
self.search_reports(
None,
None,
None,
None,
None,
None,
Some(guild_id),
None,
None,
None,
None,
None,
limit,
offset,
)
.await
}
}
@@ -417,22 +362,21 @@ fn guild_update_response(
}
fn guild_settings_request(
guild_id: &str,
settings: &serde_json::Value,
) -> ApiResult<generated_types::UpdateGuildSettingsRequest> {
) -> ApiResult<generated_types::UpdateGuildRequest> {
let patch = serde_json::from_value::<GuildSettingsPatch>(settings.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
Ok(generated_types::UpdateGuildSettingsRequest {
Ok(generated_types::UpdateGuildRequest {
content_warning_level: patch.content_warning_level,
content_warning_text: patch.content_warning_text,
default_message_notifications: patch.default_message_notifications,
disabled_operations: patch.disabled_operations,
explicit_content_filter: patch.explicit_content_filter,
guild_id: snowflake(guild_id),
mfa_level: patch.mfa_level,
nsfw: patch.nsfw,
nsfw_level: patch.nsfw_level,
verification_level: patch.verification_level,
..Default::default()
})
}
@@ -459,9 +403,8 @@ mod tests {
"nsfw": true,
"verification_level": 2,
});
let request = guild_settings_request("123", &settings).unwrap();
let request = guild_settings_request(&settings).unwrap();
let json = serde_json::to_value(request).unwrap();
assert_eq!(json["guild_id"], "123");
assert_eq!(json["disabled_operations"], 5);
assert_eq!(json["nsfw"], true);
assert_eq!(json["verification_level"], 2);
+30 -22
View File
@@ -4,19 +4,18 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
PendingRegistrationActionRequest, RegistrationUrlActionRequest,
};
impl AdminApiClient {
pub async fn get_instance_config(&self) -> ApiResult<InstanceConfigResponse> {
self.post("/admin/instance-config/get", None).await
self.get("/admin/instance/config", None).await
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
) -> ApiResult<InstanceConfigResponse> {
self.post_typed("/admin/instance-config/update", update)
self.patch_typed_with_reason("/admin/instance/config", update, None)
.await
}
@@ -24,7 +23,7 @@ impl AdminApiClient {
&self,
request: &InstanceEmailSmtpTestRequest,
) -> ApiResult<InstanceEmailSmtpTestResponse> {
self.post_typed("/admin/instance-config/integrations/smtp/test", request)
self.post_typed("/admin/instance/config/smtp-tests", request)
.await
}
@@ -32,40 +31,49 @@ impl AdminApiClient {
&self,
request: &CreateRegistrationUrlRequest,
) -> ApiResult<CreateRegistrationUrlResponse> {
self.post_typed("/admin/instance-config/registration-urls/create", request)
self.post_typed("/admin/instance/registration-urls", request)
.await
}
pub async fn revoke_registration_url(&self, id: &str) -> ApiResult<InstanceConfigResponse> {
let request = RegistrationUrlActionRequest { id: id.to_owned() };
self.post_typed("/admin/instance-config/registration-urls/revoke", &request)
.await
self.delete_with_reason(
&format!(
"/admin/instance/registration-urls/{}",
urlencoding::encode(id)
),
None,
None,
)
.await
}
pub async fn approve_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
let request = PendingRegistrationActionRequest {
user_id: user_id.to_owned(),
};
self.post_typed(
"/admin/instance-config/pending-registrations/approve",
&request,
)
.await
self.decide_pending_registration(user_id, "approved").await
}
pub async fn reject_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
let request = PendingRegistrationActionRequest {
user_id: user_id.to_owned(),
};
self.post_typed(
"/admin/instance-config/pending-registrations/reject",
&request,
self.decide_pending_registration(user_id, "rejected").await
}
async fn decide_pending_registration(
&self,
user_id: &str,
status: &str,
) -> ApiResult<InstanceConfigResponse> {
let body = serde_json::json!({"status": status});
self.patch_with_reason(
&format!(
"/admin/instance/pending-registrations/{}",
urlencoding::encode(user_id)
),
Some(&body),
None,
)
.await
}
+36 -48
View File
@@ -1,8 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::client::{AdminApiClient, ApiResult};
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
pub struct ListJobsParams {
@@ -16,51 +14,32 @@ pub struct ListJobsParams {
impl AdminApiClient {
pub async fn list_jobs(&self, params: &ListJobsParams) -> ApiResult<ListJobsResponse> {
let cursor = params
.cursor
.clone()
.map(serde_json::from_value::<generated_types::ListJobsRequestCursor>)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let status = params
.status
.as_deref()
.map(generated_types::ListJobsRequestStatus::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::ListJobsRequest {
cursor,
limit: Some(
crate::api::generated::nonzero_u32(params.limit, "limit")
.map_err(ApiError::Parse)?,
let cursor = params.cursor.as_ref();
let cursor_bucket_day = cursor_field(cursor, "bucket_day");
let cursor_created_at = cursor_field(cursor, "created_at");
let cursor_job_id = cursor_field(cursor, "job_id");
let limit = params.limit.to_string();
let max_lookback_days = params.max_lookback_days.to_string();
let query_params = [
("limit", limit.as_str()),
("cursor_bucket_day", cursor_bucket_day.as_str()),
("cursor_created_at", cursor_created_at.as_str()),
("cursor_job_id", cursor_job_id.as_str()),
("max_lookback_days", max_lookback_days.as_str()),
("status", params.status.as_deref().unwrap_or_default()),
("task_type", params.task_type.as_deref().unwrap_or_default()),
(
"requested_by_user_id",
params.requested_by_user_id.as_deref().unwrap_or_default(),
),
max_lookback_days: Some(
crate::api::generated::nonzero_u32(params.max_lookback_days, "max_lookback_days")
.map_err(ApiError::Parse)?,
),
requested_by_user_id: params
.requested_by_user_id
.as_ref()
.cloned()
.map(generated_types::SnowflakeType::from),
status,
task_type: params.task_type.clone(),
};
let response = self
.generated()
.list_jobs(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
];
self.get("/admin/jobs", Some(&query_params)).await
}
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
let body = generated_types::GetJobRequest {
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
};
let response = self
.generated()
.get_job(&body)
.get_admin_job(job_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -71,19 +50,28 @@ impl AdminApiClient {
job_id: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<CancelJobResponse> {
let body = generated_types::CancelJobRequest {
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
};
self.post_typed_with_reason("/admin/jobs/cancel", &body, audit_log_reason)
.await
self.put_with_reason(
&format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)),
None,
audit_log_reason,
)
.await
}
pub async fn list_active_jobs(&self) -> ApiResult<ActiveJobsResponse> {
let response = self
.generated()
.list_active_jobs()
.list_admin_active_jobs()
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String {
cursor
.and_then(|cursor| cursor.get(field))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned()
}
+3 -3
View File
@@ -5,14 +5,14 @@ use super::types::{LimitConfigResponse, LimitConfigUpdateRequest};
impl AdminApiClient {
pub async fn get_limit_config(&self) -> ApiResult<LimitConfigResponse> {
self.post("/admin/limit-config/get", Some(&serde_json::json!({})))
.await
self.get("/admin/limit-config", None).await
}
pub async fn update_limit_config(
&self,
request: &LimitConfigUpdateRequest,
) -> ApiResult<LimitConfigResponse> {
self.post_typed("/admin/limit-config/update", request).await
self.put_typed_with_reason("/admin/limit-config", request, None)
.await
}
}
+51 -60
View File
@@ -16,12 +16,16 @@ impl AdminApiClient {
message_id: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::DeleteMessageRequest {
channel_id: snowflake(channel_id),
message_id: snowflake(message_id),
};
let _: serde_json::Value = self
.post_typed_with_reason("/admin/messages/delete", &body, audit_log_reason)
.delete_with_reason(
&format!(
"/admin/channels/{}/messages/{}",
urlencoding::encode(channel_id),
urlencoding::encode(message_id)
),
None,
audit_log_reason,
)
.await?;
Ok(())
}
@@ -50,7 +54,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.report_message_attachment_to_ncmec(&body)
.create_admin_ncmec_report(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -62,17 +66,14 @@ impl AdminApiClient {
message_id: &str,
context_limit: u32,
) -> ApiResult<LookupMessageResponse> {
let body = generated_types::LookupMessageRequest {
channel_id: snowflake(channel_id),
context_limit: Some(
crate::api::generated::nonzero_u32(context_limit, "context_limit")
.map_err(ApiError::Parse)?,
),
message_id: snowflake(message_id),
};
let context_limit = context_limit.to_string();
let response = self
.generated()
.lookup_message(&body)
.get_admin_message(
&snowflake(channel_id),
&snowflake(message_id),
Some(context_limit.as_str()),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -86,16 +87,13 @@ impl AdminApiClient {
let entries = entries
.iter()
.cloned()
.map(serde_json::from_value::<generated_types::MessageShredRequestEntriesItem>)
.map(serde_json::from_value::<generated_types::AdminUserMessageShredRequestEntriesItem>)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::MessageShredRequest {
entries,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserMessageShredRequest { entries };
let response = self
.generated()
.queue_message_shred(&body)
.shred_admin_user_messages(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -106,13 +104,10 @@ impl AdminApiClient {
user_id: &str,
dry_run: bool,
) -> ApiResult<DeleteAllUserMessagesResponse> {
let body = generated_types::DeleteAllUserMessagesRequest {
dry_run: Some(dry_run),
user_id: snowflake(user_id),
};
let dry_run = if dry_run { "true" } else { "false" };
let response = self
.generated()
.delete_all_user_messages(&body)
.delete_admin_user_messages(&snowflake(user_id), Some(dry_run))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -122,12 +117,9 @@ impl AdminApiClient {
&self,
job_id: &str,
) -> ApiResult<MessageShredStatusResponse> {
let body = generated_types::MessageShredStatusRequest {
job_id: job_id.to_owned(),
};
let response = self
.generated()
.get_message_shred_status(&body)
.get_admin_message_shred(job_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -140,18 +132,18 @@ impl AdminApiClient {
filename: &str,
context_limit: u32,
) -> ApiResult<LookupMessageResponse> {
let body = generated_types::LookupMessageByAttachmentRequest {
attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id),
context_limit: Some(
crate::api::generated::nonzero_u32(context_limit, "context_limit")
.map_err(ApiError::Parse)?,
),
filename: filename.to_owned(),
};
let context_limit = context_limit.to_string();
let response = self
.generated()
.lookup_message_by_attachment(&body)
.search_admin_messages(
Some(&snowflake(attachment_id)),
&snowflake(channel_id),
Some(context_limit.as_str()),
Some(filename),
None,
None,
None,
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -164,18 +156,17 @@ impl AdminApiClient {
after: Option<&str>,
limit: Option<u32>,
) -> ApiResult<BrowseChannelResponse> {
let body = generated_types::BrowseChannelRequest {
after: after.map(snowflake),
before: before.map(snowflake),
channel_id: snowflake(channel_id),
limit: limit
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
.transpose()
.map_err(ApiError::Parse)?,
};
let after = after.map(snowflake);
let before = before.map(snowflake);
let limit = limit.map(|value| value.to_string());
let response = self
.generated()
.browse_channel_messages(&body)
.list_admin_channel_messages(
&snowflake(channel_id),
after.as_ref(),
before.as_ref(),
limit.as_deref(),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -187,18 +178,18 @@ impl AdminApiClient {
query: &str,
limit: Option<u32>,
) -> ApiResult<SearchChannelMessagesResponse> {
let body = generated_types::SearchChannelMessagesRequest {
channel_id: snowflake(channel_id),
limit: limit
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
.transpose()
.map_err(ApiError::Parse)?,
query: generated_types::SearchChannelMessagesRequestQuery::try_from(query)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let limit = limit.map(|value| value.to_string());
let response = self
.generated()
.search_channel_messages(&body)
.search_admin_messages(
None,
&snowflake(channel_id),
None,
None,
limit.as_deref(),
None,
Some(query),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+74 -72
View File
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
@@ -14,28 +12,21 @@ impl AdminApiClient {
limit: u32,
offset: Option<u32>,
) -> ApiResult<ListReportsResponse> {
let body = generated_types::ListReportsRequest {
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: offset.map(i64::from),
status: status
.map(generated_types::ReportStatus::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.list_reports(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let status = status.map(report_status).transpose()?.unwrap_or_default();
let limit = limit.to_string();
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
let query_params = [
("status", status),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
self.get("/admin/reports", Some(&query_params)).await
}
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
let response = self
.generated()
.get_report(report_id)
.get_admin_report(report_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -47,12 +38,16 @@ impl AdminApiClient {
public_comment: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let body = generated_types::ResolveReportRequest {
public_comment: public_comment.map(std::borrow::ToOwned::to_owned),
report_id: generated_types::SnowflakeType::from(report_id.to_owned()),
};
self.post_typed_with_reason("/admin/reports/resolve", &body, audit_log_reason)
.await
let mut body = serde_json::json!({"status": "resolved"});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
self.patch_with_reason(
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
Some(&body),
audit_log_reason,
)
.await
}
#[allow(clippy::too_many_arguments)]
@@ -73,48 +68,37 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchReportsResponse> {
let body = generated_types::SearchReportsRequest {
category: nonempty_string(category),
guild_context_id: nonempty_snowflake(guild_context_id),
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
let status = status.map(report_status).transpose()?.unwrap_or_default();
let report_type = report_type
.map(report_type_name)
.transpose()?
.unwrap_or_default();
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
let limit = limit.to_string();
let offset = offset.to_string();
let query_params = [
("q", query.unwrap_or_default()),
("status", status),
("report_type", report_type),
("category", category.unwrap_or_default()),
("reporter_id", reporter_id.unwrap_or_default()),
("reported_user_id", reported_user_id.unwrap_or_default()),
("reported_guild_id", reported_guild_id.unwrap_or_default()),
(
"reported_channel_id",
reported_channel_id.unwrap_or_default(),
),
offset: Some(i64::from(offset)),
query: nonempty_string(query),
report_type: report_type
.map(generated_types::ReportType::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
reported_channel_id: nonempty_snowflake(reported_channel_id),
reported_guild_id: nonempty_snowflake(reported_guild_id),
reported_user_id: nonempty_snowflake(reported_user_id),
reporter_id: nonempty_snowflake(reporter_id),
resolved_by_admin_id: nonempty_snowflake(resolved_by_admin_id),
sort_by: sort_by
.map(generated_types::SearchReportsRequestSortBy::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
sort_order: sort_order
.map(generated_types::SearchReportsRequestSortOrder::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
status: status
.map(generated_types::ReportStatus::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.search_reports(&body)
.await
.map_err(|e| self.generated_error(e))?;
let response = response.into_inner();
Ok(SearchReportsResponse {
reports: self.generated_value(response.reports)?,
total: response.total as u64,
offset: response.offset as u64,
limit: response.limit as u64,
})
("guild_context_id", guild_context_id.unwrap_or_default()),
(
"resolved_by_admin_id",
resolved_by_admin_id.unwrap_or_default(),
),
("sort_by", sort_by),
("sort_order", sort_order.unwrap_or_default()),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
self.get("/admin/reports", Some(&query_params)).await
}
pub async fn search_reports_by_reporter(
@@ -168,12 +152,30 @@ impl AdminApiClient {
}
}
fn nonempty_string(value: Option<&str>) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
fn report_status(value: i32) -> ApiResult<&'static str> {
match value {
0 => Ok("pending"),
1 => Ok("resolved"),
other => Err(ApiError::Parse(format!("unknown report status: {other}"))),
}
}
fn nonempty_snowflake(value: Option<&str>) -> Option<generated_types::SnowflakeType> {
nonempty_string(value).map(generated_types::SnowflakeType::from)
fn report_type_name(value: i32) -> ApiResult<&'static str> {
match value {
0 => Ok("message"),
1 => Ok("user"),
2 => Ok("guild"),
other => Err(ApiError::Parse(format!("unknown report type: {other}"))),
}
}
fn report_sort_by(value: &str) -> ApiResult<&'static str> {
match value {
"created_at" | "createdAt" => Ok("created_at"),
"reported_at" | "reportedAt" => Ok("reported_at"),
"resolved_at" | "resolvedAt" => Ok("resolved_at"),
other => Err(ApiError::Parse(format!(
"unknown report sort field: {other}"
))),
}
}
+82 -8
View File
@@ -13,13 +13,11 @@ impl AdminApiClient {
) -> ApiResult<RefreshSearchIndexResponse> {
let body = generated_types::RefreshSearchIndexRequest {
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
index_type: generated_types::RefreshSearchIndexRequestIndexType::try_from(index_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_id: None,
};
let response = self
.generated()
.refresh_search_index(&body)
.create_admin_search_index_refresh(index_type, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -29,14 +27,90 @@ impl AdminApiClient {
&self,
job_id: &str,
) -> ApiResult<IndexRefreshStatusResponse> {
let body = generated_types::GetIndexRefreshStatusRequest {
job_id: job_id.to_owned(),
};
let response = self
.generated()
.get_search_index_refresh_status(&body)
.get_admin_search_index_refresh(job_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
index_refresh_status(response.into_inner())
}
}
fn index_refresh_status(
response: generated_types::IndexRefreshStatusResponse,
) -> ApiResult<IndexRefreshStatusResponse> {
match response {
generated_types::IndexRefreshStatusResponse::Variant0 { status } => {
Ok(IndexRefreshStatusResponse::NotFound {
status: status.to_string(),
})
}
generated_types::IndexRefreshStatusResponse::Variant1 {
status,
index_type,
total,
indexed,
started_at,
completed_at,
failed_at,
error,
} => Ok(IndexRefreshStatusResponse::Progress {
status: status.to_string(),
index_type: Some(index_type),
total: total
.map(|value| float_to_u64(value, "total"))
.transpose()?,
indexed: indexed
.map(|value| float_to_u64(value, "indexed"))
.transpose()?,
started_at,
completed_at,
failed_at,
error,
}),
}
}
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_a_running_refresh_to_progress() {
let json = r#"{"status":"in_progress","index_type":"users","total":50000,"indexed":1200,"started_at":"2026-09-06T00:00:00Z"}"#;
let response: generated_types::IndexRefreshStatusResponse =
serde_json::from_str(json).unwrap();
match index_refresh_status(response).unwrap() {
IndexRefreshStatusResponse::Progress {
status,
index_type,
total,
indexed,
started_at,
..
} => {
assert_eq!(status, "in_progress");
assert_eq!(index_type.as_deref(), Some("users"));
assert_eq!(total, Some(50_000));
assert_eq!(indexed, Some(1_200));
assert_eq!(started_at.as_deref(), Some("2026-09-06T00:00:00Z"));
}
other => panic!("expected a progress status, got {other:?}"),
}
}
#[test]
fn maps_a_missing_refresh_to_not_found() {
let json = r#"{"status":"not_found"}"#;
let response: generated_types::IndexRefreshStatusResponse =
serde_json::from_str(json).unwrap();
match index_refresh_status(response).unwrap() {
IndexRefreshStatusResponse::NotFound { status } => assert_eq!(status, "not_found"),
other => panic!("expected a not found status, got {other:?}"),
}
}
}
+6 -8
View File
@@ -2,7 +2,7 @@
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::client::{AdminApiClient, ApiResult};
use super::types::{
GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse,
ReloadAllGuildsResponse,
@@ -10,12 +10,10 @@ use super::types::{
impl AdminApiClient {
pub async fn get_guild_memory_stats(&self, limit: u32) -> ApiResult<GuildMemoryStatsResponse> {
let body = generated_types::GetProcessMemoryStatsRequest {
limit: Some(i32::try_from(limit).map_err(|e| ApiError::Parse(e.to_string()))?),
};
let limit = limit.to_string();
let response = self
.generated()
.get_guild_memory_statistics(&body)
.get_admin_gateway_memory_stats(Some(limit.as_str()))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -34,7 +32,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.reload_all_specified_guilds(&body)
.create_admin_gateway_reload(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -43,7 +41,7 @@ impl AdminApiClient {
pub async fn get_node_stats(&self) -> ApiResult<NodeStatsResponse> {
let response = self
.generated()
.get_gateway_node_statistics()
.get_admin_gateway_stats()
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -54,7 +52,7 @@ impl AdminApiClient {
) -> ApiResult<GatewayVoiceStateCountsResponse> {
let response = self
.generated()
.get_gateway_voice_state_counts()
.get_admin_gateway_voice_state_counts()
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+1 -1
View File
@@ -22,7 +22,7 @@ impl AdminApiClient {
};
let response = self
.generated()
.send_system_dm(&body)
.create_admin_system_dm(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -826,13 +826,3 @@ pub struct CreateRegistrationUrlResponse {
pub code: String,
pub url: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct RegistrationUrlActionRequest {
pub id: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct PendingRegistrationActionRequest {
pub user_id: String,
}
+111 -183
View File
@@ -17,18 +17,19 @@ impl AdminApiClient {
limit: u32,
offset: u32,
) -> ApiResult<SearchUsersResponse> {
let body = generated_types::SearchUsersRequest {
email: nonempty_string(email),
last_active_ip: nonempty_string(last_active_ip),
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
query: nonempty_string(query),
};
let limit = limit.to_string();
let offset = offset.to_string();
let response = self
.generated()
.search_users(&body)
.list_admin_users(
nonempty(email),
nonempty(last_active_ip),
Some(limit.as_str()),
Some(offset.as_str()),
nonempty(query),
None,
None,
)
.await
.map_err(|e| self.generated_error(e))?;
let response = response.into_inner();
@@ -39,10 +40,9 @@ impl AdminApiClient {
}
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
let body = generated_types::LookupUserRequest::Query(query.to_owned());
let response = self
.generated()
.lookup_user(&body)
.list_admin_users(None, None, None, None, None, Some(query), None)
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
@@ -53,27 +53,18 @@ impl AdminApiClient {
if user_ids.is_empty() {
return Ok(vec![]);
}
let body = generated_types::LookupUserRequest::UserIds(
user_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
);
let response = self
.generated()
.lookup_user(&body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
let query_params: Vec<(&str, &str)> = user_ids
.iter()
.map(|user_id| ("user_id", user_id.as_str()))
.collect();
let resp: LookupUserResponse = self.get("/admin/users", Some(&query_params)).await?;
Ok(resp.users)
}
pub async fn get_user_by_id(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::LookupUserRequest::Query(user_id.to_owned());
let response = self
.generated()
.lookup_user(&body)
.get_admin_user(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
@@ -89,7 +80,7 @@ impl AdminApiClient {
pub async fn get_current_admin(&self) -> ApiResult<AdminUser> {
let response = self
.generated()
.get_authenticated_admin_user()
.get_current_admin_user()
.await
.map_err(|e| self.generated_error(e))?;
let resp: AdminUserMeResponse = self.generated_value(response.into_inner())?;
@@ -102,14 +93,13 @@ impl AdminApiClient {
add_flags: &[String],
remove_flags: &[String],
) -> ApiResult<AdminUser> {
let body = generated_types::UpdateUserFlagsRequest {
let body = generated_types::AdminUserFlagsUpdateRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
user_id: snowflake(user_id),
};
let response = self
.generated()
.update_user_flags(&body)
.update_admin_user_flags(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -124,19 +114,19 @@ impl AdminApiClient {
after: Option<&str>,
with_counts: Option<bool>,
) -> ApiResult<Vec<GuildInfo>> {
let body = generated_types::ListUserGuildsRequest {
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
limit: Some(
crate::api::generated::nonzero_u32(limit.unwrap_or(200), "limit")
.map_err(ApiError::Parse)?,
),
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
with_counts: Some(with_counts.unwrap_or(true)),
};
let after = after.map(snowflake);
let before = before.map(snowflake);
let limit = limit.unwrap_or(200).to_string();
let with_counts = bool_param(with_counts.unwrap_or(true));
let response = self
.generated()
.list_user_guilds(&body)
.list_admin_user_guilds(
&snowflake(user_id),
after.as_ref(),
before.as_ref(),
Some(limit.as_str()),
Some(with_counts),
)
.await
.map_err(|e| self.generated_error(e))?;
let resp: ListUserGuildsResponse = self.generated_value(response.into_inner())?;
@@ -147,12 +137,9 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::ListUserSessionsResponse> {
let body = generated_types::ListUserSessionsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.list_user_sessions(&body)
.list_admin_user_sessions(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -162,12 +149,9 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<TerminateSessionsResponse> {
let body = generated_types::TerminateSessionsRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.terminate_user_sessions(&body)
.terminate_admin_user_sessions(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -177,12 +161,9 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::ListUserRelationshipsResponse> {
let body = generated_types::ListUserRelationshipsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.admin_list_user_relationships(&body)
.list_admin_user_relationships(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -195,18 +176,18 @@ impl AdminApiClient {
after: Option<&str>,
limit: Option<u32>,
) -> ApiResult<super::types::ListUserDmChannelsResponse> {
let body = generated_types::ListUserDmChannelsRequest {
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
limit: Some(
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
.map_err(ApiError::Parse)?,
),
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let after = after.map(snowflake);
let before = before.map(snowflake);
let limit = limit.unwrap_or(50).to_string();
let response = self
.generated()
.list_user_dm_channels(&body)
.list_admin_user_dm_channels(
&snowflake(user_id),
after.as_ref(),
before.as_ref(),
Some(limit.as_str()),
Some(generated_types::AdminUserDmChannelType::Dm),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -216,12 +197,15 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::ListUserGroupDmChannelsResponse> {
let body = generated_types::ListUserGroupDmChannelsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.list_user_group_dm_channels(&body)
.list_admin_user_dm_channels(
&snowflake(user_id),
None,
None,
None,
Some(generated_types::AdminUserDmChannelType::GroupDm),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -233,14 +217,13 @@ impl AdminApiClient {
add_flags: &[i32],
remove_flags: &[i32],
) -> ApiResult<AdminUser> {
let body = generated_types::UpdatePremiumFlagsRequest {
let body = generated_types::AdminUserPremiumFlagsUpdateRequest {
add_flags: premium_flags(add_flags),
remove_flags: premium_flags(remove_flags),
user_id: snowflake(user_id),
};
let response = self
.generated()
.update_user_premium_flags(&body)
.update_admin_user_premium_flags(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -248,13 +231,12 @@ impl AdminApiClient {
}
pub async fn update_suspicious_flags(&self, user_id: &str, flags: i32) -> ApiResult<AdminUser> {
let body = generated_types::UpdateSuspiciousActivityFlagsRequest {
let body = generated_types::AdminUserSuspiciousActivityFlagsRequest {
flags: generated_types::SuspiciousActivityFlags::from(flags),
user_id: snowflake(user_id),
};
let response = self
.generated()
.update_suspicious_activity_flags(&body)
.update_admin_user_suspicious_activity_flags(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -262,13 +244,12 @@ impl AdminApiClient {
}
pub async fn set_user_acls(&self, user_id: &str, acls: &[String]) -> ApiResult<AdminUser> {
let body = generated_types::SetUserAclsRequest {
acls: acls.to_vec(),
user_id: snowflake(user_id),
let body = generated_types::AdminUserAclsRequest {
acls: super::admin_api_keys::parse_acls(acls)?,
};
let response = self
.generated()
.set_user_acls(&body)
.set_admin_user_acls(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -276,13 +257,12 @@ impl AdminApiClient {
}
pub async fn set_user_traits(&self, user_id: &str, traits: &[String]) -> ApiResult<AdminUser> {
let body = generated_types::SetUserTraitsRequest {
let body = generated_types::AdminUserTraitsRequest {
traits: traits.to_vec(),
user_id: snowflake(user_id),
};
let response = self
.generated()
.set_user_traits(&body)
.set_admin_user_traits(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -290,34 +270,25 @@ impl AdminApiClient {
}
pub async fn disable_mfa(&self, user_id: &str) -> ApiResult<()> {
let body = generated_types::DisableMfaRequest {
user_id: snowflake(user_id),
};
self.generated()
.disable_user_mfa(&body)
.disable_admin_user_mfa(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn resend_verification_email(&self, user_id: &str) -> ApiResult<()> {
let body = generated_types::ResendVerificationEmailRequest {
user_id: snowflake(user_id),
};
self.generated()
.admin_resend_verification_email(&body)
.resend_admin_user_verification_email(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn verify_email(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::VerifyUserEmailRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.verify_user_email(&body)
.verify_admin_user_email(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -329,13 +300,10 @@ impl AdminApiClient {
user_id: &str,
has_verified_phone: bool,
) -> ApiResult<AdminUser> {
let body = generated_types::UpdateHasVerifiedPhoneRequest {
has_verified_phone,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserPhoneVerificationRequest { has_verified_phone };
let response = self
.generated()
.update_user_has_verified_phone(&body)
.update_admin_user_phone_verification(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -349,16 +317,15 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> {
let fields = fields
.iter()
.map(generated_types::ClearUserFieldsRequestFieldsItem::try_from)
.map(|field| {
generated_types::AdminUserClearFieldsRequestFieldsItem::try_from(field.as_str())
})
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::ClearUserFieldsRequest {
fields,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserClearFieldsRequest { fields };
let response = self
.generated()
.clear_user_fields(&body)
.clear_admin_user_profile_fields(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -366,13 +333,10 @@ impl AdminApiClient {
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
let body = generated_types::SetUserBotStatusRequest {
bot: is_bot,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
let response = self
.generated()
.set_user_bot_status(&body)
.set_admin_user_bot_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -380,13 +344,10 @@ impl AdminApiClient {
}
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
let body = generated_types::SetUserSystemStatusRequest {
system: is_system,
user_id: snowflake(user_id),
};
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
let response = self
.generated()
.set_user_system_status(&body)
.set_admin_user_system_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -399,18 +360,17 @@ impl AdminApiClient {
username: &str,
discriminator: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::ChangeUsernameRequest {
let body = generated_types::AdminUserUsernameUpdateRequest {
discriminator: discriminator
.map(generated_types::DiscriminatorType::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_id: snowflake(user_id),
username: generated_types::UsernameType::try_from(username)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.change_user_username(&body)
.update_admin_user_username(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -418,13 +378,12 @@ impl AdminApiClient {
}
pub async fn change_email(&self, user_id: &str, email: &str) -> ApiResult<AdminUser> {
let body = generated_types::ChangeEmailRequest {
let body = generated_types::AdminUserEmailUpdateRequest {
email: generated_types::EmailType::from(email.to_owned()),
user_id: snowflake(user_id),
};
let response = self
.generated()
.change_user_email(&body)
.update_admin_user_email(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -438,25 +397,25 @@ impl AdminApiClient {
reason: Option<&str>,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::TempBanUserRequest {
let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?,
reason: reason.map(std::borrow::ToOwned::to_owned),
user_id: snowflake(user_id),
};
let resp: UserMutationResponse = self
.post_typed_with_reason("/admin/users/temp-ban", &body, private_reason)
.put_typed_with_reason(
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
&body,
private_reason,
)
.await?;
Ok(resp.user)
}
pub async fn unban_user(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::DisableMfaRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.unban_user(&body)
.unban_admin_user(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -470,18 +429,18 @@ impl AdminApiClient {
public_reason: Option<&str>,
days_until_deletion: u32,
) -> ApiResult<AdminUser> {
let body = generated_types::ScheduleAccountDeletionRequest {
let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code,
user_id: snowflake(user_id),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?,
};
let response = self
.generated()
.schedule_account_deletion(&body)
.schedule_admin_user_deletion(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -489,12 +448,9 @@ impl AdminApiClient {
}
pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::DisableMfaRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.cancel_account_deletion(&body)
.cancel_admin_user_deletion(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -502,13 +458,12 @@ impl AdminApiClient {
}
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
let body = generated_types::ChangeDobRequest {
let body = generated_types::AdminUserDobUpdateRequest {
date_of_birth: dob.to_owned(),
user_id: snowflake(user_id),
};
let response = self
.generated()
.change_user_dob(&body)
.update_admin_user_date_of_birth(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -516,11 +471,8 @@ impl AdminApiClient {
}
pub async fn send_password_reset(&self, user_id: &str) -> ApiResult<()> {
let body = generated_types::SendPasswordResetRequest {
user_id: snowflake(user_id),
};
self.generated()
.send_password_reset(&body)
.send_admin_user_password_reset(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -532,14 +484,10 @@ impl AdminApiClient {
target_id: &str,
category: &str,
) -> ApiResult<()> {
let body = generated_types::RemoveUserRelationshipRequest {
category: generated_types::RemoveUserRelationshipRequestCategory::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?,
target_user_id: snowflake(target_id),
user_id: snowflake(user_id),
};
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
self.generated()
.remove_user_relationship(&body)
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -550,16 +498,11 @@ impl AdminApiClient {
user_id: &str,
category: &str,
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
let body = generated_types::RemoveUserRelationshipsByCategoryRequest {
category: generated_types::RemoveUserRelationshipsByCategoryRequestCategory::try_from(
category,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_id: snowflake(user_id),
};
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.remove_user_relationships_by_category(&body)
.clear_admin_user_relationships(&snowflake(user_id), category)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -570,12 +513,8 @@ impl AdminApiClient {
user_id: &str,
credential_id: &str,
) -> ApiResult<()> {
let body = generated_types::DeleteWebAuthnCredentialRequest {
credential_id: credential_id.to_owned(),
user_id: snowflake(user_id),
};
self.generated()
.delete_user_webauthn_credential(&body)
.delete_admin_user_webauthn_credential(&snowflake(user_id), credential_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -586,17 +525,10 @@ impl AdminApiClient {
user_id: &str,
limit: Option<u32>,
) -> ApiResult<super::types::ListUserChangeLogResponse> {
let body = generated_types::ListUserChangeLogRequest {
limit: Some(
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
.map_err(ApiError::Parse)?,
),
page_token: None,
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let limit = limit.unwrap_or(50).to_string();
let response = self
.generated()
.get_user_change_log(&body)
.list_admin_user_change_log(&snowflake(user_id), Some(limit.as_str()), None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -606,24 +538,18 @@ impl AdminApiClient {
&self,
user_id: &str,
) -> ApiResult<super::types::WebAuthnCredentialListResponse> {
let body = generated_types::ListWebAuthnCredentialsRequest {
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
};
let response = self
.generated()
.list_user_webauthn_credentials(&body)
.list_admin_user_webauthn_credentials(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn cancel_bulk_message_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
let body = generated_types::CancelBulkMessageDeletionRequest {
user_id: snowflake(user_id),
};
let response = self
.generated()
.admin_cancel_bulk_message_deletion(&body)
.cancel_admin_user_message_deletion(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -631,10 +557,12 @@ impl AdminApiClient {
}
}
fn nonempty_string(value: Option<&str>) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
fn nonempty(value: Option<&str>) -> Option<&str> {
value.filter(|value| !value.is_empty())
}
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
+39 -29
View File
@@ -14,12 +14,9 @@ impl AdminApiClient {
&self,
include_servers: bool,
) -> ApiResult<ListVoiceRegionsResponse> {
let body = generated_types::ListVoiceRegionsRequest {
include_servers: Some(include_servers),
};
let response = self
.generated()
.list_voice_regions(&body)
.list_admin_voice_regions(Some(bool_param(include_servers)))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -30,13 +27,9 @@ impl AdminApiClient {
id: &str,
include_servers: bool,
) -> ApiResult<GetVoiceRegionResponse> {
let body = generated_types::GetVoiceRegionRequest {
id: id.to_owned(),
include_servers: Some(include_servers),
};
let response = self
.generated()
.get_voice_region(&body)
.get_admin_voice_region(id, Some(bool_param(include_servers)))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -51,7 +44,7 @@ impl AdminApiClient {
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.create_voice_region(&body)
.create_admin_voice_region(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -61,34 +54,31 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
let region_id = required_field(params, "id")?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_voice_region(&body)
.update_admin_voice_region(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
let body = generated_types::DeleteVoiceRegionRequest { id: id.to_owned() };
let response = self
.generated()
.delete_voice_region(&body)
.delete_admin_voice_region(id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn list_voice_servers(&self, region_id: &str) -> ApiResult<ListVoiceServersResponse> {
let body = generated_types::ListVoiceServersRequest {
region_id: region_id.to_owned(),
};
let response = self
.generated()
.list_voice_servers(&body)
.list_admin_voice_servers(region_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -99,13 +89,9 @@ impl AdminApiClient {
region_id: &str,
server_id: &str,
) -> ApiResult<GetVoiceServerResponse> {
let body = generated_types::GetVoiceServerRequest {
region_id: region_id.to_owned(),
server_id: server_id.to_owned(),
};
let response = self
.generated()
.get_voice_server(&body)
.get_admin_voice_server(region_id, server_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -115,12 +101,14 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<CreateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.create_voice_server(&body)
.create_admin_voice_server(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -130,12 +118,15 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?;
let body =
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_voice_server(&body)
.update_admin_voice_server(&region_id, &server_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -146,15 +137,34 @@ impl AdminApiClient {
region_id: &str,
server_id: &str,
) -> ApiResult<DeleteVoiceResponse> {
let body = generated_types::DeleteVoiceServerRequest {
region_id: region_id.to_owned(),
server_id: server_id.to_owned(),
};
let response = self
.generated()
.delete_voice_server(&body)
.delete_admin_voice_server(region_id, server_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
if has_coordinate("latitude") == has_coordinate("longitude") {
Ok(())
} else {
Err(ApiError::Parse(
"latitude and longitude must both be set or both be left empty".to_owned(),
))
}
}
fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String> {
params
.get(field)
.and_then(serde_json::Value::as_str)
.map(std::borrow::ToOwned::to_owned)
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
}
+15 -5
View File
@@ -41,7 +41,7 @@ pub enum RuntimeEnv {
}
impl AdminConfig {
pub fn from_env() -> Self {
pub fn from_env() -> anyhow::Result<Self> {
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_ADMIN_ENDPOINT",
@@ -51,14 +51,19 @@ impl AdminConfig {
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
&format!("{admin_endpoint}/oauth2_callback"),
));
let secret_key_base = read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "");
anyhow::ensure!(
!secret_key_base.trim().is_empty(),
"FLUXER_ADMIN_SECRET_KEY_BASE is required"
);
Self {
Ok(Self {
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
port: read_env("FLUXER_ADMIN_PORT", "3020")
.parse()
.unwrap_or(3020),
secret_key_base: read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "development-admin-secret"),
secret_key_base,
base_path,
api_endpoint: trim_trailing_slash(&read_env(
"FLUXER_API_ENDPOINT",
@@ -110,7 +115,7 @@ impl AdminConfig {
.trim()
.to_ascii_lowercase(),
},
}
})
}
pub fn is_dev(&self) -> bool {
@@ -120,6 +125,10 @@ impl AdminConfig {
pub fn is_production(&self) -> bool {
self.env == RuntimeEnv::Production
}
pub fn secure_cookies(&self) -> bool {
self.admin_endpoint.starts_with("https://")
}
}
impl RuntimeEnv {
@@ -193,10 +202,11 @@ mod tests {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
for (name, value) in vars {
unsafe { env::set_var(name, value) };
}
let config = AdminConfig::from_env();
let config = AdminConfig::from_env().expect("config loads with a secret");
for (name, _) in vars {
unsafe { env::remove_var(name) };
}
+1 -1
View File
@@ -14,7 +14,7 @@ async fn main() -> anyhow::Result<()> {
.with(tracing_subscriber::fmt::layer())
.init();
let config = AdminConfig::from_env();
let config = AdminConfig::from_env()?;
let addr = format!("{}:{}", config.host, config.port);
let router = build_router(config);
+1 -1
View File
@@ -135,7 +135,7 @@ async fn fetch_admin_user(
config: &crate::config::AdminConfig,
session: &Session,
) -> AdminFetchResult {
let url = format!("{}/admin/users/me", config.api_endpoint);
let url = format!("{}/admin/users/@me", config.api_endpoint);
let response =
match crate::api::client::with_proxy_client_ip_header(http_client.get(&url), config)
.header("Authorization", format!("Bearer {}", session.access_token))
+79 -4
View File
@@ -28,7 +28,7 @@ pub async fn csrf_protection(
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let is_production = config.is_production();
let secure_cookies = config.secure_cookies();
let user_id = request
.extensions()
@@ -76,17 +76,17 @@ pub async fn csrf_protection(
let mut response = next.run(request).await;
let cookie_name = if is_production {
let cookie_name = if secure_cookies {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if is_production { "; Secure" } else { "" };
let secure = if secure_cookies { "; Secure" } else { "" };
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
if is_production
if secure_cookies
&& let Ok(value) = HeaderValue::from_str(&format!(
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
))
@@ -199,6 +199,81 @@ pub fn get_csrf_token(request: &Request) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
use crate::state::AppState;
use axum::{Router, middleware::from_fn_with_state, routing::get};
use tower::ServiceExt;
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
AppState::new(AdminConfig {
env: RuntimeEnv::Production,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: String::new(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: String::new(),
kv_url: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
release_channel: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
})
}
async fn csrf_cookies(admin_endpoint: &str) -> Vec<String> {
let state = state_with_admin_endpoint(admin_endpoint);
let app = Router::new()
.route("/", get(|| async { "ok" }))
.layer(from_fn_with_state(state, csrf_protection));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("router responds");
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.map(|value| value.to_owned())
.collect()
}
#[tokio::test]
async fn https_admin_endpoint_sets_a_host_prefixed_secure_cookie() {
let cookies = csrf_cookies("https://example.com/admin").await;
assert!(
cookies
.iter()
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
&& cookie.contains("; Secure")),
"expected a secure __Host- cookie, got {cookies:?}"
);
}
#[tokio::test]
async fn http_admin_endpoint_sets_a_plain_cookie_without_secure() {
let cookies = csrf_cookies("http://example.com/admin").await;
assert!(
cookies
.iter()
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("Secure")),
"expected a plain csrf_token cookie, got {cookies:?}"
);
assert!(
!cookies.iter().any(|cookie| cookie.contains("__Host-")),
"expected no __Host- cookie, got {cookies:?}"
);
}
#[test]
fn oauth2_callback_is_exempt() {
+2 -2
View File
@@ -92,9 +92,9 @@ pub fn clear_flash_cookie(response: &mut Response) {
}
}
pub fn redirect_with_flash(url: &str, flash: FlashData, is_production: bool) -> Response {
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
let encoded = serialize_flash(&flash);
let secure_flag = if is_production { "; Secure" } else { "" };
let secure_flag = if secure { "; Secure" } else { "" };
let cookie_value = format!(
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
);
+3 -3
View File
@@ -119,7 +119,7 @@ async fn admin_api_keys_post(
return flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
flash,
config.is_production(),
config.secure_cookies(),
);
}
}
@@ -128,7 +128,7 @@ async fn admin_api_keys_post(
flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
FlashData::success(format!("API key action '{action}' completed.")),
config.is_production(),
config.secure_cookies(),
)
}
@@ -143,7 +143,7 @@ fn admin_api_key_flash_response(
flash::redirect_with_flash(
&format!("{}/admin-api-keys", config.base_path),
flash_data,
config.is_production(),
config.secure_cookies(),
)
}
}
+2 -2
View File
@@ -151,7 +151,7 @@ async fn application_detail_post(
return flash::redirect_with_flash(
&format!("{base}/applications/{application_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -178,6 +178,6 @@ async fn application_detail_post(
flash::redirect_with_flash(
&format!("{base}/applications/{application_id}"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
+1 -1
View File
@@ -187,7 +187,7 @@ async fn oauth2_callback_finish(
let session_cookie_value =
session::create_session(&user.id, &token.access_token, &config.secret_key_base);
let secure = if config.is_production() {
let secure = if config.secure_cookies() {
"; Secure"
} else {
""
+4 -4
View File
@@ -82,7 +82,7 @@ async fn gift_codes_post(
return flash::redirect_with_flash(
&format!("{base}/gift-codes"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -99,14 +99,14 @@ async fn gift_codes_post(
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let is_prod = config.is_production();
let secure_cookies = config.secure_cookies();
match client.generate_gift_codes(count, dur_type, dur_qty).await {
Ok(result) => {
let codes = result.codes.join(",");
flash::redirect_with_flash(
&format!("{base}/gift-codes?codes={codes}"),
FlashData::success(format!("{} gift code(s) generated", result.codes.len())),
is_prod,
secure_cookies,
)
}
Err(error) => {
@@ -114,7 +114,7 @@ async fn gift_codes_post(
flash::redirect_with_flash(
&format!("{base}/gift-codes"),
FlashData::error("Failed to generate gift codes"),
is_prod,
secure_cookies,
)
}
}
+9 -9
View File
@@ -105,7 +105,7 @@ async fn discovery_approve(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -115,7 +115,7 @@ async fn discovery_approve(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -131,7 +131,7 @@ async fn discovery_approve(
flash::redirect_with_flash(
&format!("{base}/discovery?tab=pending"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
@@ -149,7 +149,7 @@ async fn discovery_reject(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -159,7 +159,7 @@ async fn discovery_reject(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -173,7 +173,7 @@ async fn discovery_reject(
flash::redirect_with_flash(
&format!("{base}/discovery?tab=pending"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
@@ -191,7 +191,7 @@ async fn discovery_remove(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -201,7 +201,7 @@ async fn discovery_remove(
return flash::redirect_with_flash(
&format!("{base}/discovery"),
FlashData::error("Guild ID is required"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -215,6 +215,6 @@ async fn discovery_remove(
flash::redirect_with_flash(
&format!("{base}/discovery?tab=listed"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
+1 -1
View File
@@ -158,7 +158,7 @@ pub async fn render(
return None;
}
let apps = client
.list_user_applications(guild_id)
.list_guild_applications(guild_id)
.await
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications"))
.unwrap_or_default();
+4 -4
View File
@@ -191,7 +191,7 @@ async fn guild_detail_post(
return flash::redirect_with_flash(
&format!("{base}/guilds/{guild_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -203,7 +203,7 @@ async fn guild_detail_post(
} else {
format!("{base}/guilds/{guild_id}?tab={tab}")
};
flash::redirect_with_flash(&redirect, flash, config.is_production())
flash::redirect_with_flash(&redirect, flash, config.secure_cookies())
}
async fn dispatch_guild_action(
@@ -415,7 +415,7 @@ async fn dispatch_guild_action(
return FlashData::error("Emoji ID is required");
};
action_result(
client.purge_assets(&[emoji_id]).await,
client.purge_assets(guild_id, &[emoji_id]).await,
"Emoji deleted",
"Failed to delete emoji",
)
@@ -425,7 +425,7 @@ async fn dispatch_guild_action(
return FlashData::error("Sticker ID is required");
};
action_result(
client.purge_assets(&[sticker_id]).await,
client.purge_assets(guild_id, &[sticker_id]).await,
"Sticker deleted",
"Failed to delete sticker",
)
+2 -2
View File
@@ -187,7 +187,7 @@ async fn job_detail_post(
return flash::redirect_with_flash(
&format!("{base}/jobs/{job_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -214,7 +214,7 @@ async fn job_detail_post(
flash::redirect_with_flash(
&format!("{base}/jobs/{job_id}"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
+17 -13
View File
@@ -48,7 +48,7 @@ pub(crate) async fn messages_post(
return flash::redirect_with_flash(
&format!("{base}/messages"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -164,7 +164,7 @@ pub(crate) async fn system_dms_post(
return flash::redirect_with_flash(
&format!("{base}/system-dms"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -184,7 +184,11 @@ pub(crate) async fn system_dms_post(
} else {
FlashData::error("Recipients and content are required")
};
flash::redirect_with_flash(&format!("{base}/system-dms"), flash, config.is_production())
flash::redirect_with_flash(
&format!("{base}/system-dms"),
flash,
config.secure_cookies(),
)
}
pub(crate) async fn bulk_actions_post(
@@ -201,7 +205,7 @@ pub(crate) async fn bulk_actions_post(
return flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -247,7 +251,7 @@ pub(crate) async fn bulk_actions_post(
.bulk_add_guild_members(&guild_id, &user_ids, audit_log_reason.as_deref())
.await
}
"bulk-schedule-user-deletion" => {
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
let reason_code = form.parse_u32("reason_code").unwrap_or(2);
let days = form.parse_u32("days_until_deletion").unwrap_or(14);
@@ -262,17 +266,17 @@ pub(crate) async fn bulk_actions_post(
)
.await
}
"bulk_delete_users" => {
"bulk-delete-user-messages" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
client
.bulk_schedule_user_deletion(&user_ids, 0, 30, None, audit_log_reason.as_deref())
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
.await
}
_ => {
return flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Unknown bulk action"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -284,7 +288,7 @@ pub(crate) async fn bulk_actions_post(
flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::success("Bulk action submitted"),
config.is_production(),
config.secure_cookies(),
)
}
}
@@ -292,8 +296,8 @@ pub(crate) async fn bulk_actions_post(
tracing::warn!(%error, action, "admin API request failed: submit bulk action");
flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Failed to submit bulk action"),
config.is_production(),
FlashData::error(format!("Failed to submit bulk action: {error}")),
config.secure_cookies(),
)
}
}
@@ -399,14 +403,14 @@ pub(crate) async fn archives_download(
Ok(_) => flash::redirect_with_flash(
&format!("{base}/archives"),
FlashData::error("Archive download URL was empty"),
config.is_production(),
config.secure_cookies(),
),
Err(error) => {
tracing::warn!(%error, "admin API request failed: get archive download URL");
flash::redirect_with_flash(
&format!("{base}/archives"),
FlashData::error("Failed to create archive download URL"),
config.is_production(),
config.secure_cookies(),
)
}
}
+28 -5
View File
@@ -2,6 +2,7 @@
use crate::{
api::client::{AdminApiClient, ApiResultExt},
config::AdminConfig,
middleware::{
auth::AuthContext,
csrf,
@@ -22,6 +23,8 @@ use axum::{
};
use serde::Deserialize;
const MAX_REPORT_OFFSET: u32 = 10_000;
#[derive(Deserialize)]
struct ReportsQuery {
q: Option<String>,
@@ -70,6 +73,14 @@ async fn reports_list(
let page = query.page.unwrap_or(0);
let limit = query.limit.unwrap_or(25).clamp(1, 200);
let offset = page.saturating_mul(limit);
if offset > MAX_REPORT_OFFSET {
return reports_error_page(
config,
&auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
);
}
let search_query = query.q.as_deref().and_then(clean_string);
let (sort_by, sort_order) = decode_sort(query.sort.as_deref());
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let status = query.status.as_deref().and_then(|s| s.parse::<i32>().ok());
@@ -79,7 +90,7 @@ async fn reports_list(
.and_then(|s| s.parse::<i32>().ok());
let reports = client
.search_reports(
query.q.as_deref(),
search_query.as_deref(),
status,
report_type,
query.category.as_deref(),
@@ -102,7 +113,7 @@ async fn reports_list(
&auth.0,
reports.as_ref(),
&templates::pages::reports_list::ReportFilters {
query: query.q.as_deref(),
query: search_query.as_deref(),
status: query.status.as_deref(),
report_type: query.report_type.as_deref(),
category: query.category.as_deref(),
@@ -120,6 +131,18 @@ async fn reports_list(
Html(markup.into_string()).into_response()
}
fn reports_error_page(config: &AdminConfig, auth: &AuthContext, message: &str) -> Response {
let markup = templates::layout::admin_layout(
config,
auth,
"Reports",
"reports",
None,
templates::components::error_display::error_alert(message),
);
Html(markup.into_string()).into_response()
}
async fn report_detail(
State(state): State<AppState>,
headers: HeaderMap,
@@ -195,7 +218,7 @@ async fn report_resolve(
return flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -212,7 +235,7 @@ async fn report_resolve(
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::success("Report resolved"),
config.is_production(),
config.secure_cookies(),
)
}
Err(error) => {
@@ -223,7 +246,7 @@ async fn report_resolve(
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::error("Failed to resolve report"),
config.is_production(),
config.secure_cookies(),
)
}
}
+25 -25
View File
@@ -44,8 +44,8 @@ pub struct ActionQuery {
pub rule: Option<String>,
}
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, prod: bool) -> Response {
flash::redirect_with_flash(&format!("{base}{path}"), fd, prod)
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, secure: bool) -> Response {
flash::redirect_with_flash(&format!("{base}{path}"), fd, secure)
}
pub async fn gateway_post(
@@ -63,7 +63,7 @@ pub async fn gateway_post(
base,
"/gateway",
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -80,7 +80,7 @@ pub async fn gateway_post(
} else {
FlashData::error("Unknown gateway action")
};
redirect_back_with_flash(base, "/gateway", flash, config.is_production())
redirect_back_with_flash(base, "/gateway", flash, config.secure_cookies())
}
pub async fn search_index_post(
@@ -97,7 +97,7 @@ pub async fn search_index_post(
base,
"/search-index",
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -114,7 +114,7 @@ pub async fn search_index_post(
flash::redirect_with_flash(
&format!("{base}/search-index?job_id={job_id}"),
FlashData::success("Search index refresh started"),
config.is_production(),
config.secure_cookies(),
)
}
Err(error) => {
@@ -123,7 +123,7 @@ pub async fn search_index_post(
base,
"/search-index",
FlashData::error("Failed to start search index refresh"),
config.is_production(),
config.secure_cookies(),
)
}
};
@@ -132,7 +132,7 @@ pub async fn search_index_post(
base,
"/search-index",
FlashData::error("Index type is required"),
config.is_production(),
config.secure_cookies(),
)
}
@@ -157,7 +157,7 @@ pub async fn instance_config_post(
base,
"/instance-config",
flash,
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -320,7 +320,7 @@ pub async fn instance_config_post(
if htmx::is_htmx_request(&headers) {
return htmx::toast_response(&flash);
}
redirect_back_with_flash(base, "/instance-config", flash, config.is_production())
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
}
fn render_registration_url_list_response(
@@ -866,13 +866,13 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let action = aq.action.as_deref().unwrap_or("");
let is_prod = config.is_production();
let secure_cookies = config.secure_cookies();
let current = match client.get_limit_config().await {
Ok(current) => current,
Err(error) => {
@@ -881,7 +881,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Failed to fetch current limit configuration"),
is_prod,
secure_cookies,
);
}
};
@@ -895,7 +895,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
}
};
@@ -908,7 +908,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
};
let fallback = current
@@ -923,7 +923,7 @@ pub async fn limit_config_post(
"Limit configuration updated",
"Failed to update limit configuration",
);
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
"delete" => {
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
@@ -933,7 +933,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
}
};
@@ -942,7 +942,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("The default rule cannot be deleted"),
is_prod,
secure_cookies,
);
}
let old_len = limit_config.rules.len();
@@ -952,14 +952,14 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule not found"),
is_prod,
secure_cookies,
);
}
let request = LimitConfigUpdateRequest { limit_config };
let result = client.update_limit_config(&request).await;
let flash =
limit_config_result(result, "Limit rule deleted", "Failed to delete limit rule");
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
"create" => {
let rule_id = match form.clean("rule_id") {
@@ -969,7 +969,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule ID is required"),
is_prod,
secure_cookies,
);
}
};
@@ -978,7 +978,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("The default rule ID is reserved"),
is_prod,
secure_cookies,
);
}
if limit_config.rules.iter().any(|rule| rule.id == rule_id) {
@@ -986,7 +986,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::error("Rule ID already exists"),
is_prod,
secure_cookies,
);
}
let limits = current.defaults.get("default").cloned().unwrap_or_default();
@@ -1000,7 +1000,7 @@ pub async fn limit_config_post(
let result = client.update_limit_config(&request).await;
let flash =
limit_config_result(result, "Limit rule created", "Failed to create limit rule");
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
}
_ => {}
}
@@ -1008,7 +1008,7 @@ pub async fn limit_config_post(
base,
"/limit-config",
FlashData::success("Limit config updated"),
is_prod,
secure_cookies,
)
}
+22 -11
View File
@@ -2,7 +2,10 @@
use crate::{
acl,
api::client::{AdminApiClient, ApiResultExt},
api::{
client::{AdminApiClient, ApiResult, ApiResultExt},
types::AdminUser,
},
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
state::AppState,
@@ -18,6 +21,8 @@ use axum::{
};
use serde::Deserialize;
const USER_ID_LOOKUP_BATCH: usize = 100;
#[derive(Deserialize)]
struct UserListQuery {
q: Option<String>,
@@ -55,7 +60,6 @@ pub fn router() -> Router<AppState> {
.route("/users", get(users_list))
.route("/users/{user_id}", get(user_detail).post(user_detail_post))
.route("/users/{user_id}/tabs/{tab}", get(user_tab))
.route("/users/{user_id}/peek", get(user_peek))
.route("/users/{user_id}/fragment", get(user_peek))
}
@@ -84,14 +88,10 @@ async fn users_list(
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let results = if params.has_id_lookup() {
let users = client
.lookup_users_by_ids(&params.requested_ids)
lookup_users_in_batches(&client, &params.requested_ids)
.await
.map_err(
|error| tracing::warn!(%error, "admin API request failed: lookup users by ids"),
)
.unwrap_or_default();
Some((users, false))
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = params.page.saturating_mul(params.limit);
client
@@ -125,6 +125,17 @@ async fn users_list(
Html(markup.into_string()).into_response()
}
async fn lookup_users_in_batches(
client: &AdminApiClient,
user_ids: &[String],
) -> ApiResult<Vec<AdminUser>> {
let mut users = Vec::new();
for batch in user_ids.chunks(USER_ID_LOOKUP_BATCH) {
users.extend(client.lookup_users_by_ids(batch).await?);
}
Ok(users)
}
async fn user_detail(
State(state): State<AppState>,
headers: HeaderMap,
@@ -189,7 +200,7 @@ async fn user_detail_post(
return flash::redirect_with_flash(
&format!("{base}/users/{user_id}"),
flash,
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -208,7 +219,7 @@ async fn user_detail_post(
{
return htmx::toast_response(&outcome.flash);
}
flash::redirect_with_flash(&redirect, outcome.flash, config.is_production())
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
}
async fn user_tab(
+2 -2
View File
@@ -160,7 +160,7 @@ pub(crate) async fn voice_regions_post(
flash::redirect_with_flash(
&format!("{base}/voice-regions"),
flash_from_level(level, &msg),
config.is_production(),
config.secure_cookies(),
)
}
@@ -232,7 +232,7 @@ pub(crate) async fn voice_servers_post(
flash::redirect_with_flash(
&redirect_url,
flash_from_level(level, &msg),
config.is_production(),
config.secure_cookies(),
)
}
@@ -30,12 +30,6 @@ pub fn user_profile_badges(
tooltip: "Fluxer Staff".into(),
});
}
if !is_self_hosted && flags & user_flag_bits::CTP_MEMBER != 0 {
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/ctp.svg"),
tooltip: "Fluxer Community Team".into(),
});
}
if !is_self_hosted && flags & user_flag_bits::PARTNER != 0 {
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/partner.svg"),
@@ -57,6 +57,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
acl::BULK_DELETE_USER_MESSAGES,
]
),
],
@@ -51,10 +51,6 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
name: "STAFF",
value: 1 << 0,
},
UserFlag {
name: "CTP_MEMBER",
value: 1 << 1,
},
UserFlag {
name: "PARTNER",
value: 1 << 2,
@@ -205,6 +201,9 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
(bulk_schedule_deletion_section(base, csrf_token))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
(bulk_delete_user_messages_section(base, csrf_token))
}
}
};
admin_layout(config, auth, "Bulk Actions", "bulk-actions", None, content)
@@ -388,3 +387,24 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
)
}
fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
section_card_simple(
"Bulk Delete User Messages",
html! {
form method="post" action={(base) "/bulk-actions?action=bulk-delete-user-messages"} {
(csrf_input(csrf_token))
div class="space-y-4" {
p class="text-neutral-500 text-sm" {
"Deletes every message authored by each user across all channels. This cannot be undone."
}
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(form_actions(html! {
(danger_button("Delete All Messages"))
}))
}
}
},
)
}
@@ -25,8 +25,8 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
(select_input("status", "Status", &[
("", "Any"), ("queued", "Queued"), ("running", "Running"),
("succeeded", "Succeeded"), ("failed", "Failed"),
("cancelled", "Cancelled"), ("deadletter", "Dead-letter"),
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
("deadletter", "Dead-letter"),
], p.status_filter))
div class="flex flex-col gap-2" {
label for="task_type" class=(FORM_LABEL_CLASS) { "Task type" }
+52 -28
View File
@@ -218,6 +218,16 @@ async fn user_fragment_alias_returns_drawer_fragment() {
assert!(fragment.contains("SearchedUser"), "{fragment}");
}
#[tokio::test]
async fn user_peek_alias_is_gone() {
let app = setup().await;
assert_eq!(
get_status(&app, "/users/1500000000000000001/peek").await,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn drawer_triggers_use_htmx_and_native_popover() {
let app = setup().await;
@@ -644,6 +654,23 @@ async fn get(app: &TestApp, uri: &str, headers: &[(&str, &str)]) -> String {
get_with_headers(app, uri, headers).await.1
}
async fn get_status(app: &TestApp, uri: &str) -> StatusCode {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(uri)
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response.status()
}
async fn get_with_headers(
app: &TestApp,
uri: &str,
@@ -708,11 +735,11 @@ fn csrf_cookie(headers: &HeaderMap) -> Option<String> {
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
value
.split(';')
.next()
.and_then(|pair| pair.strip_prefix("csrf_token="))
.map(str::to_owned)
let pair = value.split(';').next()?;
let token = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="))?;
(!token.is_empty()).then(|| token.to_owned())
})
}
@@ -752,8 +779,9 @@ async fn spawn_mock_api() -> String {
}
async fn mock_api(method: Method, uri: Uri) -> Response {
match (method, uri.path()) {
(Method::GET, "/admin/users/me") => json_response(json!({ "user": admin_user() })),
let path = uri.path().to_owned();
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
(Method::POST, "/admin/api-keys") => json_response(json!({
"key_id": "1900000000000000001",
@@ -763,60 +791,56 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
"expires_at": null,
"acls": ["*"]
})),
(Method::POST, "/admin/users/search") => {
(Method::GET, "/admin/users") => {
json_response(json!({ "users": [searched_user()], "total": 1 }))
}
(Method::POST, "/admin/users/lookup") => {
(Method::GET, "/admin/users/1500000000000000001") => {
json_response(json!({ "users": [searched_user()] }))
}
(Method::POST, "/admin/users/update-has-verified-phone") => {
(Method::PUT, "/admin/users/1500000000000000001/phone-verification") => {
json_response(json!({ "user": searched_user() }))
}
(Method::POST, "/admin/guilds/search") => {
(Method::GET, "/admin/guilds") => {
json_response(json!({ "guilds": [searched_guild()], "total": 1 }))
}
(Method::POST, "/admin/guilds/lookup") => {
(Method::GET, "/admin/guilds/1600000000000000001") => {
json_response(json!({ "guild": searched_guild_detail() }))
}
(Method::POST, "/admin/applications/lookup") => {
json_response(json!({ "application": searched_application() }))
}
(Method::POST, "/admin/applications/list-by-owner") => {
(Method::GET, "/admin/applications") => {
json_response(json!({ "applications": [searched_application()] }))
}
(Method::POST, "/admin/reports/search") => json_response(
(Method::GET, "/admin/reports") => json_response(
json!({ "reports": [searched_report()], "total": 1, "offset": 0, "limit": 25 }),
),
(Method::GET, "/admin/reports/1800000000000000001") => json_response(searched_report()),
(Method::GET, "/admin/reports/1800000000000000002") => {
json_response(searched_message_report())
}
(Method::POST, "/admin/reports/resolve") => json_response(json!({
(Method::PATCH, "/admin/reports/1800000000000000001") => json_response(json!({
"report_id": "1800000000000000001",
"status": 1,
"resolved_at": "2026-05-26T12:03:00.000Z",
"public_comment": "done"
})),
(Method::POST, "/admin/jobs/list") => {
(Method::GET, "/admin/jobs") => {
json_response(json!({ "jobs": [searched_job()], "next_cursor": null, "cursor": null }))
}
(Method::POST, "/admin/jobs/get") => json_response(json!({ "job": searched_job() })),
(Method::POST, "/admin/instance-config/get") => json_response(instance_config()),
(Method::POST, "/admin/instance-config/registration-urls/create") => json_response(json!({
(Method::GET, "/admin/jobs/1900000000000000001") => {
json_response(json!({ "job": searched_job() }))
}
(Method::GET, "/admin/instance/config") => json_response(instance_config()),
(Method::POST, "/admin/instance/registration-urls") => json_response(json!({
"registration_url": registration_url_fixture(),
"code": "11111111-1111-4111-8111-111111111111",
"url": "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111"
})),
(Method::POST, "/admin/instance-config/registration-urls/revoke") => {
(Method::DELETE, path) if path.starts_with("/admin/instance/registration-urls/") => {
json_response(instance_config_without_registration_urls())
}
(Method::POST, "/admin/instance-config/pending-registrations/approve") => {
(Method::PATCH, path) if path.starts_with("/admin/instance/pending-registrations/") => {
json_response(instance_config_without_pending_registrations())
}
(Method::POST, "/admin/instance-config/pending-registrations/reject") => {
json_response(instance_config_without_pending_registrations())
}
(Method::POST, "/admin/limit-config/get") => json_response(limit_config()),
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
@@ -2,7 +2,7 @@
"routes": [
{
"method": "GET",
"path": "/admin/users/me",
"path": "/admin/users/@me",
"body_file": "admin_user_me.json"
},
{
@@ -21,28 +21,28 @@
"body": "{}"
},
{
"method": "POST",
"path": "/admin/users/search",
"method": "GET",
"path": "/admin/users",
"body_file": "search_users.json"
},
{
"method": "POST",
"path": "/admin/users/lookup",
"method": "GET",
"path": "/admin/users/1508576042312688531",
"body_file": "lookup_user.json"
},
{
"method": "POST",
"path": "/admin/guilds/search",
"method": "GET",
"path": "/admin/guilds",
"body_file": "search_guilds.json"
},
{
"method": "POST",
"path": "/admin/guilds/lookup",
"method": "GET",
"path": "/admin/guilds/1600000000000000001",
"body_file": "lookup_guild.json"
},
{
"method": "POST",
"path": "/admin/reports/search",
"method": "GET",
"path": "/admin/reports",
"body_file": "search_reports.json"
},
{
+1
View File
@@ -82,6 +82,7 @@
"transliteration": "catalog:",
"tsx": "catalog:",
"uint8array-extras": "catalog:",
"undici": "catalog:",
"validator": "catalog:",
"zod": "catalog:"
},
+13
View File
@@ -229,6 +229,19 @@ export class EmailService implements IEmailService {
});
}
async sendMfaBackupCodesVerification(
email: string,
username: string,
code: string,
locale: string | null = null,
): Promise<boolean> {
return this.sendTemplatedEmail(email, 'mfa_backup_codes_view', locale, {
username,
code,
expiresAt: new Date(Date.now() + ms('10 minutes')),
});
}
async sendEmailChangeOriginal(
email: string,
username: string,
@@ -92,6 +92,12 @@ export interface IEmailService {
code: string,
locale?: string | null,
): Promise<boolean>;
sendMfaBackupCodesVerification(
email: string,
username: string,
code: string,
locale?: string | null,
): Promise<boolean>;
sendDonationMagicLink(
email: string,
token: string,
@@ -223,6 +223,16 @@ export class TestEmailService implements ITestEmailService {
return this.record(email, 'password_change_verification', {code});
}
async sendMfaBackupCodesVerification(
email: string,
username: string,
code: string,
_locale?: string | null,
): Promise<boolean> {
this.logger.info(`MFA backup codes verification sent to ${email} for user ${username}`);
return this.record(email, 'mfa_backup_codes_view', {code});
}
async sendEmailChangeOriginal(
email: string,
username: string,
@@ -59,6 +59,10 @@ export const EMAIL_I18N_MESSAGES = {
subject: 'Authorize login from a new IP address',
body: "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorize this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team",
},
mfa_backup_codes_view: {
subject: 'Confirm access to your {product_name} backup codes',
body: "Hello {username},\n\nWe received a request to view the backup codes on your {product_name} account.\n\nTo confirm this request, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately.\n\n– {product_name} Team",
},
password_change_verification: {
subject: 'Confirm your {product_name} password change',
body: "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team",
@@ -15,6 +15,7 @@ export type EmailTemplateKey =
| 'harvest_completed'
| 'inactivity_warning'
| 'ip_authorization'
| 'mfa_backup_codes_view'
| 'password_change_verification'
| 'password_reset'
| 'registration_approved'
@@ -76,6 +76,11 @@ export interface EmailTemplateVariables {
ipAddress: string;
location: string;
};
mfa_backup_codes_view: {
username: string;
code: string;
expiresAt: Date;
};
password_change_verification: {
username: string;
code: string;
@@ -59,6 +59,10 @@ const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "السماح بتسجيل الدخول من عنوان IP جديد",
"body": "مرحباً {username}،\n\nلقد اكتشفنا محاولة تسجيل دخول إلى حسابك في {product_name} من عنوان IP جديد:\n\nعنوان IP: {ipAddress}\nالموقع: {location}\n\nإذا كنت أنت من قام بذلك، يرجى تفويض عنوان IP هذا بالنقر على الرابط أدناه:\n\n{authUrl}\n\nإذا لم تحاول تسجيل الدخول، يرجى تغيير كلمة المرور الخاصة بك على الفور.\n\nهذا الرابط صالح لـ 30 دقيقة.\n\n– فريق {product_name}"
},
"mfa_backup_codes_view": {
"subject": "تأكيد الوصول إلى الرموز الاحتياطية في {product_name}",
"body": "مرحباً {username}،\n\nلقد تلقينا طلبًا لعرض الرموز الاحتياطية لحسابك في {product_name}.\n\nلتأكيد هذا الطلب، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قد وصل إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور.\n\n– فريق {product_name}"
},
"password_change_verification": {
"subject": "تأكيد تغيير كلمة المرور في {product_name}",
"body": "مرحباً {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قد وصل إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Разреши влизане от нов IP адрес",
"body": "Здравей, {username},\n\nОткрихме опит за влизане в акаунта ти във {product_name} от нов IP адрес:\n\nIP адрес: {ipAddress}\nМестоположение: {location}\n\nАко това си бил ти, разреши този IP адрес, като кликнеш върху линка по-долу:\n\n{authUrl}\n\nАко не си се опитвал да влезеш, смени паролата си незабавно.\n\nТози линк е валиден 30 минути.\n\n– Екип на {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Потвърди достъпа до кодовете си за възстановяване във {product_name}",
"body": "Здравей, {username},\n\nПолучихме искане за преглед на кодовете за възстановяване на акаунта ти във {product_name}.\n\nЗа да потвърдиш това искане, въведи този код в приложението:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно.\n\n– Екип на {product_name}"
},
"password_change_verification": {
"subject": "Потвърди промяната на паролата си във {product_name}",
"body": "Здравей, {username},\n\nПолучихме искане за промяна на паролата на акаунта ти във {product_name}.\n\nЗа да потвърдиш тази промяна, въведи този код в приложението:\n\n{code}\n\nТози код изтича в {expiresAt}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно и активирай двуфакторно удостоверяване.\n\n– Екип на {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Povolit přihlášení z nové IP adresy",
"body": "Ahoj {username},\n\nZaznamenali jsme pokus o přihlášení k tvému účtu {product_name} z nové IP adresy:\n\nIP adresa: {ipAddress}\nPoloha: {location}\n\nPokud jsi to byl ty, autorizuj tuto IP adresu kliknutím na odkaz níže:\n\n{authUrl}\n\nPokud jsi se nepřihlašoval, okamžitě si změň heslo.\n\nTento odkaz je platný 30 minut.\n\n– Tým {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Potvrď přístup k záložním kódům pro {product_name}",
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na zobrazení záložních kódů tvého účtu {product_name}.\n\nPro potvrzení tohoto požadavku zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo.\n\n– Tým {product_name}"
},
"password_change_verification": {
"subject": "Potvrď změnu hesla k {product_name}",
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na změnu hesla k tvému účtu pro {product_name}.\n\nPro potvrzení této změny zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší v {expiresAt}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo a povol dvoufaktorové ověřování.\n\n– Tým {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Godkend login fra en ny IP-adresse",
"body": "Hej {username},\n\nVi har registreret et login-forsøg på din {product_name}-konto fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis dette var dig, skal du godkende denne IP-adresse ved at klikke på linket nedenfor:\n\n{authUrl}\n\nHvis du ikke har forsøgt at logge ind, skal du straks ændre din adgangskode.\n\nDette link er gyldigt i 30 minutter.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bekræft adgang til dine {product_name}-backupkoder",
"body": "Hej {username},\n\nVi har modtaget en anmodning om at se backupkoderne på din {product_name}-konto.\n\nFor at bekræfte denne anmodning skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bekræft din {product_name}-adgangskodeændring",
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre adgangskoden på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber kl. {expiresAt}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme, og aktiver totrinsgodkendelse.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Anmeldung von einer neuen IP-Adresse bestätigen",
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für deinen {product_name}-Account von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du dich nicht anmelden wolltest, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bestätige den Zugriff auf deine {product_name}-Backup-Codes",
"body": "Hallo {username},\n\nwir haben eine Anfrage erhalten, die Backup-Codes deines {product_name}-Accounts anzusehen.\n\nUm diese Anfrage zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bestätige deine {product_name}-Passwortänderung",
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft um {expiresAt} ab.\n\nWenn du diese Änderung nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Εξουσιοδότησε τη σύνδεση από μια νέα διεύθυνση IP",
"body": "Γεια σου {username},\n\nΕντοπίσαμε μια προσπάθεια σύνδεσης στον λογαριασμό σου στο {product_name} από μια νέα διεύθυνση IP:\n\nΔιεύθυνση IP: {ipAddress}\nΤοποθεσία: {location}\n\nΕάν ήσουν εσύ, εξουσιοδότησε αυτήν τη διεύθυνση IP κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{authUrl}\n\nΕάν δεν προσπάθησες να συνδεθείς, άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\nΑυτός ο σύνδεσμος ισχύει για 30 λεπτά.\n\n– Ομάδα {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Επιβεβαίωσε την πρόσβαση στους εφεδρικούς κωδικούς σου στο {product_name}",
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για προβολή των εφεδρικών κωδικών στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτό το αίτημα, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, κάποιος μπορεί να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\n– Ομάδα {product_name}"
},
"password_change_verification": {
"subject": "Επιβεβαίωσε την αλλαγή κωδικού πρόσβασης στο {product_name}",
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για αλλαγή του κωδικού πρόσβασης στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt}.\n\nΕάν δεν το ζήτησες, κάποιος μπορεί να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου και ενεργοποίησε τον έλεγχο ταυτότητας δύο παραγόντων.\n\n– Ομάδα {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Authorize login from a new IP address",
"body": "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorize this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Confirm access to your {product_name} backup codes",
"body": "Hello {username},\n\nWe received a request to view the backup codes on your {product_name} account.\n\nTo confirm this request, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Confirm your {product_name} password change",
"body": "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoriza el inicio de sesión desde una nueva dirección IP",
"body": "Hola {username}:\n\nDetectamos un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva dirección IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, por favor, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, por favor, cambia tu contraseña de inmediato.\n\nEste enlace es válido por 30 minutos.\n\n– Equipo de {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirma el acceso a tus códigos de respaldo de {product_name}",
"body": "Hola {username}:\n\nRecibimos una solicitud para ver los códigos de respaldo de tu cuenta de {product_name}.\n\nPara confirmar esta solicitud, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente.\n\n– Equipo de {product_name}"
},
"password_change_verification": {
"subject": "Confirma tu cambio de contraseña de {product_name}",
"body": "Hola {username}:\n\nRecibimos una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence a las {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y habilita la autenticación de dos factores.\n\n– Equipo de {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autorizar inicio de sesión desde una nueva dirección IP",
"body": "Hola {username}:\n\nHemos detectado un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, cambia tu contraseña de inmediato.\n\nEste enlace es válido durante 30 minutos.\n\n– Equipo de {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirma el acceso a tus códigos de respaldo de {product_name}",
"body": "Hola {username}:\n\nHemos recibido una solicitud para ver los códigos de respaldo de tu cuenta de {product_name}.\n\nPara confirmar esta solicitud, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente.\n\n– Equipo de {product_name}"
},
"password_change_verification": {
"subject": "Confirma tu cambio de contraseña en {product_name}",
"body": "Hola {username}:\n\nHemos recibido una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y activa la autenticación de dos factores.\n\n– Equipo de {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Hyväksy kirjautuminen uudesta IP-osoitteesta",
"body": "Hei {username},\n\nHavaitsimme kirjautumisyrityksen {product_name}-tilillesi uudesta IP-osoitteesta:\n\nIP-osoite: {ipAddress}\nSijainti: {location}\n\nJos tämä olit sinä, valtuuta tämä IP-osoite napsauttamalla alla olevaa linkkiä:\n\n{authUrl}\n\nJos et yrittänyt kirjautua sisään, vaihda salasanasi välittömästi.\n\nTämä linkki on voimassa 30 minuuttia.\n\n– {product_name}-tiimi"
},
"mfa_backup_codes_view": {
"subject": "Vahvista pääsy {product_name}-tilisi varmuuskoodeihin",
"body": "Hei {username},\n\nSaimme pyynnön tarkastella varmuuskoodeja {product_name}-tililläsi.\n\nVahvistaaksesi tämän pyynnön, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi.\n\n– {product_name}-tiimi"
},
"password_change_verification": {
"subject": "Vahvista salasanan muutos {product_name}-tilillä",
"body": "Hei {username},\n\nSaimme pyynnön muuttaa salasanaa {product_name}-tililläsi.\n\nVahvistaaksesi tämän muutoksen, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi ja ota käyttöön kaksivaiheinen todennus.\n\n– {product_name}-tiimi"
@@ -59,6 +59,10 @@ const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoriser la connexion depuis une nouvelle adresse IP",
"body": "Bonjour {username},\n\nNous avons détecté une tentative de connexion à ton compte {product_name} depuis une nouvelle adresse IP :\n\nAdresse IP : {ipAddress}\nLocalisation : {location}\n\nSi c'était toi, autorise cette adresse IP en cliquant sur le lien ci-dessous :\n\n{authUrl}\n\nSi tu n'as pas tenté de te connecter, change ton mot de passe immédiatement.\n\nCe lien est valide pendant 30 minutes.\n\n– L'équipe {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirme l'accès à tes codes de secours {product_name}",
"body": "Bonjour {username},\n\nNous avons reçu une demande de consultation des codes de secours de ton compte {product_name}.\n\nPour confirmer cette demande, saisis ce code dans l'application :\n\n{code}\n\nCe code expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé cela, quelqu'un a peut-être eu accès à ton compte. Change ton mot de passe immédiatement.\n\n– L'équipe {product_name}"
},
"password_change_verification": {
"subject": "Confirme la modification de ton mot de passe {product_name}",
"body": "Bonjour {username},\n\nNous avons reçu une demande de modification du mot de passe de ton compte {product_name}.\n\nPour confirmer cette modification, saisis ce code dans l'application :\n\n{code}\n\nCe code expire à {expiresAt}.\n\nSi tu n'as pas demandé cela, quelqu'un a peut-être eu accès à ton compte. Change ton mot de passe immédiatement et active l'authentification à deux facteurs.\n\n– L'équipe {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "אישור כניסה מכתובת IP חדשה",
"body": "שלום {username},\n\nזיהינו ניסיון התחברות לחשבון ה-{product_name} שלכם מכתובת IP חדשה:\n\nכתובת IP: {ipAddress}\nמיקום: {location}\n\nאם זה הייתם אתם, אנא אשרו כתובת IP זו על ידי לחיצה על הקישור למטה:\n\n{authUrl}\n\nאם לא ניסיתם להתחבר, אנא שנו את הסיסמה שלכם מיד.\n\nקישור זה תקף למשך 30 דקות.\n\nצוות {product_name}"
},
"mfa_backup_codes_view": {
"subject": "אישור גישה לקודי הגיבוי שלכם ב-{product_name}",
"body": "שלום {username},\n\nקיבלנו בקשה לצפייה בקודי הגיבוי בחשבון ה-{product_name} שלכם.\n\nכדי לאשר בקשה זו, הזינו קוד זה באפליקציה:\n\n{code}\n\nקוד זה יפוג ב- {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם זאת, ייתכן שלמישהו יש גישה לחשבונכם. שנו את הסיסמה שלכם מיד.\n\nצוות {product_name}"
},
"password_change_verification": {
"subject": "אישור שינוי הסיסמה שלכם ב-{product_name}",
"body": "שלום {username},\n\nקיבלנו בקשה לשינוי הסיסמה בחשבון ה-{product_name} שלכם.\n\nכדי לאשר שינוי זה, הזינו קוד זה באפליקציה:\n\n{code}\n\nקוד זה יפוג ב- {expiresAt}.\n\nאם לא ביקשתם זאת, ייתכן שלמישהו יש גישה לחשבונכם. שנו את הסיסמה שלכם מיד ואפשרו אימות דו-שלבי.\n\nצוות {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "एक नए IP एड्रेस से लॉगिन को ऑथराइज़ करें",
"body": "नमस्ते {username},\n\nहमें एक नए IP एड्रेस से आपके {product_name} अकाउंट में लॉगिन करने का प्रयास किया गया है:\n\nIP एड्रेस: {ipAddress}\nलोकेशन: {location}\n\nअगर यह आपने ही किया था, तो कृपया नीचे दिए गए लिंक पर क्लिक करके इस IP एड्रेस को ऑथराइज़ करें:\n\n{authUrl}\n\nअगर आपने लॉगिन करने का प्रयास नहीं किया था, तो कृपया तुरंत अपना पासवर्ड बदलें।\n\nयह लिंक 30 मिनट के लिए वैलिड है।\n\n– {product_name} टीम"
},
"mfa_backup_codes_view": {
"subject": "अपने {product_name} बैकअप कोड के एक्सेस की पुष्टि करें",
"body": "नमस्ते {username},\n\nहमें आपके {product_name} अकाउंट के बैकअप कोड देखने का अनुरोध मिला है।\n\nइस अनुरोध की पुष्टि करने के लिए, ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो हो सकता है कि किसी और के पास आपके अकाउंट का एक्सेस हो। तुरंत अपना पासवर्ड बदलें।\n\n– {product_name} टीम"
},
"password_change_verification": {
"subject": "अपने {product_name} पासवर्ड चेंज की पुष्टि करें",
"body": "नमस्ते {username},\n\nहमें आपके {product_name} अकाउंट पर पासवर्ड बदलने का अनुरोध मिला है।\n\nइस बदलाव की पुष्टि करने के लिए, ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो हो सकता है कि किसी और के पास आपके अकाउंट का एक्सेस हो। तुरंत अपना पासवर्ड बदलें और टू-फैक्टर ऑथेंटिकेशन इनेबल करें।\n\n– {product_name} टीम"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HR_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoriziraj prijavu s nove IP adrese",
"body": "Pozdrav {username},\n\nOtkrili smo pokušaj prijave na tvoj {product_name} račun s nove IP adrese:\n\nIP adresa: {ipAddress}\nLokacija: {location}\n\nAko si to bio ti, potvrdi ovu IP adresu klikom na poveznicu ispod:\n\n{authUrl}\n\nAko se nisi ti pokušao prijaviti, odmah promijeni lozinku.\n\nOva poveznica vrijedi 30 minuta.\n\n– {product_name} Tim"
},
"mfa_backup_codes_view": {
"subject": "Potvrdi pristup svojim {product_name} rezervnim kodovima",
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za prikaz rezervnih kodova na tvom {product_name} računu.\n\nDa bi potvrdio ovaj zahtjev, unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod istječe {expiresAt, date, full} u {expiresAt, time, short}.\n\nAko ovo nisi zatražio, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku.\n\n– {product_name} Tim"
},
"password_change_verification": {
"subject": "Potvrdi promjenu lozinke za {product_name}",
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za promjenu lozinke na tvom {product_name} računu.\n\nDa bi potvrdio ovu promjenu, unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod vrijedi do {expiresAt}.\n\nAko ovo nisi zatražio, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku i omogući dvofaktorsku autentifikaciju.\n\n– {product_name} Tim"
@@ -59,6 +59,10 @@ const EMAIL_I18N_HU_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Új IP-címről való bejelentkezés engedélyezése",
"body": "Szia {username},\n\nBejelentkezési kísérletet észleltünk a {product_name} fiókodba egy új IP-címről:\n\nIP-cím: {ipAddress}\nHelyszín: {location}\n\nHa te voltál, engedélyezd ezt az IP-címet az alábbi linkre kattintva:\n\n{authUrl}\n\nHa nem te próbáltál bejelentkezni, azonnal változtasd meg a jelszavadat.\n\nEz a link 30 percig érvényes.\n\n– {product_name} Csapat"
},
"mfa_backup_codes_view": {
"subject": "Erősítsd meg a {product_name} biztonsági kódjaidhoz való hozzáférést",
"body": "Szia {username},\n\nKérést kaptunk a {product_name} fiókod biztonsági kódjainak megtekintésére.\n\nA kérés megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nEz a kód {expiresAt, date, full} {expiresAt, time, short} időpontban jár le.\n\nHa nem kérted ezt, valaki hozzáférhet a fiókodhoz. Azonnal változtasd meg a jelszavadat.\n\n– {product_name} Csapat"
},
"password_change_verification": {
"subject": "Erősítsd meg a {product_name} jelszócserédet",
"body": "Szia {username},\n\nKérést kaptunk a {product_name} fiókod jelszavának megváltoztatására.\n\nA változtatás megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nEz a kód {expiresAt}-kor jár le.\n\nHa nem kérted ezt, valaki hozzáférhet a fiókodhoz. Azonnal változtasd meg a jelszavadat, és engedélyezd a kétfaktoros hitelesítést.\n\n– {product_name} Csapat"
@@ -59,6 +59,10 @@ const EMAIL_I18N_ID_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Otorisasi masuk dari alamat IP barumu",
"body": "Halo {username},\n\nKami mendeteksi upaya masuk ke akun {product_name}mu dari alamat IP baru:\n\nAlamat IP: {ipAddress}\nLokasi: {location}\n\nKalau ini kamu, otorisasi alamat IP ini dengan mengeklik tautan di bawah ini:\n\n{authUrl}\n\nJika kamu tidak mencoba masuk, segera ubah kata sandimu.\n\nTautan ini berlaku selama 30 menit.\n\n– Tim {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Konfirmasi akses ke kode cadangan {product_name}mu",
"body": "Halo {username},\n\nKami menerima permintaan untuk melihat kode cadangan di akun {product_name}mu.\n\nUntuk mengonfirmasi permintaan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt, date, full} pukul {expiresAt, time, short}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera.\n\n– Tim {product_name}"
},
"password_change_verification": {
"subject": "Konfirmasi perubahan kata sandi {product_name}mu",
"body": "Halo {username},\n\nKami menerima permintaan untuk mengubah kata sandi di akun {product_name}mu.\n\nUntuk mengonfirmasi perubahan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera dan aktifkan autentikasi dua faktor.\n\n– Tim {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_IT_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autorizza l'accesso da un nuovo indirizzo IP",
"body": "Ciao {username},\n\nAbbiamo rilevato un tentativo di accesso al tuo account {product_name} da un nuovo indirizzo IP:\n\nIndirizzo IP: {ipAddress}\nPosizione: {location}\n\nSe sei stato tu, autorizza questo indirizzo IP cliccando sul link qui sotto:\n\n{authUrl}\n\nSe non hai tentato di accedere, cambia subito la password.\n\nQuesto link è valido per 30 minuti.\n\n– Team {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Conferma l'accesso ai tuoi codici di backup di {product_name}",
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di visualizzazione dei codici di backup del tuo account {product_name}.\n\nPer confermare questa richiesta, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade il {expiresAt, date, full} alle {expiresAt, time, short}.\n\nSe non hai richiesto questa operazione, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password.\n\n– Team {product_name}"
},
"password_change_verification": {
"subject": "Conferma la modifica della password di {product_name}",
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di modifica della password del tuo account {product_name}.\n\nPer confermare questa modifica, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade alle {expiresAt}.\n\nSe non hai richiesto questa modifica, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password e abilita l'autenticazione a due fattori.\n\n– Team {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "新しいIPアドレスからのログインを承認",
"body": "こんにちは、{username}さん\n\n新しいIPアドレスから{product_name}アカウントへのログイン試行を検出しました。\n\nIPアドレス: {ipAddress}\n場所: {location}\n\nこれがご自身によるものである場合は、以下のリンクをクリックしてこのIPアドレスを承認してください。\n\n{authUrl}\n\nログインを試行していない場合は、すぐにパスワードを変更してください。\n\nこのリンクは30分間有効です。\n\n– {product_name}チーム"
},
"mfa_backup_codes_view": {
"subject": "{product_name}バックアップコードへのアクセスを確認",
"body": "こんにちは、{username}さん\n\n{product_name}アカウントのバックアップコードを表示するリクエストを受け付けました。\n\nこのリクエストを確認するには、アプリでこのコードを入力してください。\n\n{code}\n\nこのコードは{expiresAt, date, full} {expiresAt, time, short}に期限切れになります。\n\nご自身でリクエストしていない場合は、誰かがあなたのアカウントにアクセスしている可能性があります。すぐにパスワードを変更してください。\n\n– {product_name}チーム"
},
"password_change_verification": {
"subject": "{product_name}パスワードの変更を確認",
"body": "こんにちは、{username}さん\n\n{product_name}アカウントのパスワード変更リクエストを受け付けました。\n\nこの変更を確認するには、アプリでこのコードを入力してください。\n\n{code}\n\nこのコードは{expiresAt}に期限切れになります。\n\nご自身でリクエストしていない場合は、誰かがあなたのアカウントにアクセスしている可能性があります。すぐにパスワードを変更し、二段階認証を有効にしてください。\n\n– {product_name}チーム"
@@ -59,6 +59,10 @@ const EMAIL_I18N_KO_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "새 IP 주소에서 로그인을 승인해 주세요",
"body": "안녕하세요, {username}님.\n\n새로운 IP 주소에서 {product_name} 계정으로 로그인 시도가 감지되었어요:\n\nIP 주소: {ipAddress}\n위치: {location}\n\n본인이 시도한 경우, 아래 링크를 클릭하여 이 IP 주소를 승인해 주세요:\n\n{authUrl}\n\n로그인을 시도하지 않으셨다면 즉시 비밀번호를 변경하세요.\n\n이 링크는 30분 동안 유효해요.\n\n– {product_name} 팀"
},
"mfa_backup_codes_view": {
"subject": "{product_name} 백업 코드 액세스를 확인해 주세요",
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 백업 코드를 보려는 요청이 접수되었어요.\n\n이 요청을 확인하려면 앱에 이 코드를 입력해 주세요:\n\n{code}\n\n이 코드는 {expiresAt, date, full} {expiresAt, time, short}에 만료돼요.\n\n요청하지 않으셨다면 누군가 계정에 액세스했을 수 있어요. 즉시 비밀번호를 변경하세요.\n\n– {product_name} 팀"
},
"password_change_verification": {
"subject": "{product_name} 비밀번호 변경을 확인해 주세요",
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 비밀번호 변경 요청이 접수되었어요.\n\n이 변경을 확인하려면 앱에 이 코드를 입력해 주세요:\n\n{code}\n\n이 코드는 {expiresAt}에 만료돼요.\n\n요청하지 않으셨다면 누군가 계정에 액세스했을 수 있어요. 즉시 비밀번호를 변경하고 2단계 인증을 활성화하세요.\n\n– {product_name} 팀"
@@ -59,6 +59,10 @@ const EMAIL_I18N_LT_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Leisti prisijungimą iš naujo IP adreso",
"body": "Sveikas, {username},\n\nAptikome bandymą prisijungti prie tavo {product_name} paskyros iš naujo IP adreso:\n\nIP adresas: {ipAddress}\nVieta: {location}\n\nJei tai buvai tu, patvirtink šį IP adresą spustelėdamas žemiau esančią nuorodą:\n\n{authUrl}\n\nJei nebandei prisijungti, nedelsdamas pakeisk slaptažodį.\n\nŠi nuoroda galioja 30 minučių.\n\n– {product_name} komanda"
},
"mfa_backup_codes_view": {
"subject": "Patvirtink prieigą prie savo {product_name} atsarginių kodų",
"body": "Sveikas, {username},\n\nGavome prašymą peržiūrėti tavo {product_name} paskyros atsarginius kodus.\n\nNorėdamas patvirtinti šį prašymą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt, date, full} {expiresAt, time, short}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsdamas pakeisk slaptažodį.\n\n– {product_name} komanda"
},
"password_change_verification": {
"subject": "Patvirtink savo {product_name} slaptažodžio keitimą",
"body": "Sveikas, {username},\n\nGavome prašymą pakeisti tavo {product_name} paskyros slaptažodį.\n\nNorėdamas patvirtinti šį pakeitimą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsdamas pakeisk slaptažodį ir įjunk dviejų veiksnių autentifikavimą.\n\n– {product_name} komanda"
@@ -59,6 +59,10 @@ const EMAIL_I18N_NL_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoriseer inlog vanaf een nieuw IP-adres",
"body": "Hallo {username},\n\nWe hebben een inlogpoging op je {product_name}-account gedetecteerd vanaf een nieuw IP-adres:\n\nIP-adres: {ipAddress}\nLocatie: {location}\n\nAls jij dit was, autoriseer dan dit IP-adres door op de onderstaande link te klikken:\n\n{authUrl}\n\nAls je niet hebt geprobeerd in te loggen, wijzig dan onmiddellijk je wachtwoord.\n\nDeze link is 30 minuten geldig.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bevestig toegang tot je {product_name}-back-upcodes",
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om de back-upcodes van je {product_name}-account te bekijken.\n\nOm dit verzoek te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt op {expiresAt, date, full} om {expiresAt, time, short}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bevestig je {product_name}-wachtwoordwijziging",
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om het wachtwoord van je {product_name}-account te wijzigen.\n\nOm deze wijziging te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt om {expiresAt}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord en schakel tweefactorauthenticatie in.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_NO_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Godkjenn pålogging fra en ny IP-adresse",
"body": "Hei {username},\n\nVi oppdaget et påloggingsforsøk til {product_name}-kontoen din fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis dette var deg, godkjenn denne IP-adressen ved å klikke på lenken nedenfor:\n\n{authUrl}\n\nHvis du ikke forsøkte å logge inn, endre passordet ditt med en gang.\n\nDenne lenken er gyldig i 30 minutter.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bekreft tilgang til {product_name}-reservekodene dine",
"body": "Hei {username},\n\nVi mottok en forespørsel om å se reservekodene på {product_name}-kontoen din.\n\nFor å bekrefte denne forespørselen, skriv inn denne koden i appen:\n\n{code}\n\nDenne koden utløper {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bekreft din {product_name}-passordendring",
"body": "Hei {username},\n\nVi mottok en forespørsel om å endre passordet på {product_name}-kontoen din.\n\nFor å bekrefte denne endringen, skriv inn denne koden i appen:\n\n{code}\n\nDenne koden utløper {expiresAt}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang og aktiver tofaktorautentisering.\n\n– {product_name} Team"
@@ -59,6 +59,10 @@ const EMAIL_I18N_PL_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autoryzuj logowanie z nowego adresu IP",
"body": "Witaj {username},\n\nWykryliśmy próbę logowania do Twojego konta {product_name} z nowego adresu IP:\n\nAdres IP: {ipAddress}\nLokalizacja: {location}\n\nJeśli to Ty, autoryzuj ten adres IP, klikając poniższy link:\n\n{authUrl}\n\nJeśli nie próbowałeś się zalogować, natychmiast zmień hasło.\n\nTen link jest ważny przez 30 minut.\n\n– Zespół {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Potwierdź dostęp do kodów zapasowych w {product_name}",
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o wyświetlenie kodów zapasowych Twojego konta {product_name}.\n\nAby potwierdzić tę prośbę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa {expiresAt, date, full} o {expiresAt, time, short}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło.\n\n– Zespół {product_name}"
},
"password_change_verification": {
"subject": "Potwierdź zmianę hasła w {product_name}",
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o zmianę hasła do Twojego konta {product_name}.\n\nAby potwierdzić tę zmianę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa o godzinie {expiresAt}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło i włącz uwierzytelnianie dwuskładnikowe.\n\n– Zespół {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_PT_BR_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autorizar login de um novo endereço IP",
"body": "Olá, {username},\n\nDetectamos uma tentativa de login em sua conta do {product_name} de um novo endereço IP:\n\nEndereço IP: {ipAddress}\nLocalização: {location}\n\nSe foi você, por favor, autorize este endereço IP clicando no link abaixo:\n\n{authUrl}\n\nSe você não tentou entrar, por favor, altere sua senha imediatamente.\n\nEste link é válido por 30 minutos.\n\n– Equipe do {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirme o acesso aos códigos de backup da sua conta do {product_name}",
"body": "Olá, {username},\n\nRecebemos uma solicitação para visualizar os códigos de backup da sua conta do {product_name}.\n\nPara confirmar esta solicitação, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt, date, full} às {expiresAt, time, short}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente.\n\n– Equipe do {product_name}"
},
"password_change_verification": {
"subject": "Confirme a alteração da senha da sua conta do {product_name}",
"body": "Olá, {username},\n\nRecebemos uma solicitação para alterar a senha da sua conta do {product_name}.\n\nPara confirmar esta alteração, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente e ative a autenticação de dois fatores.\n\n– Equipe do {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_RO_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Autorizează conectarea de la o nouă adresă IP",
"body": "Salut {username},\n\nAm detectat o tentativă de conectare la contul tău {product_name} de la o nouă adresă IP:\n\nAdresă IP: {ipAddress}\nLocație: {location}\n\nDacă ai fost tu, te rugăm să autorizezi această adresă IP apăsând pe linkul de mai jos:\n\n{authUrl}\n\nDacă nu ai încercat să te conectezi, te rugăm să-ți schimbi parola imediat.\n\nAcest link este valabil 30 de minute.\n\n– Echipa {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Confirmă accesul la codurile tale de rezervă pentru {product_name}",
"body": "Salut {username},\n\nAm primit o solicitare de vizualizare a codurilor de rezervă ale contului tău {product_name}.\n\nPentru a confirma această solicitare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la data de {expiresAt, date, full} la ora {expiresAt, time, short}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat.\n\n– Echipa {product_name}"
},
"password_change_verification": {
"subject": "Confirmă schimbarea parolei pentru {product_name}",
"body": "Salut {username},\n\nAm primit o solicitare de schimbare a parolei contului tău {product_name}.\n\nPentru a confirma această modificare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la {expiresAt}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat și activează autentificarea cu doi factori.\n\n– Echipa {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_RU_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Подтверди вход с нового IP-адреса",
"body": "Привет, {username},\n\nМы обнаружили попытку входа в твой аккаунт {product_name} с нового IP-адреса:\n\nIP-адрес: {ipAddress}\nМестоположение: {location}\n\nЕсли это был ты, пожалуйста, подтверди этот IP-адрес, нажав на ссылку ниже:\n\n{authUrl}\n\nЕсли ты не пытался войти, пожалуйста, немедленно смени пароль.\n\nЭта ссылка действительна в течение 30 минут.\n\n– Команда {product_name}"
},
"mfa_backup_codes_view": {
"subject": "Подтверди доступ к резервным кодам {product_name}",
"body": "Привет, {username},\n\nМы получили запрос на просмотр резервных кодов твоего аккаунта {product_name}.\n\nЧтобы подтвердить этот запрос, введи этот код в приложении:\n\n{code}\n\nЭтот код истекает {expiresAt, date, full} в {expiresAt, time, short}.\n\nЕсли ты не запрашивал это, возможно, кто-то получил доступ к твоему аккаунту. Немедленно смени пароль.\n\n– Команда {product_name}"
},
"password_change_verification": {
"subject": "Подтверди смену пароля для {product_name}",
"body": "Привет, {username},\n\nМы получили запрос на изменение пароля твоего аккаунта {product_name}.\n\nЧтобы подтвердить это изменение, введи этот код в приложении:\n\n{code}\n\nЭтот код истекает в {expiresAt}.\n\nЕсли ты не запрашивал это, возможно, кто-то получил доступ к твоему аккаунту. Немедленно смени пароль и включи двухфакторную аутентификацию.\n\n– Команда {product_name}"
@@ -59,6 +59,10 @@ const EMAIL_I18N_SV_SE_MESSAGES = defineEmailI18nLocaleMessages({
"subject": "Auktorisera inloggning från en ny IP-adress",
"body": "Hej {username},\n\nVi upptäckte ett inloggningsförsök till ditt {product_name}-konto från en ny IP-adress:\n\nIP-adress: {ipAddress}\nPlats: {location}\n\nOm detta var du, godkänn denna IP-adress genom att klicka på länken nedan:\n\n{authUrl}\n\nOm du inte försökte logga in, ändra ditt lösenord omedelbart.\n\nDenna länk är giltig i 30 minuter.\n\n– {product_name} Team"
},
"mfa_backup_codes_view": {
"subject": "Bekräfta åtkomst till dina {product_name}-reservkoder",
"body": "Hej {username},\n\nVi har mottagit en begäran om att visa reservkoderna för ditt {product_name}-konto.\n\nFör att bekräfta denna begäran, ange denna kod i appen:\n\n{code}\n\nDenna kod upphör att gälla {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart.\n\n– {product_name} Team"
},
"password_change_verification": {
"subject": "Bekräfta din {product_name}-lösenordsändring",
"body": "Hej {username},\n\nVi har mottagit en begäran om att ändra lösenordet för ditt {product_name}-konto.\n\nFör att bekräfta denna ändring, ange denna kod i appen:\n\n{code}\n\nDenna kod upphör att gälla {expiresAt}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart och aktivera tvåfaktorsautentisering.\n\n– {product_name} Team"

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