Compare commits

...
Author SHA1 Message Date
HampusandGitHub 6c36d934f7 fix(api): widen guild IP ban guard to shared-access networks (#2607) 2026-09-08 22:09:39 +02:00
HampusandGitHub 63e3be5750 fix(media-proxy): tone map HDR video instead of refusing it (#2606) 2026-09-08 21:18:55 +02:00
HampusandGitHub cdecda7f78 ci: exclude the gateway build output from the rebar3 cache (#2604) 2026-09-08 19:47:44 +02:00
HampusandGitHub 4ad2858773 test(api): isolate the instance policy test files (#2603) 2026-09-08 19:46:07 +02:00
HampusandGitHub fda41bb57a style(gateway): apply erlfmt to the voice disconnect modules (#2602) 2026-09-08 19:29:38 +02:00
HampusandGitHub ce08f82a92 refactor(gateway): remove voice reconciliation v3 (#2601) 2026-09-08 19:17:48 +02:00
HampusandGitHub ef067f36c6 fix(voice): report real state in voice diagnostics (#2600) 2026-09-08 19:16:22 +02:00
HampusandGitHub fc2b6b5299 fix(app): correct shortcuts, nagbar, stream menu, share audio (#2599) 2026-09-08 19:09:59 +02:00
HampusandGitHub 55846b24ea fix(api): respect age gating in search and stabilise discovery (#2598) 2026-09-08 19:07:59 +02:00
HampusandGitHub 20a15ac11d fix(voice): scope disconnects, correct VAD and stream lifecycle (#2597) 2026-09-08 19:06:42 +02:00
HampusandGitHub 667ac7da8e fix(voice): rank h264 baseline first and gate opus stereo (#2596) 2026-09-08 19:04:24 +02:00
HampusandGitHub 1b81c14c48 fix(api): stop treating a LiveKit 404 as an empty room (#2595) 2026-09-08 19:02:43 +02:00
HampusandGitHub ceec183d38 docs: remove duplicated statements from the reference (#2594) 2026-09-08 17:55:55 +02:00
HampusandGitHub 69ddc07ebb docs(operator): tighten the get started guide (#2593) 2026-09-08 17:38:29 +02:00
HampusandGitHub 2f008b8653 docs: rewrite reference prose and correct field code citations (#2592) 2026-09-08 17:13:37 +02:00
HampusandGitHub 3d38d3f694 fix(self-host): add FLUXER_NATS_AUTH_TOKEN to .env.example (#2590) 2026-09-08 16:32:29 +02:00
HampusandGitHub ad86a04e67 feat(app): describe every role and channel permission toggle (#2589) 2026-09-08 16:20:37 +02:00
HampusandGitHub 600c15e17d chore(github): drop mobile build hint from the bug report form (#2588) 2026-09-08 15:37:26 +02:00
HampusandGitHub 08c9fe9886 docs: correct misreadable and factually wrong reference prose (#2587) 2026-09-08 15:36:50 +02:00
HampusandGitHub 43924e3ac5 chore(github): link mobile bug reports, drop security duplicate (#2586) 2026-09-08 15:34:34 +02:00
HampusandGitHub 824b5c86c9 fix(api): dedupe and budget ipinfo lookups across api pods (#2584) 2026-09-08 15:13:32 +02:00
HampusandGitHub a2a68847fd fix(api): let channel managers edit a mature channel (#2583) 2026-09-08 14:51:47 +02:00
HampusandGitHub 2019909a5e fix(api): skip the mature gate when no birth date is collected (#2582) 2026-09-08 14:51:41 +02:00
HampusandGitHub d46c8d49c6 fix(svc): authenticate to nats with the configured token (#2581) 2026-09-08 14:51:34 +02:00
HampusandGitHub 45530ebbf5 docs(operator): drop the redundant caddy forwarded-for setter (#2580) 2026-09-08 14:51:29 +02:00
HampusandGitHub 9cdad046b1 fix(self-host): keep seaweedfs inside its memory ceiling (#2579) 2026-09-08 14:51:24 +02:00
HampusandGitHub b6c6928073 fix(self-host): strip the caddy file capability in fluxer-static (#2578) 2026-09-08 14:51:19 +02:00
HampusandGitHub dd1ee999a4 fix(docs): drop visible pipe escapes from union notation prose (#2577) 2026-09-08 14:27:41 +02:00
HampusandGitHub c506d6d5e3 fix(webhook): stop gating webhook file uploads on creator perms (#2576) 2026-09-08 14:25:59 +02:00
HampusandGitHub 8a65832a65 feat(theme): default new accounts to the dark theme (#2575) 2026-09-08 14:05:10 +02:00
HampusandGitHub fd6ae4abd7 fix(app): distrust windows loaded across a connection gap (#2574) 2026-09-08 13:06:53 +02:00
HampusandGitHub 24b84c419c docs(http-api): reword the supplementary members paragraph (#2573) 2026-09-08 12:53:43 +02:00
HampusandGitHub 746a75187a fix(api): snapshot the new message id when opening a closed DM (#2569) 2026-09-07 11:00:03 +02:00
HampusandGitHub 10ba2ca896 fix(app): show the format toolbar on double-click selections (#2566) 2026-09-07 00:13:30 +02:00
HampusandGitHub 977b6767cd fix(app): refetch the tail when a channel window falls behind (#2565) 2026-09-06 23:51:08 +02:00
HampusandGitHub f00c6ee47a docs(http-api): name the endpoint a third-party client reads (#2564) 2026-09-06 23:50:52 +02:00
HampusandGitHub 82859dc2f6 fix(api): keep a deferral while the phone gate state is unknown (#2554) 2026-09-06 23:41:29 +02:00
HampusandGitHub 328dc06ab0 feat(installer): drive podman as well as docker (#2563) 2026-09-06 23:28:40 +02:00
HampusandGitHub ea6e4a75db fix(markdown): let a backslash escape a code fence (#2562) 2026-09-06 22:45:29 +02:00
HampusandGitHub 69ca462930 fix(app): keep popouts in the window they were opened in (#2561) 2026-09-06 22:42:32 +02:00
HampusandGitHub f38619d974 fix(app): isolate bidi usernames from message timestamps (#2560) 2026-09-06 22:41:57 +02:00
HampusandGitHub 6c0ce9369b fix(app): refresh mutual communities on membership change (#2559) 2026-09-06 22:38:31 +02:00
HampusandGitHub 00c1b19809 fix(app): inherit category mute when hiding muted channels (#2558) 2026-09-06 22:10:09 +02:00
HampusandGitHub 2fd5daf104 fix(app): keep the client active while the user is typing (#2557) 2026-09-06 21:29:06 +02:00
HampusandGitHub fbf0f6adfe fix(app): load more bookmarks as the list scrolls (#2556) 2026-09-06 21:05:57 +02:00
HampusandGitHub d91b5bec66 fix(app): show unread channels in muted collapsed categories (#2555) 2026-09-06 20:45:11 +02:00
HampusandGitHub 0f24cfb6ef ci(docs): check the installer upgrade key lists for drift (#2553) 2026-09-06 20:43:50 +02:00
HampusandGitHub 7a6691cdbe fix(installer): make the record and rollback paths trustworthy (#2552) 2026-09-06 20:36:59 +02:00
HampusandGitHub 73d3a4f843 fix(app): widen the custom status modal (#2551) 2026-09-06 20:19:28 +02:00
HampusandGitHub 091755fe78 fix(self-hosting): adapt the upgrade to existing instances (#2550) 2026-09-06 19:40:32 +02:00
HampusandGitHub 1fb2790bb9 fix(api): stop bounding the pin listing by the wall clock (#2549) 2026-09-06 19:03:15 +02:00
HampusandGitHub 798e64b224 refactor(app): remove the report modal path selection step (#2548) 2026-09-06 18:49:35 +02:00
HampusandGitHub a2d6477b42 fix(admin): route the bulk user deletion action correctly (#2545) 2026-09-06 18:42:37 +02:00
HampusandGitHub a2ca24eeb4 fix(admin): search archives across both subject types (#2542) 2026-09-06 18:42:33 +02:00
HampusandGitHub 8dcd00a8fe fix(admin): batch user id lookups on the users page (#2547) 2026-09-06 18:41:46 +02:00
HampusandGitHub be8a52c823 fix(admin): bound the reports page offset (#2546) 2026-09-06 18:41:18 +02:00
HampusandGitHub 43e420b0ab fix(admin): require paired voice server coordinates (#2544) 2026-09-06 18:40:50 +02:00
HampusandGitHub f8947adf62 fix(admin): map the index refresh status response union (#2543) 2026-09-06 18:40:20 +02:00
HampusandGitHub 81fccaf0ab docs(media-proxy): stop documenting literal response bodies (#2541) 2026-09-06 18:39:50 +02:00
HampusandGitHub 7a42291baf fix(api): search all reports when no status filter is given (#2540) 2026-09-06 18:39:18 +02:00
HampusandGitHub d9f983b08e fix(api): return the terminated count from terminate sessions (#2539) 2026-09-06 18:38:46 +02:00
HampusandGitHub 2f159852a7 fix(api): make an empty admin guild patch apply no change (#2538) 2026-09-06 18:38:13 +02:00
HampusandGitHub 5ef402b8ee fix(api): apply the nsfw and content warning guild settings (#2537) 2026-09-06 18:37:38 +02:00
HampusandGitHub a8d6e5ab73 refactor(api): remove premium-based voice track muting (#2536) 2026-09-06 18:37:01 +02:00
HampusandGitHub e805a3797f fix(api): stop entrance sound play probing channel existence (#2535) 2026-09-06 18:36:24 +02:00
HampusandGitHub 8f4fa82a9e fix(api): always return the page total when listing reports (#2534) 2026-09-06 17:38:21 +02:00
HampusandGitHub d2438b2fdd docs(operator): note the upload relay secret an upgrade now needs (#2533) 2026-09-06 17:21:06 +02:00
HampusandGitHub 133640ef2b fix(docs): allow unused pnpm patches in the docs image deploy (#2531) 2026-09-06 16:19:46 +02:00
HampusandGitHub 8e0516a8c3 feat(api): drop explicit media classification on asset uploads (#2530) 2026-09-06 16:12:25 +02:00
HampusandGitHub d784c0692e fix(app): set the jsx runtime in tsconfig so vitest parses tsx (#2529) 2026-09-06 15:51:18 +02:00
HampusandGitHub fffa265117 chore(i18n): refresh the client message catalogues (#2528) 2026-09-06 15:41:52 +02:00
HampusandGitHub 5367c0ab42 docs: move the reference site to astro starlight (#2527) 2026-09-06 15:40:22 +02:00
HampusandGitHub 7f8f09ee51 feat(admin)!: move the admin api to rest and fix its defects (#2515) 2026-09-06 15:36:41 +02:00
HampusandGitHub a70924d4b0 fix(admin): require the admin secret key base at boot (#2514) 2026-09-06 15:36:08 +02:00
HampusandGitHub 1a5925f9cb chore(app): remove message scheduling and a dead descriptor (#2513) 2026-09-06 15:35:36 +02:00
HampusandGitHub 43c778aae4 fix(api): guard the rpc session init test harness route (#2512) 2026-09-06 15:34:57 +02:00
HampusandGitHub 34cf8f821f refactor(api)!: drop unused helpers, parameters and a route (#2511) 2026-09-06 15:34:25 +02:00
HampusandGitHub 226cfd062e fix(worker): rebuild the deletion queue and cancel system dms (#2510) 2026-09-06 15:33:53 +02:00
HampusandGitHub e8f4e35c32 fix(api): gate stream keys by channel type and cover previews (#2509) 2026-09-06 15:33:20 +02:00
HampusandGitHub ef559f3d8c fix(api): handle bad manifests, unfurl errors and the apns key (#2508) 2026-09-06 15:32:47 +02:00
HampusandGitHub ee7206ac66 fix(api): batch connection reorders, dispatch on failed recheck (#2507) 2026-09-06 15:32:16 +02:00
HampusandGitHub 1ba9592308 fix(api): correct webhook dedupe and the instatus transforms (#2506) 2026-09-06 15:31:43 +02:00
HampusandGitHub d07f520b13 fix(api)!: correct pagination and locking, drop toggle routes (#2505) 2026-09-06 15:31:13 +02:00
HampusandGitHub 632f4c7b6c fix(api): correct report targets and ticket handling (#2504) 2026-09-06 15:30:41 +02:00
HampusandGitHub 1f627c9cc5 fix(api): correct user content, read state and harvest paths (#2501) 2026-09-06 15:30:08 +02:00
HampusandGitHub cc110b9f5a fix(api): raise coded errors for prerequisites and bounds (#2502) 2026-09-06 15:29:36 +02:00
HampusandGitHub f06d65db54 fix(api): reject unparsable bodies and screen non-form ones (#2503) 2026-09-06 15:29:04 +02:00
HampusandGitHub f8a04b8985 fix(api)!: enforce declared rate limits and correct route auth (#2500) 2026-09-06 15:28:32 +02:00
HampusandGitHub 908e1b8bd4 fix(api): correct guild permission and mfa checks (#2499) 2026-09-06 15:28:01 +02:00
HampusandGitHub f392636857 fix(auth): correct mfa errors, sudo methods and birth dates (#2498) 2026-09-06 15:27:30 +02:00
HampusandGitHub 150115cc0c fix(media-proxy): bound the relay body and drop the unread ttl (#2496) 2026-09-06 15:26:57 +02:00
HampusandGitHub 710a6f1c5d fix(media-proxy): correct route errors and test the ip gate (#2495) 2026-09-06 15:26:33 +02:00
HampusandGitHub 7c3e722085 chore(gateway): delete modules with no callers (#2494) 2026-09-06 15:26:08 +02:00
HampusandGitHub d8f2aa3184 feat(gateway): add an undrain endpoint and sweep orphan tables (#2493) 2026-09-06 15:25:43 +02:00
HampusandGitHub e828398e06 fix(gateway): close oversized bot identify with code 4011 (#2497) 2026-09-06 15:25:19 +02:00
HampusandGitHub 31d7cb81d6 fix(gateway): return precise rpc errors and bound snowflakes (#2491) 2026-09-06 15:24:54 +02:00
HampusandGitHub 214d19d45a fix(gateway): resync permissions and validate voice leaves (#2492) 2026-09-06 15:24:23 +02:00
HampusandGitHub d3170fc320 fix(gateway): repair the session lifecycle, limits and dead code (#2490) 2026-09-06 15:23:59 +02:00
HampusandGitHub 9a229c1b73 fix(api): answer 403 when the client ip header is unparsable (#2483) 2026-09-06 15:23:35 +02:00
HampusandGitHub a036d9a2e1 fix(api): resolve missing user rows for webhooks and sessions (#2487) 2026-09-06 15:23:03 +02:00
HampusandGitHub d5bfa5a73d fix(api)!: align error codes with throw sites and image bounds (#2488) 2026-09-06 15:21:38 +02:00
HampusandGitHub 4534822355 fix(config): derive the VAPID public point to verify the pair (#2521) 2026-09-06 15:13:32 +02:00
HampusandGitHub bff29d8f07 fix(api)!: always send Retry-After and reclassify two limits (#2489) 2026-09-06 15:07:00 +02:00
HampusandGitHub bec34ea147 fix(api)!: correct declared bounds and hide public bot mfa (#2485) 2026-09-06 15:06:15 +02:00
Hampus Kraft 3be4171256 feat(self-host)!: rework the compose stack and demand secrets (#2486) 2026-09-06 15:02:20 +02:00
Hampus Kraft 5bcaa7cfac fix(config)!: validate and derive config, drop the unread keys (#2482) 2026-09-06 15:02:20 +02:00
HampusandGitHub ea93ef5352 fix(api): find every live route when generating openapi.json (#2484) 2026-09-06 14:54:21 +02:00
HampusandGitHub 8734956d86 fix(auth): explain why a phone number was rejected (#2480) 2026-09-06 03:09:26 +02:00
HampusandGitHub 519b3a6127 fix(i18n): translate shipped English, repair broken catalogs (#2479) 2026-09-06 02:58:56 +02:00
HampusandGitHub 9b3773c1e6 feat(auth): let phone-gated accounts set the check aside (#2478) 2026-09-06 01:11:24 +02:00
HampusandGitHub e12b60078a fix(self-host): probe the seaweedfs s3 health endpoint (#2476) 2026-09-06 00:24:31 +02:00
HampusandGitHub 7cb8f9f4ae fix(app): pick default channel when guild channels arrive late (#2475) 2026-09-06 00:04:43 +02:00
HampusandGitHub 622bd124b9 fix(fonts): stop SC and TC from claiming kana (#2473) 2026-09-05 22:25:44 +02:00
HampusandGitHub fed8b2d089 feat(admin): add bulk delete user messages tool (#2472) 2026-09-05 22:20:14 +02:00
HampusandGitHub 12718eabbc refactor(api): drop cookie support for sudo mode (#2466) 2026-09-05 01:29:34 +02:00
HampusandGitHub ab68b61653 refactor: remove the CTP_MEMBER user flag (#2465) 2026-09-05 01:13:34 +02:00
HampusandGitHub 639ade3802 refactor(app-proxy): drop invite metadata and database access (#2464) 2026-09-05 00:13:04 +02:00
HampusandGitHub 3f1f899b23 fix(api): keep a deprecated nsfw field for older clients (#2463) 2026-09-04 23:08:34 +02:00
HampusandGitHub 51cb750502 feat(api): drop NSFW classification for emojis and stickers (#2462) 2026-09-04 22:55:58 +02:00
HampusandGitHub 1e6c332eae fix(app): show empty categories when hiding muted channels (#2460) 2026-09-04 21:04:34 +02:00
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
2361 changed files with 215886 additions and 87150 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 -2
View File
@@ -36,8 +36,7 @@ body:
label: Build information
description: >-
Open User Settings, scroll to the bottom of the left sidebar, and select
the build information. Fluxer copies it to the clipboard. On mobile,
select the build information at the bottom of the settings list.
the build information. Fluxer copies it to the clipboard.
validations:
required: true
+3 -3
View File
@@ -1,15 +1,15 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
blank_issues_enabled: false
contact_links:
- name: Mobile client bugs
url: https://github.com/fluxerapp/flutter_client#bug-reporting
about: Read the reporting instructions for the Fluxer mobile client.
- name: Account and billing support
url: https://fluxer.app/help
about: Find account help and support contact details.
- name: Feature proposals
url: https://github.com/orgs/fluxerapp/discussions
about: Propose a feature in a discussion.
- name: Security vulnerabilities
url: https://github.com/fluxerapp/fluxer/security/advisories/new
about: Submit a private vulnerability report.
- name: Translations
url: https://weblate.fluxer.tools
about: Improve an existing locale or start a new one.
-1
View File
@@ -33,5 +33,4 @@ jobs:
with:
image: fluxer-docs
dockerfile: fluxer_docs/Dockerfile
context: fluxer_docs
build-version: ${{ inputs['build-version'] }}
+67 -10
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
@@ -282,8 +314,8 @@ jobs:
path: |
~/.cache/rebar3
fluxer_gateway/_build
!fluxer_gateway/_build/default/lib/fluxer_gateway
!fluxer_gateway/_build/test/lib/fluxer_gateway
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >-
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
@@ -313,8 +345,8 @@ jobs:
path: |
~/.cache/rebar3
fluxer_gateway/_build
!fluxer_gateway/_build/default/lib/fluxer_gateway
!fluxer_gateway/_build/test/lib/fluxer_gateway
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >-
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
@@ -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",
+11
View File
@@ -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
+150 -27
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,14 +94,24 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
# The token every service sends to NATS. The bundled NATS runs without
# authentication, so this stays empty unless a Compose override points the stack
# at an external NATS that requires a token. Compose forwards the name to every
# container that connects.
#FLUXER_NATS_AUTH_TOKEN=
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
FLUXER_VAPID_EMAIL=[email protected]
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
# only if that mailbox does not exist.
#[email protected]
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
@@ -68,10 +124,22 @@ [email protected]
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
# only when a browser must reach an origin the defaults do not cover, such as a
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
# sources with spaces or commas.
# sources with spaces or commas. Every one of them is empty by default, and the
# three carrying a value below are illustrations, not defaults.
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
#FLUXER_CSP_EXTRA_MEDIA_SRC=
#FLUXER_CSP_EXTRA_FONT_SRC=
#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com
#FLUXER_CSP_EXTRA_STYLE_SRC=
#FLUXER_CSP_EXTRA_FRAME_SRC=
#FLUXER_CSP_EXTRA_WORKER_SRC=
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
# directive off the header.
#FLUXER_CSP_REPORT_URI=
# Allow the SSO identity provider to resolve to a private or internal address.
# Off by default: the API refuses to call non-public addresses so a misconfigured
@@ -80,22 +148,30 @@ [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
# LiveKit finds the address browsers dial by asking a STUN server. A host that
# cannot reach one over UDP stops with "could not resolve external IP", and the
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
# server to keep the lookup and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
FLUXER_KLIPY_API_KEY=
@@ -112,20 +188,58 @@ FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. Every service limit and reservation below has a default that
# assumes a host with at least 16 GB of RAM. Limits are per-container ceilings, so
# their sum may exceed host RAM; the reservations are what protect the services
# whose death takes the whole instance down. Lower these on a smaller host.
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
# The four reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from the services whose death takes the whole instance down. They do
# not reserve anything. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
#FLUXER_NATS_MEMORY_LIMIT=256mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
#FLUXER_API_MEMORY_LIMIT=2560mb
#FLUXER_API_MEMORY_RESERVATION=1gb
#FLUXER_WORKER_MEMORY_LIMIT=2560mb
#FLUXER_WORKER_MEMORY_RESERVATION=1gb
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
#FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT=256mb
#FLUXER_USERS_MEMORY_LIMIT=128mb
#FLUXER_USERS_SHARD_MEMORY_LIMIT=256mb
#FLUXER_GIFS_MEMORY_LIMIT=128mb
#FLUXER_GIFS_SHARD_MEMORY_LIMIT=256mb
#FLUXER_MESSAGES_MEMORY_LIMIT=128mb
#FLUXER_MESSAGES_SHARD_MEMORY_LIMIT=256mb
#FLUXER_UNFURL_MEMORY_LIMIT=128mb
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
# which is the container ceiling the indexer shares with the search process.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
# SeaweedFS heap ceiling. Go collects against this value instead of against the
# container limit, which it cannot see, so without it an upload burst grows the
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
# together: the peak is the parts of one upload in flight at once, which is 25 MB
# times 20 for a 500 MB attachment.
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
# Node sizes its own heap from the container memory limit by default, at roughly
# 55 percent of it, which always leaves room for the buffers and stacks that live
@@ -140,18 +254,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 +279,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
+8 -3
View File
@@ -1,12 +1,17 @@
{
servers {
trusted_proxies static private_ranges
trusted_proxies static {$FLUXER_EDGE_TRUSTED_PROXIES:private_ranges}
trusted_proxies_strict
}
}
{$FLUXER_CADDY_SITE_ADDRESS} {
{$FLUXER_EDGE_SITE_ADDRESS} {
encode zstd gzip
handle /_health {
respond "OK" 200
}
handle_path /api/* {
reverse_proxy api:8080
}
@@ -52,7 +57,7 @@
}
:8088 {
handle_path /api/* {
handle /.well-known/fluxer {
reverse_proxy api:8080
}
}
@@ -0,0 +1,17 @@
# Overlay for running Fluxer behind your own reverse proxy.
#
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
#
# Or set this once in .env and keep using plain `docker compose up -d`:
#
# COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
#
# Fluxer stops binding 80 and 443 and serves plain HTTP on one port instead.
# That port already does all internal routing, so the proxy in front needs a
# single rule: send everything to it. Terminate TLS there.
services:
edge:
ports: !override
- "${FLUXER_EDGE_BIND:-127.0.0.1:8080}:8080"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":8080"
+60 -26
View File
@@ -26,6 +26,7 @@ x-fluxer-env: &fluxer-env
FLUXER_KV_URL: redis://valkey:6379/0
FLUXER_NATS_URL: nats://nats:4222
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
FLUXER_SVC_NATS_URL: nats://nats:4222
FLUXER_SVC_SHARD_COUNT: "1"
@@ -44,8 +45,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 +74,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 +119,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
start_interval: 1s
services:
caddy:
edge:
image: caddy:2.10-alpine
deploy:
resources:
@@ -127,11 +132,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 +166,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 +244,7 @@ services:
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_MAX_INDEXING_MEMORY: 384mb
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
- meilisearch-data:/meili_data
@@ -253,17 +259,20 @@ services:
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-512mb}
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
restart: unless-stopped
networks: [fluxer]
environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
command: ["server", "-s3", "-dir=/data"]
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/"]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
seaweedfs-init:
image: chrislusf/seaweedfs:4.34
@@ -275,6 +284,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 +304,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 +327,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 +400,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 +439,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 +465,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 +492,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 +508,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 +552,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 +568,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 +640,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 +655,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 +672,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 +714,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"
},
{
+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}"

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