Compare commits

...
Author SHA1 Message Date
omsterandGitHub ca719e7b5e feat(admin,api): restrict community creation on self-hosted (#3055) 2026-09-30 16:32:45 +02:00
HampusandGitHub ab4069ed0e fix(app): let hidden sidebar buttons be shown again (#3061) 2026-09-30 15:03:44 +02:00
HampusandGitHub 12bfaa83ba fix(sso): route mobile sign-in through the web callback (#3060) 2026-09-30 14:48:24 +02:00
HampusandGitHub 1076728241 perf(gateway): keep large guilds responsive under floods (#3058) 2026-09-30 12:26:21 +02:00
HampusandGitHub 360b984adc fix(ci): repair admin test config and a ttl race in api tests (#3054) 2026-09-30 02:39:46 +02:00
HampusandGitHub dcdf7e1d93 fix(api): let new channels inherit the adult-only setting (#3053) 2026-09-30 02:31:47 +02:00
HampusandGitHub e8cb167dbf feat(premium): add App Store and Google Play purchases (#3052) 2026-09-30 01:55:19 +02:00
HampusandGitHub 0b3418dcbe fix(app): make unchecked checkbox border visible (#3050) 2026-09-30 01:23:43 +02:00
HampusandGitHub ec7649193c docs(admin): document notify_reporter on report resolve (#3049) 2026-09-30 01:01:22 +02:00
HampusandGitHub 2b8a743dc5 refactor(self-hosting): forward every setting, drop dead config (#3047) 2026-09-30 00:58:43 +02:00
HampusandGitHub 39f9beda5a fix(api): send correct staff emails and allow suppressing them (#3048) 2026-09-29 23:55:54 +02:00
HampusandGitHub f0b3c82cfd fix(app): scroll quick switcher selection after typing (#3044) 2026-09-29 20:54:08 +02:00
HampusandGitHub 2808edf6d0 fix(push): keep notification images within the web push budget (#3043) 2026-09-29 20:35:56 +02:00
HampusandGitHub 071263188a fix(push): run the stale DM read check on presence nodes (#3042) 2026-09-29 19:51:36 +02:00
HampusandGitHub f9108f24ce feat(self-host): add an overlay that turns off bundled seaweedfs (#3041) 2026-09-29 19:49:02 +02:00
HampusandGitHub e98b77a54a fix(api): stop treating users without a birth date as minors (#3040) 2026-09-29 18:27:15 +02:00
HampusandGitHub 2636e9cc13 fix(voice): lower DeepFilterNet attenuation limit to 30 dB (#3039) 2026-09-29 18:16:13 +02:00
JiraliteandGitHub 944b586f22 fix(UseForwardDestinations): hide system user (#3038) 2026-09-29 18:14:19 +02:00
HampusandGitHub 4f968bbc47 feat(captcha): make ALTCHA the only captcha (#3035) 2026-09-29 17:00:15 +02:00
HampusandGitHub d433a039b5 feat(profile): ship profile timezone to everyone (#3034) 2026-09-29 16:28:40 +02:00
HampusandGitHub b30ea361d3 fix(push): stop pushes for read, silent and muted messages (#3033) 2026-09-29 15:58:46 +02:00
HampusandGitHub 9908518f5b feat(app): add quick reply and edit keybinds (#3032) 2026-09-29 15:50:56 +02:00
HampusandGitHub 364084c819 refactor(api): emit moderation events and apply account actions (#3031) 2026-09-29 12:24:15 +02:00
HampusandGitHub c488906131 feat(voice): ship noise suppression treatment to everyone (#3029) 2026-09-29 03:43:58 +02:00
HampusandGitHub 39c72f0fb0 fix(desktop): require readable keyboards for Linux input access (#3026) 2026-09-28 22:27:20 +02:00
HampusandGitHub 3736d94d73 feat(premium): let self-hosted instances sell premium and gifts (#3025) 2026-09-28 21:21:51 +02:00
HampusandGitHub 192cec689a fix(app): keep voice connections of one session across channels (#3023) 2026-09-28 19:50:47 +02:00
HampusandGitHub e895c41bf0 fix(app): tighten the composer status row (#3022) 2026-09-28 19:50:00 +02:00
HampusandGitHub 997d98c65c fix(app): fade messages behind the composer status row (#3020) 2026-09-28 18:47:42 +02:00
HampusandGitHub c9ae5b6ee8 fix(app): smooth the fluxer.com migration and expired re-login (#3019) 2026-09-28 18:11:18 +02:00
HampusandGitHub a728be4062 fix(app): respect time format setting in profile local time (#3018) 2026-09-28 17:48:55 +02:00
HampusandGitHub fce81367fb fix(app): stop message text showing through the slowmode hint (#3017) 2026-09-28 17:29:15 +02:00
HampusandGitHub 5a4edc0b59 fix(api): make read state clear endpoint a no-op (#3015) 2026-09-28 16:31:30 +02:00
HampusandGitHub 713ae5f7f5 feat(api): restrict dms to friends by default for new users (#3013) 2026-09-28 15:02:20 +02:00
HampusandGitHub eaee820216 feat(experiments): target rollouts by guild and premium status (#3012) 2026-09-28 14:34:19 +02:00
HampusandGitHub 564c5ae164 feat(profile): move profile timezone from staff to an experiment (#3011) 2026-09-28 12:57:26 +02:00
HampusandGitHub dd8ed6f205 fix(app): react at once when picking a +: autocomplete emoji (#3010) 2026-09-28 12:30:45 +02:00
HampusandGitHub ed8c412415 perf(gateway): make channel moves cheap in large guilds (#3008) 2026-09-28 02:07:47 +02:00
HampusandGitHub 12417a6942 fix(app): keep the caret after inserted emoji (#3007) 2026-09-28 01:56:08 +02:00
HampusandGitHub d05f6c9aaa fix(gateway): push held users whose sessions end during grace (#3006) 2026-09-28 01:47:55 +02:00
HampusandGitHub 0ca035c547 fix(messages): accept null version on legacy message rows (#3004) 2026-09-28 01:10:52 +02:00
HampusandGitHub dfd46ccc2c ci(gateway): drop cached gateway build output before compiling (#3003) 2026-09-28 01:08:52 +02:00
HampusandGitHub f6df3169ca fix(app): use +:shortcode: for reactions, no space before emoji (#3001) 2026-09-28 00:48:23 +02:00
HampusandGitHub 5b280898c5 refactor(push): retire the push service delivery experiment (#3000) 2026-09-28 00:45:22 +02:00
HampusandGitHub 2a9e25c788 fix(dev): drop the stray -- from the tunnel public URL hint (#2999) 2026-09-28 00:43:32 +02:00
HampusandGitHub 463c03fb6d feat(app): make +emoji react on send and target replies (#2998) 2026-09-28 00:08:40 +02:00
HampusandGitHub 153dad11e1 feat(installer): let upgrades copy the uploads uncompressed (#2995) 2026-09-27 23:51:24 +02:00
HampusandGitHub e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
HampusandGitHub 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
HampusandGitHub 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
HampusandGitHub fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
HampusandGitHub 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
HampusandGitHub 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
HampusandGitHub 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
HampusandGitHub 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
HampusandGitHub 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
HampusandGitHub 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
HampusandGitHub 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
HampusandGitHub 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
HampusandGitHub 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
HampusandGitHub 841fb7af41 feat(auth): migrate passkeys to fluxer.com (#2964) 2026-09-25 22:33:50 +02:00
HampusandGitHub 08e65d41c0 fix(api): clear the perks-sanitized latch when premium returns (#2963) 2026-09-25 20:13:00 +02:00
HampusandGitHub f76c4dc041 fix(api): cancel only the subscription the refund belongs to (#2962) 2026-09-25 20:10:54 +02:00
HampusandGitHub f1f8ba2031 fix(app): add copy link to link channel context menus (#2959) 2026-09-25 18:16:20 +02:00
HampusandGitHub 5ab8d745c0 fix(i18n): correct the fluxer.com migration translations (#2958) 2026-09-25 17:46:07 +02:00
HampusandGitHub ff62bc89a4 feat(app): add passkey popup bridge for password managers (#2957) 2026-09-25 17:43:19 +02:00
HampusandGitHub 838bbdb5ec fix(app): only start the domain migration when the app opens (#2956) 2026-09-25 16:44:58 +02:00
HampusandGitHub 1c36a59b2c feat(app): rework quick switcher ranking and show origin icons (#2953) 2026-09-25 13:59:25 +02:00
HampusandGitHub 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
HampusandGitHub e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
HampusandGitHub f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
HampusandGitHub bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
HampusandGitHub e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
HampusandGitHub 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
HampusandGitHub b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
HampusandGitHub 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot]andGitHub 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot]andGitHub 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
HampusandGitHub 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
HampusandGitHub 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
HampusandGitHub 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
HampusandGitHub a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
HampusandGitHub 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
HampusandGitHub 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
HampusandGitHub a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
HampusandGitHub 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
HampusandGitHub c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
HampusandGitHub dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
HampusandGitHub 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
HampusandGitHub 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
HampusandGitHub deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omsterandGitHub 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omsterandGitHub 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
HampusandGitHub f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
HampusandGitHub ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
HampusandGitHub bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
HampusandGitHub b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
HampusandGitHub b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
HampusandGitHub 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
HampusandGitHub b16989d567 feat(push): ring incoming calls on Apple PushKit devices (#2911) 2026-09-23 20:21:08 +02:00
HampusandGitHub c9754ac11a fix(api): exempt internal rpc from the client ip check (#2910) 2026-09-23 18:03:36 +02:00
fluxer-weblate[bot]andGitHub f34e4a5115 chore(i18n): update translations from Weblate (#2903) 2026-09-23 17:28:25 +02:00
fluxer-weblate[bot]andGitHub 44b3615298 chore(i18n): update translations from Weblate (#2904) 2026-09-23 17:27:59 +02:00
HampusandGitHub 211e98307d perf(push): cache endpoint guard dns verdicts (#2907) 2026-09-23 17:27:19 +02:00
HampusandGitHub 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
JiraliteandGitHub 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
WagnerandGitHub 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot]andGitHub 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omsterandGitHub a1e2bf2c8d feat(dev/linux): select the wayland backend when reachable in the native desktop app (#2899)
Signed-off-by: omstr <[email protected]>
2026-09-22 21:27:59 +02:00
HampusandGitHub 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
HampusandGitHub c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
HampusandGitHub 91340c5c84 fix(markdown): compile the parser wasm asynchronously (#2900) 2026-09-22 19:58:38 +02:00
HampusandGitHub 045dd5d027 test(api): make the harvest token tamper test deterministic (#2894) 2026-09-22 02:20:18 +02:00
HampusandGitHub a21b9c4659 docs(readme): clean up the download prose (#2893) 2026-09-22 02:08:42 +02:00
HampusandGitHub 4b1b869802 docs(readme): point Linux installs at Flathub (#2892) 2026-09-22 02:04:06 +02:00
HampusandGitHub 1ab7e7dfcc fix(api): unfurl links to a self-hosted instance's own domain (#2891) 2026-09-22 02:00:51 +02:00
HampusandGitHub 31c53d2dff fix(app): stop pending stickers from reloading the channel (#2890) 2026-09-22 02:00:26 +02:00
HampusandGitHub 412a1ae79d perf(api): stop ledgering session payment reconciliation (#2889) 2026-09-22 01:37:21 +02:00
HampusandGitHub 0b2306ec3d fix(api): honour default TTLs and expire stale job ledger rows (#2887) 2026-09-21 23:16:39 +02:00
HampusandGitHub 242ed3a934 fix(desktop): drop orphaned Squirrel uninstall entry (#2885) 2026-09-21 20:03:33 +02:00
HampusandGitHub 70e1ce682a feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883) 2026-09-21 16:26:06 +02:00
HampusandGitHub 7601bf98ee fix(channel): sync a cleared group DM name without a reload (#2882) 2026-09-21 15:34:18 +02:00
c7ec2a0f58 chore(tooling): Ignore .vscode/ in .gitignore (#2868)
Co-authored-by: Hampus <[email protected]>
2026-09-21 13:29:33 +02:00
XeonandGitHub 6a5e0056a8 fix(flatpak): Add a release tag and make small corrections (#2872) 2026-09-21 13:28:36 +02:00
HampusandGitHub 78d105b46e fix(desktop): stop looping on an update that never installs (#2879) 2026-09-21 03:36:11 +02:00
HampusandGitHub c68d62b8a0 fix(voice): darken screen share source titles in light theme (#2878) 2026-09-21 01:20:54 +02:00
HampusandGitHub df58020f4c fix(api): keep premium paid for after a subscription cancels (#2875) 2026-09-20 23:50:49 +02:00
HampusandGitHub f052ce05aa fix(workspace): point the Erlang extension at the repo root (#2871) 2026-09-20 19:49:06 +02:00
HampusandGitHub eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
HampusandGitHub 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
HampusandGitHub 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
HampusandGitHub a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
HampusandGitHub 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
HampusandGitHub c157ab5752 feat(voice): rework screen share delivery behind an experiment (#2859) 2026-09-20 06:10:20 +02:00
HampusandGitHub 574a93257c docs(downloads): the pacman repository is signed (#2858) 2026-09-20 05:54:28 +02:00
HampusandGitHub ba7d8781cf feat(auth): make passkey two-factor authentication opt-in (#2857) 2026-09-20 05:06:50 +02:00
HampusandGitHub 3256af8d92 refactor(app-proxy): remove the stable time freeze (#2856) 2026-09-20 04:02:47 +02:00
HampusandGitHub 86043212f2 docs(downloads): one pacman repository holds both channels (#2855) 2026-09-20 02:50:08 +02:00
HampusandGitHub 5d85e88532 fix(search): suggest yourself in DM from: and mentions: filters (#2854) 2026-09-20 01:24:43 +02:00
HampusandGitHub e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
HampusandGitHub 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
HampusandGitHub a3454e8245 fix(installer): name the services that are not ready (#2851) 2026-09-19 23:34:08 +02:00
HampusandGitHub bf7567b768 fix(user): push guild member updates on profile field changes (#2850) 2026-09-19 23:30:25 +02:00
HampusandGitHub ac3450ab32 feat(ci): publish appimage zsync control files (#2849) 2026-09-19 22:22:54 +02:00
HampusandGitHub 5d034becb8 fix(installer): stop waiting for an absent bucket initialiser (#2848) 2026-09-19 22:14:13 +02:00
HampusandGitHub f9397d0db9 feat(ci): publish linux repositories from the desktop release (#2847) 2026-09-19 22:01:06 +02:00
HampusandGitHub 9005139dc8 fix(voice): stop stereo microphones publishing as mono (#2846) 2026-09-19 21:54:38 +02:00
HampusandGitHub d93604afa2 fix(voice): let screen shares use the hardware H.264 encoder (#2845) 2026-09-19 21:54:30 +02:00
HampusandGitHub c4f0b2ece0 feat(desktop): self-update appimages in place (#2843) 2026-09-19 19:06:53 +02:00
HampusandGitHub 98a42f612b fix(desktop): supersede the legacy linux packages on upgrade (#2842) 2026-09-19 18:50:37 +02:00
HampusandGitHub cc75e1318d fix(ci): raise the macos minimum to 13.0 (#2841) 2026-09-19 18:35:05 +02:00
HampusandGitHub 9027cbdf3e fix(voice): send screen shares at the quality the user picked (#2840) 2026-09-19 16:46:22 +02:00
HampusandGitHub 2119e10ed5 chore(static): update marketing screenshots and readme cover (#2839) 2026-09-19 16:44:32 +02:00
HampusandGitHub 87f3eb3c81 feat(desktop): add flatpak and arch packaging inputs (#2838) 2026-09-19 15:31:41 +02:00
HampusandGitHub f9bb8bd585 test(voice): remove the slow screen share delivery proof (#2836) 2026-09-19 02:33:32 +02:00
HampusandGitHub bc47a724af fix(voice): stop screen shares failing to reach their viewers (#2835) 2026-09-19 02:17:25 +02:00
HampusandGitHub f32356801d feat(api): make tor and breached password lookups opt-in (#2834) 2026-09-19 01:22:17 +02:00
HampusandGitHub efd677f32b feat(api): exempt configured ASNs from abusive IP auto-bans (#2833) 2026-09-18 23:01:58 +02:00
HampusandGitHub 3cec27ba57 fix(static): vendor the deepfilternet 1.3.0 assets (#2832) 2026-09-18 18:47:42 +02:00
HampusandGitHub 1f810ba04d fix(api): drop the upload segment signal and dead exports (#2831) 2026-09-18 17:35:58 +02:00
HampusandGitHub 522cf08e61 feat(media-proxy): sign attachment URLs and gate origins (#2830) 2026-09-18 15:57:32 +02:00
HampusandGitHub 025c01ab13 fix(api): chunk guild permission batch RPC over 100 guilds (#2829) 2026-09-18 12:54:03 +02:00
HampusandGitHub dc41b53d60 fix(desktop): drop redundant casts flagged by clippy 1.98 (#2826) 2026-09-17 21:28:48 +02:00
HampusandGitHub 3b552e00ef chore(deps): upgrade all dependencies, toolchains and images (#2825) 2026-09-17 21:08:56 +02:00
HampusandGitHub 56e04e7b53 test(backend): remove duplicate and useless tests (#2820) 2026-09-17 15:32:25 +02:00
HampusandGitHub deac653a9e test(app): remove useless frontend tests (#2819) 2026-09-17 15:05:36 +02:00
HampusandGitHub ed9528834d fix(gateway): stop dead sessions leaving voice states behind (#2818) 2026-09-17 14:55:18 +02:00
HampusandGitHub 4cecbf1f43 fix(auth): disable TOTP with one code instead of two (#2816) 2026-09-17 04:21:50 +02:00
HampusandGitHub b019f4a91f fix(gateway): act on voice states in the voice server (#2815) 2026-09-17 03:59:36 +02:00
HampusandGitHub 34b6ecfbd2 chore(admin): remove the heap snapshot endpoint (#2814) 2026-09-16 18:56:01 +02:00
HampusandGitHub 4ef9c4c65b fix(api): restore commas in geoip location labels (#2812) 2026-09-16 18:27:50 +02:00
HampusandGitHub 3276039e41 feat(admin): audit admin reads and filter the log by access (#2811) 2026-09-16 17:23:03 +02:00
HampusandGitHub 03d1354562 chore(voice): remove voice reconciliation leftovers (#2810) 2026-09-16 17:09:57 +02:00
HampusandGitHub 964845d7a7 chore(voice): remove the recon service (#2808) 2026-09-16 16:53:30 +02:00
HampusandGitHub 3bc5dd8e0f fix(gateway): always clear expired custom statuses (#2807) 2026-09-16 16:52:22 +02:00
HampusandGitHub 17292fd6a5 fix(app): stop plain unicode symbols rendering as color emoji (#2806) 2026-09-16 16:33:13 +02:00
TarekandGitHub f753659899 feat(instance): make the status page URL configurable (#1159) 2026-09-16 15:20:37 +02:00
HampusandGitHub 7412ec3395 refactor(api): purge cache by canonical media prefix (#2802) 2026-09-16 02:32:36 +02:00
HampusandGitHub 570c8776c4 fix(api): require manage messages to remove others' reactions (#2799) 2026-09-15 18:16:55 +02:00
HampusandGitHub 910db6734b feat(experiments): ship seven treatments to everyone (#2798) 2026-09-15 18:03:35 +02:00
HampusandGitHub 9e614026d7 fix(api): stop exporting the change feed stats type (#2797) 2026-09-15 17:27:09 +02:00
HampusandGitHub b38e7c6433 feat(api): publish object storage changes to a JetStream feed (#2796) 2026-09-15 17:20:26 +02:00
HampusandGitHub 83c8e91955 fix(app): fit the user area popout shadow to its card (#2795) 2026-09-15 17:11:43 +02:00
HampusandGitHub 0532dd0440 fix(app): stop guild banner jumps and restore hover animation (#2792) 2026-09-15 09:31:52 +02:00
HampusandGitHub a08615e306 fix(gateway): match member search on username and global name (#2791) 2026-09-15 08:48:33 +02:00
HampusandGitHub f4c5fee17e feat(app): rank forward destinations and preview the message (#2790) 2026-09-15 07:23:26 +02:00
HampusandGitHub c5aaf65a10 fix(app): list friends with closed DMs in the forward modal (#2787) 2026-09-15 00:39:18 +02:00
HampusandGitHub 951e39da3d feat(api): add expression source guild routes (#2786) 2026-09-15 00:31:47 +02:00
HampusandGitHub b693d84d2b fix(openapi): restore named discriminated union branches (#2785) 2026-09-14 23:42:26 +02:00
HampusandGitHub 9bbf6c513b fix(installer): say what the email prompt is for (#2784) 2026-09-14 23:07:03 +02:00
HampusandGitHub 50cec92738 fix(api): batch member user lookups on guild load (#2783) 2026-09-14 23:06:36 +02:00
HampusandGitHub c212d315f4 fix(app): gate reworked typing indicators behind an experiment (#2782) 2026-09-14 21:54:52 +02:00
HampusandGitHub 1861432a53 fix(app): scope message rings and reach the composer by key (#2781) 2026-09-14 21:44:12 +02:00
HampusandGitHub d0c6146429 feat(app): gate a collapsing guild header behind an experiment (#2779) 2026-09-14 21:15:31 +02:00
HampusandGitHub 550e6b05a1 fix(app): show hover highlight and inset the focus ring (#2778) 2026-09-14 20:51:59 +02:00
HampusandGitHub 5b6949170f chore(donations): drop the donor email case backfill script (#2776) 2026-09-14 20:45:38 +02:00
HampusandGitHub f32bc37794 fix(guild): correct activity log sentence presentation (#2777) 2026-09-14 20:43:08 +02:00
HampusandGitHub 8ee2279b4b feat(donations): add Nordic currencies, raise amount ceilings (#2775) 2026-09-14 20:27:24 +02:00
HampusandGitHub 6339c3b8ad feat(app): gate the expression info card behind an experiment (#2773) 2026-09-14 20:14:58 +02:00
HampusandGitHub 7c9274847f feat(gateway): list bot ready guilds as unavailable (#2774) 2026-09-14 20:08:26 +02:00
HampusandGitHub 5d1dddc093 fix(deps): update rustls for RUSTSEC-2026-0285 (#2772) 2026-09-14 19:01:22 +02:00
HampusandGitHub 04481d7235 fix(app): keep blockquotes open across pasted lines (#2771) 2026-09-14 18:54:04 +02:00
Hampus Kraft a3d6cf37cb fix(guild): render activity log entries deterministically (#2766) 2026-09-14 17:39:19 +02:00
HampusandGitHub ed10f9d323 fix(app): gate blocked group rendering behind an experiment (#2763) 2026-09-14 16:08:19 +02:00
HampusandGitHub 4b278a0da8 fix(app): gate one-Tab message focus behind an experiment (#2762) 2026-09-14 15:55:00 +02:00
HampusandGitHub 1281045648 fix(app): gate single-source message hover behind an experiment (#2761) 2026-09-14 15:37:37 +02:00
HampusandGitHub 69c42cff90 docs: reword vague sentences and fix wrong claims (#2760) 2026-09-14 15:18:53 +02:00
HampusandGitHub 7d56481aba fix(app): copy selected message text without markdown (#2759) 2026-09-14 14:53:53 +02:00
HampusandGitHub 91a2604e9e fix(admin): apply audit logs and side effects to bulk actions (#2758) 2026-09-14 14:34:43 +02:00
HampusandGitHub a9dc74a520 fix(app): hide unread channels in muted collapsed categories (#2752) 2026-09-13 23:59:28 +02:00
HampusandGitHub a9cc04d277 fix(media-proxy): retry relay uploads on dropped connections (#2751) 2026-09-13 23:35:09 +02:00
HampusandGitHub 8cb097f954 fix(i18n): review translations and fix i18n library misuse (#2750) 2026-09-13 22:58:09 +02:00
HampusandGitHub 78f783f0c4 fix(media-proxy): retry dropped connections and log the cause (#2749) 2026-09-13 22:39:13 +02:00
Hampus Kraft d14998ff69 fix(app): back off image, reaction and settings retries on 429 (#2743) 2026-09-13 21:04:31 +02:00
HampusandGitHub ca7ddd9272 refactor(api): drop Bunny for pluggable cache purge adapters (#2742) 2026-09-13 20:28:56 +02:00
HampusandGitHub 84dcf6b8a8 refactor(imports): replace relative imports with path aliases (#2741) 2026-09-13 20:18:22 +02:00
HampusandGitHub a59b80ce11 docs(api): correct the synced preferences size limits (#2740) 2026-09-13 19:37:32 +02:00
HampusandGitHub 007f338823 feat(schema): add double tap reaction to synced preferences (#2739) 2026-09-13 18:47:21 +02:00
HampusandGitHub 7d34d25497 fix(ci): stop verifying published assets against the CDN (#2738) 2026-09-13 18:04:08 +02:00
HampusandGitHub 7375ac9d80 docs: simplify the reference and tighten the verifier (#2736) 2026-09-13 17:38:50 +02:00
HampusandGitHub 6af33c7188 refactor(svc): tidy the rust services and build tooling (#2735) 2026-09-13 17:38:32 +02:00
HampusandGitHub 33737e0f79 refactor(admin): tidy the admin routes and templates (#2734) 2026-09-13 17:38:15 +02:00
HampusandGitHub 5afbf67a70 refactor(api): tidy the api and shared packages (#2733) 2026-09-13 17:37:48 +02:00
HampusandGitHub 580401d2dc chore(marketing): remove the private marketing submodule (#2732) 2026-09-13 16:37:55 +02:00
HampusandGitHub 38b7c63431 fix(admin): declare gateway cluster_metrics in node stats (#2728) 2026-09-12 22:48:22 +02:00
HampusandGitHub 57d08cd091 fix(api): keep stickers on messages sent to personal notes (#2727) 2026-09-12 20:20:26 +02:00
HampusandGitHub 91e2d31614 style(voice): format the room_finished webhook test 2026-09-12 16:05:26 +02:00
HampusandGitHub d375dc7946 fix(ci): stop a new component blocking every other image promote 2026-09-12 16:01:47 +02:00
HampusandGitHub 3fffce2a4a fix(voice): correct the room_finished test harness types (#2723) 2026-09-12 15:49:47 +02:00
HampusandGitHub 376c509083 docs(self-host): tighten the env example comments (#2722) 2026-09-12 15:39:24 +02:00
HampusandGitHub 156315fd5a docs: drop exact counts that go stale when inventory changes (#2721) 2026-09-12 15:39:07 +02:00
HampusandGitHub c7b9e9b9bd fix(voice): stop room_finished evicting a whole channel (#2720) 2026-09-12 15:38:50 +02:00
HampusandGitHub 12397032e3 feat(voice): add the recon service and remove the old worker (#2719) 2026-09-12 15:38:32 +02:00
HampusandGitHub 4a285cbb11 fix(docs): drop the orphaned voice command footnote (#2718) 2026-09-12 01:45:27 +02:00
HampusandGitHub 6d600990fe fix(app): derive unread from an ack timestamp floor (#2717) 2026-09-12 01:40:36 +02:00
HampusandGitHub e1bc6c2f7e refactor(voice): remove the unused voice state ack (#2716) 2026-09-12 01:37:45 +02:00
HampusandGitHub 3e79530389 fix(app): defer non-critical gateway dispatches (#2715) 2026-09-12 01:36:04 +02:00
HampusandGitHub d0c84b3d9b fix(voice): apply noise suppression in the mic test (#2714) 2026-09-12 01:14:36 +02:00
HampusandGitHub 3affd295e8 feat(guild): require opt-in for emoji and sticker cloning (#2712) 2026-09-12 00:33:23 +02:00
HampusandGitHub 7b15e5be0f fix(admin): reject synthetic user ids on mutating routes (#2711) 2026-09-12 00:23:04 +02:00
HampusandGitHub adab646d1e fix(schema): preserve WebAuthn payloads and align fixtures (#2710) 2026-09-12 00:19:44 +02:00
HampusandGitHub de1fd95a99 refactor(schema): simplify validation and OpenAPI generation (#2709) 2026-09-11 23:24:26 +02:00
HampusandGitHub 4bc5593f9f chore(i18n): translate the voice quality catalog additions (#2707) 2026-09-11 22:59:12 +02:00
HampusandGitHub 172791316b feat(voice): add noise suppression backends and rollout (#2706) 2026-09-11 22:24:05 +02:00
HampusandGitHub b30a4f5d14 fix(admin): omit synthetic accounts from user lookup and search (#2705) 2026-09-11 22:06:29 +02:00
HampusandGitHub f254ed679b fix(app): never lower the read-state unread watermark (#2704) 2026-09-11 22:02:24 +02:00
HampusandGitHub 258fe6f742 feat(voice): add audio bitrate guild features and 96 kbps cap (#2703) 2026-09-11 22:00:15 +02:00
HampusandGitHub cfa20b7093 fix(admin): let voice restriction lists be cleared (#2701) 2026-09-11 21:28:26 +02:00
HampusandGitHub 569146c5bc fix(app): pick favorite GIF preview kind from content type (#2696) 2026-09-11 21:06:00 +02:00
HampusandGitHub 1b1d48b05e feat(voice): soft connection limits for voice servers (#2694) 2026-09-11 20:05:14 +02:00
HampusandGitHub cadca2c18e test(voice): build watch attempt keys from the shared builder (#2693) 2026-09-11 19:44:34 +02:00
HampusandGitHub 2e3f78b3c6 fix(app): stop restarting the read-state ack batch window (#2689) 2026-09-11 16:26:34 +02:00
HampusandGitHub b57545b1a4 refactor(api): replace stripe mock currency ternary chains (#2688) 2026-09-11 16:02:09 +02:00
HampusandGitHub e490be2f35 feat(app): prompt to delete when clearing a message edit (#2687) 2026-09-11 15:56:05 +02:00
fluxer-ci[bot]andGitHub ab07fd23cf chore(i18n): update public marketing catalogs (#2686) 2026-09-11 15:50:16 +02:00
fluxer-ci[bot]andGitHub c1fd2234b8 chore(marketing): advance pointer 7867cf8 → 23cd1c9 (#2685) 2026-09-11 15:50:05 +02:00
HampusandGitHub 3af43b3366 feat(api): add SEK, DKK and NOK as localized currencies (#2684) 2026-09-11 15:48:56 +02:00
HampusandGitHub 0e470f532e test(voice): rename the watch failure deadline test file (#2683) 2026-09-11 15:18:08 +02:00
HampusandGitHub c541b86c00 fix(voice): show buffering while screen share recovery runs (#2682) 2026-09-11 15:03:21 +02:00
HampusandGitHub 53b3fa2f4a fix(voice): key watch attempts by published track (#2681) 2026-09-11 15:01:14 +02:00
HampusandGitHub 67e01be34a fix(voice): judge H.264 hardware support by negotiated format (#2680) 2026-09-11 14:59:04 +02:00
HampusandGitHub 81fd8c9aad fix(gateway): skip empty dm partner registration casts (#2677) 2026-09-11 13:49:03 +02:00
HampusandGitHub bcef7b3123 feat(app): edit blockquote lines in the composer (#2676) 2026-09-11 13:27:50 +02:00
HampusandGitHub a98d8ef679 fix(app): wrap multiline selections in code blocks (#2675) 2026-09-11 13:22:03 +02:00
HampusandGitHub a5af857564 fix(app): insert a newline on Enter inside code blocks (#2674) 2026-09-11 13:20:26 +02:00
HampusandGitHub 2830221949 fix(desktop): download the version a linux update prompt names (#2673) 2026-09-11 13:19:25 +02:00
HampusandGitHub 84aa8880f5 fix(app): format typed @everyone and @here in the composer (#2672) 2026-09-11 13:18:48 +02:00
HampusandGitHub 395ec1d60f fix(ci): publish desktop update feeds only after the release (#2671) 2026-09-11 13:18:15 +02:00
HampusandGitHub e6ee3b8059 fix(api): only offer desktop builds whose release is published (#2670) 2026-09-11 13:17:41 +02:00
HampusandGitHub 61a13e1c1a fix(app): download the version a linux update prompt names (#2669) 2026-09-11 13:16:27 +02:00
HampusandGitHub fc0e2628a4 fix(app): honour @silent in the message composer (#2668) 2026-09-11 13:16:13 +02:00
HampusandGitHub 87fdfd9c34 fix(app): show DMs opened by an incoming message as unread (#2667) 2026-09-11 13:14:06 +02:00
HampusandGitHub 88a5ff9c45 feat(voice): record watch failures and decode counters (#2666) 2026-09-11 13:05:34 +02:00
HampusandGitHub 320949a79d fix(gateway): drop dead clauses in dm partner visibility (#2665) 2026-09-11 13:00:23 +02:00
HampusandGitHub 3a862f1484 fix(voice): record why a screen share stopped (#2664) 2026-09-11 12:59:51 +02:00
HampusandGitHub 5da256df12 fix(voice): poll the current video element for a first frame (#2663) 2026-09-11 12:57:52 +02:00
HampusandGitHub baf2cbf3fd fix(voice): rebind codec negotiation after a region hot swap (#2662) 2026-09-11 12:55:50 +02:00
HampusandGitHub 74782dc4f2 fix(voice): confirm a decode stall before withdrawing a codec (#2661) 2026-09-11 12:53:29 +02:00
HampusandGitHub 7d8778495f chore(desktop): drop Chromium switches that no longer exist (#2660) 2026-09-11 12:50:54 +02:00
HampusandGitHub 53399ffb44 fix(gateway): track dm partner presence in mutual guilds (#2658) 2026-09-11 04:23:20 +02:00
HampusandGitHub 35d73eae76 fix(voice): stop asking for camera and mic access on page load (#2657) 2026-09-11 02:00:48 +02:00
HampusandGitHub 54128e049a test(api): restore the stripe webhook secret after mocking it (#2656) 2026-09-11 01:36:26 +02:00
HampusandGitHub 7d8d0ff804 fix(ci): retry release publish after transient GitHub failures (#2655) 2026-09-11 01:35:24 +02:00
HampusandGitHub 2e8f381efc fix(gateway): keep ets tids opaque in the permission cache (#2654) 2026-09-11 01:31:58 +02:00
HampusandGitHub b29da84282 perf(gateway): trim large guild connect snapshots by default (#2653) 2026-09-11 01:03:22 +02:00
HampusandGitHub 2988c846c8 perf(gateway): read cached members from the guild member table (#2652) 2026-09-11 00:58:17 +02:00
fluxer-ci[bot]andGitHub 8e91c1412b chore(marketing): advance pointer 5908507 → 7867cf8 (#2650) 2026-09-11 00:51:33 +02:00
fluxer-ci[bot]andGitHub 5b2099c777 chore(i18n): update public marketing catalogs (#2651) 2026-09-11 00:51:25 +02:00
HampusandGitHub f97841a58f fix(installer): resolve the compose file name Compose loads (#2649) 2026-09-11 00:38:09 +02:00
HampusandGitHub 0421c86039 fix(api): price gifts in the base currency everywhere (#2648) 2026-09-11 00:28:14 +02:00
HampusandGitHub d17f320bd7 fix(app): tidy the Plutonium billing and pricing layout (#2647) 2026-09-10 23:06:18 +02:00
HampusandGitHub f708586c59 feat(api)!: always use localized pricing where it is offered (#2646) 2026-09-10 21:22:32 +02:00
HampusandGitHub 905af5dd5a fix(app): shrink stored favorite gifs and raise their budget (#2643) 2026-09-10 18:43:42 +02:00
HampusandGitHub 5fea319f4e fix(app): stop other youtube embeds when one starts playing (#2642) 2026-09-10 18:42:30 +02:00
HampusandGitHub 01fd11fea9 fix(api): unexport the search lookup result type (#2641) 2026-09-10 18:24:16 +02:00
HampusandGitHub d028679b90 fix(api): batch the message lookups behind message search (#2640) 2026-09-10 18:18:49 +02:00
HampusandGitHub 4a93b677af feat(api): retire prices safely and add a self-serve switch (#2637) 2026-09-10 17:28:16 +02:00
HampusandGitHub d79cd99050 refactor(api): tidy message helper internals (#2636) 2026-09-10 02:07:03 +02:00
HampusandGitHub 167862a8a6 perf(api): harvest messages a page at a time (#2633) 2026-09-09 20:59:38 +02:00
HampusandGitHub 48b569b9d4 fix(api): harvest every authored message, not the first 100000 (#2631) 2026-09-09 11:52:55 +02:00
HampusandGitHub 75be6aa492 fix(gateway): deliver mention updates to passive sessions (#2632) 2026-09-09 11:31:17 +02:00
HampusandGitHub 9fe65d5036 fix(api): honour the configured S3 addressing on uploads (#2626) 2026-09-09 11:26:30 +02:00
HampusandGitHub bfa9bf221d docs(api): tidy up the reference prose (#2628) 2026-09-09 02:11:38 +02:00
HampusandGitHub 184eeb0846 fix(gateway): keep dispatch ordered under broadcaster load (#2627) 2026-09-09 02:06:36 +02:00
HampusandGitHub 0eff26a1c9 test(config): match the configurable client-IP trust defaults (#2625) 2026-09-09 01:32:36 +02:00
HampusandGitHub d729f641ff fix(app): do not crash when the browser translates the page (#2624) 2026-09-09 01:24:14 +02:00
HampusandGitHub 044a2c101d feat(self-hosting): make the bundled services configurable (#2621) 2026-09-09 01:17:58 +02:00
HampusandGitHub 38e2c8db3e fix(admin): keep server traits when an operator saves traits (#2622) 2026-09-09 00:13:07 +02:00
HampusandGitHub 1b22d14f3d feat(self-hosting): run postgres or the object store outside (#2620) 2026-09-08 23:36:52 +02:00
HampusandGitHub 98cceae59d fix(i18n): point static catalog translation at weblate (#2619) 2026-09-08 23:10:10 +02:00
HampusandGitHub 3e32414849 test(config): expand the shipped stack on another port (#2612) 2026-09-08 23:10:00 +02:00
HampusandGitHub 7707b9531c chore(i18n): translate the new setup and email domain strings (#2613) 2026-09-08 22:57:07 +02:00
HampusandHampus Kraft 86745e01e9 docs(operator): cover serving on a non-default port (#2611) 2026-09-08 22:18:19 +02:00
HampusandHampus Kraft 098830a95a fix(admin): compare the request origin against an origin (#2610) 2026-09-08 22:18:10 +02:00
HampusandHampus Kraft cf83f66911 fix(app): keep the new admin when setup meets one 401 (#2609) 2026-09-08 22:18:02 +02:00
HampusandHampus Kraft c577b97f35 fix(api): reject a mail-less email domain by its own code (#2608) 2026-09-08 22:17:53 +02:00
HampusandGitHub 8cc485cf81 fix(self-host): tie the public address to a single origin (#2605) 2026-09-08 22:17:39 +02:00
HampusandGitHub 6c36d934f7 fix(api): widen guild IP ban guard to shared-access networks (#2607) 2026-09-08 22:09:39 +02:00
HampusandGitHub 63e3be5750 fix(media-proxy): tone map HDR video instead of refusing it (#2606) 2026-09-08 21:18:55 +02:00
HampusandGitHub cdecda7f78 ci: exclude the gateway build output from the rebar3 cache (#2604) 2026-09-08 19:47:44 +02:00
HampusandGitHub 4ad2858773 test(api): isolate the instance policy test files (#2603) 2026-09-08 19:46:07 +02:00
HampusandGitHub fda41bb57a style(gateway): apply erlfmt to the voice disconnect modules (#2602) 2026-09-08 19:29:38 +02:00
HampusandGitHub ce08f82a92 refactor(gateway): remove voice reconciliation v3 (#2601) 2026-09-08 19:17:48 +02:00
HampusandGitHub ef067f36c6 fix(voice): report real state in voice diagnostics (#2600) 2026-09-08 19:16:22 +02:00
HampusandGitHub fc2b6b5299 fix(app): correct shortcuts, nagbar, stream menu, share audio (#2599) 2026-09-08 19:09:59 +02:00
HampusandGitHub 55846b24ea fix(api): respect age gating in search and stabilise discovery (#2598) 2026-09-08 19:07:59 +02:00
HampusandGitHub 20a15ac11d fix(voice): scope disconnects, correct VAD and stream lifecycle (#2597) 2026-09-08 19:06:42 +02:00
HampusandGitHub 667ac7da8e fix(voice): rank h264 baseline first and gate opus stereo (#2596) 2026-09-08 19:04:24 +02:00
HampusandGitHub 1b81c14c48 fix(api): stop treating a LiveKit 404 as an empty room (#2595) 2026-09-08 19:02:43 +02:00
HampusandGitHub ceec183d38 docs: remove duplicated statements from the reference (#2594) 2026-09-08 17:55:55 +02:00
HampusandGitHub 69ddc07ebb docs(operator): tighten the get started guide (#2593) 2026-09-08 17:38:29 +02:00
HampusandGitHub 2f008b8653 docs: rewrite reference prose and correct field code citations (#2592) 2026-09-08 17:13:37 +02:00
HampusandGitHub 3d38d3f694 fix(self-host): add FLUXER_NATS_AUTH_TOKEN to .env.example (#2590) 2026-09-08 16:32:29 +02:00
HampusandGitHub ad86a04e67 feat(app): describe every role and channel permission toggle (#2589) 2026-09-08 16:20:37 +02:00
HampusandGitHub 600c15e17d chore(github): drop mobile build hint from the bug report form (#2588) 2026-09-08 15:37:26 +02:00
HampusandGitHub 08c9fe9886 docs: correct misreadable and factually wrong reference prose (#2587) 2026-09-08 15:36:50 +02:00
HampusandGitHub 43924e3ac5 chore(github): link mobile bug reports, drop security duplicate (#2586) 2026-09-08 15:34:34 +02:00
HampusandGitHub 824b5c86c9 fix(api): dedupe and budget ipinfo lookups across api pods (#2584) 2026-09-08 15:13:32 +02:00
HampusandGitHub a2a68847fd fix(api): let channel managers edit a mature channel (#2583) 2026-09-08 14:51:47 +02:00
HampusandGitHub 2019909a5e fix(api): skip the mature gate when no birth date is collected (#2582) 2026-09-08 14:51:41 +02:00
HampusandGitHub d46c8d49c6 fix(svc): authenticate to nats with the configured token (#2581) 2026-09-08 14:51:34 +02:00
HampusandGitHub 45530ebbf5 docs(operator): drop the redundant caddy forwarded-for setter (#2580) 2026-09-08 14:51:29 +02:00
HampusandGitHub 9cdad046b1 fix(self-host): keep seaweedfs inside its memory ceiling (#2579) 2026-09-08 14:51:24 +02:00
HampusandGitHub b6c6928073 fix(self-host): strip the caddy file capability in fluxer-static (#2578) 2026-09-08 14:51:19 +02:00
HampusandGitHub dd1ee999a4 fix(docs): drop visible pipe escapes from union notation prose (#2577) 2026-09-08 14:27:41 +02:00
HampusandGitHub c506d6d5e3 fix(webhook): stop gating webhook file uploads on creator perms (#2576) 2026-09-08 14:25:59 +02:00
HampusandGitHub 8a65832a65 feat(theme): default new accounts to the dark theme (#2575) 2026-09-08 14:05:10 +02:00
HampusandGitHub fd6ae4abd7 fix(app): distrust windows loaded across a connection gap (#2574) 2026-09-08 13:06:53 +02:00
HampusandGitHub 24b84c419c docs(http-api): reword the supplementary members paragraph (#2573) 2026-09-08 12:53:43 +02:00
HampusandGitHub 746a75187a fix(api): snapshot the new message id when opening a closed DM (#2569) 2026-09-07 11:00:03 +02:00
HampusandGitHub 10ba2ca896 fix(app): show the format toolbar on double-click selections (#2566) 2026-09-07 00:13:30 +02:00
HampusandGitHub 977b6767cd fix(app): refetch the tail when a channel window falls behind (#2565) 2026-09-06 23:51:08 +02:00
HampusandGitHub f00c6ee47a docs(http-api): name the endpoint a third-party client reads (#2564) 2026-09-06 23:50:52 +02:00
HampusandGitHub 82859dc2f6 fix(api): keep a deferral while the phone gate state is unknown (#2554) 2026-09-06 23:41:29 +02:00
HampusandGitHub 328dc06ab0 feat(installer): drive podman as well as docker (#2563) 2026-09-06 23:28:40 +02:00
HampusandGitHub ea6e4a75db fix(markdown): let a backslash escape a code fence (#2562) 2026-09-06 22:45:29 +02:00
HampusandGitHub 69ca462930 fix(app): keep popouts in the window they were opened in (#2561) 2026-09-06 22:42:32 +02:00
HampusandGitHub f38619d974 fix(app): isolate bidi usernames from message timestamps (#2560) 2026-09-06 22:41:57 +02:00
HampusandGitHub 6c0ce9369b fix(app): refresh mutual communities on membership change (#2559) 2026-09-06 22:38:31 +02:00
HampusandGitHub 00c1b19809 fix(app): inherit category mute when hiding muted channels (#2558) 2026-09-06 22:10:09 +02:00
HampusandGitHub 2fd5daf104 fix(app): keep the client active while the user is typing (#2557) 2026-09-06 21:29:06 +02:00
HampusandGitHub fbf0f6adfe fix(app): load more bookmarks as the list scrolls (#2556) 2026-09-06 21:05:57 +02:00
HampusandGitHub d91b5bec66 fix(app): show unread channels in muted collapsed categories (#2555) 2026-09-06 20:45:11 +02:00
HampusandGitHub 0f24cfb6ef ci(docs): check the installer upgrade key lists for drift (#2553) 2026-09-06 20:43:50 +02:00
HampusandGitHub 7a6691cdbe fix(installer): make the record and rollback paths trustworthy (#2552) 2026-09-06 20:36:59 +02:00
HampusandGitHub 73d3a4f843 fix(app): widen the custom status modal (#2551) 2026-09-06 20:19:28 +02:00
HampusandGitHub 091755fe78 fix(self-hosting): adapt the upgrade to existing instances (#2550) 2026-09-06 19:40:32 +02:00
HampusandGitHub 1fb2790bb9 fix(api): stop bounding the pin listing by the wall clock (#2549) 2026-09-06 19:03:15 +02:00
HampusandGitHub 798e64b224 refactor(app): remove the report modal path selection step (#2548) 2026-09-06 18:49:35 +02:00
HampusandGitHub a2d6477b42 fix(admin): route the bulk user deletion action correctly (#2545) 2026-09-06 18:42:37 +02:00
HampusandGitHub a2ca24eeb4 fix(admin): search archives across both subject types (#2542) 2026-09-06 18:42:33 +02:00
HampusandGitHub 8dcd00a8fe fix(admin): batch user id lookups on the users page (#2547) 2026-09-06 18:41:46 +02:00
HampusandGitHub be8a52c823 fix(admin): bound the reports page offset (#2546) 2026-09-06 18:41:18 +02:00
HampusandGitHub 43e420b0ab fix(admin): require paired voice server coordinates (#2544) 2026-09-06 18:40:50 +02:00
HampusandGitHub f8947adf62 fix(admin): map the index refresh status response union (#2543) 2026-09-06 18:40:20 +02:00
HampusandGitHub 81fccaf0ab docs(media-proxy): stop documenting literal response bodies (#2541) 2026-09-06 18:39:50 +02:00
HampusandGitHub 7a42291baf fix(api): search all reports when no status filter is given (#2540) 2026-09-06 18:39:18 +02:00
HampusandGitHub d9f983b08e fix(api): return the terminated count from terminate sessions (#2539) 2026-09-06 18:38:46 +02:00
HampusandGitHub 2f159852a7 fix(api): make an empty admin guild patch apply no change (#2538) 2026-09-06 18:38:13 +02:00
HampusandGitHub 5ef402b8ee fix(api): apply the nsfw and content warning guild settings (#2537) 2026-09-06 18:37:38 +02:00
HampusandGitHub a8d6e5ab73 refactor(api): remove premium-based voice track muting (#2536) 2026-09-06 18:37:01 +02:00
HampusandGitHub e805a3797f fix(api): stop entrance sound play probing channel existence (#2535) 2026-09-06 18:36:24 +02:00
HampusandGitHub 8f4fa82a9e fix(api): always return the page total when listing reports (#2534) 2026-09-06 17:38:21 +02:00
HampusandGitHub d2438b2fdd docs(operator): note the upload relay secret an upgrade now needs (#2533) 2026-09-06 17:21:06 +02:00
HampusandGitHub 133640ef2b fix(docs): allow unused pnpm patches in the docs image deploy (#2531) 2026-09-06 16:19:46 +02:00
HampusandGitHub 8e0516a8c3 feat(api): drop explicit media classification on asset uploads (#2530) 2026-09-06 16:12:25 +02:00
HampusandGitHub d784c0692e fix(app): set the jsx runtime in tsconfig so vitest parses tsx (#2529) 2026-09-06 15:51:18 +02:00
HampusandGitHub fffa265117 chore(i18n): refresh the client message catalogues (#2528) 2026-09-06 15:41:52 +02:00
HampusandGitHub 5367c0ab42 docs: move the reference site to astro starlight (#2527) 2026-09-06 15:40:22 +02:00
HampusandGitHub 7f8f09ee51 feat(admin)!: move the admin api to rest and fix its defects (#2515) 2026-09-06 15:36:41 +02:00
HampusandGitHub a70924d4b0 fix(admin): require the admin secret key base at boot (#2514) 2026-09-06 15:36:08 +02:00
HampusandGitHub 1a5925f9cb chore(app): remove message scheduling and a dead descriptor (#2513) 2026-09-06 15:35:36 +02:00
HampusandGitHub 43c778aae4 fix(api): guard the rpc session init test harness route (#2512) 2026-09-06 15:34:57 +02:00
HampusandGitHub 34cf8f821f refactor(api)!: drop unused helpers, parameters and a route (#2511) 2026-09-06 15:34:25 +02:00
HampusandGitHub 226cfd062e fix(worker): rebuild the deletion queue and cancel system dms (#2510) 2026-09-06 15:33:53 +02:00
HampusandGitHub e8f4e35c32 fix(api): gate stream keys by channel type and cover previews (#2509) 2026-09-06 15:33:20 +02:00
HampusandGitHub ef559f3d8c fix(api): handle bad manifests, unfurl errors and the apns key (#2508) 2026-09-06 15:32:47 +02:00
HampusandGitHub ee7206ac66 fix(api): batch connection reorders, dispatch on failed recheck (#2507) 2026-09-06 15:32:16 +02:00
HampusandGitHub 1ba9592308 fix(api): correct webhook dedupe and the instatus transforms (#2506) 2026-09-06 15:31:43 +02:00
HampusandGitHub d07f520b13 fix(api)!: correct pagination and locking, drop toggle routes (#2505) 2026-09-06 15:31:13 +02:00
HampusandGitHub 632f4c7b6c fix(api): correct report targets and ticket handling (#2504) 2026-09-06 15:30:41 +02:00
HampusandGitHub 1f627c9cc5 fix(api): correct user content, read state and harvest paths (#2501) 2026-09-06 15:30:08 +02:00
HampusandGitHub cc110b9f5a fix(api): raise coded errors for prerequisites and bounds (#2502) 2026-09-06 15:29:36 +02:00
HampusandGitHub f06d65db54 fix(api): reject unparsable bodies and screen non-form ones (#2503) 2026-09-06 15:29:04 +02:00
HampusandGitHub f8a04b8985 fix(api)!: enforce declared rate limits and correct route auth (#2500) 2026-09-06 15:28:32 +02:00
HampusandGitHub 908e1b8bd4 fix(api): correct guild permission and mfa checks (#2499) 2026-09-06 15:28:01 +02:00
HampusandGitHub f392636857 fix(auth): correct mfa errors, sudo methods and birth dates (#2498) 2026-09-06 15:27:30 +02:00
HampusandGitHub 150115cc0c fix(media-proxy): bound the relay body and drop the unread ttl (#2496) 2026-09-06 15:26:57 +02:00
HampusandGitHub 710a6f1c5d fix(media-proxy): correct route errors and test the ip gate (#2495) 2026-09-06 15:26:33 +02:00
HampusandGitHub 7c3e722085 chore(gateway): delete modules with no callers (#2494) 2026-09-06 15:26:08 +02:00
HampusandGitHub d8f2aa3184 feat(gateway): add an undrain endpoint and sweep orphan tables (#2493) 2026-09-06 15:25:43 +02:00
HampusandGitHub e828398e06 fix(gateway): close oversized bot identify with code 4011 (#2497) 2026-09-06 15:25:19 +02:00
HampusandGitHub 31d7cb81d6 fix(gateway): return precise rpc errors and bound snowflakes (#2491) 2026-09-06 15:24:54 +02:00
HampusandGitHub 214d19d45a fix(gateway): resync permissions and validate voice leaves (#2492) 2026-09-06 15:24:23 +02:00
HampusandGitHub d3170fc320 fix(gateway): repair the session lifecycle, limits and dead code (#2490) 2026-09-06 15:23:59 +02:00
HampusandGitHub 9a229c1b73 fix(api): answer 403 when the client ip header is unparsable (#2483) 2026-09-06 15:23:35 +02:00
HampusandGitHub a036d9a2e1 fix(api): resolve missing user rows for webhooks and sessions (#2487) 2026-09-06 15:23:03 +02:00
HampusandGitHub d5bfa5a73d fix(api)!: align error codes with throw sites and image bounds (#2488) 2026-09-06 15:21:38 +02:00
HampusandGitHub 4534822355 fix(config): derive the VAPID public point to verify the pair (#2521) 2026-09-06 15:13:32 +02:00
HampusandGitHub bff29d8f07 fix(api)!: always send Retry-After and reclassify two limits (#2489) 2026-09-06 15:07:00 +02:00
HampusandGitHub bec34ea147 fix(api)!: correct declared bounds and hide public bot mfa (#2485) 2026-09-06 15:06:15 +02:00
Hampus Kraft 3be4171256 feat(self-host)!: rework the compose stack and demand secrets (#2486) 2026-09-06 15:02:20 +02:00
Hampus Kraft 5bcaa7cfac fix(config)!: validate and derive config, drop the unread keys (#2482) 2026-09-06 15:02:20 +02:00
HampusandGitHub ea93ef5352 fix(api): find every live route when generating openapi.json (#2484) 2026-09-06 14:54:21 +02:00
HampusandGitHub 8734956d86 fix(auth): explain why a phone number was rejected (#2480) 2026-09-06 03:09:26 +02:00
HampusandGitHub 519b3a6127 fix(i18n): translate shipped English, repair broken catalogs (#2479) 2026-09-06 02:58:56 +02:00
HampusandGitHub 9b3773c1e6 feat(auth): let phone-gated accounts set the check aside (#2478) 2026-09-06 01:11:24 +02:00
HampusandGitHub e12b60078a fix(self-host): probe the seaweedfs s3 health endpoint (#2476) 2026-09-06 00:24:31 +02:00
HampusandGitHub 7cb8f9f4ae fix(app): pick default channel when guild channels arrive late (#2475) 2026-09-06 00:04:43 +02:00
HampusandGitHub 622bd124b9 fix(fonts): stop SC and TC from claiming kana (#2473) 2026-09-05 22:25:44 +02:00
HampusandGitHub fed8b2d089 feat(admin): add bulk delete user messages tool (#2472) 2026-09-05 22:20:14 +02:00
HampusandGitHub 12718eabbc refactor(api): drop cookie support for sudo mode (#2466) 2026-09-05 01:29:34 +02:00
HampusandGitHub ab68b61653 refactor: remove the CTP_MEMBER user flag (#2465) 2026-09-05 01:13:34 +02:00
HampusandGitHub 639ade3802 refactor(app-proxy): drop invite metadata and database access (#2464) 2026-09-05 00:13:04 +02:00
HampusandGitHub 3f1f899b23 fix(api): keep a deprecated nsfw field for older clients (#2463) 2026-09-04 23:08:34 +02:00
HampusandGitHub 51cb750502 feat(api): drop NSFW classification for emojis and stickers (#2462) 2026-09-04 22:55:58 +02:00
HampusandGitHub 1e6c332eae fix(app): show empty categories when hiding muted channels (#2460) 2026-09-04 21:04:34 +02:00
HampusandGitHub 18ae2e563e fix(worker): fit the job streams to the jetstream budget (#2458) 2026-09-04 19:31:20 +02:00
HampusandGitHub a4d039c910 fix(app-proxy): publish source maps with the asset tree (#2457) 2026-09-04 19:10:30 +02:00
HampusandGitHub 6163fd5644 fix(message): turn mentions red in failed messages (#2456) 2026-09-04 19:03:30 +02:00
HampusandGitHub 3e2ddaca4f fix(message): turn links red in failed messages (#2455) 2026-09-04 18:21:27 +02:00
HampusandGitHub 054a59e622 fix(message): keep reply previews on one line after a mention (#2454) 2026-09-04 18:20:14 +02:00
HampusandGitHub 84d7290ed9 fix(api): tighten guild emoji and sticker mutation limits (#2453) 2026-09-04 17:58:08 +02:00
HampusandGitHub f4c1254d91 fix(media-proxy): stop serving animated originals as stills (#2452) 2026-09-04 16:56:58 +02:00
HampusandGitHub c01d22dc05 fix(self-host): unfurl media hosted by the instance itself (#2451) 2026-09-04 16:56:35 +02:00
HampusandGitHub 4c0f02d8a5 chore(i18n): refresh catalogs for the window share audio scope (#2450) 2026-09-04 16:41:53 +02:00
HampusandGitHub 2770482baf perf(app-proxy): hold the frozen snapshot by reference (#2449) 2026-09-04 16:00:23 +02:00
HampusandGitHub 8e39a00e34 perf(app-proxy): run on jemalloc to curb arena growth (#2448) 2026-09-04 15:57:47 +02:00
HampusandGitHub 7bd0d3a962 fix(app-proxy): remove the SPA document render reservation (#2447) 2026-09-04 15:55:59 +02:00
HampusandGitHub bc7f701e87 feat(voice): give window shares their own audio scope (#2446) 2026-09-04 15:48:37 +02:00
HampusandGitHub 0d8116d73e fix(workspace): restore the devcontainer compose project name (#2445) 2026-09-04 15:41:38 +02:00
HampusandGitHub 255cbc1248 perf(app-proxy): drop dynamic brotli compression (#2444) 2026-09-04 14:47:49 +02:00
HampusandGitHub 098aeef412 fix(media-proxy): stop lifting bt709 video thumbnails (#2443) 2026-09-04 13:17:30 +02:00
HampusandGitHub baa18aed5b fix(media-proxy): link source-built native libs first (#2442) 2026-09-04 03:42:56 +02:00
HampusandGitHub b7c8dab019 fix(media-proxy): pin builder libheif, fix the image build (#2441) 2026-09-04 02:06:02 +02:00
HampusandGitHub 587324fa38 fix(voice): reuse the Linux audio capture across routing changes (#2440) 2026-09-04 01:00:32 +02:00
HampusandGitHub cc3a9c8613 fix(app): jitter gateway reconnects and recover status nagbar (#2439) 2026-09-03 23:21:32 +02:00
HampusandGitHub b0645300ec fix(i18n): reaction tooltip word order and plural agreement (#2438) 2026-09-03 22:11:59 +02:00
HampusandGitHub d7d4e8da03 refactor(media-proxy): split into modules and harden streaming (#2437) 2026-09-03 22:04:00 +02:00
HampusandGitHub 16ae98e189 fix(auth): regenerate backup codes with the emailed challenge (#2436) 2026-09-03 21:29:16 +02:00
HampusandGitHub add0a3dfc6 fix(voice): start bitrate for non-SVC screen share codecs (#2434) 2026-09-03 21:07:42 +02:00
HampusandGitHub 90c349392b fix(auth): email code to view backup codes, fix login matching (#2433) 2026-09-03 21:06:30 +02:00
HampusandGitHub eb4562b6a6 feat(voice): add screen share subscription debug helper (#2432) 2026-09-03 20:22:59 +02:00
HampusandGitHub 6c634b686f feat(voice): rework screen share audio source selection (#2431) 2026-09-03 20:01:28 +02:00
HampusandGitHub 48e03edccc chore(desktop): upgrade Electron to 44.1.1 (#2430) 2026-09-03 18:53:23 +02:00
HampusandGitHub cef6f11fd0 fix(app): correct the connection nagbar button styling (#2428) 2026-09-03 18:17:06 +02:00
HampusandGitHub 2757659989 fix(gateway): satisfy dialyzer after the hotpatch reconcile (#2427) 2026-09-03 17:26:51 +02:00
HampusandGitHub f090395c21 fix(voice): republish screen share when its codec goes stale (#2426) 2026-09-03 17:09:48 +02:00
HampusandGitHub dd1d554cc6 fix(gateway): reconcile hotpatched member-list and push fixes (#2425) 2026-09-03 17:04:32 +02:00
HampusandGitHub 9c1b38aeaf fix(api): always allow opening a dm channel (#2423) 2026-09-03 16:35:01 +02:00
HampusandGitHub 902dd60ff9 fix(voice): keep published codecs inside the opt-in policy (#2422) 2026-09-03 15:06:48 +02:00
HampusandGitHub 2426a5769d feat(voice): drive screen share quality from viewer demand (#2421) 2026-09-03 04:49:29 +02:00
HampusandGitHub 66517c925b feat(voice): show a passive badge when a stream underperforms (#2420) 2026-09-03 04:49:07 +02:00
HampusandGitHub 5f90e7d535 fix(api): downgrade oversized video instead of ending the call (#2419) 2026-09-03 04:47:02 +02:00
HampusandGitHub 9d63eb15a9 fix(voice): request a real camera frame rate at capture (#2418) 2026-09-03 04:46:32 +02:00
HampusandGitHub c97bc53342 refactor(voice): remove screen share codec renegotiation (#2417) 2026-09-03 04:46:11 +02:00
HampusandGitHub f5f60c66e7 refactor(voice): remove adaptive screen share quality system (#2416) 2026-09-03 04:41:04 +02:00
HampusandGitHub 3e15b97c8c fix(voice): stop clamping stored video quality preferences (#2415) 2026-09-03 04:36:04 +02:00
HampusandGitHub 6c08813f9b feat(voice): scale screen share bitrate to the selected rung (#2414) 2026-09-03 04:35:37 +02:00
HampusandGitHub 8158d44732 fix(voice): keep screen share resolution under constraint (#2413) 2026-09-03 04:35:12 +02:00
HampusandGitHub 9bd0019759 fix(api): declare undici for the bundled http client (#2410) 2026-09-02 19:55:11 +02:00
HampusandGitHub a74f1b0e7b fix(ci): clear knip, refresh openapi, close kv schema race (#2409) 2026-09-02 18:12:48 +02:00
HampusandGitHub 2b12f5db6c feat(voice): enable web camera background effects (#2408) 2026-09-02 17:40:13 +02:00
HampusandGitHub af4a52173c fix(voice): dispatch sourceLifecycle.removed on unbind (#2407) 2026-09-02 17:40:00 +02:00
HampusandGitHub 5bc1f21d46 fix(voice): drive call tiles from gateway voice state (#2406) 2026-09-02 17:39:48 +02:00
HampusandGitHub 917e437939 feat(voice-menus): add call controls to private call user menus (#2405) 2026-09-02 17:39:25 +02:00
HampusandGitHub 7ee4fb37d6 feat(voice): add a live input level meter to voice menus (#2404) 2026-09-02 17:39:01 +02:00
HampusandGitHub 6155eec804 feat(voice): flatten voice menus, gate ptt on a bound key (#2403) 2026-09-02 17:38:37 +02:00
HampusandGitHub 43d8637153 fix(voice): gate the camera preview and update effects in place (#2402) 2026-09-02 17:38:14 +02:00
HampusandGitHub 250db2fdab fix(voice): hide stream volume without remote share audio (#2401) 2026-09-02 17:37:52 +02:00
HampusandGitHub 7bd021cd5c feat(voice): open voice popouts in the browser (#2400) 2026-09-02 17:37:28 +02:00
HampusandGitHub adb9a689f0 feat(voice): share the voice room across popout trees (#2399) 2026-09-02 17:37:04 +02:00
HampusandGitHub d8e0c2ec20 fix(settings): stop auto-requesting devices, warn when none (#2398) 2026-09-02 17:36:49 +02:00
HampusandGitHub f98a74170a feat(settings): move macos permission review into desktop tab (#2397) 2026-09-02 17:36:26 +02:00
HampusandGitHub 17b9821879 fix(voice-menus): drive stream actions from the published source (#2396) 2026-09-02 17:36:05 +02:00
HampusandGitHub 4b5bdefcb9 fix(voice-menus): sentence-case participant menu labels (#2395) 2026-09-02 17:35:49 +02:00
HampusandGitHub 45cbabd94d fix(voice): abort screen share when its audio cannot start (#2394) 2026-09-02 17:35:26 +02:00
HampusandGitHub 8402eb53c8 feat(voice): skip the screen-share picker modal on web (#2393) 2026-09-02 17:35:15 +02:00
HampusandGitHub d04ace5789 feat(voice): redesign the screen-share source picker (#2392) 2026-09-02 17:35:01 +02:00
HampusandGitHub e94f587535 feat(voice): sequence join chimes ahead of entrance sounds (#2391) 2026-09-02 17:34:36 +02:00
HampusandGitHub e73285060e fix(voice): honour the codec preference in fallback selection (#2390) 2026-09-02 17:34:24 +02:00
HampusandGitHub f1734704ef fix(voice): guard stale screen-share negotiation and probes (#2389) 2026-09-02 17:34:10 +02:00
HampusandGitHub 59f6217267 refactor(voice): bound the screen-share codec wire formats (#2388) 2026-09-02 17:33:57 +02:00
HampusandGitHub 90f4a222b7 refactor(voice): request mic and camera permission separately (#2387) 2026-09-02 17:33:45 +02:00
HampusandGitHub 749d2091eb fix(voice): log local voice state hydration failures (#2386) 2026-09-02 17:33:32 +02:00
HampusandGitHub 8d34c6bcaa refactor(voice): make screen-share source swaps atomic (#2385) 2026-09-02 17:33:18 +02:00
HampusandGitHub 62577b25bb feat(voice): request web share audio via the browser picker (#2384) 2026-09-02 17:32:55 +02:00
HampusandGitHub 475f5a7dae fix(voice): refresh camera capture when the device changes (#2383) 2026-09-02 17:32:43 +02:00
HampusandGitHub 1772b3aad0 fix(voice): polish the stream settings menu (#2382) 2026-09-02 17:32:26 +02:00
HampusandGitHub 7263b21a06 refactor(voice): pin screen share to a fixed 7 Mbps bitrate (#2381) 2026-09-02 17:32:01 +02:00
HampusandGitHub 501adff13d refactor(voice): clamp premium video quality at read time (#2380) 2026-09-02 17:31:39 +02:00
HampusandGitHub 12c6fb7b7f feat(voice): add screen-share audio and rollback error handling (#2379) 2026-09-02 17:31:27 +02:00
HampusandGitHub 376168c922 feat(voice): add the camera background effects pipeline (#2378) 2026-09-02 17:31:04 +02:00
HampusandGitHub cadab239a2 feat(backgrounds): accept webm custom call backgrounds (#2377) 2026-09-02 17:30:50 +02:00
HampusandGitHub f57dc77c6d vendor(livekit): make local track swaps transactional (#2376) 2026-09-02 17:30:35 +02:00
HampusandGitHub 497a494c37 vendor(livekit): await setParameters in setDegradationPreference (#2375) 2026-09-02 17:30:22 +02:00
HampusandGitHub 02069e8e5d feat(voice-engine): add a sourceLifecycle.removed event (#2374) 2026-09-02 17:30:08 +02:00
HampusandGitHub 626293392c fix(ui): let callers style the audio level meter (#2373) 2026-09-02 17:29:55 +02:00
HampusandGitHub dfe42ae3e3 feat(sound): play one-shot sounds immediately and abortably (#2372) 2026-09-02 17:29:43 +02:00
HampusandGitHub 453abfa145 fix(ui): keep context menus clear of the native titlebar (#2371) 2026-09-02 17:29:29 +02:00
HampusandGitHub 8ebc9400ce fix(permissions): gate role hierarchy on known membership (#2370) 2026-09-02 17:29:06 +02:00
HampusandGitHub 1598f48edd fix(guild): invalidate member sidebar on role changes (#2369) 2026-09-02 17:28:44 +02:00
HampusandGitHub cc92f37f0c test(app): stop reading gl calls as react hooks (#2368) 2026-09-02 17:28:32 +02:00
HampusandGitHub 9322aca6cb fix(channel): restore composer draft and message focus (#2367) 2026-09-02 17:28:19 +02:00
HampusandGitHub ee8fbd6f4f fix(ui): keep the focus ring stable across refocus (#2366) 2026-09-02 17:27:57 +02:00
HampusandGitHub 1acd61a112 fix(ui): hand tooltips over between adjacent triggers (#2365) 2026-09-02 17:27:46 +02:00
HampusandGitHub e836686a71 fix(permissions): map not-determined media status to prompt (#2364) 2026-09-02 17:27:35 +02:00
HampusandGitHub ea6c417378 fix(discovery): keep category counts when a search resolves (#2363) 2026-09-02 17:27:22 +02:00
HampusandGitHub 16cc9a9e69 fix(app): clamp persisted accessibility values (#2362) 2026-09-02 17:27:10 +02:00
HampusandGitHub 6b5316fa84 fix(desktop): return a generic clipboard copy error (#2361) 2026-09-02 17:26:57 +02:00
HampusandGitHub a53f5d1289 fix(desktop): verify privileged ipc senders (#2360) 2026-09-02 17:26:45 +02:00
HampusandGitHub de2ea99928 fix(desktop): pin outbound fetches to a validated address (#2359) 2026-09-02 17:26:32 +02:00
HampusandGitHub 25f4332b9e fix(auth): guard stale submissions and rework form error mapping (#2358) 2026-09-02 17:26:18 +02:00
HampusandGitHub f703969e80 fix(auth): require a hashed poll secret for desktop handoff (#2357) 2026-09-02 17:25:54 +02:00
HampusandGitHub b82681b77a fix(auth): revoke the parsed token on logout (#2356) 2026-09-02 17:25:33 +02:00
HampusandGitHub ef248a8515 fix(platform): parse api error responses in one place (#2355) 2026-09-02 17:25:21 +02:00
HampusandGitHub 73a2345c26 fix(app-proxy): validate config, csp sources, cap resource use (#2354) 2026-09-02 17:25:10 +02:00
HampusandGitHub 9f33177eab fix(gateway): rate limit resume like identify (#2353) 2026-09-02 17:24:58 +02:00
HampusandGitHub d5a752c338 fix(gateway): only trust the client ip header when enabled (#2352) 2026-09-02 17:24:46 +02:00
HampusandGitHub 4021a2d697 fix(gateway): bound the zstd decompression window (#2351) 2026-09-02 17:24:35 +02:00
HampusandGitHub bea4a6dcbc fix(read-state): keep a failed ack dispatch from failing acks (#2350) 2026-09-02 17:24:24 +02:00
HampusandGitHub 4f48e04cad feat(api): serve well-known discovery with etag and 304 (#2349) 2026-09-02 17:24:11 +02:00
HampusandGitHub 5719dfe8a3 fix(auth): bucket phone attempt risk by v4 and v6 subnet (#2348) 2026-09-02 17:23:58 +02:00
HampusandGitHub 4fb14e85e8 fix(auth): harden login rate keys and totp reuse (#2347) 2026-09-02 17:23:47 +02:00
HampusandGitHub 1d84689b45 fix(api): validate push and domain verification targets (#2346) 2026-09-02 17:23:34 +02:00
HampusandGitHub 693aec2b4d fix(api): trust recorded upload types and bound edit sizes (#2345) 2026-09-02 17:23:23 +02:00
HampusandGitHub c79c0ee138 fix(api): bound storage listings, ranges and search paging (#2344) 2026-09-02 17:23:12 +02:00
HampusandGitHub e86e24a2db fix(captcha): drop the body-email contact policy exemption (#2343) 2026-09-02 17:23:02 +02:00
HampusandGitHub 0f7ad484ce fix(constants): add captcha, cache and feature headers (#2342) 2026-09-02 17:22:51 +02:00
HampusandGitHub bcd95b2af9 fix(http-client): validate public addresses at connect time (#2341) 2026-09-02 17:22:37 +02:00
HampusandGitHub 32dcd5ed1c chore(i18n): resync message catalogs with source (#2340) 2026-09-02 17:22:21 +02:00
HampusandGitHub 5119febb5b fix(api): send stickers through webhooks (#2338) 2026-09-02 13:55:54 +02:00
HampusandGitHub 20cdfd3009 fix(api): stop exporting unused worker heartbeat symbols (#2334) 2026-09-01 21:03:26 +02:00
HampusandGitHub 9f739427c4 fix(desktop): update rtrb past the double free advisory (#2336) 2026-09-01 21:03:19 +02:00
HampusandGitHub 0201cafd7e fix(admin): mark the crate unpublished so cargo deny passes (#2335) 2026-09-01 21:03:12 +02:00
HampusandGitHub 37f57bb29f fix(desktop): restore offline Flatpak builds (#2332) 2026-09-01 20:51:18 +02:00
HampusandGitHub ebd723679b docs(operator): refresh the bundle pin and tunnel setup (#2333) 2026-09-01 20:51:00 +02:00
HampusandGitHub 961fa1f007 fix(self-hosting): correct compose probes and origins (#2330) 2026-09-01 20:47:20 +02:00
HampusandGitHub 7900a4da0c feat(ci): pin releases to an immutable image set (#2327) 2026-09-01 20:47:20 +02:00
HampusandGitHub 8a24730884 fix(kv): align rust and typescript schema migration (#2328) 2026-09-01 20:47:19 +02:00
HampusandGitHub 1688e7dc50 fix(api): survive transient database errors in the worker (#2326) 2026-09-01 20:47:19 +02:00
HampusandGitHub aa267b54ec fix(api): dead-letter retired worker task types (#2323) 2026-09-01 20:47:19 +02:00
HampusandGitHub bc40073a02 fix(config): carry the public port into derived endpoints (#2329) 2026-09-01 20:47:18 +02:00
HampusandGitHub a93f9dd0af fix(app-proxy): separate readiness from liveness (#2322) 2026-09-01 20:47:18 +02:00
HampusandGitHub 53a9fdc4b6 fix(app-proxy): share one asset tree across architectures (#2325) 2026-09-01 20:47:17 +02:00
HampusandGitHub cef600277c fix(media-proxy): probe health with the binary not /dev/tcp (#2324) 2026-09-01 20:47:17 +02:00
HampusandGitHub bdac438329 fix(docker): emit consistent OCI metadata on every image (#2321) 2026-09-01 20:47:16 +02:00
HampusandGitHub 2d77f36a0b fix(ci): generate locale and channel files before typecheck (#2320) 2026-09-01 20:47:16 +02:00
HampusandGitHub 90aa810ce4 fix(gateway): share the relay dispatch bound across producers (#2315) 2026-09-01 04:34:41 +02:00
HampusandGitHub cf3af50464 fix(kv): bound multi key fan out by pipelining per hash slot (#2313) 2026-09-01 02:59:00 +02:00
HampusandGitHub 3b5b20c139 fix(gateway): bound relay dispatch without per-event probes (#2312) 2026-09-01 02:56:21 +02:00
HampusandGitHub 24cd163acd fix(kv): restore keyset paging for numeric key scans (#2314) 2026-09-01 02:54:10 +02:00
HampusandGitHub 49f76e5b40 fix(api): abort startup on unverifiable deletion queue state (#2311) 2026-09-01 02:45:29 +02:00
HampusandGitHub 871788f0a9 fix(gateway): reclaim ip connection counts from dead sockets (#2308) 2026-09-01 01:39:25 +02:00
HampusandGitHub 24138b70f1 fix(kv): stop multi-key commands spanning cluster slots (#2310) 2026-09-01 01:39:16 +02:00
HampusandGitHub da3332e711 fix(gateway): bound relay worker mailboxes without reordering (#2309) 2026-09-01 01:38:36 +02:00
HampusandGitHub 06e5cf2032 perf(gateway): evict presence tombstones in insertion order (#2307) 2026-09-01 01:34:57 +02:00
HampusandGitHub d4b1923c23 fix(gateway): stop presence evictions suppressing repair (#2305) 2026-09-01 01:33:13 +02:00
HampusandGitHub 42df4f6731 fix(kv): drop the row_key order probe that cliffed paged scans (#2306) 2026-09-01 01:32:33 +02:00
HampusandGitHub f1e6e94041 fix(cache): stop a timed out produce pinning its tracking entry (#2304) 2026-09-01 01:28:12 +02:00
HampusandGitHub 0cd12b2f32 test(api): build deletion queue users from the real row type (#2303) 2026-09-01 00:57:41 +02:00
HampusandGitHub 5da4d24d38 fix(kv): page scans by keyset so deletes cannot skip rows (#2302) 2026-09-01 00:29:24 +02:00
HampusandGitHub 7806d2ac02 fix(gateway): stop stale guild connect timers aborting connects (#2301) 2026-09-01 00:23:42 +02:00
HampusandGitHub 2c4d182d1f fix(gateway): keep dispatch ordered under relay backpressure (#2300) 2026-09-01 00:17:12 +02:00
HampusandGitHub dcd5f88d65 fix(gateway): stop rate limit tables dying with their creator (#2299) 2026-09-01 00:16:26 +02:00
HampusandGitHub 662f4ac93b fix(worker): stop skipped accounts starving the deletion queue (#2294) 2026-09-01 00:16:08 +02:00
HampusandGitHub a2480c6a02 fix(cache): time out a getOrSet produce that never settles (#2297) 2026-09-01 00:15:44 +02:00
HampusandGitHub c49460a44f fix(gateway): stop anti-entropy resurrecting deleted presence (#2298) 2026-09-01 00:14:32 +02:00
HampusandGitHub 6786dfe7e3 fix(gateway): stop dropping newly requested lazy ranges (#2293) 2026-09-01 00:14:24 +02:00
HampusandGitHub 7d710d881a fix(worker): lease premium reconciliation queue entries (#2296) 2026-09-01 00:14:08 +02:00
HampusandGitHub 2ea2e79f6f fix(voice): stop occupancy writes spanning kv cluster slots (#2295) 2026-09-01 00:11:29 +02:00
HampusandGitHub cd42dd8ca7 fix(api): rebuild the deletion queue under its lock (#2292) 2026-09-01 00:06:33 +02:00
HampusandGitHub f2eddeae4d fix(gateway): stop rate limit sweepers outliving their table (#2291) 2026-08-31 23:25:00 +02:00
HampusandGitHub 8e1a8fc7e3 fix(gateway): sweep stale shared ip and user rate buckets (#2290) 2026-08-31 22:53:37 +02:00
HampusandGitHub 87c08b051f fix(voice): finish the reconciliation sweep before stopping (#2289) 2026-08-31 22:38:04 +02:00
HampusandGitHub 9d95a80857 fix(worker): renew the deletion queue lock during a rebuild (#2287) 2026-08-31 22:38:00 +02:00
HampusandGitHub 9371b6d5de fix(worker): count each channel once in a bulk reindex (#2286) 2026-08-31 22:37:56 +02:00
HampusandGitHub bdcf4b25c0 chore(expressions): remove the pack residue cleanup tool (#2288) 2026-08-31 22:31:54 +02:00
HampusandGitHub 3dc344be65 fix(worker): keep attachment decay state on a stale expiry row (#2285) 2026-08-31 22:26:21 +02:00
HampusandGitHub 17ed0f70aa fix(gateway): release the user session count on a handoff fence (#2284) 2026-08-31 22:25:14 +02:00
HampusandGitHub be3e12e60d fix(gateway): clamp a heartbeat ack to the session sequence (#2283) 2026-08-31 22:23:43 +02:00
HampusandGitHub 4261cc2ea5 fix(worker): resubscribe when the job stream ends unexpectedly (#2282) 2026-08-31 22:22:14 +02:00
HampusandGitHub 88dbc27019 fix(gateway): group debounced reactions by their own message (#2281) 2026-08-31 22:21:14 +02:00
HampusandGitHub f38fc80c31 fix(gateway): clear the presence pid cache on a presence down (#2279) 2026-08-31 22:19:25 +02:00
HampusandGitHub 803fdaf443 fix(worker): stop replaying requeued asset deletions in a run (#2280) 2026-08-31 22:19:22 +02:00
HampusandGitHub 55d85db401 fix(gateway): drop the channel engine on an empty range list (#2278) 2026-08-31 22:17:19 +02:00
HampusandGitHub 7ce3d71c44 fix(gateway): stop a non-map opcode payload crashing the socket (#2277) 2026-08-31 22:14:26 +02:00
HampusandGitHub 04e150e4bf fix(gateway): keep the replay buffer across a session transfer (#2276) 2026-08-31 22:10:44 +02:00
HampusandGitHub 0f6b118921 fix(worker): catch up cron jobs missed by a delayed tick (#2275) 2026-08-31 22:06:15 +02:00
HampusandGitHub 44277e6aa2 fix(worker): drain in-flight jobs before the runner stops (#2274) 2026-08-31 22:06:12 +02:00
HampusandGitHub bb7e8cc6f1 fix(gateway): flush buffered presences in arrival order (#2273) 2026-08-31 22:04:49 +02:00
HampusandGitHub 32a64fb097 fix(cache): refcount produce tracking so deletes are not lost (#2272) 2026-08-31 22:00:48 +02:00
HampusandGitHub c4594397e7 fix(desktop): accept array-form AppRun sandbox fallback (#2271) 2026-08-31 21:42:03 +02:00
HampusandGitHub 6a188a4cdf fix(api): enforce guild bans when approving registrations (#2270) 2026-08-31 20:19:18 +02:00
HampusandGitHub 0ca0defd24 fix(desktop): keep notification sounds during fullscreen apps (#2269) 2026-08-31 19:51:42 +02:00
HampusandGitHub b0b84f9c98 fix(media-proxy): cap external streams with no declared length (#2267) 2026-08-31 19:23:48 +02:00
HampusandGitHub ef8d1225b5 refactor(api): split webhook attachment schemas (#2268) 2026-08-31 19:13:51 +02:00
HampusandGitHub 240b7e4388 feat(expressions): add an expression pack residue cleanup tool (#2265) 2026-08-31 19:06:31 +02:00
HampusandGitHub bd205d2250 fix(app-proxy): honour the shared Postgres settings (#2262) 2026-08-31 19:00:43 +02:00
HampusandGitHub e5e5bcccee docs(operator): pin self-hosting downloads to stable revision (#2266) 2026-08-31 18:57:58 +02:00
HampusandGitHub a5395b0109 fix(api): support presigned webhook attachments (#2264) 2026-08-31 18:54:25 +02:00
HampusandGitHub 09cea4394f fix(app-proxy): give each test fixture its own temp directory (#2261) 2026-08-31 18:49:30 +02:00
HampusandGitHub afeaddea22 fix(cache): do not fan a failed getOrSet out to its joiners (#2260) 2026-08-31 18:44:31 +02:00
HampusandGitHub 45f694310a fix(api): make the http header and request timeouts tunable (#2259) 2026-08-31 18:43:14 +02:00
HampusandGitHub 995f5118b2 fix(message): reap orphaned rows on the build paths (#2257) 2026-08-31 17:23:46 +02:00
HampusandGitHub 415888a615 fix(admin): stop a stale CSRF cookie wedging actions (#2258) 2026-08-31 17:23:35 +02:00
HampusandGitHub 542fb9176a fix(svc): allow disabling named Postgres prepared statements (#2256) 2026-08-31 17:19:44 +02:00
HampusandGitHub b8f8d8d859 feat(expressions): remove the unfinished packs feature (#2250) 2026-08-31 16:34:22 +02:00
HampusandGitHub 0eef611b6d test(message): pin response mapping across batch boundaries (#2255) 2026-08-31 16:24:33 +02:00
HampusandGitHub f0612ee860 fix(message): key batched message responses by message id (#2254) 2026-08-31 16:19:29 +02:00
HampusandGitHub 8c85cce75c fix(message): bound batched message response requests by size (#2252) 2026-08-31 16:13:36 +02:00
HampusandGitHub 5036ac3efa fix(api): allow disabling named Postgres prepared statements (#2251) 2026-08-31 16:10:52 +02:00
HampusandGitHub 9025e03422 feat(admin): remove the unfinished billing APIs and panel UI (#2248) 2026-08-31 15:57:11 +02:00
HampusandGitHub e82e8529bf fix(gateway): replay voice state updates after a resume (#2249) 2026-08-31 15:56:32 +02:00
HampusandGitHub eb1ed69489 chore(api): drop scheduled messages from the openapi spec (#2247) 2026-08-31 15:43:34 +02:00
HampusandGitHub e81f3f7eae fix(cache): do not resurrect a key deleted during getOrSet (#2246) 2026-08-31 15:41:05 +02:00
HampusandGitHub 4b9964bc89 fix(api): stop bounding request body receipt at the header timeout (#2245) 2026-08-31 15:36:54 +02:00
HampusandGitHub b4a2af75d0 fix(media-proxy): read content length from the response header (#2244) 2026-08-31 15:33:49 +02:00
HampusandGitHub 8c3e3285f7 fix(gateway): clamp the derived BEAM scheduler count (#2243) 2026-08-31 15:29:58 +02:00
HampusandGitHub 0a4f6ff9fb fix(test): surface docker errors when a test container fails (#2242) 2026-08-31 15:16:53 +02:00
HampusandGitHub bfa1367ca2 fix(gateway): restore erlfmt style in the presence rpc module (#2241) 2026-08-31 15:10:59 +02:00
HampusandGitHub bb81a2f165 fix(self-host): persist valkey and stop evicting durable state (#2240) 2026-08-31 14:48:58 +02:00
HampusandGitHub 7f448b1cab fix(message): always flag a message when a reaction is added (#2239) 2026-08-31 14:47:16 +02:00
HampusandGitHub 2dd35c0d6e fix(gateway): reject unknown rpc methods instead of crashing (#2238) 2026-08-31 14:34:35 +02:00
HampusandGitHub 0e73346c5f fix(svc): treat a shard overload reply as retryable backpressure (#2237) 2026-08-31 14:23:11 +02:00
HampusandGitHub 8476595507 fix(api): let node size its heap from the container limit (#2236) 2026-08-31 14:09:13 +02:00
HampusandGitHub 7b9284edb9 fix(build): allow unused patches when deploying the api subset (#2235) 2026-08-31 14:06:07 +02:00
HampusandGitHub c982b33212 fix(self-host): size memory limits and make them overridable (#2234) 2026-08-31 13:42:12 +02:00
HampusandGitHub 3c8466d714 feat(message): remove the unfinished scheduled messages feature (#2233) 2026-08-31 13:32:55 +02:00
HampusandGitHub eeea391b63 fix(kv): claim parked jobs by member instead of secondary key (#2232) 2026-08-31 13:16:52 +02:00
HampusandGitHub 5c2dca1c51 chore(workspace): tighten quality gates (#2230) 2026-08-31 04:43:20 +02:00
HampusandGitHub e6e4c6f7b5 perf(api): stop exempting self-hosted from response gating (#2228) 2026-08-31 02:04:07 +02:00
HampusandGitHub 5f6f9428ac build(api): bundle the api instead of transpiling at boot (#2227) 2026-08-31 01:26:30 +02:00
HampusandGitHub ba54b61dcf perf(guild): add a lean channel auth context rpc (#2226) 2026-08-31 01:04:55 +02:00
HampusandGitHub 8dc2bad843 perf(auth): cache auth session lookups by token hash (#2225) 2026-08-31 01:04:51 +02:00
HampusandGitHub 3594cbd5ca perf(svc): forward messages shard replies without transcoding (#2224) 2026-08-31 01:04:47 +02:00
HampusandGitHub 21b1e4e719 perf(ready): stop sending read state twice per session (#2223) 2026-08-31 01:04:43 +02:00
HampusandGitHub 50a17b6263 fix(metrics): reject non-loopback callers on metrics endpoints (#2222) 2026-08-31 00:23:00 +02:00
HampusandGitHub 0a920def2b fix(kv): mark the messages migration done instead of rescanning (#2221) 2026-08-31 00:22:56 +02:00
HampusandGitHub c4b1471923 perf(api): cache channel and guild reads for the request (#2220) 2026-08-31 00:22:52 +02:00
HampusandGitHub ab08ed0d7c chore(self-host): give every compose service a memory ceiling (#2212) 2026-08-31 00:22:48 +02:00
HampusandGitHub 34c13a747d chore(self-host): probe the api readiness during startup (#2216) 2026-08-31 00:17:09 +02:00
HampusandGitHub d559d8853d perf(gateway): size replay buffer entries once per dispatch (#2210) 2026-08-31 00:14:57 +02:00
HampusandGitHub a96d9cd075 perf(worker): skip the bunny purge cron when purging is off (#2208) 2026-08-31 00:14:39 +02:00
HampusandGitHub b163888cf3 perf(gateway): stop building discarded debug logs on fanout (#2219) 2026-08-31 00:13:27 +02:00
HampusandGitHub b07e2c397c perf(api): resolve the client ip once per request (#2218) 2026-08-31 00:13:24 +02:00
HampusandGitHub 3eeba1da2b fix(gateway): stop discarding container logs and add readiness (#2217) 2026-08-31 00:13:20 +02:00
HampusandGitHub e160b1bf07 perf(api): fast path json bodies with no large integers (#2214) 2026-08-31 00:13:17 +02:00
HampusandGitHub 1199b36d1a perf(worker): stop rereading rows in the discovery index sync (#2211) 2026-08-31 00:13:13 +02:00
HampusandGitHub 7a506478c7 perf(message): unlog bucket index writes on the read path (#2209) 2026-08-31 00:13:09 +02:00
HampusandGitHub 6faa40e0c2 fix(worker): bound the jobs stream and shed on overflow (#2204) 2026-08-31 00:13:04 +02:00
HampusandGitHub 768657d7e5 perf(worker): batch the inactivity sweep activity lookups (#2215) 2026-08-31 00:07:07 +02:00
HampusandGitHub 7157cca22f perf(worker): match the user export zip level to the guild one (#2213) 2026-08-31 00:07:03 +02:00
HampusandGitHub 7aec79d3ad perf(worker): throttle the cancel check in the domain sync (#2207) 2026-08-31 00:06:59 +02:00
HampusandGitHub 4357d5ec5d fix(search): stop leaking meilisearch task ids on the api (#2206) 2026-08-31 00:06:56 +02:00
HampusandGitHub 7c1c8b2749 perf(rate-limit): precompute bucket hash and client identifier (#2205) 2026-08-31 00:06:52 +02:00
HampusandGitHub 15656bd5c8 perf(users): read only the partial columns for partial requests (#2203) 2026-08-31 00:06:48 +02:00
HampusandGitHub c4897a7026 fix(svc): bound scylla request timeout below the rpc budget (#2202) 2026-08-31 00:06:44 +02:00
HampusandGitHub e993a47720 perf(guild): fetch guild members a thousand at a time (#2201) 2026-08-31 00:06:41 +02:00
HampusandGitHub 0d4c65ad79 perf(cassandra): skip re-registering identical select metadata (#2200) 2026-08-31 00:06:37 +02:00
HampusandGitHub f09bdb2b00 fix(cache): single-flight getOrSet and cache null results (#2199) 2026-08-31 00:06:33 +02:00
HampusandGitHub f1400ae58e fix(cassandra): shorten the read timeout below the rpc deadline (#2198) 2026-08-31 00:06:29 +02:00
HampusandGitHub b5496097d2 perf(message): reuse the resolved channel for dm send checks (#2197) 2026-08-31 00:06:25 +02:00
HampusandGitHub d5fb495e19 perf(read-state): read acked read states in one query (#2196) 2026-08-31 00:06:20 +02:00
HampusandGitHub 00e716bc3f perf(worker): skip the premium sweep on self-hosted instances (#2195) 2026-08-31 00:06:16 +02:00
HampusandGitHub ea4edd668f fix(worker): heartbeat long running jobs to hold the ack (#2194) 2026-08-31 00:06:12 +02:00
HampusandGitHub a22db125a9 perf(ready): send timing diagnostics only to staff sessions (#2190) 2026-08-30 23:50:09 +02:00
HampusandGitHub e7f68c2e20 test(message): count permission fetches instead of view checks (#2193) 2026-08-30 23:25:47 +02:00
HampusandGitHub 933b13f3fa perf(gateway): re-enable generational GC on hot processes (#2188) 2026-08-30 23:23:26 +02:00
HampusandGitHub 0be6c9c734 perf(gateway): skip permission cache rebuild on no-op updates (#2187) 2026-08-30 23:23:22 +02:00
HampusandGitHub 5aac331368 perf(gateway): skip materialising member list subscribers (#2184) 2026-08-30 23:23:18 +02:00
HampusandGitHub 57ec484626 fix(app): persist input access nagbar dismissal (#2192) 2026-08-30 23:20:51 +02:00
HampusandGitHub 9cd832bfa9 fix(app): let backspace cross a composer soft-wrap boundary (#2191) 2026-08-30 23:19:02 +02:00
HampusandGitHub 299cc40ff5 fix(gateway): split inbound and outbound rpc concurrency keys (#2186) 2026-08-30 23:16:53 +02:00
HampusandGitHub 6d305bacdf build(rust): enable fat lto and one codegen unit in release (#2185) 2026-08-30 23:16:49 +02:00
HampusandGitHub 6fd3177844 fix(auth): time-bound outbound fetches and re-key pwned cache (#2183) 2026-08-30 23:16:44 +02:00
HampusandGitHub 5586d34293 fix(app): preserve input access nagbar dismissal (#2189) 2026-08-30 23:11:57 +02:00
HampusandGitHub d43d242b16 perf(api): compile the phrase blocklist into one matcher (#2164) 2026-08-30 23:05:55 +02:00
HampusandGitHub 9f620e8c4b perf(user): prefetch user partials for list endpoints (#2182) 2026-08-30 23:05:17 +02:00
HampusandGitHub 6c9afcc734 perf(gateway): skip member list resync on inert presence deltas (#2180) 2026-08-30 23:05:13 +02:00
HampusandGitHub 43d6c85f7e perf(push): batch badge count invalidation per mention chunk (#2178) 2026-08-30 23:05:08 +02:00
HampusandGitHub 78056e0041 perf(gateway): pre-encode voice state update fanout (#2177) 2026-08-30 23:05:03 +02:00
HampusandGitHub e83a2d6aec perf(user): stop double-writing last active on every request (#2148) 2026-08-30 23:04:59 +02:00
HampusandGitHub bac06fe182 perf(gateway): restore generational GC as the vm default (#2181) 2026-08-30 23:01:25 +02:00
HampusandGitHub 8ff6518797 perf(postgres): name the fixed kv statement shapes (#2173) 2026-08-30 23:01:21 +02:00
HampusandGitHub f0e7c25e4c perf(kv): collate key columns in C and drop the duplicate index (#2162) 2026-08-30 23:01:06 +02:00
HampusandGitHub 0da94965dc perf(push): cache empty subscriptions and chunk fanout lookups (#2176) 2026-08-30 22:53:07 +02:00
HampusandGitHub d82eed16b7 perf(push): batch guild settings lookups for push eligibility (#2175) 2026-08-30 22:53:03 +02:00
HampusandGitHub b26748a2a7 fix(api): bound http server limits and shed on overload (#2170) 2026-08-30 22:53:00 +02:00
HampusandGitHub a2d7f5e8cc perf(app-proxy): serve precompressed assets and pass through (#2165) 2026-08-30 22:52:15 +02:00
HampusandGitHub 72ffa3bd9a perf(gateway): memoise the parsed internal rpc url (#2174) 2026-08-30 22:48:11 +02:00
HampusandGitHub e2fccaee74 fix(gateway): derive BEAM scheduler count from the environment (#2169) 2026-08-30 22:48:07 +02:00
HampusandGitHub e41b209cb8 perf(media-proxy): cache-probe and stream external fetches (#2168) 2026-08-30 22:48:03 +02:00
HampusandGitHub 2517caf674 fix(svc): shed overload instead of buffering router requests (#2171) 2026-08-30 22:45:50 +02:00
HampusandGitHub b9ec0d5f53 perf(gateway): avoid per-key exceptions in guild data wire (#2167) 2026-08-30 22:45:47 +02:00
HampusandGitHub 02e614632f perf(api): construct request services lazily (#2166) 2026-08-30 22:45:43 +02:00
HampusandGitHub 3ca73901c9 perf(gateway): cache zstd availability instead of reprobing it (#2160) 2026-08-30 22:45:38 +02:00
HampusandGitHub c379eed266 perf(gateway): split circuit breaker state from its window (#2154) 2026-08-30 22:45:34 +02:00
HampusandGitHub 5bac4fd719 perf(api): gate response schema revalidation to non-production (#2134) 2026-08-30 22:45:29 +02:00
HampusandGitHub dd610e4c0f fix(messages): pass message refs to the mention context helpers (#2179) 2026-08-30 22:44:12 +02:00
HampusandGitHub bf080cb001 fix(search): omit referenced message from search results (#2172) 2026-08-30 22:42:07 +02:00
HampusandGitHub 169088df26 perf(gateway): drop per-recipient mailbox probe on dispatch (#2146) 2026-08-30 22:42:03 +02:00
HampusandGitHub 4a2a29f154 perf(kv): merge row data in one statement on upsert and patch (#2161) 2026-08-30 22:37:18 +02:00
HampusandGitHub 1054962008 perf(kv): chunk oversized IN lists instead of scanning (#2163) 2026-08-30 22:36:51 +02:00
HampusandGitHub 8bc8603460 perf(message): skip redundant has_reaction writes on reactions (#2149) 2026-08-30 22:36:47 +02:00
HampusandGitHub 6538b0bfeb perf(message): skip reaction deletes for unreacted messages (#2147) 2026-08-30 22:36:44 +02:00
HampusandGitHub 9d2339bb3b perf(cassandra): memoize CQL statement metadata per query (#2155) 2026-08-30 22:34:49 +02:00
HampusandGitHub 096f38d365 perf(media-proxy): bound mime sniff scans and sniff once (#2159) 2026-08-30 22:29:00 +02:00
HampusandGitHub 1046edd903 perf(media-proxy): drop the extra HEAD on passthrough GETs (#2157) 2026-08-30 22:27:51 +02:00
HampusandGitHub cbf504dbb8 perf(api): memoise the effective bluesky oauth config (#2156) 2026-08-30 22:27:47 +02:00
HampusandGitHub 8491872908 perf(user): batch mention and saved message reads (#2139) 2026-08-30 22:27:41 +02:00
HampusandGitHub c207918e90 perf(api): fetch channel permissions in one gateway call (#2145) 2026-08-30 22:17:23 +02:00
HampusandGitHub 12717f692b chore(self-host): match shard admission to the postgres pool (#2153) 2026-08-30 22:17:12 +02:00
HampusandGitHub 36d630b37b perf(cassandra): drop allocations from the undefined param guard (#2152) 2026-08-30 22:16:26 +02:00
HampusandGitHub 5333fe7c3a perf(guild): unlog the audit log index batch (#2151) 2026-08-30 22:16:23 +02:00
HampusandGitHub efae78056e fix(worker): park far-future jobs in a KV due queue (#2144) 2026-08-30 22:16:18 +02:00
HampusandGitHub 6eca64a8f7 fix(user): stop invalidating user cache on profile reads (#2142) 2026-08-30 22:16:14 +02:00
HampusandGitHub fcb629ca06 perf(app-proxy): stream local assets instead of buffering them (#2141) 2026-08-30 22:16:10 +02:00
HampusandGitHub 289f1af253 perf(worker): skip guild settings for direct mentions (#2140) 2026-08-30 22:16:06 +02:00
HampusandGitHub 8adc3ecb0b perf(message): build pin responses in one messages round trip (#2138) 2026-08-30 22:16:02 +02:00
HampusandGitHub e328c001a1 perf(messages): prefilter mention extraction and drop a pass (#2137) 2026-08-30 22:15:58 +02:00
HampusandGitHub f796a31613 perf(worker): stop ledgering the two per-message tasks (#2136) 2026-08-30 22:15:54 +02:00
HampusandGitHub e1bab2e353 perf(message): reuse channel auth across send and edit (#2135) 2026-08-30 22:15:49 +02:00
HampusandGitHub 1c135176d2 perf(messages): stop channel history scan once the page is full (#2133) 2026-08-30 22:15:45 +02:00
HampusandGitHub 723f0d6e6e perf(logger): stop building a throwaway pino root per child (#2132) 2026-08-30 22:15:40 +02:00
HampusandGitHub e14d193b43 fix(api): order service timeouts so inner hops expire first (#2131) 2026-08-30 22:15:36 +02:00
HampusandGitHub 9d1733bdb3 perf(kv-client): use EVALSHA for rate limit scripts (#2130) 2026-08-30 22:15:32 +02:00
HampusandGitHub e06436d6f5 perf(svc): use cached prepared statements for postgres kv reads (#2129) 2026-08-30 22:15:28 +02:00
HampusandGitHub 4f67e2b362 perf(messages): overlap user partial fetch with response joins (#2128) 2026-08-30 22:15:24 +02:00
HampusandGitHub 8aa3415d73 perf(message): unlog bulk message delete batches (#2126) 2026-08-30 22:15:19 +02:00
HampusandGitHub 74f22e89ce perf(svc): reuse the decoded request instead of reparsing it (#2125) 2026-08-30 22:15:15 +02:00
HampusandGitHub 7d826d1602 perf(rpc): bound concurrency in user batch fanout handlers (#2124) 2026-08-30 22:15:11 +02:00
HampusandGitHub 8aa39bc7db perf(message): drop two reads and a write from message create (#2123) 2026-08-30 22:15:07 +02:00
HampusandGitHub 36dcf51024 fix(cassandra): bound driver in-flight requests per connection (#2122) 2026-08-30 22:15:02 +02:00
HampusandGitHub 3e74180bdc chore(self-host): budget postgres pool sizes across services (#2127) 2026-08-30 22:14:56 +02:00
HampusandGitHub 45ed740575 chore(self-host): tune bundled postgres for the shipped box (#2143) 2026-08-30 22:14:52 +02:00
HampusandGitHub 8092ad8c4d fix(media-proxy): support current FFmpeg APIs (#2158) 2026-08-30 22:08:55 +02:00
HampusandGitHub b4d9cdc584 refactor(voice): remove heartbeat and debug logging sessions (#2121) 2026-08-30 21:08:46 +02:00
HampusandGitHub 36b85512c6 chore(api): drop knip-unused Postgres KV exports (#2119) 2026-08-30 18:58:50 +02:00
HampusandGitHub 14ae64f5f3 perf(api): stop Postgres KV reads scanning whole tables (#2118) 2026-08-30 18:34:23 +02:00
M0N7Y5andGitHub 990176ac7c feat(markdown): add a binary AST envelope to the native ABI (#2117) 2026-08-30 17:47:58 +02:00
M0N7Y5andGitHub 69ef46356b feat(markdown): add a native C ABI for 64-bit FFI hosts (#2115) 2026-08-30 15:47:34 +02:00
HampusandGitHub bd88c7b04b fix(desktop): don't fail startup on inconclusive native probe (#2114) 2026-08-30 15:22:48 +02:00
HampusandGitHub 03641f622f refactor(voice): flatten participant context menu and extract stream menus (#2112) 2026-08-30 03:02:30 +02:00
HampusandGitHub 3b1eb56713 fix(voice): never auto-select AV1 or HEVC for screen sharing (#2111) 2026-08-30 02:05:21 +02:00
HampusandGitHub 059bcc6c53 chore(app): regenerate theme variable manifest for font fallbacks (#2110) 2026-08-30 01:30:00 +02:00
HampusandGitHub 82043ce2a8 fix(guild): show duplicated role in its final spot without flicker (#2109) 2026-08-30 01:25:34 +02:00
HampusandGitHub 470e752fba chore(i18n): refresh catalogs for role and permission menus (#2108) 2026-08-30 00:17:31 +02:00
HampusandGitHub 3cc7b9050c fix(app): clear stuck spellcheck reload banner (#2107) 2026-08-30 00:12:48 +02:00
HampusandGitHub b1f7c78c7e fix(app): overwrite context menu hover and delete danger item (#2106) 2026-08-30 00:12:41 +02:00
HampusandGitHub 8f20b29b16 feat(guild): duplicate role, plus delete and hover in roles sidebar (#2105) 2026-08-30 00:12:36 +02:00
HampusandGitHub 19efbd3d61 fix(app): scope the show-send-button toggle to the main composer (#2104) 2026-08-30 00:12:31 +02:00
HampusandGitHub f35c0effa2 chore(i18n): drop unused gift redemption string (#2102) 2026-08-29 23:31:28 +02:00
HampusandGitHub 8363cc0844 fix(app): send only the gift link when gifting to a friend (#2101) 2026-08-29 23:29:49 +02:00
HampusandGitHub 5a11cacbae chore(i18n): refresh catalogs for spellcheck copy (#2100) 2026-08-29 23:03:01 +02:00
HampusandGitHub 27151a9487 fix(desktop): restore Linux spellcheck, prefer OS engine (#2099) 2026-08-29 22:58:48 +02:00
HampusandGitHub c152b25deb chore(i18n): refresh catalogs for global shortcut string (#2098) 2026-08-29 22:57:18 +02:00
HampusandGitHub 04d3afaf7b fix(app): default voice shortcuts to global and add a toggle (#2097) 2026-08-29 22:46:30 +02:00
HampusandGitHub dbc63e9ef7 fix(voice): surface stream audio volume for screen-share viewers (#2095) 2026-08-29 22:05:06 +02:00
HampusandGitHub ce91ff95ab fix(app): render raw unicode emoji with OS color emoji fonts (#2094) 2026-08-29 21:09:08 +02:00
HampusandGitHub d75a29f099 feat(app): composer send-button toggle and active button hover (#2093) 2026-08-29 20:25:49 +02:00
HampusandGitHub cb889b1160 fix(api): correct apns clear payload and badge handling (#2092) 2026-08-29 20:07:18 +02:00
HampusandGitHub 8db4f5cb63 feat(app): full-Unicode font fallback with self-hosted Noto (#2091) 2026-08-29 19:27:47 +02:00
HampusandGitHub d297dc5805 feat(api): exempt APP_STORE_REVIEWER accounts from captcha (#2090) 2026-08-29 18:00:52 +02:00
HampusandGitHub 9b8659a40b fix(desktop): stop the updater button flickering after download (#2089) 2026-08-29 17:44:43 +02:00
HampusandGitHub 96c5db3f5d fix(markdown): open code blocks for fences after text (#2088) 2026-08-29 17:32:43 +02:00
HampusandGitHub 78e403819e fix(admin): set delete_message_seconds on ban request (#2087) 2026-08-29 16:35:40 +02:00
HampusandGitHub 805acf4e5e feat(ban): accept delete_message_seconds and app options (#2086) 2026-08-29 16:22:12 +02:00
HampusandGitHub 9f099a9127 fix(api): keep the saved placeholder on sent memes (#2085) 2026-08-29 16:04:58 +02:00
HampusandGitHub 4d15c39cd7 fix(app): give mature media blur the real media dimensions (#2084) 2026-08-29 16:04:55 +02:00
HampusandGitHub 827451d12d fix(unfurl): trust curated klipy media without rescanning (#2083) 2026-08-29 16:04:52 +02:00
HampusandGitHub 03603662c6 fix(media-proxy): require corroborating frames for nsfw (#2082) 2026-08-29 16:04:48 +02:00
HampusandGitHub 34eb10cd88 fix(markdown): only open code fences at line start (#2081) 2026-08-29 15:59:04 +02:00
HampusandGitHub 82941c08c9 fix(api): enforce single-role MFA, fix reindex and NCMEC scans (#2080) 2026-08-29 15:31:42 +02:00
HampusandGitHub 8d21c97d08 fix(admin): sort session imports to satisfy rustfmt (#2079) 2026-08-29 15:05:04 +02:00
HampusandGitHub 71a56f590d fix(ci): read dependent load flags at the pe32+ offset (#2078) 2026-08-29 15:00:38 +02:00
HampusandGitHub 38297c4fe7 chore(api): drop knip-unused search and SSO exports (#2077) 2026-08-29 15:00:09 +02:00
HampusandGitHub cf7ec06d85 fix(api): enforce scoped perms, age gates, audit logs (#2076) 2026-08-29 14:55:13 +02:00
HampusandGitHub f2ea10f951 fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074) 2026-08-29 14:55:09 +02:00
HampusandGitHub bbd93df239 fix(api): expire auth tokens and harden login checks (#2073) 2026-08-29 14:55:04 +02:00
HampusandGitHub 9a6ab93e01 fix(media-proxy): bound GIF decode and BMFF box walking (#2075) 2026-08-29 14:55:00 +02:00
HampusandGitHub 38eed7cce6 fix(gateway): restrict /_metrics to loopback callers (#2072) 2026-08-29 14:54:56 +02:00
HampusandGitHub 79064c3399 fix(admin): sign CSRF tokens and escape them in scripts (#2071) 2026-08-29 14:54:51 +02:00
HampusandGitHub 0496b2f530 fix(ci): reject path traversal in S3 prefix downloads (#2070) 2026-08-29 14:54:37 +02:00
HampusandGitHub 9d0be1ebd1 fix(desktop): drop game capture injection and pin dll search (#2069) 2026-08-29 14:26:28 +02:00
6352 changed files with 781228 additions and 662324 deletions
+37 -11
View File
@@ -1,14 +1,14 @@
FROM chrislusf/seaweedfs:4.31 AS seaweedfs
FROM chrislusf/seaweedfs:4.47 AS seaweedfs
FROM erlang:28.5.0.1
FROM erlang:28.5.0.6
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=1000
ARG NODE_MAJOR=24
ARG ELP_VERSION=2026-02-27
ARG PNPM_VERSION=10.29.3
ARG WASM_BINDGEN_VERSION=0.2.122
ARG NODE_MAJOR=26
ARG ELP_VERSION=2026-08-10
ARG PNPM_VERSION=11.27.0
ARG WASM_BINDGEN_VERSION=0.2.128
ENV DEBIAN_FRONTEND=noninteractive
@@ -32,6 +32,7 @@ RUN apt-get update \
jq \
libasound2 \
libatk-bridge2.0-0 \
libaom-dev \
libavcodec-dev \
libavfilter-dev \
libavformat-dev \
@@ -51,9 +52,15 @@ RUN apt-get update \
libcurl4-openssl-dev \
libswresample-dev \
libswscale-dev \
libdav1d-dev \
libde265-dev \
liblcms2-dev \
libvips-dev \
libyuv-dev \
libwayland-dev \
libwebp-dev \
nasm \
yasm \
libssl-dev \
libx11-xcb1 \
libxcb-dri3-0 \
@@ -78,6 +85,7 @@ RUN apt-get update \
unzip \
xz-utils \
xdg-utils \
zlib1g-dev \
zstd \
&& rm -rf /var/lib/apt/lists/*
@@ -123,10 +131,31 @@ RUN apt-get update \
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
&& npm install -g "pnpm@${PNPM_VERSION}" \
&& pnpm --version
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 \
@@ -139,7 +168,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
arm64) ELP_ARCH="aarch64" ;; \
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
esac \
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.5.tar.gz" -o /tmp/elp.tgz \
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
&& chmod +x /usr/local/bin/elp \
&& rm /tmp/elp.tgz
@@ -166,7 +195,4 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
&& pnpm --version
WORKDIR /workspaces/fluxer
+6 -2
View File
@@ -14,7 +14,7 @@
"DOCKER_HOST": "unix:///var/run/docker.sock"
},
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3020, 8333],
"portsAttributes": {
"8088": {
"label": "Fluxer dev proxy",
@@ -38,7 +38,11 @@
"customizations": {
"vscode": {
"settings": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"erlang.includePaths": ["."],
"search.exclude": {
"**/_build/default/lib/fluxer_gateway": true
}
},
"extensions": [
"biomejs.biome",
+8 -15
View File
@@ -1,3 +1,5 @@
name: fluxer-dev
services:
workspace:
build:
@@ -7,7 +9,7 @@ services:
init: true
environment:
DOCKER_HOST: unix:///var/run/docker.sock
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
pnpm_config_store_dir: /home/vscode/.local/share/pnpm/store
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
@@ -21,7 +23,6 @@ services:
FLUXER_S3_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_LIVEKIT_URL: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/livekit"
FLUXER_LIVEKIT_INTERNAL_URL: "http://livekit:7880"
FLUXER_LIVEKIT_WEBHOOK_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api/webhooks/livekit"
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
@@ -200,11 +201,6 @@ services:
target: /workspaces/fluxer/fluxer_api/pkgs/rate_limit/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-sms-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/sms/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-virus-scan-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/virus_scan/node_modules
@@ -241,7 +237,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
@@ -255,7 +250,6 @@ services:
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
depends_on:
@@ -292,13 +286,13 @@ services:
start_period: 5s
valkey:
image: valkey/valkey:8.1.7-alpine
image: valkey/valkey:9.1.2-alpine
command: ["valkey-server", "--save", "", "--appendonly", "no"]
ports:
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
nats:
image: nats:2.14.2-alpine
image: nats:2.14.7-alpine
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- nats-data:/data
@@ -321,9 +315,10 @@ services:
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
meilisearch:
image: getmeili/meilisearch:v1.12
image: getmeili/meilisearch:v1.53
environment:
MEILI_NO_ANALYTICS: "true"
MEILI_UPGRADE_DB: "true"
MEILI_MASTER_KEY: fluxer-dev-meilisearch
volumes:
- meilisearch-data:/meili_data
@@ -337,7 +332,7 @@ services:
start_period: 5s
mailpit:
image: axllent/mailpit:v1.30
image: axllent/mailpit:v1.31
environment:
MP_DATABASE: /data/mailpit.db
MP_MAX_MESSAGES: 5000
@@ -349,7 +344,6 @@ services:
volumes:
pnpm-store:
docs-venv:
root-node-modules:
fluxer-api-node-modules:
fluxer-app-node-modules:
@@ -384,7 +378,6 @@ volumes:
fluxer-api-mime-utils-node-modules:
fluxer-api-nats-node-modules:
fluxer-api-rate-limit-node-modules:
fluxer-api-sms-node-modules:
fluxer-api-virus-scan-node-modules:
fluxer-api-worker-node-modules:
fluxer-app-list-utils-node-modules:
+1 -2
View File
@@ -28,8 +28,7 @@ for path in \
/home/vscode/.cargo/registry \
/home/vscode/.cargo/git \
/home/vscode/.local \
/home/vscode/.local/share/pnpm/store \
/workspaces/fluxer/fluxer_docs/.venv; do
/home/vscode/.local/share/pnpm/store; do
repair_tree "$path"
done
+11 -4
View File
@@ -7,10 +7,16 @@ QUICK=0
SKIP_INSTALL=0
for arg in "$@"; do
case "$arg" in
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h | --help)
sed -n '2,25p' "$0"
exit 0
;;
*)
echo "unknown argument: $arg" >&2
exit 2
;;
esac
done
@@ -64,6 +70,7 @@ stage "app: typecheck" pnpm --filter fluxer_app typecheck
stage "app: unit tests" pnpm --filter fluxer_app exec vitest run
if [ "$QUICK" -eq 0 ]; then
stage "desktop: typecheck" pnpm --filter fluxer_desktop typecheck
stage "app: production build" pnpm --filter fluxer_app build
fi
+3 -2
View File
@@ -9,7 +9,6 @@
**/.git/**
/.github/
/.pnpm-store/
/fluxer_marketing
**/.env
**/.env.*.local
@@ -29,9 +28,11 @@
**/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/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
-5
View File
@@ -1,7 +1,2 @@
/.github/CODEOWNERS @fluxerapp/developers
/.github/workflows/ @fluxerapp/developers
/fluxer_marketing @fluxerapp/developers
/.gitmodules @fluxerapp/developers
/.github/workflows/dispatch-private-marketing-build.yaml @fluxerapp/developers
/packages/i18n/marketing/ @fluxerapp/developers
/scripts/setup-private-marketing.sh @fluxerapp/developers
-18
View File
@@ -89,21 +89,3 @@ Submit translations through [Weblate](https://weblate.fluxer.tools), not through
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
## Private marketing project
The marketing implementation is maintained in a private repository at the `fluxer_marketing` submodule path. The public workspace, bootstrap, checks, and development stack work without initializing it.
Authorized maintainers can initialize only that submodule and install its independent dependencies:
```sh
./scripts/setup-private-marketing.sh
pnpm --dir fluxer_marketing install --frozen-lockfile
cargo metadata --locked --manifest-path fluxer_marketing/Cargo.toml
```
To run the private marketing service in the local development stack and direct application links to it, add this override to the ignored `config/env/local.env` file:
```sh
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
```
+1 -2
View File
@@ -36,8 +36,7 @@ body:
label: Build information
description: >-
Open User Settings, scroll to the bottom of the left sidebar, and select
the build information. Fluxer copies it to the clipboard. On mobile,
select the build information at the bottom of the settings list.
the build information. Fluxer copies it to the clipboard.
validations:
required: true
+3 -3
View File
@@ -1,15 +1,15 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
blank_issues_enabled: false
contact_links:
- name: Mobile client bugs
url: https://github.com/fluxerapp/flutter_client#bug-reporting
about: Read the reporting instructions for the Fluxer mobile client.
- name: Account and billing support
url: https://fluxer.app/help
about: Find account help and support contact details.
- name: Feature proposals
url: https://github.com/orgs/fluxerapp/discussions
about: Propose a feature in a discussion.
- name: Security vulnerabilities
url: https://github.com/fluxerapp/fluxer/security/advisories/new
about: Submit a private vulnerability report.
- name: Translations
url: https://weblate.fluxer.tools
about: Improve an existing locale or start a new one.
+3 -5
View File
@@ -22,17 +22,15 @@ f:docs:
f:gateway:
- changed-files:
- any-glob-to-any-file: fluxer_gateway/**/*
f:marketing:
- changed-files:
- any-glob-to-any-file:
- fluxer_marketing
- packages/i18n/marketing/**/*
f:media_proxy:
- changed-files:
- any-glob-to-any-file: fluxer_media_proxy/**/*
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
+26 -26
View File
@@ -58,13 +58,13 @@ jobs:
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
@@ -101,25 +101,30 @@ jobs:
- platform: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: true
provenance: false
provenance: mode=min
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
${{ inputs.extra-build-args }}
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
@@ -136,15 +141,15 @@ jobs:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -185,17 +190,12 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
VERSION: ${{ needs.meta.outputs.build_version }}
MOVING_TAGS: ${{ inputs.moving-tags }}
run: |
set -euo pipefail
tag_args=()
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
for raw in "${moving[@]}"; do
tag="$(echo "$raw" | xargs)"
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
done
if (( ${#tag_args[@]} > 0 )); then
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
fi
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component "${{ inputs.image }}"
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags "${MOVING_TAGS}"
@@ -15,6 +15,13 @@ permissions:
contents: write
packages: write
concurrency:
group: publish-fluxer-app-proxy-self-hosted
cancel-in-progress: false
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve build release
@@ -26,13 +33,207 @@ jobs:
- name: approved
run: echo "Build release approved."
build:
meta:
name: resolve metadata
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-app-proxy-self-hosted
dockerfile: fluxer_app_proxy/Dockerfile
build-version: ${{ inputs['build-version'] }}
extra-build-args: |
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: set variables
id: vars
run: >-
tools/ci/run.sh build-app-proxy
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
dist:
name: build the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
actions: read
contents: read
packages: write
env:
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/fluxer-app-proxy-self-hosted
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: ""
BUNDLE_LOCAL_ASSETS: "true"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build the dist once and publish it as the canonical asset image
env:
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_dist
- name: generate asset manifest
run: >-
tools/ci/run.sh build-app-proxy
--step generate_asset_manifest
- name: verify every manifest asset ships in the image
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build ${{ matrix.platform }}
needs: [meta, dist]
runs-on: ${{ matrix.runner }}
timeout-minutes: 75
permissions:
actions: read
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: .
file: fluxer_app_proxy/Dockerfile
push: true
provenance: false
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
merge:
name: merge multi-arch manifest
needs: [meta, build]
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-amd64
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: create and push multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
VERSION: ${{ needs.meta.outputs.build_version }}
run: |
set -euo pipefail
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: >-
tools/ci/run.sh release
publish
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
- name: Advance moving image tags
env:
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+101 -32
View File
@@ -43,13 +43,13 @@ jobs:
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: set variables
id: vars
run: >-
@@ -57,28 +57,28 @@ jobs:
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
build:
name: build app-proxy (amd64)
dist:
name: build and publish the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 60
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
@@ -87,17 +87,17 @@ jobs:
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image + extract assets
- name: build the dist once and publish it as the canonical asset image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_and_extract
--step build_dist
- name: generate asset manifest
run: >-
@@ -114,9 +114,63 @@ jobs:
tools/ci/run.sh build-app-proxy
--step upload_assets
- name: verify every uploaded asset is readable
env:
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build app-proxy (amd64)
needs: [meta, dist]
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
SOURCE_SHA: ${{ github.sha }}
SOURCE_DATE: ${{ steps.source.outputs.date }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_image
build-arm64:
name: build app-proxy (arm64)
needs: meta
needs: [meta, dist]
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
permissions:
@@ -124,16 +178,19 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: .
file: fluxer_app_proxy/Dockerfile
@@ -143,8 +200,10 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
BUNDLE_LOCAL_ASSETS=false
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
@@ -155,24 +214,33 @@ jobs:
name: merge multi-arch manifest
needs: [meta, build, build-arm64]
runs-on: ubuntu-24.04
timeout-minutes: 10
timeout-minutes: 20
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: fuse amd64 + arm64 into a multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
@@ -212,10 +280,11 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
docker buildx imagetools create
-t "${IMAGE}:v1"
-t "${IMAGE}:latest"
"${IMAGE}:${VERSION}"
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+68 -101
View File
@@ -11,11 +11,6 @@ on:
- stable
- canary
default: stable
test_build:
description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release).
required: false
default: false
type: boolean
build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false
@@ -32,13 +27,12 @@ permissions:
actions: read
concurrency:
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }}
group: desktop-${{ inputs.channel }}
cancel-in-progress: true
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
jobs:
meta:
@@ -53,19 +47,17 @@ jobs:
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
test_build: ${{ steps.meta.outputs.test_build }}
s3_prefix: ${{ steps.meta.outputs.s3_prefix }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Create token
id: create-token
@@ -85,7 +77,6 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
--test-build "${{ inputs.test_build }}"
matrix:
name: Resolve build matrix
@@ -98,12 +89,12 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Build platform matrix
id: set-matrix
@@ -113,7 +104,7 @@ jobs:
--skip-targets "${{ inputs.skip_targets }}"
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }})
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
@@ -137,41 +128,34 @@ jobs:
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Set up Python (Windows)
if: runner.os == 'Windows'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.13"
python-version: "3.14"
- name: Ensure python3 command (Windows)
if: runner.os == 'Windows'
@@ -196,14 +180,14 @@ jobs:
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
node-version: 26
- name: Set up pnpm via corepack
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm_corepack
--step setup_pnpm
- name: Resolve pnpm store path (Windows)
if: runner.os == 'Windows'
@@ -247,9 +231,9 @@ jobs:
- name: Set up Rust toolchain (Unix)
if: matrix.platform != 'windows'
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
- name: Install MSVC ARM64 build tools
@@ -260,7 +244,7 @@ jobs:
- name: Set up MSVC env (Windows)
if: matrix.platform == 'windows'
uses: TheMrMilchmann/setup-msvc-dev@79dac248aac9d0059f86eae9d8b5bfab4e95e97c
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9
with:
arch: ${{ matrix.arch == 'arm64' && 'amd64_arm64' || 'amd64' }}
@@ -302,9 +286,9 @@ jobs:
- name: Set up .NET SDK (Windows)
if: matrix.platform == 'windows'
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
with:
dotnet-version: "8.0.x"
dotnet-version: "10.0.x"
- name: Install Velopack CLI
if: matrix.platform == 'windows'
@@ -363,7 +347,7 @@ jobs:
- name: Azure login for Artifact Signing
if: matrix.platform == 'windows'
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -477,6 +461,12 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_artifacts_unix
- name: Build AppImage update feed (Linux)
if: matrix.platform == 'linux'
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_appimage_update_feed
- name: Normalize updater YAML (macOS)
if: matrix.platform == 'macos'
run: >-
@@ -495,13 +485,23 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step generate_checksums_windows
- name: Upload artifacts to S3 handoff
- name: Stage build artifacts
id: handoff
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step upload_handoff
--step stage_handoff
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ${{ steps.handoff.outputs.artifact_name }}
path: upload_staging
if-no-files-found: error
retention-days: 1
compression-level: 0
upload:
name: Upload to S3
name: Assemble desktop release assets
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
@@ -520,33 +520,26 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Download S3 handoff artifacts
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_handoff
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: artifacts
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
- name: Build S3 payload layout (+ manifest.json)
- name: Build payload layout (+ manifest.json)
env:
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
@@ -555,42 +548,27 @@ jobs:
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_summary
- name: Cleanup S3 handoff
if: ${{ success() }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step cleanup_handoff
publish_release:
name: Publish GitHub desktop release
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
if: ${{ !cancelled() && needs.upload.result == 'success' }}
needs:
- meta
- upload
@@ -602,26 +580,22 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-release-assets
path: release_assets
- name: Create token
id: create-token
@@ -653,10 +627,3 @@ jobs:
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
-1
View File
@@ -33,5 +33,4 @@ jobs:
with:
image: fluxer-docs
dockerfile: fluxer_docs/Dockerfile
context: fluxer_docs
build-version: ${{ inputs['build-version'] }}
+36
View File
@@ -0,0 +1,36 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build push
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: write
jobs:
approve:
name: approve build release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Build release approved."
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-push
dockerfile: fluxer_push/Dockerfile
build-version: ${{ inputs['build-version'] }}
+6 -6
View File
@@ -19,22 +19,22 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout fluxer
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -1,230 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: Dispatch private marketing build
on:
push:
branches:
- main
paths:
- fluxer_marketing
- Cargo.toml
- fluxer_common/**
- packages/fonts/manifest.json
- packages/fonts/NOTICE.md
- packages/fonts/LICENSE-IBM-PLEX.txt
- packages/fonts/css/locale-fallbacks.css
- packages/fonts/files/FluxerSans/**
- packages/fonts/files/FluxerMono/**
- packages/fonts/marketing/**
- packages/i18n/marketing/**
- fluxer_static/marketing/branding/**
- .github/workflows/dispatch-private-marketing-build.yaml
permissions:
actions: read
contents: read
concurrency:
group: private-marketing-dispatch
cancel-in-progress: false
jobs:
metadata:
name: resolve exact private build metadata
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
parent_sha: ${{ steps.inputs.outputs.parent_sha }}
gitlink_sha: ${{ steps.inputs.outputs.gitlink_sha }}
build_version: ${{ steps.inputs.outputs.build_version }}
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
steps:
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Resolve trusted build inputs
id: inputs
env:
EVENT_AFTER: ${{ github.event.after }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
PARENT_SHA: ${{ github.sha }}
PUBLIC_REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
set -euo pipefail
[[ "$GITHUB_EVENT_NAME" == "push" ]]
[[ "$GITHUB_REF" == "refs/heads/main" ]]
[[ "$PUBLIC_REPOSITORY" == "fluxerapp/fluxer" ]]
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$EVENT_AFTER" == "$PARENT_SHA" ]]
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
(( 10#$RUN_ATTEMPT <= 10 ))
main_sha="$(gh api "repos/$PUBLIC_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
[[ "$main_sha" =~ ^[0-9a-f]{40}$ ]]
main_comparison="$(gh api "repos/$PUBLIC_REPOSITORY/compare/$PARENT_SHA...$main_sha")"
main_status="$(jq -r .status <<<"$main_comparison")"
[[ "$main_status" == "identical" || "$main_status" == "ahead" ]]
[[ "$(jq -r .merge_base_commit.sha <<<"$main_comparison")" == "$PARENT_SHA" ]]
commit="$(gh api "repos/$PUBLIC_REPOSITORY/git/commits/$PARENT_SHA")"
[[ "$(jq -r .sha <<<"$commit")" == "$PARENT_SHA" ]]
tree_sha="$(jq -r .tree.sha <<<"$commit")"
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]]
entry="$(
gh api "repos/$PUBLIC_REPOSITORY/git/trees/$tree_sha" |
jq -cer '[.tree[] | select(.path == "fluxer_marketing")] | if length == 1 then .[0] else error("expected exactly one marketing gitlink") end'
)"
mode="$(jq -r .mode <<<"$entry")"
type="$(jq -r .type <<<"$entry")"
gitlink_sha="$(jq -r .sha <<<"$entry")"
path="$(jq -r .path <<<"$entry")"
if [[ "$mode" != "160000" || "$type" != "commit" || "$path" != "fluxer_marketing" || ! "$gitlink_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Public parent does not contain a valid fluxer_marketing gitlink."
exit 1
fi
run="$(gh api "repos/$PUBLIC_REPOSITORY/actions/runs/$RUN_ID")"
[[ "$(jq -r .id <<<"$run")" == "$RUN_ID" ]]
[[ "$(jq -r .run_attempt <<<"$run")" == "$RUN_ATTEMPT" ]]
[[ "$(jq -r .event <<<"$run")" == "push" ]]
[[ "$(jq -r .head_sha <<<"$run")" == "$PARENT_SHA" ]]
run_created_at="$(jq -r .created_at <<<"$run")"
[[ "$run_created_at" =~ ^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
run_created_epoch="$(date -u -d "$run_created_at" +%s)"
[[ "$run_created_epoch" =~ ^[1-9][0-9]*$ ]]
build_epoch=$((run_created_epoch + 10#$RUN_ATTEMPT - 1))
read -r year month day time_segment <<<"$(date -u -d "@$build_epoch" '+%Y %m %d %H%M%S')"
month="$((10#$month))"
micro="$((10#$time_segment))"
build_version="$year.$month$day.$micro"
[[ "$build_version" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
correlation_id="public-${RUN_ID}-${RUN_ATTEMPT}"
[[ "$correlation_id" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
{
echo "parent_sha=$PARENT_SHA"
echo "gitlink_sha=$gitlink_sha"
echo "build_version=$build_version"
echo "correlation_id=$correlation_id"
} >>"$GITHUB_OUTPUT"
dispatch:
name: dispatch exact private build
needs: metadata
runs-on: ubuntu-24.04
timeout-minutes: 65
environment: private-marketing-dispatch
permissions: {}
steps:
- name: Validate trusted build inputs
env:
DISPATCH_ENABLED: ${{ vars.MARKETING_DISPATCH_ENABLED }}
EXPECTED_PARENT_SHA: ${{ github.sha }}
EXPECTED_CORRELATION_ID: public-${{ github.run_id }}-${{ github.run_attempt }}
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
run: |
set -euo pipefail
[[ "$GITHUB_EVENT_NAME" == "push" ]]
[[ "$GITHUB_REF" == "refs/heads/main" ]]
[[ "$GITHUB_REPOSITORY" == "fluxerapp/fluxer" ]]
[[ "$PARENT_SHA" == "$EXPECTED_PARENT_SHA" ]]
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$GITLINK_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$BUILD_VERSION" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
[[ "$CORRELATION_ID" == "$EXPECTED_CORRELATION_ID" ]]
[[ "$CORRELATION_ID" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
if [[ "$DISPATCH_ENABLED" != "true" ]]; then
echo "::error::Private marketing dispatch is intentionally disabled until the package cutover guard completes."
exit 1
fi
- name: Create private dispatch token
id: private-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: marketing
permission-actions: write
- name: Dispatch exact private build
env:
GH_TOKEN: ${{ steps.private-token.outputs.token }}
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
run: |
set -euo pipefail
gh api --method POST repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/dispatches \
--field ref=main \
--field "inputs[parent_sha]=$PARENT_SHA" \
--field "inputs[gitlink_sha]=$GITLINK_SHA" \
--field "inputs[build_version]=$BUILD_VERSION" \
--field "inputs[correlation_id]=$CORRELATION_ID"
- name: Wait for private build conclusion
env:
GH_TOKEN: ${{ steps.private-token.outputs.token }}
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
run: |
set -euo pipefail
expected_title="marketing-build correlation=$CORRELATION_ID parent=$PARENT_SHA gitlink=$GITLINK_SHA version=$BUILD_VERSION"
deadline=$((SECONDS + 3600))
run_id=""
while (( SECONDS < deadline )); do
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
count="$(jq 'length' <<<"$matches")"
if [[ "$count" == "1" ]]; then
run_id="$(jq -r '.[0].id' <<<"$matches")"
break
fi
if [[ "$count" != "0" ]]; then
echo "::error::Private build correlation matched multiple workflow runs."
exit 1
fi
sleep 10
done
if [[ -z "$run_id" ]]; then
echo "::error::Timed out waiting for the private build dispatch to appear."
exit 1
fi
while (( SECONDS < deadline )); do
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
if [[ "$(jq 'length' <<<"$matches")" != "1" || "$(jq -r '.[0].id' <<<"$matches")" != "$run_id" ]]; then
echo "::error::Private build correlation is missing or ambiguous."
exit 1
fi
run="$(jq '.[0]' <<<"$matches")"
status="$(jq -r '.status' <<<"$run")"
conclusion="$(jq -r '.conclusion // empty' <<<"$run")"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" != "success" ]]; then
echo "::error::Private marketing build concluded with $conclusion."
exit 1
fi
echo "Private marketing build completed successfully."
exit 0
fi
sleep 15
done
echo "::error::Timed out waiting for the private marketing build."
exit 1
+6 -6
View File
@@ -35,24 +35,24 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
token: ${{ steps.create-token.outputs.token }}
fetch-depth: 0
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: "24"
node-version: "26"
cache: "pnpm"
- name: Install dependencies
+8 -8
View File
@@ -40,7 +40,7 @@ jobs:
permission-pull-requests: write
- name: Checkout Weblate branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
token: ${{ steps.create-token.outputs.token }}
ref: ${{ env.WEBLATE_BRANCH }}
@@ -48,17 +48,17 @@ jobs:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: "24"
node-version: "26"
cache: "pnpm"
- name: Install dependencies
@@ -77,14 +77,14 @@ jobs:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
run: |
set -euo pipefail
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
echo "No generated catalog changes."
exit 0
fi
git config user.name "fluxer-ci[bot]"
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
git add fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
git commit -m "i18n: compile Weblate catalogs"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push origin "HEAD:$WEBLATE_BRANCH"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
permission-pull-requests: write
- name: Label pull request
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13
with:
repo-token: ${{ steps.create-token.outputs.token }}
configuration-path: .github/labeller.yaml
+142
View File
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: release image set
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
from-tag:
description: "Image tag every component is read from (v1 snapshots today's moving tags, a CalVer pins a coordinated build)"
type: string
required: false
default: "v1"
component-versions:
description: "Per-component overrides, one <image>=<version> entry per line (for example fluxer-api=2026.830.191141)"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: read
concurrency:
group: release-image-set
cancel-in-progress: false
defaults:
run:
shell: bash
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve image set release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Image set release approved."
manifest:
name: resolve and publish the image set
needs: approve
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
permission-packages: read
- name: set variables
id: vars
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
run: >-
tools/ci/run.sh resolve-calver
--github-output
- name: resolve release image set
id: resolve
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
FROM_TAG: ${{ inputs['from-tag'] }}
COMPONENT_VERSIONS: ${{ inputs['component-versions'] }}
run: |
set -euo pipefail
args=(
image-set resolve
--version "${VERSION}"
--registry "ghcr.io/${GHCR_OWNER}"
--from-tag "${FROM_TAG}"
--out-dir release-out
--github-output
)
while IFS= read -r entry; do
entry="$(echo "$entry" | xargs)"
if [ -n "$entry" ]; then
args+=( --component-version "$entry" )
fi
done <<< "${COMPONENT_VERSIONS}"
tools/ci/run.sh "${args[@]}"
- name: verify release image set
env:
VERSION: ${{ steps.vars.outputs.build_version }}
run: >-
tools/ci/run.sh image-set verify
--manifest "release-out/fluxer-release-${VERSION}.json"
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
BUNDLE_COMMIT: ${{ steps.resolve.outputs.bundle_commit }}
run: |
set -euo pipefail
if [ -z "${BUNDLE_COMMIT}" ]; then
echo "image-set resolve reported no bundle commit" >&2
exit 1
fi
gh release create "fluxer-release@${VERSION}" \
--repo fluxerapp/fluxer \
--target "${BUNDLE_COMMIT}" \
--title "fluxer-release ${VERSION}" \
--latest=true \
--notes "Immutable image set for ${VERSION}. Every image in the set contains ${BUNDLE_COMMIT}, the commit this tag points at, so the bundle here is never newer than the images. Pin with: docker compose -f docker-compose.yml -f fluxer-release-${VERSION}.yml up -d" \
"release-out/fluxer-release-${VERSION}.json" \
"release-out/fluxer-release-${VERSION}.yml"
+198 -68
View File
@@ -28,12 +28,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
@@ -42,7 +42,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -55,16 +55,16 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -83,12 +83,12 @@ jobs:
PNPM_TEST_WORKSPACE_CONCURRENCY: '2'
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
@@ -97,7 +97,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -105,12 +105,12 @@ jobs:
run: cargo build --locked --package fluxer-ci
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -123,12 +123,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -142,56 +146,106 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
rust:
runs-on: ubuntu-24.04
timeout-minutes: 30
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
components: clippy, rustfmt
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Cache cargo
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
workspaces: |
. -> target
fluxer_desktop/native/rust -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check libfluxwebp dependencies
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
- name: Cache native media dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: /opt/fluxer-native
key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}
- name: Install native dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
build-essential \
libcurl4-openssl-dev \
libvips-dev \
binutils \
clang \
cmake \
curl \
libaom-dev \
libavfilter-dev \
libclang-dev \
libcurl4-openssl-dev \
libdav1d-dev \
libde265-dev \
libfido2-dev \
libheif-dev \
libwebp-dev
liblcms2-dev \
libpipewire-0.3-dev \
libspa-0.2-dev \
libssl-dev \
libudev-dev \
libvips-dev \
libwebp-dev \
libyuv-dev \
meson \
nasm \
ninja-build \
pkg-config \
xz-utils \
yasm \
zlib1g-dev
sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native
echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV"
echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV"
echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH"
- name: Install Node.js dependencies
run: pnpm --filter fluxer_admin install
@@ -199,17 +253,32 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (desktop native)
run: cargo fmt --manifest-path fluxer_desktop/native/rust/Cargo.toml --all -- --check
- name: Check formatting (libfluxwebp)
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
- name: Clippy (warnings as errors)
run: cargo clippy --workspace -- -D warnings
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
run: tools/ci/check-desktop-native-workspaces.sh clippy
- name: Verify the source-built ffmpeg CLI is on PATH
run: |
set -euo pipefail
command -v ffmpeg
test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg
ffmpeg -hide_banner -version
- name: Run tests
run: cargo test --workspace
env:
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
run: cargo test --workspace --all-features --locked
- name: Run desktop native tests
run: cargo test --manifest-path fluxer_desktop/native/rust/Cargo.toml
- name: Run desktop native workspace tests on Linux
run: tools/ci/check-desktop-native-workspaces.sh test
gateway:
runs-on: ubuntu-24.04
@@ -218,12 +287,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Cache cargo (gateway NIFs)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
@@ -239,7 +308,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -250,7 +319,7 @@ jobs:
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
with:
otp-version: '28'
rebar3-version: '3.24.0'
rebar3-version: '3.27.0'
- name: Restore rebar3 dependencies
id: rebar3-cache
@@ -259,13 +328,16 @@ 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',
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
restore-keys: |
rebar3-${{ runner.os }}-otp28-rebar3.24.0-
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
- name: Drop restored gateway build output
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
- name: Check formatting
run: |
@@ -290,10 +362,10 @@ 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',
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
knip:
@@ -303,12 +375,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
@@ -317,7 +389,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -325,12 +397,12 @@ jobs:
run: cargo build --locked --package fluxer-ci
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -343,12 +415,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -362,29 +438,58 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
lint:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check formatting and lint
run: pnpm exec biome ci .
- name: Lint JSX for browser-translation safety
run: pnpm exec eslint . --max-warnings 0
i18n:
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -405,17 +510,42 @@ jobs:
exit 1
fi
docs:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '26'
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
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.13"
python-version: "3.14"
- name: Install font tooling
run: python3 -m pip install -r tools/fonts/requirements.txt
+2
View File
@@ -10,6 +10,7 @@
/.direnv/
/.fluxer/
/.pnpm-store/
/.vscode/
**/*.css.d.ts
**/*.tsbuildinfo
@@ -25,6 +26,7 @@
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
-4
View File
@@ -1,4 +0,0 @@
[submodule "fluxer_marketing"]
path = fluxer_marketing
url = https://github.com/fluxerapp/marketing.git
update = none
Generated
+866 -1140
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -7,9 +7,9 @@ members = [
"fluxer_gifs",
"fluxer_svc",
"fluxer_messages",
"fluxer_push",
"fluxer_snowflakes",
"tools/ci",
"tools/content/update-frozen-snapshot",
"tools/dev",
"tools/i18n_auto",
"fluxer_users",
@@ -17,7 +17,6 @@ members = [
"packages/markdown_parser/rust",
]
exclude = [
"fluxer_marketing",
"packages/markdown_parser/rust/fuzz",
]
resolver = "2"
@@ -25,3 +24,8 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "AGPL-3.0-or-later"
[profile.release]
lto = "fat"
codegen-units = 1
strip = "symbols"
+158 -3
View File
@@ -6,18 +6,173 @@
</p>
<p align="center">
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="https://fluxer.app/download">
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
<a href="https://docs.fluxer.app">
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="./LICENSE">
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
</p>
<p align="center">
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
</p>
# Fluxer
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
</p>
## Download
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
| | | [AppImage (x64)][linux-appimage-x64] | | |
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
| | | [tar.gz (x64)][linux-targz-x64] | | |
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
## Linux package repositories
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
### Flatpak
Stable is on [Flathub][flathub], the easiest route on most desktops:
```sh
flatpak install flathub app.fluxer.Fluxer
```
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
From a terminal:
```sh
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
```
### Debian and Ubuntu
```sh
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
sudo apt update && sudo apt install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
sudo apt update && sudo apt install fluxer-canary
```
A `.deb` installed from a download only updates once its channel's entry is added.
### Fedora and RHEL
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
sudo dnf install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
sudo dnf install fluxer-canary
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
### Arch Linux
The repository is signed, so pacman needs the key once:
```sh
sudo pacman-key --init
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
```
`--lsign-key` is what makes pacman trust it. Then add the repository:
```sh
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
[fluxer]
SigLevel = Required TrustedOnly
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
REPO
sudo pacman -Syu fluxer
```
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
## Other ways to run it
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
- [Host your own instance][docs-selfhost] from this repository.
## Documentation
- [Documentation home][docs]
- [Downloads][docs-downloads]
- [Self-hosting][docs-selfhost]
## License
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
imagery are copyright Fluxer, all rights reserved, as set out in
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
terms, listed in
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
Public availability of this repository does not grant trademark, brand, or
endorsement rights.
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
[docs]: https://docs.fluxer.app
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
+38 -4
View File
@@ -20,7 +20,7 @@
"bracketSpacing": false,
"bracketSameLine": false
},
"globals": ["React"]
"globals": ["React", "__webpack_base_uri__"]
},
"json": {
"formatter": {
@@ -48,7 +48,7 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"preset": "recommended",
"complexity": {
"noForEach": "off",
"noImportantStyles": "off",
@@ -83,11 +83,23 @@
}
},
"useConst": "error",
"noDescendingSpecificity": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off"
"noParameterAssign": "off",
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"@lingui/react": {
"importNames": ["I18nProvider"],
"message": "Use AppI18nProvider from @app/features/i18n/components/AppI18nProvider so <Trans> output stays safe under page translation."
}
}
}
}
},
"a11y": {
"recommended": true,
"preset": "recommended",
"useAriaPropsForRole": "error",
"useValidAriaRole": "error",
"useValidAriaValues": "error",
@@ -115,6 +127,28 @@
}
},
"assist": {"actions": {"source": {"organizeImports": "on"}}},
"overrides": [
{
"includes": ["fluxer_app/src/**/*.tsx"],
"plugins": ["./tools/lint/no-adjacent-jsx-text.grit"]
},
{
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
},
{
"includes": [
"fluxer_app/src/features/i18n/components/AppI18nProvider.tsx",
"fluxer_app/src/features/i18n/components/AppI18nProvider.test.tsx"
],
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
},
{
"includes": ["**/*.astro"],
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
"assist": {"actions": {"source": {"organizeImports": "off"}}}
}
],
"vcs": {
"enabled": true,
"clientKind": "git",
+3 -19
View File
@@ -34,7 +34,6 @@ FLUXER_KV_URL=redis://valkey:6379/0
FLUXER_NATS_URL=nats://nats:4222
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
FLUXER_INTERNAL_API_ENDPOINT=http://127.0.0.1:8080
FLUXER_INTERNAL_GATEWAY_ENDPOINT=http://127.0.0.1:8771
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT=http://127.0.0.1:8082
FLUXER_MEDIA_PROXY_ENDPOINT=http://127.0.0.1:8082
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=http://localhost:8088/media
@@ -42,8 +41,6 @@ FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=http://localhost:8088/media
FLUXER_SVC_NATS_URL=nats://nats:4222
FLUXER_SVC_SHARD_COUNT=1
FLUXER_SVC_CACHE_TTL_MS=30000
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
FLUXER_SVC_MAX_CONCURRENT_REQUESTS=64
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
@@ -53,7 +50,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_S3_BUCKET_STATIC=fluxer-static
@@ -63,13 +59,11 @@ FLUXER_LIVEKIT_URL=ws://localhost:8088/livekit
FLUXER_LIVEKIT_INTERNAL_URL=http://localhost:7880
FLUXER_LIVEKIT_API_KEY=devkey
FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
FLUXER_API_PORT=8080
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
FLUXER_API_WORKER_MODE=all_lanes
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION=true
FLUXER_APP_DEV_PORT=3000
FLUXER_APP_PROXY_PORT=8773
FLUXER_STATIC_DIR=fluxer_app/dist
@@ -90,18 +84,13 @@ 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
FLUXER_MARKETING_HOST=0.0.0.0
FLUXER_MARKETING_BASE_PATH=/marketing
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
@@ -115,9 +104,6 @@ FLUXER_EMAIL_SMTP_PORT=1025
FLUXER_EMAIL_SMTP_USERNAME=dev
FLUXER_EMAIL_SMTP_PASSWORD=dev
FLUXER_EMAIL_SMTP_SECURE=false
FLUXER_SMS_ENABLED=false
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_SEARCH_ENGINE=meilisearch
FLUXER_SEARCH_URL=http://meilisearch:7700
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
@@ -137,7 +123,5 @@ PUBLIC_RELEASE_CHANNEL=canary
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
AWS_EC2_METADATA_DISABLED=true
AWS_ACCESS_KEY_ID=fluxer
AWS_SECRET_ACCESS_KEY=fluxer-secret
AWS_DEFAULT_REGION=us-east-1
+43 -14
View File
@@ -1,13 +1,9 @@
# cargo-deny configuration for the Fluxer workspace.
#
# Applies to the root workspace (Cargo.toml at the repo root) AND to every
# per-addon crate under fluxer_desktop/native/* (each addon has its own
# [workspace], so we invoke cargo-deny with --config pointing here).
#
# Used by the native desktop security gate in CI.
# Applies to the root workspace (Cargo.toml at the repo root).
[graph]
all-features = false
all-features = true
no-default-features = false
[output]
@@ -44,13 +40,11 @@ allow = [
"BSD-3-Clause",
"ISC",
"MPL-2.0",
"Unicode-DFS-2016",
"Unicode-3.0",
"Zlib",
"CC0-1.0",
"AGPL-3.0-or-later",
"BSL-1.0",
"OpenSSL",
"CDLA-Permissive-2.0",
]
# Explicitly deny GPL-only / strong-copyleft licenses that don't compose with
@@ -72,21 +66,56 @@ license-files = [
[bans]
multiple-versions = "warn"
wildcards = "deny"
# Per-addon crates path-depend on ../rust (the shared `fluxer_desktop_native`
# crate) without a version. cargo-deny flags that as a wildcard; we allow it
# because path deps can't realistically pin a SemVer range, and this only
# affects intra-repo workspace links (registry wildcards remain denied).
# Internal workspace crates use path dependencies without registry versions.
# Registry wildcards remain denied.
allow-wildcard-paths = true
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
# Keep desktop packaging and native addons away from the obsolete libfuse2 stack.
# Keep workspace artifacts away from the obsolete libfuse2 stack.
# AppImage packaging must use the static electron-builder runtime instead.
deny = [
{ crate = "fuse", reason = "libfuse2-based Rust wrapper; use a maintained FUSE3-native crate only if Fluxer ever needs FUSE directly" },
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
]
skip = []
skip = [
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older crypto API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
]
skip-tree = []
# ---------------------------------------------------------------------------
+559 -16
View File
@@ -1,7 +1,67 @@
# Every variable docker-compose.yml reads. A value an install must set is
# uncommented. A commented line shows the default, or an example where its comment
# says so, and nothing after = means the service decides. An empty value keeps the
# default too. Compose expands top to bottom, so a line using ${...} must sit below
# every name it reads.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
# The address browsers use. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below decide
# which host ports Fluxer binds.
# By default Fluxer binds 80 and 443 and gets its own certificate. Point DNS here.
# Behind your own reverse proxy, uncomment this instead: Fluxer then serves plain
# HTTP on 127.0.0.1:8080. Keep the scheme and port above describing the public
# address, not this one.
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
# Where that plain-HTTP port binds. Use 0.0.0.0:8080 only when the proxy is on
# another machine, and firewall it to that machine.
#FLUXER_EDGE_BIND=127.0.0.1:8080
# Which hops may set X-Forwarded-For. The default covers private and loopback
# addresses. Set your proxy's address if it reaches Fluxer from a public IP.
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
# The origin browsers see, no trailing slash. Set it when browsers reach the
# instance on a host FLUXER_DOMAIN does not name, and it wins over the three
# values above. Scheme, host and optional port only. It does not move the
# published ports.
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
# The address the edge listens on inside its container. Both proxy overlays set
# this themselves, so a value here is ignored under either. Include the scheme.
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
# The old name for the line above, read only when it is unset.
#FLUXER_CADDY_SITE_ADDRESS=
# Host ports. Container 80 handles the redirect and the certificate challenge,
# container 443 the TLS site. FLUXER_HTTPS_PORT moves TCP and UDP together, since
# HTTP/3 needs both. Both accept a bind address. Give them different host ports.
#FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80
#FLUXER_HTTPS_PORT=127.0.0.1:443
# HTTPS on 8443. Host 80 stays published for the certificate challenge, which
# only ever arrives on public 80 or 443. Serve your own certificate if nothing
# forwards those.
#FLUXER_PUBLIC_PORT=8443
#FLUXER_HTTPS_PORT=8443
# Plain HTTP on 19080. Nothing binds host 80, and the last line parks the idle
# 443 publish on loopback.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_HTTP_PORT=19080
#FLUXER_HTTPS_PORT=127.0.0.1:443
# A tunnel needs no HTTPS publish. tunnel.compose.yml ships beside this file and
# leaves one loopback HTTP publish. Needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
FLUXER_REGISTRY_OWNER=fluxerapp
FLUXER_REGISTRY=ghcr.io/${FLUXER_REGISTRY_OWNER}
@@ -9,47 +69,242 @@ FLUXER_IMAGE_TAG=v1
POSTGRES_PASSWORD=CHANGE_ME
MEILI_MASTER_KEY=CHANGE_ME
# Set these to run Postgres or the object store outside the stack. Backing up a
# store you moved out is yours to arrange. An upgrade dumps the bundled postgres
# service and skips the dump only when the stack defines none. The values below
# are examples.
#FLUXER_POSTGRES_HOST=db.example.com
#FLUXER_POSTGRES_PORT=5432
#FLUXER_POSTGRES_DATABASE=fluxer
#FLUXER_POSTGRES_USERNAME=fluxer
#FLUXER_POSTGRES_SSL=true
#FLUXER_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
#FLUXER_S3_REGION=eu-central-1
#FLUXER_S3_FORCE_PATH_STYLE=false
# Bucket names. The bundled store creates these. An outside store needs them to
# exist already.
#FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
# With the object store outside the stack, add this overlay to COMPOSE_FILE and
# the bundled seaweedfs no longer starts. Put it after any other overlay, such as
# docker-compose.yml:docker-compose.proxy.yml:external-object-store.compose.yml.
# Needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:external-object-store.compose.yml
# A full connection URL wins over the host, port and database above. The URL is an
# example. The CA is the PEM text of the certificate, with \n for line breaks.
#FLUXER_POSTGRES_URL=postgres://fluxer:[email protected]:5432/fluxer
#FLUXER_POSTGRES_SSL_CA=
# The Postgres table that holds the key-value store.
#FLUXER_POSTGRES_KV_TABLE=fluxer_kv
# media-proxy reads through these when the store serves reads from another
# address or bucket.
#FLUXER_S3_READ_ENDPOINT=
#FLUXER_S3_READ_BUCKET=
#FLUXER_S3_READ_BUCKET_STYLE=
# A temporary S3 session token, read by media-proxy only.
#FLUXER_S3_SESSION_TOKEN=
# The bundled store refuses unsigned reads. Set false only for a public-read bucket.
#FLUXER_S3_READ_SIGNED=true
# The other bundled services, pointed elsewhere. Removing a service from the
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
# The URLs below are examples.
#FLUXER_KV_URL=redis://cache.example.com:6379/0
#FLUXER_NATS_URL=nats://mq.example.com:4222
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
#FLUXER_SEARCH_URL=https://search.example.com
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
# How the stack talks to those services.
#FLUXER_KV_MODE=standalone
#FLUXER_SEARCH_ENGINE=meilisearch
#FLUXER_SEARCH_USERNAME=
#FLUXER_SEARCH_PASSWORD=
#FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED=true
# Voice off. The livekit service still runs until an override removes it.
#FLUXER_LIVEKIT_ENABLED=false
# Optional systems, each off unless configured.
#FLUXER_STRIPE_ENABLED=false
#FLUXER_STRIPE_SECRET_KEY=
#FLUXER_STRIPE_WEBHOOK_SECRET=
# Stripe prices as one JSON object. The admin dashboard can set them instead.
#FLUXER_STRIPE_PRICES={}
#FLUXER_STRIPE_LEGACY_PRICES={}
#FLUXER_API_DONATION_PROXY_KEY=
#FLUXER_VISIONARIES_GUILD_ID=
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
# NCMEC CyberTipline reporting, off by default. All four values are required
# once it is on. The values below are examples.
#FLUXER_NCMEC_ENABLED=true
#FLUXER_NCMEC_BASE_URL=https://report.cybertip.org/ispws
#FLUXER_NCMEC_USERNAME=
#FLUXER_NCMEC_PASSWORD=
#[email protected]
# Upload virus scanning, off by default. No ClamAV container ships, so point
# this at your own. The values below are examples.
#FLUXER_CLAMAV_ENABLED=true
#FLUXER_CLAMAV_HOST=clamav
#FLUXER_CLAMAV_PORT=3310
#FLUXER_CLAMAV_FAIL_OPEN=false
# Outside lookups, off unless turned on. The breached password check asks
# api.pwnedpasswords.com.
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
#FLUXER_IPINFO_API_KEY=
# A local path, or an s3:// URL read with the S3 credentials of this file.
#FLUXER_GEOIP_DB_PATH=
# The client address. The edge sets X-Forwarded-For on every hop, so keep the
# trust on and the default header. Turning the trust off makes the api refuse
# every request outside its exempt routes with a 403.
#FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
#FLUXER_TRUST_CLIENT_IP_HEADER=true
# How much the services write. LOG_LEVEL covers the api and worker and takes trace,
# debug, info, warn, error or fatal. RUST_LOG covers the Rust services and takes
# an EnvFilter such as debug. The gateway takes an Erlang level such as notice,
# and LOGGER_LEVEL beats FLUXER_GATEWAY_LOGGER_LEVEL.
#LOG_LEVEL=info
#RUST_LOG=info
#FLUXER_GATEWAY_LOGGER_LEVEL=info
#LOGGER_LEVEL=
FLUXER_S3_ACCESS_KEY=fluxer
FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
# The token every service sends to NATS. The bundled NATS needs none, so this
# stays empty unless an override points at an external one.
#FLUXER_NATS_AUTH_TOKEN=
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
FLUXER_VAPID_EMAIL=[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
# invalidates every passkey already registered against the old value.
# Defaults to admin@ followed by FLUXER_DOMAIN. Set it if that mailbox does not
# exist.
#[email protected]
# The passkey RP ID defaults to FLUXER_DOMAIN, whatever FLUXER_PUBLIC_ORIGIN says.
# Changing the RP ID invalidates every passkey registered against the old value.
#FLUXER_PASSKEY_RP_ID=chat.example.com
#FLUXER_PASSKEY_RP_NAME=Fluxer
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
# only when a browser must reach an origin the defaults do not cover, such as a
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
# sources with spaces or commas.
# Notification jobs the push container holds at once, 1 to 1000000.
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
# Provider requests the push container sends at once, 1 to 65536.
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
# The push container's provider addresses and relay hosts.
#FLUXER_PUSH_SERVICE_APNS_BASE_URL=
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
# Direct mobile push through your own APNs and FCM credentials, off by default.
#FLUXER_PUSH_APNS_ENABLED=false
#FLUXER_PUSH_APNS_TEAM_ID=
#FLUXER_PUSH_APNS_KEY_ID=
#FLUXER_PUSH_APNS_PRIVATE_KEY=
#FLUXER_PUSH_APNS_PRIVATE_KEY_PATH=
#FLUXER_PUSH_APNS_APPS=
#FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT=production
#FLUXER_PUSH_FCM_ENABLED=false
#FLUXER_PUSH_FCM_PROJECT_ID=
#FLUXER_PUSH_FCM_CLIENT_EMAIL=
#FLUXER_PUSH_FCM_PRIVATE_KEY=
#FLUXER_PUSH_FCM_PRIVATE_KEY_PATH=
#FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH=
#FLUXER_PUSH_FCM_TOKEN_URI=https://oauth2.googleapis.com/token
#FLUXER_PUSH_FCM_APPS=
# Optional media policies, both off by default. See the operator docs.
#
# CORS limits which web origins may read media. A request with no Origin is
# always served. Add https://web.fluxer.app if people use the hosted client.
#
# Signatures make an attachment read need a signed URL, so a copied link stops
# working. Needs a secret from openssl rand -base64 32, first entry signs and
# every entry verifies.
#
# Each mode is off, report or enforce, and off is the default. Start at report.
# media-proxy reads these at start, so apply with docker compose up -d
# media-proxy. The values below are examples.
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
#FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE=enforce
# Extra Content-Security-Policy sources, appended to the built-in ones. Set one
# only when a browser must reach an origin the defaults do not cover. Separate
# several with spaces or commas. The three values below are illustrations.
#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=
# 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
# provider URL cannot be used to reach internal services. Turn it on only when the
# provider genuinely lives on your own network, such as split-horizon DNS or a LAN
# identity provider, and only when you trust everyone who can configure SSO.
# One report-uri for CSP violation reports. Empty leaves the directive off.
#FLUXER_CSP_REPORT_URI=
# Let the SSO provider resolve to a private address. Off by default, so a
# misconfigured provider URL cannot reach internal services. Turn it on only for
# a provider on your own network. The value below is an example.
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
# These reach both LiveKit and the api. Change them together.
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# The URL browsers use for voice signalling. Built from the public origin plus
# /livekit. Set it only when LiveKit is served from another host.
#FLUXER_LIVEKIT_URL=
# Media ports. LiveKit advertises these, so forward the same numbers.
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# LiveKit finds its public address over STUN. A host that cannot reach one stops
# with "could not resolve external IP", so set the address by hand instead, or
# point STUN elsewhere. The values below are examples.
#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
# The voice region users see, and how much LiveKit logs.
#FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
#FLUXER_LIVEKIT_LOG_LEVEL=info
FLUXER_KLIPY_API_KEY=
#FLUXER_YOUTUBE_API_KEY=
# Hosts the api never unfurls, comma separated.
#FLUXER_API_UNFURL_IGNORED_HOSTS=
FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
@@ -61,7 +316,295 @@ FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=
FLUXER_EMAIL_SMTP_PASSWORD=
FLUXER_EMAIL_SMTP_SECURE=true
#FLUXER_EMAIL_WEBHOOK_SECRET=
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_DISCOVERY_ENABLED=true
#FLUXER_DISCOVERY_MIN_MEMBER_COUNT=1
# Instance identity and account policy.
#FLUXER_APP_PRODUCT_NAME=Fluxer
#FLUXER_APP_ICON_URL=
#FLUXER_APP_SYMBOL_URL=
#FLUXER_APP_LOGO_URL=
#FLUXER_APP_WORDMARK_URL=
#FLUXER_APP_FAVICON_URL=
#FLUXER_APP_THEME_COLOR=
#FLUXER_APP_STATUS_PAGE_URL=
#FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL=
#FLUXER_INSTANCE_SETUP_CONFIGURED=false
#FLUXER_AUTO_JOIN_INVITE_CODE=
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
# Sign in with Bluesky, off unless turned on.
#FLUXER_AUTH_BLUESKY_ENABLED=false
#FLUXER_AUTH_BLUESKY_CLIENT_NAME=Fluxer
#FLUXER_AUTH_BLUESKY_CLIENT_URI=
#FLUXER_AUTH_BLUESKY_LOGO_URI=
#FLUXER_AUTH_BLUESKY_TOS_URI=
#FLUXER_AUTH_BLUESKY_POLICY_URI=
#FLUXER_AUTH_BLUESKY_KEYS=
# Public addresses. Each follows the public origin unless set here.
#FLUXER_API_ENDPOINT=
#FLUXER_API_CLIENT_ENDPOINT=
#FLUXER_APP_ENDPOINT=
#FLUXER_GATEWAY_ENDPOINT=
#FLUXER_MEDIA_ENDPOINT=
#FLUXER_STATIC_CDN_ENDPOINT=
#FLUXER_ADMIN_ENDPOINT=
#FLUXER_MARKETING_ENDPOINT=
#FLUXER_INVITE_ENDPOINT=
#FLUXER_GIFT_ENDPOINT=
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
# Extra hosts for static assets, invites, gifts and the web app. Empty by default.
#FLUXER_STATIC_CDN_DOMAIN=
#FLUXER_INVITE_DOMAIN=
#FLUXER_GIFT_DOMAIN=
#FLUXER_APP_ORIGIN_ALIASES=
# The path the admin panel is served under. The edge and the admin service read
# it. The api follows it through the default FLUXER_ADMIN_ENDPOINT, and not when
# FLUXER_ADMIN_ENDPOINT is set. Write it with a leading slash and no trailing
# slash.
#FLUXER_ADMIN_BASE_PATH=/admin
# The compression the edge offers, as Caddy encode arguments.
#FLUXER_EDGE_ENCODE=zstd gzip
# Images of the bundled services, for a mirror or another tag. A new Postgres
# major needs a dump and restore, as the upgrade guide describes.
#FLUXER_CADDY_IMAGE=caddy:2.11-alpine
#FLUXER_POSTGRES_IMAGE=postgres:16-alpine
#FLUXER_VALKEY_IMAGE=valkey/valkey:9.1-alpine
#FLUXER_NATS_IMAGE=nats:2.14-alpine
#FLUXER_MEILISEARCH_IMAGE=getmeili/meilisearch:v1.53
#FLUXER_SEAWEEDFS_IMAGE=chrislusf/seaweedfs:4.47
#FLUXER_LIVEKIT_IMAGE=livekit/livekit-server:v1.12.0
# Restart policy for every long-running service.
#FLUXER_RESTART_POLICY=unless-stopped
# Health checks. Raise the retries or start periods on a slow host.
#FLUXER_HEALTHCHECK_INTERVAL=10s
#FLUXER_HEALTHCHECK_TIMEOUT=5s
#FLUXER_HEALTHCHECK_RETRIES=10
#FLUXER_APP_HEALTHCHECK_RETRIES=30
#FLUXER_APP_HEALTHCHECK_START_PERIOD=90s
#FLUXER_SVC_HEALTHCHECK_START_PERIOD=60s
#FLUXER_WORKER_HEALTHCHECK_RETRIES=3
#FLUXER_SEAWEEDFS_HEALTHCHECK_RETRIES=20
#FLUXER_SEAWEEDFS_HEALTHCHECK_START_PERIOD=60s
#FLUXER_SEAWEEDFS_INIT_ATTEMPTS=60
# Container memory. These are ceilings, not allocations, and the defaults suit a
# 16 GB host. The reservations bias the kernel away from reclaiming from services
# whose death takes the instance down. 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_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_PUSH_MEMORY_LIMIT=256mb
#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 the container limit above.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
#FLUXER_MEILISEARCH_ENV=production
#FLUXER_MEILISEARCH_NO_ANALYTICS=true
# SeaweedFS heap ceiling. Go cannot see the container limit, so without this an
# upload burst gets the container OOM-killed. Keep it near three quarters of
# FLUXER_SEAWEEDFS_MEMORY_LIMIT and raise both together.
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
#FLUXER_SEAWEEDFS_TELEMETRY=false
# Node sizes its heap from the container limit by default. Leave these unset
# unless you need to pin it. A heap ceiling above the container limit gets the
# container OOM-killed instead of reporting a heap error. The values below are
# examples.
#FLUXER_API_NODE_HEAP_MB=1792
#FLUXER_WORKER_NODE_HEAP_MB=1792
# Extra Node flags for api and worker, appended to NODE_OPTIONS. Empty by
# default. The value below is an example.
#FLUXER_API_NODE_OPTIONS=--heapsnapshot-near-heap-limit=1
#FLUXER_WORKER_NODE_OPTIONS=--heapsnapshot-near-heap-limit=1
# Extra CA certificates api and worker trust, as a PEM bundle path inside the
# container. The default is the image's system bundle.
#FLUXER_NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
# Bundled Postgres tuning. Keep it consistent with the memory limit above. This
# is the server setting, not the per-service pool sizes.
#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
#FLUXER_POSTGRES_SHM_SIZE=1gb
#FLUXER_POSTGRES_RANDOM_PAGE_COST=1.1
#FLUXER_POSTGRES_EFFECTIVE_IO_CONCURRENCY=200
#FLUXER_POSTGRES_DEFAULT_STATISTICS_TARGET=200
#FLUXER_POSTGRES_JIT=off
#FLUXER_POSTGRES_MIN_WAL_SIZE=512MB
#FLUXER_POSTGRES_MAX_WAL_SIZE=2GB
#FLUXER_POSTGRES_CHECKPOINT_COMPLETION_TARGET=0.9
#FLUXER_POSTGRES_WAL_BUFFERS=16MB
#FLUXER_POSTGRES_WAL_COMPRESSION=zstd
#FLUXER_POSTGRES_BGWRITER_DELAY=50ms
#FLUXER_POSTGRES_BGWRITER_LRU_MAXPAGES=1000
#FLUXER_POSTGRES_AUTOVACUUM_VACUUM_SCALE_FACTOR=0.05
#FLUXER_POSTGRES_AUTOVACUUM_ANALYZE_SCALE_FACTOR=0.02
#FLUXER_POSTGRES_AUTOVACUUM_VACUUM_COST_LIMIT=2000
#FLUXER_POSTGRES_TRACK_IO_TIMING=on
#FLUXER_POSTGRES_SHARED_PRELOAD_LIBRARIES=pg_stat_statements
# Postgres pool size of each service that opens a pool.
#FLUXER_API_POSTGRES_MAX_CONNECTIONS=25
#FLUXER_WORKER_POSTGRES_MAX_CONNECTIONS=25
#FLUXER_USERS_SHARD_POSTGRES_MAX_CONNECTIONS=20
#FLUXER_MESSAGES_SHARD_POSTGRES_MAX_CONNECTIONS=20
# The bundled Valkey holds durable state as well as cache, so it runs with an
# append-only file and with noeviction, which fails an over-limit write instead
# of dropping queued work. Change the policy only if that state lives elsewhere.
#FLUXER_VALKEY_MAXMEMORY=192mb
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
#FLUXER_VALKEY_APPENDFSYNC=everysec
# The gateway derives its scheduler count from the CPU quota, clamped here. One
# scheduler lets a single blocking operation stall every websocket on the node.
#FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# A fixed scheduler count skips the clamp. Dirty CPU schedulers default to two
# thirds of it.
#FLUXER_ERLANG_SCHEDULERS=
#FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS=
# Gateway push and RPC tuning.
#FLUXER_GATEWAY_PUSH_ENABLED=true
#FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED=true
#FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS=100000
#FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES=128
#FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
#FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY=512
#FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS=512
#FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD=6
#FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS=15000
# In-flight request ceiling for every svc router and shard. Unset, each keeps its
# own default: 192 for messages, 320 for snowflakes and 64 for the rest. One value
# replaces all of them, so size it for the busiest. Too low a value rejects
# requests rather than slowing them, and the api turns that into a 503. The value
# below is an example.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=320
# svc caches, and how the api calls the svc services over NATS.
#FLUXER_SVC_CACHE_MAX_ENTRIES=100000
#FLUXER_SVC_CACHE_TTL_MS=30000
#FLUXER_GIFS_SHARD_CACHE_MAX_BYTES=536870912
#FLUXER_GIF_SERVICE_NATS_CLIENT_NAME=fluxer-api-gifs
#FLUXER_GIF_SERVICE_TIMEOUT_MS=12000
#FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS=3000
#FLUXER_USERS_SERVICE_NATS_CLIENT_NAME=fluxer-api-users
#FLUXER_USERS_SERVICE_TIMEOUT_MS=6000
#FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES=10000
#FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME=fluxer-api-snowflakes
#FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE=128
#FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK=
#FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS=5000
#FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS=6000
# Worker concurrency per lane, as a JSON object keyed by lane.
#FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES=
# Named prepared statements need a session that outlives the transaction, so set
# this to false behind a transaction-pooling connection pooler. The bundled
# compose talks to Postgres directly, where the default is correct.
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
# How long a client may take to send a request. The header timeout covers the
# request line and headers, the request timeout the whole exchange, and the first
# is clamped down to the second. Milliseconds, 1000 to 3600000.
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
# api request limits and IP bans. A refresh interval of 0 stops the periodic
# ban reload.
#FLUXER_API_MAX_INFLIGHT_REQUESTS=512
#FLUXER_API_IP_BAN_EXEMPT_IPS=
#FLUXER_IP_BAN_REFRESH_INTERVAL_MS=300000
# Uploads and data exports. Presigned exports link to FLUXER_S3_PUBLIC_ENDPOINT,
# so turn them on only once browsers can reach it.
#FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
#FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED=false
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES=524288000
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS=900
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES=
#FLUXER_API_STORAGE_CHANGE_FEED_ENABLED=false
#FLUXER_API_STORAGE_CHANGE_FEED_STREAM=STORAGE_CHANGES
#FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS=
#FLUXER_CACHE_PURGE_ADAPTER=none
#FLUXER_CACHE_PURGE_HTTP_ENDPOINT=
#FLUXER_CACHE_PURGE_HTTP_TOKEN=
#FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS=10000
# media-proxy limits and timeouts.
#FLUXER_MEDIA_PROXY_READ_ONLY=false
#FLUXER_MEDIA_PROXY_NSFW_THRESHOLD=0.85
#FLUXER_NSFW_SERVICE_ENDPOINT=
#FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS=
#FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY=
#FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS=30000
#FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES=20000
#FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS=15000
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES=268435456
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES=67108864
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS=120000
#FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS=30000
#FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS=30000
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS=900000
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES=33554432
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES=536870912
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR=
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
# app-proxy discovery refresh, index upstream and manifest scope.
#DISCOVERY_REFRESH_INTERVAL_MS=60000
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
+11 -6
View File
@@ -1,11 +1,16 @@
{
servers {
trusted_proxies static private_ranges
trusted_proxies static {$FLUXER_EDGE_TRUSTED_PROXIES:private_ranges}
trusted_proxies_strict
}
}
{$FLUXER_CADDY_SITE_ADDRESS} {
encode zstd gzip
{$FLUXER_EDGE_SITE_ADDRESS} {
encode {$FLUXER_EDGE_ENCODE:zstd gzip}
handle /_health {
respond "OK" 200
}
handle_path /api/* {
reverse_proxy api:8080
@@ -28,12 +33,12 @@
reverse_proxy livekit:7880
}
handle /admin {
handle {$FLUXER_ADMIN_BASE_PATH:/admin} {
rewrite * /
reverse_proxy admin:8080
}
handle_path /admin/* {
handle_path {$FLUXER_ADMIN_BASE_PATH:/admin}/* {
reverse_proxy admin: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"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
services:
seaweedfs:
profiles: [bundled-object-store]
seaweedfs-init:
profiles: [bundled-object-store]
api:
depends_on:
seaweedfs-init: !reset null
worker:
depends_on:
seaweedfs-init: !reset null
media-proxy:
depends_on:
seaweedfs-init: !reset null
-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
+6
View File
@@ -0,0 +1,6 @@
services:
edge:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
environment:
FLUXER_EDGE_SITE_ADDRESS: ":80"
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import reactGoogleTranslate from 'eslint-plugin-react-google-translate';
import tseslint from 'typescript-eslint';
export default [
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/coverage/**',
'**/*.generated.*',
'fluxer_app/src/features/i18n/locales/*/messages.mjs',
],
},
{
files: ['fluxer_app/src/**/*.tsx'],
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
languageOptions: {
parser: tseslint.parser,
parserOptions: {
project: './fluxer_app/tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {'react-google-translate': reactGoogleTranslate},
rules: {
'react-google-translate/no-conditional-text-nodes-with-siblings': [
'error',
{ignoreParents: ['Trans', 'Plural', 'Select', 'SelectOrdinal']},
],
'react-google-translate/no-return-text-nodes': 'error',
},
},
];
+17 -15
View File
@@ -3,36 +3,38 @@ name = "fluxer_admin"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
build = "build.rs"
[dependencies]
anyhow = "1.0.102"
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1"
base64 = "0.23.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.1"
cookie = "0.18.2"
fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10"
regress = "0.11"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
regress = "0.12"
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
time = { version = "0.3.47", features = ["formatting", "parsing"] }
tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal"] }
time = { version = "0.3.55", features = ["formatting", "parsing"] }
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["compression-gzip", "trace"] }
tower-http = { version = "0.7.1", features = ["compression-gzip", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-subscriber = "0.3.23"
url = "2.5"
urlencoding = "2.1.3"
progenitor-client = { version = "0.14.0", default-features = false }
progenitor-client = { version = "0.15.0", default-features = false }
[build-dependencies]
openapiv3 = "2.2.0"
prettyplease = "0.2"
progenitor = { version = "0.14.0", default-features = false }
prettyplease = "0.3"
progenitor = { version = "0.15.0", default-features = false }
serde_json = "1"
sha2 = "0.11.0"
syn = "2"
syn = "3"
+24 -7
View File
@@ -1,15 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM rust:1-bookworm AS builder
FROM rust:1-trixie AS builder
ARG BUILD_VERSION=""
ARG TARGETARCH
WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
&& npm install -g pnpm@10.29.3 \
&& npm install -g pnpm@11.27.0 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
@@ -24,6 +23,7 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
COPY Cargo.lock Cargo.lock
COPY fluxer_admin fluxer_admin
COPY fluxer_common fluxer_common
COPY packages/fonts/manifest.json packages/fonts/manifest.json
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
@@ -31,16 +31,19 @@ COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
RUN printf '%s\n' \
'[workspace]' \
'members = ["fluxer_admin"]' \
'members = ["fluxer_admin", "fluxer_common"]' \
'resolver = "2"' \
'' \
'[workspace.package]' \
'edition = "2024"' \
'license = "AGPL-3.0-or-later"' \
'' \
'[profile.release]' \
'lto = "fat"' \
'codegen-units = 1' \
'strip = "symbols"' \
> Cargo.toml
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
RUN cargo build --release -p fluxer_admin \
&& cp target/release/fluxer_admin /usr/local/bin/fluxer-admin
@@ -51,9 +54,23 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
&& echo "Latin-core fonts bundled successfully"
FROM debian:bookworm-slim AS runtime
FROM debian:trixie-slim AS runtime
ARG BUILD_VERSION=""
ARG SOURCE_SHA=""
ARG SOURCE_DATE=""
LABEL org.opencontainers.image.title="fluxer-admin"
LABEL org.opencontainers.image.description="Fluxer admin console"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.vendor="Fluxer"
LABEL org.opencontainers.image.url="https://fluxer.app"
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/local/bin
+401 -3
View File
@@ -35,25 +35,423 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
}
let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json");
let spec: openapiv3::OpenAPI =
let mut spec: openapiv3::OpenAPI =
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
adapt_progenitor_throttled_errors(&mut spec);
relax_guild_audit_log_schemas(&mut spec);
relax_progenitor_schema_strictness(&mut spec);
let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional);
settings.with_inner_type(
"reqwest::header::HeaderMap"
.parse()
.expect("valid generated client header type"),
);
let mut generator = progenitor::Generator::new(&settings);
let tokens = generator
.generate_tokens(&spec)
.expect("failed to generate admin API client");
let content = prettyplease::unparse(
let content = relax_required_nullable_fields(&prettyplease::unparse(
&syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens"),
);
));
let output_path = out_dir.join("admin_api_generated.rs");
fs::write(&output_path, content).expect("failed to write generated API code");
}
fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
let schemas = &spec
.components
.as_ref()
.expect("missing API components")
.schemas;
let error = serde_json::to_value(schemas.get("Error").expect("missing Error schema"))
.expect("failed to inspect Error schema");
let mut throttled = serde_json::to_value(
schemas
.get("ThrottledError")
.expect("missing ThrottledError schema"),
)
.expect("failed to inspect ThrottledError schema");
assert_eq!(
error["additionalProperties"],
serde_json::json!({}),
"Progenitor error adaptation requires Error to retain all additional fields"
);
let properties = throttled["properties"]
.as_object_mut()
.expect("ThrottledError must be an object schema");
assert_eq!(
properties
.remove("retry_after")
.expect("missing retry_after")["type"],
"number"
);
assert_eq!(
properties.remove("global").expect("missing global")["type"],
"boolean"
);
assert_eq!(
throttled, error,
"ThrottledError must extend the common Error schema"
);
for path in spec.paths.paths.values_mut() {
let openapiv3::ReferenceOr::Item(path) = path else {
panic!("Progenitor error adaptation requires inline API paths");
};
for operation in [
&mut path.get,
&mut path.put,
&mut path.post,
&mut path.delete,
&mut path.options,
&mut path.head,
&mut path.patch,
&mut path.trace,
]
.into_iter()
.flatten()
{
let Some(response) = operation
.responses
.responses
.get_mut(&openapiv3::StatusCode::Code(429))
else {
continue;
};
let openapiv3::ReferenceOr::Item(response) = response else {
panic!("Progenitor error adaptation requires inline 429 responses");
};
let schema = &mut response
.content
.get_mut("application/json")
.expect("429 responses must return JSON")
.schema;
assert_eq!(
schema,
&Some(openapiv3::ReferenceOr::ref_(
"#/components/schemas/ThrottledError"
)),
"Progenitor only supports one error type per operation"
);
*schema = Some(openapiv3::ReferenceOr::ref_("#/components/schemas/Error"));
}
}
}
fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
let components = spec.components.as_mut().expect("missing API components");
let entry = object_schema_mut(components, "GuildAuditLogEntryResponse");
entry.additional_properties = None;
let openapiv3::ReferenceOr::Item(options) = entry
.properties
.get_mut("options")
.expect("GuildAuditLogEntryResponse has no options property")
else {
panic!("GuildAuditLogEntryResponse options must be an inline schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(options)) = &mut options.schema_kind
else {
panic!("GuildAuditLogEntryResponse options must be an object schema");
};
options.additional_properties = None;
let change = object_schema_mut(components, "AuditLogChangeSchema");
change.additional_properties = None;
for property in ["old_value", "new_value"] {
change.properties.insert(
property.to_string(),
openapiv3::ReferenceOr::Item(Box::new(openapiv3::Schema {
schema_data: openapiv3::SchemaData::default(),
schema_kind: openapiv3::SchemaKind::Any(openapiv3::AnySchema::default()),
})),
);
}
}
fn object_schema_mut<'a>(
components: &'a mut openapiv3::Components,
name: &str,
) -> &'a mut openapiv3::ObjectType {
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(name) else {
panic!("missing inline {name} schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) = &mut schema.schema_kind
else {
panic!("{name} must be an object schema");
};
object
}
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
fn relax_required_nullable_fields(generated: &str) -> String {
const PRESENCE_CHECK: &str =
"#[serde(deserialize_with = \"::std::option::Option::deserialize\")]";
generated
.lines()
.filter(|line| line.trim() != PRESENCE_CHECK)
.flat_map(|line| [line, "\n"])
.collect()
}
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
let registry = spec.components.clone().unwrap_or_default();
if let Some(components) = spec.components.as_mut() {
for schema in components.schemas.values_mut() {
relax_schema_reference(schema, &registry);
}
for response in components.responses.values_mut() {
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
for parameter in components.parameters.values_mut() {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for request_body in components.request_bodies.values_mut() {
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
relax_content(&mut request_body.content, &registry);
}
}
for header in components.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, &registry);
}
}
}
for path in spec.paths.paths.values_mut() {
let openapiv3::ReferenceOr::Item(path) = path else {
continue;
};
for parameter in &mut path.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
for operation in [
&mut path.get,
&mut path.put,
&mut path.post,
&mut path.delete,
&mut path.options,
&mut path.head,
&mut path.patch,
&mut path.trace,
]
.into_iter()
.flatten()
{
for parameter in &mut operation.parameters {
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
relax_parameter(parameter, &registry);
}
}
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
operation.request_body.as_mut()
{
relax_content(&mut request_body.content, &registry);
}
for response in operation
.responses
.responses
.values_mut()
.chain(operation.responses.default.iter_mut())
{
if let openapiv3::ReferenceOr::Item(response) = response {
relax_response(response, &registry);
}
}
}
}
}
fn relax_response(response: &mut openapiv3::Response, registry: &openapiv3::Components) {
relax_content(&mut response.content, registry);
for header in response.headers.values_mut() {
if let openapiv3::ReferenceOr::Item(header) = header {
relax_parameter_format(&mut header.format, registry);
}
}
}
fn relax_content(content: &mut openapiv3::Content, registry: &openapiv3::Components) {
for media_type in content.values_mut() {
if let Some(schema) = media_type.schema.as_mut() {
relax_schema_reference(schema, registry);
}
}
}
fn relax_parameter(parameter: &mut openapiv3::Parameter, registry: &openapiv3::Components) {
let format = match parameter {
openapiv3::Parameter::Query { parameter_data, .. }
| openapiv3::Parameter::Header { parameter_data, .. }
| openapiv3::Parameter::Path { parameter_data, .. }
| openapiv3::Parameter::Cookie { parameter_data, .. } => &mut parameter_data.format,
};
relax_parameter_format(format, registry);
}
fn relax_parameter_format(
format: &mut openapiv3::ParameterSchemaOrContent,
registry: &openapiv3::Components,
) {
match format {
openapiv3::ParameterSchemaOrContent::Schema(schema) => {
relax_schema_reference(schema, registry)
}
openapiv3::ParameterSchemaOrContent::Content(content) => relax_content(content, registry),
}
}
fn relax_schema_reference(
schema: &mut openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_boxed_schema_reference(
schema: &mut openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
registry: &openapiv3::Components,
) {
if let openapiv3::ReferenceOr::Item(schema) = schema {
relax_schema(schema, registry);
}
}
fn relax_schema(schema: &mut openapiv3::Schema, registry: &openapiv3::Components) {
if flattens_objects_beside_scalars(&schema.schema_kind, registry) {
schema.schema_kind = openapiv3::SchemaKind::Any(openapiv3::AnySchema::default());
return;
}
match &mut schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
relax_additional_properties(&mut object.additional_properties, registry);
for property in object.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
}
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
if let Some(items) = array.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
}
openapiv3::SchemaKind::Type(_) => {}
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => {
for subschema in subschemas {
relax_schema_reference(subschema, registry);
}
}
openapiv3::SchemaKind::Not { not } => relax_schema_reference(not, registry),
openapiv3::SchemaKind::Any(any) => {
relax_additional_properties(&mut any.additional_properties, registry);
for property in any.properties.values_mut() {
relax_boxed_schema_reference(property, registry);
}
if let Some(items) = any.items.as_mut() {
relax_boxed_schema_reference(items, registry);
}
for subschema in any
.one_of
.iter_mut()
.chain(any.all_of.iter_mut())
.chain(any.any_of.iter_mut())
{
relax_schema_reference(subschema, registry);
}
if let Some(not) = any.not.as_mut() {
relax_schema_reference(not, registry);
}
}
}
}
fn relax_additional_properties(
additional_properties: &mut Option<openapiv3::AdditionalProperties>,
registry: &openapiv3::Components,
) {
match additional_properties {
Some(openapiv3::AdditionalProperties::Any(false)) => *additional_properties = None,
Some(openapiv3::AdditionalProperties::Schema(schema)) => {
relax_schema_reference(schema, registry)
}
_ => {}
}
}
fn flattens_objects_beside_scalars(
schema_kind: &openapiv3::SchemaKind,
registry: &openapiv3::Components,
) -> bool {
let subschemas = match schema_kind {
openapiv3::SchemaKind::OneOf { one_of } => one_of,
openapiv3::SchemaKind::AnyOf { any_of } => any_of,
_ => return false,
};
let mut objects = false;
let mut scalars = false;
for subschema in subschemas {
if resolves_to_object(subschema, registry, MAX_SCHEMA_REFERENCE_DEPTH) {
objects = true;
} else {
scalars = true;
}
}
objects && scalars
}
fn resolves_to_object(
schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
registry: &openapiv3::Components,
depth: usize,
) -> bool {
let Some(depth) = depth.checked_sub(1) else {
return false;
};
let schema = match schema {
openapiv3::ReferenceOr::Reference { reference } => {
let Some(target) = reference
.strip_prefix("#/components/schemas/")
.and_then(|name| registry.schemas.get(name))
else {
return false;
};
return resolves_to_object(target, registry, depth);
}
openapiv3::ReferenceOr::Item(schema) => schema,
};
match &schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => true,
openapiv3::SchemaKind::Type(_) => false,
openapiv3::SchemaKind::OneOf { one_of: subschemas }
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => subschemas
.iter()
.any(|subschema| resolves_to_object(subschema, registry, depth)),
openapiv3::SchemaKind::Not { .. } => false,
openapiv3::SchemaKind::Any(any) => {
any.typ.as_deref() == Some("object")
|| !any.properties.is_empty()
|| any.additional_properties.is_some()
}
}
}
struct Face {
css_family: String,
weight: u64,
+11045 -11133
View File
File diff suppressed because it is too large Load Diff
+3 -14
View File
@@ -17,9 +17,6 @@ pub const JOBS_CANCEL: &str = "jobs:cancel";
pub const BAN_EMAIL_ADD: &str = "ban:email:add";
pub const BAN_EMAIL_CHECK: &str = "ban:email:check";
pub const BAN_EMAIL_REMOVE: &str = "ban:email:remove";
pub const SUSPICIOUS_EMAIL_DOMAIN_ADD: &str = "suspicious_email_domain:add";
pub const SUSPICIOUS_EMAIL_DOMAIN_CHECK: &str = "suspicious_email_domain:check";
pub const SUSPICIOUS_EMAIL_DOMAIN_REMOVE: &str = "suspicious_email_domain:remove";
pub const BAN_PHRASE_ADD: &str = "ban:phrase:add";
pub const BAN_PHRASE_CHECK: &str = "ban:phrase:check";
pub const BAN_PHRASE_REMOVE: &str = "ban:phrase:remove";
@@ -41,11 +38,9 @@ pub const BAN_AVATAR_HASH_REMOVE: &str = "ban:avatar_hash:remove";
pub const BAN_PROFILE_SUBSTRING_ADD: &str = "ban:profile_substring:add";
pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
pub const BILLING_MANAGE_SUBSCRIPTION: &str = "billing:manage_subscription";
pub const BILLING_REFUND: &str = "billing:refund";
pub const BILLING_VIEW: &str = "billing:view";
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
pub const BULK_UPDATE_SUSPICIOUS_ACTIVITY: &str = "bulk:update:suspicious_activity";
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
@@ -81,7 +76,6 @@ pub const REPORT_RESOLVE: &str = "report:resolve";
pub const REPORT_VIEW: &str = "report:view";
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
pub const SYSTEM_HEAP_SNAPSHOT: &str = "system:heap_snapshot";
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_DELETE: &str = "user:delete";
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
@@ -124,6 +118,7 @@ pub const ALL_ACLS: &[&str] = &[
ARCHIVE_TRIGGER_GUILD,
ARCHIVE_TRIGGER_USER,
ARCHIVE_VIEW_ALL,
ASSET_PURGE,
AUDIT_LOG_VIEW,
AUTHENTICATE,
JOBS_VIEW,
@@ -131,9 +126,6 @@ pub const ALL_ACLS: &[&str] = &[
BAN_EMAIL_ADD,
BAN_EMAIL_CHECK,
BAN_EMAIL_REMOVE,
SUSPICIOUS_EMAIL_DOMAIN_ADD,
SUSPICIOUS_EMAIL_DOMAIN_CHECK,
SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
BAN_PHRASE_ADD,
BAN_PHRASE_CHECK,
BAN_PHRASE_REMOVE,
@@ -155,11 +147,9 @@ pub const ALL_ACLS: &[&str] = &[
BAN_PROFILE_SUBSTRING_ADD,
BAN_PROFILE_SUBSTRING_CHECK,
BAN_PROFILE_SUBSTRING_REMOVE,
BILLING_MANAGE_SUBSCRIPTION,
BILLING_REFUND,
BILLING_VIEW,
BULK_ADD_GUILD_MEMBERS,
BULK_DELETE_USERS,
BULK_DELETE_USER_MESSAGES,
BULK_UPDATE_GUILD_FEATURES,
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
BULK_UPDATE_USER_FLAGS,
@@ -195,7 +185,6 @@ pub const ALL_ACLS: &[&str] = &[
REPORT_VIEW,
REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND,
SYSTEM_HEAP_SNAPSHOT,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_DELETE,
USER_DISABLE_SUSPICIOUS,
-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,
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::tooltip::{Hint, HintLink};
pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
match key {
"feature_guild_create" => Some(Hint {
name: Some("Community Creation Access"),
body: "Admins with the wildcard ACL can always create communities.",
link: Some(HintLink::new(
"/instance-config#community-creation",
"Community creation policy",
)),
}),
_ => None,
}
}
+13 -3
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
@@ -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 = snowflake(key_id);
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
}
}
+38 -29
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
@@ -11,13 +11,12 @@ impl AdminApiClient {
user_id: &str,
include_attachments: bool,
) -> ApiResult<Archive> {
let body = generated_types::TriggerUserArchiveRequest {
include_attachments: include_attachments.then_some(true),
user_id: snowflake(user_id),
let body = generated_types::AdminArchiveCreateRequest {
include_attachments,
};
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),
include_attachments: include_attachments.then_some(true),
let body = generated_types::AdminArchiveCreateRequest {
include_attachments,
};
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(
@@ -71,15 +78,17 @@ impl AdminApiClient {
subject_id: &str,
archive_id: &str,
) -> ApiResult<ArchiveDownloadUrlResponse> {
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.get_archive_download_url(subject_type, subject_id, archive_id)
.get_admin_archive_download(
subject_type,
&snowflake(subject_id),
&snowflake(archive_id),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
+7 -3
View File
@@ -1,15 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult};
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(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+108 -67
View File
@@ -3,8 +3,6 @@
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
#[cfg(test)]
use super::types::AuditLogEntry;
use super::types::AuditLogsListResponse;
pub struct SearchAuditLogsParams {
@@ -12,10 +10,11 @@ pub struct SearchAuditLogsParams {
pub admin_user_id: Option<String>,
pub target_id: Option<String>,
pub target_type: Option<String>,
pub access: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub limit: u32,
pub offset: u32,
pub offset: u64,
}
impl AdminApiClient {
@@ -23,81 +22,66 @@ 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 access = params
.access
.as_deref()
.map(audit_access)
.transpose()?
.map(|value| value.to_string());
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", params.query.as_deref().unwrap_or_default()),
(
"admin_user_id",
params.admin_user_id.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
(
"target_type",
params.target_type.as_deref().unwrap_or_default(),
),
("target_id", params.target_id.as_deref().unwrap_or_default()),
("access", access.as_deref().unwrap_or_default()),
("sort_by", sort_by.as_deref().unwrap_or_default()),
("sort_order", sort_order.as_deref().unwrap_or_default()),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
self.get("/admin/audit-logs", Some(&query_params)).await
}
}
#[cfg(test)]
fn audit_logs_response(
response: generated_types::AuditLogsListResponseSchema,
) -> ApiResult<AuditLogsListResponse> {
Ok(AuditLogsListResponse {
logs: response.logs.into_iter().map(audit_log_entry).collect(),
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
})
fn audit_access(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsAccess> {
generated_types::ListAdminAuditLogsAccess::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
#[cfg(test)]
fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem) -> AuditLogEntry {
AuditLogEntry {
log_id: String::from(entry.log_id),
admin_user_id: String::from(entry.admin_user_id),
admin_user: None,
action: entry.action,
target_id: entry.target_id,
target_type: entry.target_type,
target_user: None,
target_guild: None,
target_channel: None,
related_users: Default::default(),
related_guilds: Default::default(),
related_channels: Default::default(),
audit_log_reason: entry.audit_log_reason,
metadata: entry.metadata,
created_at: entry.created_at,
}
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::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()))
}
fn nonempty_string(value: Option<&str>) -> Option<String> {
value
.filter(|value| !value.is_empty())
.map(std::borrow::ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -112,12 +96,69 @@ mod tests {
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc");
}
#[test]
fn accepts_only_known_access_filters() {
assert_eq!(audit_access("read").unwrap().to_string(), "read");
assert_eq!(audit_access("write").unwrap().to_string(), "write");
assert!(audit_access("all").is_err());
}
#[test]
fn rejects_lossy_audit_totals() {
let response = generated_types::AuditLogsListResponseSchema {
logs: Vec::new(),
total: 1.5,
};
assert!(audit_logs_response(response).is_err());
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
let response = serde_json::json!({"logs": [], "total": total});
assert!(serde_json::from_value::<AuditLogsListResponse>(response).is_err());
}
}
#[test]
fn deserializes_audit_fields_without_losing_generated_string_values() {
let json = serde_json::json!({
"logs": [{
"log_id": "123456789012345678",
"admin_user_id": "234567890123456789",
"admin_user": null,
"action": "USER_UPDATE",
"access": "write",
"target_id": "345678901234567890",
"target_type": "user",
"target_user": null,
"target_guild": null,
"target_channel": null,
"related_users": {},
"related_guilds": {},
"related_channels": {},
"audit_log_reason": "Account review",
"metadata": {"field": "username"},
"created_at": "2026-09-11T12:00:00.000Z"
}],
"total": 1
});
let generated: generated_types::AuditLogsListResponseSchema =
serde_json::from_value(json.clone()).unwrap();
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
assert_eq!(response.logs[0].access.as_deref(), Some("write"));
assert_eq!(serde_json::to_value(response).unwrap(), json);
}
#[test]
fn deserializes_audit_entries_from_an_api_without_access() {
let json = serde_json::json!({
"logs": [{
"log_id": "123456789012345678",
"admin_user_id": "234567890123456789",
"action": "USER_UPDATE",
"target_id": "345678901234567890",
"target_type": "user",
"audit_log_reason": null,
"metadata": {},
"created_at": "2026-09-11T12:00:00.000Z"
}],
"total": 1
});
let response: AuditLogsListResponse = serde_json::from_value(json).unwrap();
assert_eq!(response.logs[0].access, None);
}
}
+229 -248
View File
@@ -1,215 +1,139 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
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(())
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"email",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
},
),
audit_log_reason,
)
.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)
pub async fn unban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("email", email, None, audit_log_reason)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
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(())
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanIpRequest { ip: ip.to_owned() },
),
audit_log_reason,
)
.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)
pub async fn unban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("ip", ip, None, audit_log_reason)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
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(())
pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"phrase",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
},
),
audit_log_reason,
)
.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)
pub async fn unban_phrase(
&self,
phrase: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("phrase", phrase, None, audit_log_reason)
.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)
.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(())
}
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(())
}
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(())
pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"url",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
},
),
audit_log_reason,
)
.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)
pub async fn unban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("url", url, None, audit_log_reason)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
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(())
pub async fn ban_url_domain(
&self,
domain: &str,
match_subdomains: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
"url-domain",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains,
notes: None,
severity: None,
source_url: None,
},
),
audit_log_reason,
)
.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)
pub async fn unban_url_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("url-domain", domain, None, audit_log_reason)
.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 +141,21 @@ 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
self.create_blocklist_entry(
"file-sha",
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,
},
),
audit_log_reason,
)
.await
}
pub async fn unban_file_sha(
@@ -234,23 +163,13 @@ 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)
self.delete_blocklist_entry("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,78 +180,89 @@ 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(())
pub async fn ban_avatar_hash(
&self,
hash_short: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
"avatar-hash",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
},
),
audit_log_reason,
)
.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)
pub async fn unban_avatar_hash(
&self,
hash_short: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("avatar-hash", hash_short, None, audit_log_reason)
.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(
&generated_types::SnowflakeType::from(user_id.to_owned()),
&body,
)
.ban_admin_user_avatar(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
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(())
pub async fn ban_profile_substring(
&self,
scope: &str,
substring: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
generated_types::AdminBlocklistEntryCreateRequest::from(profile_substring_request(
scope, substring,
)?),
audit_log_reason,
)
.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)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
pub async fn unban_profile_substring(
&self,
scope: &str,
substring: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry(
PROFILE_SUBSTRING_LIST,
substring,
Some(scope),
audit_log_reason,
)
.await
}
pub async fn check_profile_substring_ban(
@@ -340,23 +270,74 @@ 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,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
self.generated_with_reason(audit_log_reason)?
.create_admin_blocklist_entry(list_type, &body)
.await
.map(drop)
.map_err(|error| self.generated_error(error))
}
async fn delete_blocklist_entry(
&self,
list_type: &str,
entry_value: &str,
scope: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_delete_scope).transpose()?;
self.generated_with_reason(audit_log_reason)?
.delete_admin_blocklist_entry(list_type, entry_value, scope)
.await
.map(drop)
.map_err(|error| self.generated_error(error))
}
async fn check_blocklist_entry(
&self,
list_type: &str,
entry_value: &str,
scope: Option<&str>,
) -> ApiResult<BanCheckResult> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_get_scope).transpose()?;
let response = self
.generated()
.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())
}
}
fn suspicious_email_domain_request(
domain: &str,
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
Ok(generated_types::SuspiciousEmailDomainRequest {
domain: generated_types::SuspiciousEmailDomainRequestDomain::try_from(domain)
.map_err(|e| ApiError::Parse(e.to_string()))?,
})
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
generated_types::AdminBlocklistListType::try_from(list_type)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn blocklist_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 profile_substring_request(
-154
View File
@@ -1,154 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BillingOverview, InvoiceListResponse, PaymentListResponse, PaymentMethodListResponse,
RefundCancelResponse, SubscriptionResponse,
};
impl AdminApiClient {
pub async fn get_billing_overview(&self, user_id: &str) -> ApiResult<BillingOverview> {
let response = self
.generated()
.admin_billing_overview(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payments(&self, user_id: &str) -> ApiResult<PaymentListResponse> {
let response = self
.generated()
.admin_billing_list_payments(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_subscription(&self, user_id: &str) -> ApiResult<SubscriptionResponse> {
let response = self
.generated()
.admin_billing_get_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payment_methods(
&self,
user_id: &str,
) -> ApiResult<PaymentMethodListResponse> {
let response = self
.generated()
.admin_billing_list_payment_methods(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_invoices(
&self,
user_id: &str,
limit: u32,
starting_after: Option<&str>,
) -> ApiResult<InvoiceListResponse> {
let limit_str = limit.to_string();
let mut params: Vec<(&str, &str)> = vec![("limit", &limit_str)];
if let Some(sa) = starting_after {
params.push(("starting_after", sa));
}
self.get(
&format!("/admin/billing/users/{user_id}/invoices"),
Some(&params),
)
.await
}
pub async fn issue_refund(
&self,
user_id: &str,
payment_intent_id: &str,
amount_cents: Option<u64>,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingRefundRequest {
amount_cents: amount_cents
.map(|value| crate::api::generated::nonzero_u64(value, "amount_cents"))
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
payment_intent_id: payment_intent_id.to_owned(),
reason: reason
.map(generated_types::AdminBillingRefundRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_refund(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn refund_policy_cancel_now(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<RefundCancelResponse> {
let body = generated_types::AdminBillingRefundLatestInvoiceCancelRequest {
reason: reason
.map(generated_types::AdminBillingRefundLatestInvoiceCancelRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.admin_billing_refund_policy_cancel_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn cancel_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_cancel_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn cancel_subscription_immediately(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingCancelImmediatelyRequest {
reason: reason
.map(generated_types::AdminBillingCancelImmediatelyRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_cancel_subscription_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn reactivate_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_reactivate_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn end_premium_grace_period(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_end_premium_grace_period(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
+46 -40
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::BulkJobResponse;
@@ -13,12 +13,12 @@ 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),
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
add_flags: user_flags(add_flags)?,
remove_flags: user_flags(remove_flags)?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk/update-user-flags", &body, audit_log_reason)
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -29,17 +29,13 @@ impl AdminApiClient {
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateSuspiciousActivityFlagsRequest {
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason(
"/admin/bulk/update-suspicious-activity-flags",
&body,
audit_log_reason,
)
.await
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
pub async fn bulk_update_guild_features(
@@ -49,12 +45,12 @@ impl AdminApiClient {
remove_features: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkUpdateGuildFeaturesRequest {
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
add_features: guild_features(add_features),
guild_ids: snowflakes(guild_ids),
remove_features: guild_features(remove_features),
};
self.post_typed_with_reason("/admin/bulk/update-guild-features", &body, audit_log_reason)
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -64,11 +60,23 @@ impl AdminApiClient {
user_ids: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::BulkAddGuildMembersRequest {
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
guild_id: snowflake(guild_id),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk/add-guild-members", &body, audit_log_reason)
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::DeleteUserMessages {
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
@@ -78,43 +86,41 @@ impl AdminApiClient {
reason_code: u32,
days_until_deletion: u32,
public_reason: Option<&str>,
notify_user: bool,
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)?,
),
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
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()))?,
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
"reason_code",
)
.map_err(ApiError::Parse)?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason(
"/admin/bulk/schedule-user-deletion",
&body,
audit_log_reason,
)
.await
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
values
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect()
values.iter().map(|value| snowflake(value)).collect()
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
.cloned()
.map(generated_types::UserFlags::from)
.map(|value| {
generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect()
}
+297 -112
View File
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::GeneratedClient;
use crate::{config::AdminConfig, session::Session};
use progenitor_client::ClientInfo;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
use reqwest::{Method, RequestBuilder};
use serde::Serialize;
@@ -34,46 +36,39 @@ impl<T> ApiResultExt<T> for ApiResult<T> {
}
pub struct AdminApiClient {
http_client: reqwest::Client,
generated: crate::api::generated::GeneratedClient,
base_url: String,
access_token: String,
proxy_client_ip_headers: HeaderMap,
generated: GeneratedClient,
}
impl AdminApiClient {
pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self {
let generated_http_client = build_generated_http_client(config, session);
let generated = crate::api::generated::GeneratedClient::new_with_client(
let generated = GeneratedClient::new_with_client(
&config.api_endpoint,
generated_http_client,
http_client.clone(),
build_session_headers(config, session),
);
Self {
http_client: http_client.clone(),
generated,
base_url: config.api_endpoint.clone(),
access_token: session.access_token.clone(),
proxy_client_ip_headers: build_proxy_client_ip_headers(config),
}
Self { generated }
}
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
let base = format!("{}{}", self.base_url, path);
match query_params {
None => base,
Some(params) => {
let filtered: Vec<_> = params.iter().filter(|(_, v)| !v.is_empty()).collect();
if filtered.is_empty() {
return base;
}
let query = filtered
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{base}?{query}")
}
let mut url = format!("{}{}", self.generated.baseurl(), path);
let query = query_params
.unwrap_or_default()
.iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
if !query.is_empty() {
url.push('?');
url.push_str(&query);
}
url
}
fn request(
@@ -81,30 +76,23 @@ impl AdminApiClient {
method: Method,
path: &str,
query_params: Option<&[(&str, &str)]>,
) -> RequestBuilder {
let url = self.build_url(path, query_params);
self.http_client
.request(method, &url)
.header("Authorization", format!("Bearer {}", self.access_token))
.header("Content-Type", "application/json")
.headers(self.proxy_client_ip_headers.clone())
}
fn with_audit_log_reason(
builder: RequestBuilder,
audit_log_reason: Option<&str>,
) -> RequestBuilder {
match audit_log_reason {
Some(reason) => builder.header("X-Audit-Log-Reason", reason),
None => builder,
}
) -> ApiResult<RequestBuilder> {
let url = self.build_url(path, query_params);
Ok(self
.generated
.client()
.request(method, &url)
.header("Content-Type", "application/json")
.headers(self.headers_with_reason(audit_log_reason)?))
}
fn with_json_body(builder: RequestBuilder, body: Option<&serde_json::Value>) -> RequestBuilder {
match body {
Some(body) => builder.json(body),
None => builder,
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
let mut headers = self.generated.inner().clone();
if let Some(reason) = audit_log_reason {
headers.insert("x-audit-log-reason", audit_log_reason_header(reason)?);
}
Ok(headers)
}
async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> {
@@ -114,13 +102,29 @@ impl AdminApiClient {
.map_err(|e| ApiError::Network(e.to_string()))
}
async fn send_json<B: Serialize + ?Sized>(
&self,
method: Method,
path: &str,
body: Option<&B>,
audit_log_reason: Option<&str>,
) -> ApiResult<reqwest::Response> {
let builder = self.request(method, path, None, audit_log_reason)?;
let builder = match body {
Some(body) => builder.json(body),
None => builder,
};
Self::send_request(builder).await
}
pub async fn get<T: DeserializeOwned>(
&self,
path: &str,
query_params: Option<&[(&str, &str)]>,
) -> ApiResult<T> {
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
self.parse_response(response).await
let response =
Self::send_request(self.request(Method::GET, path, query_params, None)?).await?;
Self::parse_response(response).await
}
pub async fn post<T: DeserializeOwned>(
@@ -149,10 +153,10 @@ impl AdminApiClient {
T: DeserializeOwned,
B: Serialize + ?Sized,
{
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(builder.json(body)).await?;
self.parse_response(response).await
let response = self
.send_json(Method::POST, path, Some(body), audit_log_reason)
.await?;
Self::parse_response(response).await
}
pub async fn post_with_reason<T: DeserializeOwned>(
@@ -161,10 +165,10 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
self.parse_response(response).await
let response = self
.send_json(Method::POST, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
@@ -177,9 +181,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let builder =
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
let response = self
.send_json(Method::POST, path, body, audit_log_reason)
.await?;
Self::parse_void_response(response).await
}
@@ -188,36 +192,135 @@ 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.parse_response(response).await
self.patch_with_reason(path, body, None).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_with_reason<T: DeserializeOwned>(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<T> {
let response = self
.send_json(Method::PATCH, path, body, audit_log_reason)
.await?;
Self::parse_response(response).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 response = self
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
.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 response = self
.send_json(Method::PUT, path, body, audit_log_reason)
.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 response = self
.send_json(Method::PUT, path, Some(body), audit_log_reason)
.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 response = self
.send_json(Method::PUT, path, body, audit_log_reason)
.await?;
Self::parse_void_response(response).await
}
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
if response.status().is_success() {
Ok(())
} else {
let status = response.status().as_u16();
let text = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
Err(ApiError::Http {
status,
message: text,
})
}
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
self.delete_void_with_reason(path, body, None).await
}
pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient {
pub async fn delete_void_with_reason(
&self,
path: &str,
body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
let response = self
.send_json(Method::DELETE, path, body, audit_log_reason)
.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 response = self
.send_json(Method::DELETE, path, body, audit_log_reason)
.await?;
Self::parse_response(response).await
}
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
Self::check_response_status(response).await.map(drop)
}
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
Err(ApiError::Http { status, message })
}
pub(crate) fn generated(&self) -> &GeneratedClient {
&self.generated
}
pub(crate) fn generated_with_reason(
&self,
audit_log_reason: Option<&str>,
) -> ApiResult<GeneratedClient> {
Ok(GeneratedClient::new_with_client(
self.generated.baseurl(),
self.generated.client().clone(),
self.headers_with_reason(audit_log_reason)?,
))
}
pub(crate) fn generated_value<T, U>(&self, value: U) -> ApiResult<T>
where
T: DeserializeOwned,
@@ -240,28 +343,16 @@ impl AdminApiClient {
}
}
async fn parse_response<T: DeserializeOwned>(
&self,
response: reqwest::Response,
) -> ApiResult<T> {
let status = response.status();
if status.as_u16() == 204 {
return serde_json::from_value(serde_json::Value::Null)
.map_err(|e| ApiError::Parse(e.to_string()));
}
if !status.is_success() {
let text = response.text().await.map_err(|error| {
ApiError::Network(format!("failed to read error response body: {error}"))
})?;
return Err(ApiError::Http {
status: status.as_u16(),
message: text,
});
}
let text = response
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?;
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
let response = Self::check_response_status(response).await?;
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
String::new()
} else {
response
.text()
.await
.map_err(|e| ApiError::Network(e.to_string()))?
};
if text.is_empty() {
return serde_json::from_value(serde_json::Value::Null)
.map_err(|e| ApiError::Parse(e.to_string()));
@@ -270,17 +361,14 @@ impl AdminApiClient {
}
}
fn build_generated_http_client(config: &AdminConfig, session: &Session) -> reqwest::Client {
fn build_session_headers(config: &AdminConfig, session: &Session) -> HeaderMap {
let mut headers = HeaderMap::new();
let auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
.expect("failed to build generated API Authorization header");
auth_value.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_value);
headers.extend(build_proxy_client_ip_headers(config));
reqwest::Client::builder()
.user_agent(format!("FluxerAdmin/{} (Rust)", config.build_version))
.default_headers(headers)
.build()
.expect("failed to create generated API HTTP client")
headers
}
pub(crate) fn with_proxy_client_ip_header(
@@ -330,3 +418,100 @@ impl std::fmt::Display for ApiError {
}
}
}
fn audit_log_reason_header(reason: &str) -> ApiResult<HeaderValue> {
let mut value = HeaderValue::from_bytes(reason.as_bytes())
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
value.set_sensitive(true);
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
#[test]
fn audit_log_reason_header_carries_utf8_bytes() {
let reason = "§ 3 Regel – wiederholt 日本";
let value = audit_log_reason_header(reason).expect("valid reason header");
assert_eq!(value.as_bytes(), reason.as_bytes());
assert!(value.is_sensitive());
assert!(audit_log_reason_header("line one\nline two").is_err());
}
fn response(status: u16, body: &'static str) -> reqwest::Response {
axum::http::Response::builder()
.status(status)
.body(body)
.expect("valid response")
.into()
}
#[tokio::test]
async fn parses_successful_json_and_empty_responses() {
for (status, body, expected) in [
(200, r#"{"value":1}"#, json!({"value": 1})),
(201, "[1,2]", json!([1, 2])),
(202, "null", Value::Null),
(200, "", Value::Null),
(204, "ignored body", Value::Null),
] {
let actual: Value = AdminApiClient::parse_response(response(status, body))
.await
.expect("valid response body");
assert_eq!(actual, expected, "HTTP {status}: {body}");
}
}
#[tokio::test]
async fn empty_responses_preserve_null_deserialization_errors() {
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
.expect_err("null is not a list")
.to_string();
for (status, body) in [(200, ""), (204, "ignored body")] {
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
.await
.expect_err("missing list");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn malformed_json_preserves_deserialization_errors() {
for body in [" ", "{", "not JSON"] {
let expected = serde_json::from_str::<Value>(body)
.expect_err("malformed JSON")
.to_string();
let error = AdminApiClient::parse_response::<Value>(response(200, body))
.await
.expect_err("malformed response");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn void_responses_do_not_parse_successful_bodies() {
for status in [200, 201, 202, 204] {
AdminApiClient::parse_void_response(response(status, "not JSON"))
.await
.expect("successful void response");
}
}
#[tokio::test]
async fn typed_and_void_responses_preserve_http_errors() {
for status in [302, 400, 403, 404, 500] {
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
.await
.map(drop);
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
for result in [typed, empty] {
let error = result.expect_err("unsuccessful response");
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
}
}
}
}
}
+8 -8
View File
@@ -9,24 +9,24 @@ impl AdminApiClient {
pub async fn generate_gift_codes(
&self,
count: u32,
duration_type: &str,
duration_type: generated_types::GiftCodeDurationTypeSchema,
duration_quantity: u32,
) -> ApiResult<CodesResponse> {
let body = generated_types::GenerateGiftCodesRequest {
count: crate::api::generated::nonzero_u32(count, "count").map_err(ApiError::Parse)?,
count: crate::api::generated::nonzero_u32(count, "count")
.map_err(ApiError::Parse)?
.into(),
duration_quantity: crate::api::generated::nonzero_u32(
duration_quantity,
"duration_quantity",
)
.map_err(ApiError::Parse)?,
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from(
duration_type,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
.map_err(ApiError::Parse)?
.into(),
duration_type,
};
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())
+15 -17
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -13,11 +13,10 @@ 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
.into_inner()
Vec::from(response.into_inner())
.into_iter()
.map(pending_discovery_application)
.collect()
@@ -26,11 +25,10 @@ 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
.into_inner()
Vec::from(response.into_inner())
.into_iter()
.map(listed_guild)
.collect()
@@ -41,16 +39,16 @@ impl AdminApiClient {
guild_id: &str,
reason: Option<&str>,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let body = generated_types::DiscoveryAdminReviewRequest {
let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
reason: reason
.map(generated_types::DiscoveryAdminReviewRequestReason::try_from)
.map(generated_types::DiscoveryReviewReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
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())
@@ -61,14 +59,14 @@ impl AdminApiClient {
guild_id: &str,
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)
let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
reason: generated_types::DiscoveryRejectionReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.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())
@@ -79,14 +77,14 @@ impl AdminApiClient {
guild_id: &str,
reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminRemoveRequest {
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
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())
+29 -4
View File
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo};
use reqwest::header::HeaderMap;
#[allow(
clippy::all,
unused_imports,
@@ -16,6 +19,24 @@ pub use inner::types;
pub use inner::Client as GeneratedClient;
impl ClientHooks<HeaderMap> for GeneratedClient {
async fn pre<E>(
&self,
request: &mut reqwest::Request,
_info: &OperationInfo,
) -> Result<(), Error<E>> {
let headers = request.headers_mut();
for (name, value) in self.inner() {
headers.entry(name.clone()).or_insert_with(|| value.clone());
}
Ok(())
}
}
pub(crate) fn snowflake(value: &str) -> types::SnowflakeType {
types::SnowflakeType::Variant0(value.to_owned())
}
pub(crate) fn number_to_u64(value: f64, field: &str) -> Result<u64, String> {
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
@@ -32,8 +53,12 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
pub(crate) fn nonzero_u64(value: u64, field: &str) -> Result<std::num::NonZeroU64, String> {
std::num::NonZeroU64::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
pub(crate) fn deletion_reason_code(
value: i32,
field: &str,
) -> Result<types::DeletionReasonCode, String> {
types::DeletionReasonCode::try_from(value)
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
}
#[cfg(test)]
@@ -122,10 +147,10 @@ mod tests {
let response: SearchGuildsResponse =
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
assert_eq!(response.total as i64, 1);
assert_eq!(response.total, 1.0);
assert_eq!(response.guilds.len(), 1);
assert_eq!(response.guilds[0].name, "Test Guild");
assert_eq!(response.guilds[0].member_count, 42);
assert_eq!(*response.guilds[0].member_count, 42);
}
#[test]
+5 -5
View File
@@ -1,16 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
impl AdminApiClient {
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let response = self
.generated()
.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())
@@ -20,10 +20,10 @@ impl AdminApiClient {
&self,
guild_id: &str,
) -> ApiResult<ListGuildStickersResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
let guild_id = snowflake(guild_id);
let response = self
.generated()
.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())
+84 -159
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use serde::Deserialize;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::reports::SearchReportsParams;
use super::types::{
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
@@ -15,34 +16,21 @@ impl AdminApiClient {
&self,
query: &str,
limit: u32,
offset: u32,
offset: u64,
) -> 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)
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
resp.guild
self.lookup_guild(guild_id)
.await?
.map(GuildInfo::from)
.ok_or_else(|| super::client::ApiError::Http {
status: 404,
@@ -51,12 +39,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 +54,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 +81,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())
@@ -115,45 +97,34 @@ impl AdminApiClient {
&self,
guild_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<ListGuildMembersResponse> {
let body = generated_types::ListGuildMembersRequest {
guild_id: snowflake(guild_id),
limit: Some(
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
),
offset: Some(i64::from(offset)),
};
let limit = limit.to_string();
let offset = offset.to_string();
let response = self
.generated()
.admin_list_guild_members(&body)
.list_admin_guild_members(
&snowflake(guild_id),
Some(limit.as_str()),
Some(offset.as_str()),
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
let body = generated_types::BanGuildMemberRequest {
ban_duration_seconds: None,
delete_message_days: None,
guild_id: snowflake(guild_id),
reason: None,
user_id: snowflake(user_id),
};
let body = generated_types::BanGuildMemberBody::default();
self.generated()
.admin_ban_guild_member(&body)
.ban_admin_guild_member(&snowflake(guild_id), &snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn kick_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
let body = generated_types::KickGuildMemberRequest {
guild_id: snowflake(guild_id),
user_id: snowflake(user_id),
};
self.generated()
.kick_guild_member(&body)
.kick_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -165,21 +136,21 @@ 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()))?;
let response = self
.generated()
.list_guild_audit_logs_admin(&body)
.list_admin_guild_audit_logs(
&snowflake(guild_id),
None,
None,
before.as_ref(),
limit,
None,
)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -188,15 +159,15 @@ impl AdminApiClient {
pub async fn clear_guild_fields(&self, guild_id: &str, fields: &[String]) -> ApiResult<()> {
let fields = fields
.iter()
.map(generated_types::ClearGuildFieldsRequestFieldsItem::try_from)
.map(|field| generated_types::UpdateGuildRequestFieldsItem::try_from(field.as_str()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::ClearGuildFieldsRequest {
let body = generated_types::UpdateGuildRequest {
fields,
guild_id: snowflake(guild_id),
..Default::default()
};
self.generated()
.clear_guild_fields(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -207,10 +178,10 @@ impl AdminApiClient {
guild_id: &str,
settings: &serde_json::Value,
) -> ApiResult<GuildUpdateResponse> {
let body = guild_settings_request(guild_id, settings)?;
let body = guild_settings_request(settings)?;
let response = self
.generated()
.update_guild_settings(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
guild_update_response(response.into_inner())
@@ -221,13 +192,13 @@ impl AdminApiClient {
guild_id: &str,
name: &str,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildNameRequest {
guild_id: snowflake(guild_id),
name: name.to_owned(),
let body = generated_types::UpdateGuildRequest {
name: Some(name.to_owned()),
..Default::default()
};
let response = self
.generated()
.update_guild_name(&body)
.update_admin_guild(&snowflake(guild_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -238,37 +209,27 @@ impl AdminApiClient {
guild_id: &str,
vanity: Option<&str>,
) -> ApiResult<GuildUpdateResponse> {
let body = generated_types::UpdateGuildVanityRequest {
guild_id: snowflake(guild_id),
vanity_url_code: vanity.map(std::borrow::ToOwned::to_owned),
};
let response = self
.generated()
.update_guild_vanity(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let body = serde_json::json!({"vanity_url_code": vanity});
self.patch(
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
Some(&body),
)
.await
}
pub async fn reload_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::ReloadGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.reload_guild(&body)
.create_admin_guild_reload(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn shutdown_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
let body = generated_types::ShutdownGuildRequest {
guild_id: snowflake(guild_id),
};
let response = self
.generated()
.shutdown_guild(&body)
.create_admin_guild_shutdown(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -279,13 +240,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())
@@ -295,55 +252,29 @@ impl AdminApiClient {
&self,
guild_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> 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(&SearchReportsParams {
reported_guild_id: Some(guild_id),
limit,
offset,
..Default::default()
})
.await
}
}
#[derive(Deserialize)]
struct GuildSettingsPatch {
content_warning_level: Option<generated_types::ContentWarningLevel>,
content_warning_level: Option<generated_types::ContentWarningLevelInput>,
content_warning_text: Option<String>,
default_message_notifications: Option<generated_types::DefaultMessageNotifications>,
default_message_notifications: Option<generated_types::DefaultMessageNotificationsInput>,
disabled_operations: Option<generated_types::GuildOperations>,
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>,
mfa_level: Option<generated_types::GuildMfaLevel>,
explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
mfa_level: Option<generated_types::GuildMfaLevelInput>,
nsfw: Option<bool>,
nsfw_level: Option<generated_types::NsfwLevel>,
verification_level: Option<generated_types::GuildVerificationLevel>,
nsfw_level: Option<generated_types::NsfwLevelInput>,
verification_level: Option<generated_types::GuildVerificationLevelInput>,
}
fn search_guilds_response(
@@ -371,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
owner_global_name: response.owner_global_name,
owner_discriminator: response.owner_discriminator,
member_count: crate::api::generated::i64_to_u64(
i64::from(response.member_count),
i64::from(i32::from(response.member_count)),
"member_count",
)
.map_err(ApiError::Parse)?,
@@ -379,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
nsfw_level: response.nsfw_level.map(i32::from),
nsfw: response.nsfw,
content_warning_level: response.content_warning_level.map(i32::from),
content_warning_text: response.content_warning_text,
content_warning_text: response.content_warning_text.map(String::from),
description: None,
vanity_url_code: None,
})
@@ -392,9 +323,9 @@ fn guild_update_response(
Ok(GuildUpdateResponse {
guild: GuildInfo {
id: String::from(guild.id),
name: guild.name,
icon: guild.icon,
banner: guild.banner,
name: String::from(guild.name),
icon: guild.icon.map(String::from),
banner: guild.banner.map(String::from),
owner_id: String::from(guild.owner_id),
owner_username: None,
owner_global_name: None,
@@ -404,11 +335,11 @@ fn guild_update_response(
"member_count",
)
.map_err(ApiError::Parse)?,
features: guild.features,
features: guild.features.into_iter().map(String::from).collect(),
nsfw_level: guild.nsfw_level.map(i32::from),
nsfw: guild.nsfw,
content_warning_level: guild.content_warning_level.map(i32::from),
content_warning_text: guild.content_warning_text,
content_warning_text: guild.content_warning_text.map(String::from),
description: None,
vanity_url_code: None,
},
@@ -416,29 +347,24 @@ 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()
})
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
values
.iter()
@@ -458,9 +384,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);
+35 -22
View File
@@ -4,19 +4,23 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
PendingRegistrationActionRequest, RegistrationUrlActionRequest,
InstancePremiumDiscovery,
};
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 get_instance_premium_discovery(&self) -> ApiResult<InstancePremiumDiscovery> {
self.get("/.well-known/fluxer", 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 +28,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 +36,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 -47
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::client::{AdminApiClient, ApiResult};
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
pub struct ListJobsParams {
@@ -16,51 +16,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),
("cursor_created_at", cursor_created_at),
("cursor_job_id", cursor_job_id),
("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(&snowflake(job_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -71,19 +52,27 @@ 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<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
cursor
.and_then(|cursor| cursor.get(field))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
}
+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
}
}
+56 -66
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -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(())
}
@@ -39,7 +43,8 @@ impl AdminApiClient {
attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id),
confirmed_viewed: true,
filename: filename.to_owned(),
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.to_string()))?,
message_id: snowflake(message_id),
reporter_full_name:
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
@@ -50,7 +55,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 +67,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 +88,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 +105,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 +118,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(&snowflake(job_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -140,18 +133,20 @@ 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 filename = generated_types::SearchAdminMessagesFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.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 +159,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,24 +181,20 @@ 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())
}
}
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
-1
View File
@@ -8,7 +8,6 @@ pub mod archives;
pub mod assets;
pub mod audit;
pub mod bans;
pub mod billing;
pub mod bulk;
pub mod client;
pub mod codes;
+182 -116
View File
@@ -1,12 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
};
#[derive(Default)]
pub struct SearchReportsParams<'a> {
pub query: Option<&'a str>,
pub status: Option<i32>,
pub report_type: Option<i32>,
pub category: Option<&'a str>,
pub reporter_id: Option<&'a str>,
pub reported_user_id: Option<&'a str>,
pub reported_guild_id: Option<&'a str>,
pub reported_channel_id: Option<&'a str>,
pub guild_context_id: Option<&'a str>,
pub resolved_by_admin_id: Option<&'a str>,
pub sort_by: Option<&'a str>,
pub sort_order: Option<&'a str>,
pub limit: u32,
pub offset: u64,
}
impl AdminApiClient {
pub async fn list_reports(
&self,
@@ -14,28 +32,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(&snowflake(report_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -45,100 +56,89 @@ impl AdminApiClient {
&self,
report_id: &str,
public_comment: Option<&str>,
notify_reporter: bool,
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);
}
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
self.patch_with_reason(
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
Some(&body),
audit_log_reason,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn search_reports(
&self,
query: Option<&str>,
status: Option<i32>,
report_type: Option<i32>,
category: Option<&str>,
reporter_id: Option<&str>,
reported_user_id: Option<&str>,
reported_guild_id: Option<&str>,
reported_channel_id: Option<&str>,
guild_context_id: Option<&str>,
resolved_by_admin_id: Option<&str>,
sort_by: Option<&str>,
sort_order: Option<&str>,
limit: u32,
offset: u32,
params: &SearchReportsParams<'_>,
) -> ApiResult<SearchReportsResponse> {
let 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 = params
.status
.map(report_status)
.transpose()?
.unwrap_or_default();
let report_type = params
.report_type
.map(report_type_name)
.transpose()?
.unwrap_or_default();
let sort_by = params
.sort_by
.map(report_sort_by)
.transpose()?
.unwrap_or_default();
let limit = params.limit.to_string();
let offset = params.offset.to_string();
let query_params = [
("q", params.query.unwrap_or_default()),
("status", status),
("report_type", report_type),
("category", params.category.unwrap_or_default()),
("reporter_id", params.reporter_id.unwrap_or_default()),
(
"reported_user_id",
params.reported_user_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,
})
(
"reported_guild_id",
params.reported_guild_id.unwrap_or_default(),
),
(
"reported_channel_id",
params.reported_channel_id.unwrap_or_default(),
),
(
"guild_context_id",
params.guild_context_id.unwrap_or_default(),
),
(
"resolved_by_admin_id",
params.resolved_by_admin_id.unwrap_or_default(),
),
("sort_by", sort_by),
("sort_order", params.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(
&self,
reporter_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
Some(reporter_id),
None,
None,
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reporter_id: Some(reporter_id),
limit,
offset,
)
..Default::default()
})
.await
}
@@ -146,34 +146,100 @@ impl AdminApiClient {
&self,
reported_user_id: &str,
limit: u32,
offset: u32,
offset: u64,
) -> ApiResult<SearchReportsResponse> {
self.search_reports(
None,
None,
None,
None,
None,
Some(reported_user_id),
None,
None,
None,
None,
None,
None,
self.search_reports(&SearchReportsParams {
reported_user_id: Some(reported_user_id),
limit,
offset,
)
..Default::default()
})
.await
}
}
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}"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_statuses_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "pending"), (1, "resolved")] {
assert_eq!(report_status(value).expect("supported status"), expected);
}
for value in [-1, 2] {
assert_eq!(
report_status(value)
.expect_err("unknown status")
.to_string(),
format!("parse error: unknown report status: {value}")
);
}
}
#[test]
fn report_types_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "message"), (1, "user"), (2, "guild")] {
assert_eq!(report_type_name(value).expect("supported type"), expected);
}
for value in [-1, 3] {
assert_eq!(
report_type_name(value)
.expect_err("unknown type")
.to_string(),
format!("parse error: unknown report type: {value}")
);
}
}
#[test]
fn report_sort_fields_accept_only_the_existing_aliases() {
for (field, expected) in [
("createdAt", "created_at"),
("created_at", "created_at"),
("reportedAt", "reported_at"),
("reported_at", "reported_at"),
("resolvedAt", "resolved_at"),
("resolved_at", "resolved_at"),
] {
assert_eq!(
report_sort_by(field).expect("supported sort field"),
expected
);
}
assert_eq!(
report_sort_by("unknown")
.expect_err("unknown sort field")
.to_string(),
"parse error: unknown report sort field: unknown"
);
}
}
+87 -10
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
@@ -11,15 +11,16 @@ impl AdminApiClient {
index_type: &str,
guild_id: Option<&str>,
) -> ApiResult<RefreshSearchIndexResponse> {
let index_type =
generated_types::CreateAdminSearchIndexRefreshIndexName::try_from(index_type)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let body = generated_types::RefreshSearchIndexRequest {
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
index_type: generated_types::RefreshSearchIndexRequestIndexType::try_from(index_type)
.map_err(|e| ApiError::Parse(e.to_string()))?,
guild_id: guild_id.map(snowflake),
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 +30,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:?}"),
}
}
}
+8 -14
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, 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())
@@ -26,15 +24,11 @@ impl AdminApiClient {
guild_ids: &[String],
) -> ApiResult<ReloadAllGuildsResponse> {
let body = generated_types::ReloadGuildsRequest {
guild_ids: guild_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
};
let response = self
.generated()
.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 +37,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 +48,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())
+3 -7
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::SendSystemDmResponse;
@@ -14,15 +14,11 @@ impl AdminApiClient {
let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
};
let response = self
.generated()
.send_system_dm(&body)
.create_admin_system_dm(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
+2
View File
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
#[serde(default)]
pub admin_user: Option<AuditLogUserSummary>,
pub action: String,
#[serde(default)]
pub access: Option<String>,
pub target_id: String,
pub target_type: String,
#[serde(default)]
-39
View File
@@ -1,39 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BillingOverview {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SubscriptionResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentMethodListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InvoiceListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RefundCancelResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
+17 -3
View File
@@ -6,8 +6,14 @@ pub fn deserialize_discriminator<'de, D: Deserializer<'de>>(d: D) -> Result<Stri
let v: serde_json::Value = Deserialize::deserialize(d)?;
match v {
serde_json::Value::String(s) => Ok(format!("{:0>4}", s)),
serde_json::Value::Number(n) => Ok(format!("{:04}", n.as_u64().unwrap_or(0))),
_ => Ok("0000".to_owned()),
serde_json::Value::Number(n) => n
.as_u64()
.map(|value| format!("{value:04}"))
.ok_or_else(|| serde::de::Error::custom("expected an unsigned integer discriminator")),
serde_json::Value::Null => Ok("0000".to_owned()),
_ => Err(serde::de::Error::custom(
"expected string or unsigned integer discriminator",
)),
}
}
@@ -15,7 +21,9 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
let v: serde_json::Value = Deserialize::deserialize(d)?;
match v {
serde_json::Value::String(s) => s.parse::<u64>().map_err(serde::de::Error::custom),
serde_json::Value::Number(n) => Ok(n.as_u64().unwrap_or(0)),
serde_json::Value::Number(n) => n
.as_u64()
.ok_or_else(|| serde::de::Error::custom("expected an unsigned 64-bit integer")),
serde_json::Value::Null => Ok(0),
_ => Err(serde::de::Error::custom("expected string or number")),
}
@@ -114,6 +122,12 @@ pub struct AdminUser {
pub pending_bulk_message_deletion_at: Option<String>,
pub deletion_reason_code: Option<i32>,
pub deletion_public_reason: Option<String>,
#[serde(default)]
pub deletion_audit_log_reason: Option<String>,
#[serde(default)]
pub deletion_scheduled_by: Option<String>,
#[serde(default)]
pub deletion_scheduled_at: Option<String>,
pub last_active_at: Option<String>,
pub last_active_ip: Option<String>,
pub last_active_ip_reverse: Option<String>,
@@ -0,0 +1,332 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::{InstanceConfigResponse, PremiumMode};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const BILLING_MAX_CURRENCIES: usize = 64;
pub const BILLING_MAX_COUNTRY_CURRENCIES: usize = 300;
pub const BILLING_MAX_LEGACY_SLOTS: usize = 256;
pub const BILLING_MAX_LEGACY_PRICES_PER_SLOT: usize = 32;
pub const BILLING_PRICE_SLOTS: [&str; 4] = ["monthly", "yearly", "gift_1_month", "gift_1_year"];
pub const PREMIUM_PRODUCT_NAME_MAX_CHARS: usize = 40;
pub const TRI_STATE_DEFAULT: &str = "default";
pub const TRI_STATE_ON: &str = "on";
pub const TRI_STATE_OFF: &str = "off";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BillingCatalogMode {
#[default]
Env,
Operator,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct BillingPriceSet {
pub monthly: Option<String>,
pub yearly: Option<String>,
pub gift_1_month: Option<String>,
pub gift_1_year: Option<String>,
}
impl BillingPriceSet {
pub fn has_recurring_pair(&self) -> bool {
self.monthly.is_some() && self.yearly.is_some()
}
pub fn is_empty(&self) -> bool {
self.monthly.is_none()
&& self.yearly.is_none()
&& self.gift_1_month.is_none()
&& self.gift_1_year.is_none()
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceBillingResponse {
pub enabled: Option<bool>,
#[serde(default)]
pub effective_enabled: bool,
#[serde(default)]
pub stripe_secret_key_set: bool,
#[serde(default)]
pub stripe_webhook_secret_set: bool,
#[serde(default)]
pub stripe_secret_key_stored: bool,
#[serde(default)]
pub stripe_webhook_secret_stored: bool,
pub default_currency: Option<String>,
pub prices: Option<BTreeMap<String, BillingPriceSet>>,
pub country_currencies: Option<BTreeMap<String, String>>,
pub legacy_prices: Option<BTreeMap<String, Vec<String>>>,
#[serde(default)]
pub billing_active: bool,
#[serde(default)]
pub stripe_serviceable: bool,
#[serde(default)]
pub catalog_mode: BillingCatalogMode,
#[serde(default)]
pub webhook_url: String,
pub automatic_tax: Option<bool>,
pub tax_id_collection: Option<bool>,
pub terms_consent_required: Option<bool>,
#[serde(default)]
pub effective_automatic_tax: bool,
#[serde(default)]
pub effective_tax_id_collection: bool,
#[serde(default)]
pub effective_terms_consent_required: bool,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceBillingUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_secret_key: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_webhook_secret: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_currency: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prices: Option<Option<BTreeMap<String, BillingPriceSet>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_currencies: Option<Option<BTreeMap<String, String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legacy_prices: Option<Option<BTreeMap<String, Vec<String>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automatic_tax: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id_collection: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_consent_required: Option<Option<bool>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscovery {
#[serde(default)]
pub app_public: InstancePremiumDiscoveryAppPublic,
#[serde(default)]
pub features: InstancePremiumDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryAppPublic {
#[serde(default)]
pub branding: InstancePremiumDiscoveryBranding,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding {
pub premium_product_name: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryFeatures {
#[serde(default)]
pub premium_enabled: bool,
}
impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> {
self.app_public
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PremiumBranding {
pub name: Option<String>,
pub premium_enabled: bool,
}
impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
Self {
name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled,
}
}
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts(
config.self_hosted,
&config.app_public.branding.premium_product_name,
config.policy.premium_mode,
)
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self {
name: (!name.is_empty()).then(|| name.to_owned()),
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn billing_response_round_trips_through_the_generated_contract() {
let value = json!({
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1Old"]},
"billing_active": false,
"stripe_serviceable": false,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"automatic_tax": null,
"tax_id_collection": false,
"terms_consent_required": true,
"effective_automatic_tax": false,
"effective_tax_id_collection": false,
"effective_terms_consent_required": true
});
let generated: generated_types::InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("generated billing response");
let ours: InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("hand-written billing response");
assert_eq!(ours.catalog_mode, BillingCatalogMode::Operator);
assert!(ours.stripe_secret_key_stored);
assert_eq!(ours.automatic_tax, None);
assert_eq!(ours.tax_id_collection, Some(false));
assert!(ours.effective_terms_consent_required);
assert!(ours.prices.as_ref().expect("prices")["GBP"].has_recurring_pair());
assert_eq!(serde_json::to_value(&ours).expect("serializable"), value);
assert_eq!(
serde_json::to_value(generated).expect("serializable generated"),
value
);
}
#[test]
fn default_billing_response_matches_the_generated_contract() {
let value = serde_json::to_value(InstanceBillingResponse::default()).expect("serializable");
serde_json::from_value::<generated_types::InstanceBillingResponse>(value.clone())
.expect("generated billing response");
assert_eq!(value["catalog_mode"], json!("env"));
assert_eq!(value["prices"], json!(null));
}
#[test]
fn billing_update_preserves_explicit_nulls_and_omits_untouched_fields() {
let mut prices = BTreeMap::new();
prices.insert(
"SEK".to_owned(),
BillingPriceSet {
monthly: Some("price_1Monthly".to_owned()),
yearly: Some("price_1Yearly".to_owned()),
..Default::default()
},
);
let update = InstanceBillingUpdateRequest {
enabled: Some(None),
stripe_secret_key: Some(None),
default_currency: Some(None),
prices: Some(Some(prices)),
country_currencies: Some(None),
legacy_prices: Some(Some(BTreeMap::new())),
automatic_tax: Some(None),
tax_id_collection: Some(Some(true)),
terms_consent_required: Some(Some(false)),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::InstanceBillingUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": {
"SEK": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": null,
"legacy_prices": {},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
})
);
assert_eq!(
serde_json::to_value(InstanceBillingUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn premium_discovery_reads_the_name_and_feature_flag() {
let discovery: InstancePremiumDiscovery = serde_json::from_value(json!({
"app_public": {"branding": {"product_name": "Example", "premium_product_name": " Gold "}},
"features": {"premium_enabled": true, "stripe_enabled": false}
}))
.expect("discovery");
assert_eq!(discovery.premium_product_name(), Some("Gold"));
assert!(discovery.features.premium_enabled);
let empty: InstancePremiumDiscovery =
serde_json::from_value(json!({})).expect("empty discovery");
assert_eq!(empty.premium_product_name(), None);
assert!(!empty.features.premium_enabled);
assert_eq!(
PremiumBranding::from_discovery(&discovery),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true
}
);
}
#[test]
fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false
}
);
assert!(
PremiumBranding::from_config_parts(true, "Gold", PremiumMode::Mirror).premium_enabled
);
assert_eq!(
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding {
name: None,
premium_enabled: true
}
);
}
}
+252 -73
View File
@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize};
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse {
pub sso: SsoConfigResponse,
@@ -18,6 +20,16 @@ pub struct InstanceConfigResponse {
pub integrations: InstanceIntegrationsResponse,
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -31,34 +43,18 @@ pub struct InstancePolicyResponse {
pub direct_messages_locked: bool,
#[serde(default)]
pub premium_mode: PremiumMode,
#[serde(default = "default_guild_create_access")]
pub guild_create_access: bool,
#[serde(default)]
pub services: InstanceServicesOverrides,
#[serde(default)]
pub services_resolved: InstanceServicesResolved,
#[serde(default)]
pub services_available: InstanceServicesAvailable,
#[serde(default)]
pub deferred_phone_gate: DeferredPhoneGateResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeferredPhoneGateResponse {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub window_hours: f64,
#[serde(default)]
pub member_threshold: i64,
}
impl Default for DeferredPhoneGateResponse {
fn default() -> Self {
Self {
enabled: true,
window_hours: 6.0,
member_threshold: 50,
}
}
fn default_guild_create_access() -> bool {
true
}
impl Default for InstancePolicyResponse {
@@ -69,10 +65,10 @@ impl Default for InstancePolicyResponse {
direct_messages_disabled: false,
direct_messages_locked: false,
premium_mode: PremiumMode::Everyone,
guild_create_access: default_guild_create_access(),
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
deferred_phone_gate: DeferredPhoneGateResponse::default(),
}
}
}
@@ -111,8 +107,6 @@ pub struct InstanceIntegrationsResponse {
#[serde(default)]
pub youtube: InstanceYoutubeIntegrationResponse,
#[serde(default)]
pub captcha: InstanceCaptchaIntegrationResponse,
#[serde(default)]
pub email: InstanceEmailIntegrationResponse,
#[serde(default)]
pub bluesky: InstanceBlueskyIntegrationResponse,
@@ -133,21 +127,6 @@ pub struct InstanceYoutubeIntegrationResponse {
pub effective_available: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceCaptchaIntegrationResponse {
pub provider: Option<String>,
#[serde(default)]
pub effective_provider: String,
pub hcaptcha_site_key: Option<String>,
#[serde(default)]
pub hcaptcha_secret_key_set: bool,
pub turnstile_site_key: Option<String>,
#[serde(default)]
pub turnstile_secret_key_set: bool,
#[serde(default)]
pub effective_enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceEmailIntegrationResponse {
pub enabled: Option<bool>,
@@ -320,6 +299,11 @@ pub struct AppBrandingConfigResponse {
pub wordmark_url: Option<String>,
pub favicon_url: Option<String>,
pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
#[serde(default = "default_premium_product_name")]
pub premium_product_name: String,
pub premium_info_url: Option<String>,
}
impl Default for AppBrandingConfigResponse {
@@ -332,6 +316,10 @@ impl Default for AppBrandingConfigResponse {
wordmark_url: None,
favicon_url: None,
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
premium_product_name: default_premium_product_name(),
premium_info_url: None,
}
}
}
@@ -340,6 +328,10 @@ fn default_product_name() -> String {
"Fluxer".to_owned()
}
fn default_premium_product_name() -> String {
"Premium".to_owned()
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AppSetupConfigResponse {
#[serde(default)]
@@ -436,6 +428,131 @@ impl VoiceE2eeScope {
}
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PushRelayConfigResponse {
pub relay_consent_accepted: bool,
pub relay_consent_accepted_at: Option<String>,
pub relay_consent_accepted_by: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushRelayConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub relay_consent_accepted: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct DomainMigrationConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for DomainMigrationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CaptchaConfigResponse {
pub enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for CaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: true,
cost: 5_000,
max_counter: 1_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct CaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
pub poll_interval_seconds: u64,
pub poll_jitter_percent: u32,
}
impl Default for ExperimentDeliveryConfigResponse {
fn default() -> Self {
Self {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ExperimentDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_interval_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub poll_jitter_percent: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceRegistrationResponse {
pub mode: RegistrationMode,
@@ -525,6 +642,16 @@ pub struct InstanceConfigUpdateRequest {
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<InstanceMediaUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -536,21 +663,11 @@ pub struct InstancePolicyUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub direct_messages_disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_create_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DeferredPhoneGateUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_hours: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_threshold: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -570,8 +687,6 @@ pub struct InstanceIntegrationsUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub youtube: Option<InstanceYoutubeIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<InstanceCaptchaIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<InstanceEmailIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bluesky: Option<InstanceBlueskyIntegrationUpdateRequest>,
@@ -589,20 +704,6 @@ pub struct InstanceYoutubeIntegrationUpdateRequest {
pub api_key: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceCaptchaIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hcaptcha_site_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hcaptcha_secret_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turnstile_site_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turnstile_secret_key: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceEmailIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -729,6 +830,14 @@ pub struct AppBrandingConfigUpdateRequest {
pub favicon_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme_color: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_incident_history_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_product_name: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_info_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -827,12 +936,82 @@ pub struct CreateRegistrationUrlResponse {
pub url: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct RegistrationUrlActionRequest {
pub id: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[derive(Clone, Debug, Serialize)]
pub struct PendingRegistrationActionRequest {
pub user_id: String,
#[test]
fn default_instance_config_sections_match_the_published_contract() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../../openapi-admin.json"))
.expect("admin schema");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
delivery
);
for (name, value) in [
("DomainMigrationConfigResponse", domain_migration),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
assert_eq!(
value, &schema["components"]["schemas"][name]["properties"][field]["default"],
"{name}.{field}"
);
}
}
}
#[test]
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
let update = DomainMigrationConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+2 -2
View File
@@ -4,12 +4,12 @@ mod admin_api_keys;
mod applications;
mod archives;
mod audit;
mod billing;
mod bulk;
mod codes;
mod common;
mod discovery;
mod guild_assets;
mod instance_billing;
mod instance_config;
mod jobs;
mod limit_config;
@@ -24,12 +24,12 @@ pub use admin_api_keys::*;
pub use applications::*;
pub use archives::*;
pub use audit::*;
pub use billing::*;
pub use bulk::*;
pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
pub use limit_config::*;
+1
View File
@@ -28,6 +28,7 @@ pub struct VoiceServer {
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub is_active: Option<bool>,
pub soft_connection_limit: Option<i64>,
pub vip_only: Option<bool>,
#[serde(default)]
pub required_guild_features: Vec<String>,
+183 -212
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
@@ -15,34 +15,35 @@ impl AdminApiClient {
email: Option<&str>,
last_active_ip: Option<&str>,
limit: u32,
offset: u32,
offset: u64,
) -> 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();
Ok(SearchUsersResponse {
users: self.generated_value(response.users)?,
total: response.total as u64,
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
})
}
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 +54,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 +81,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 +94,13 @@ impl AdminApiClient {
add_flags: &[String],
remove_flags: &[String],
) -> ApiResult<AdminUser> {
let body = generated_types::UpdateUserFlagsRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
user_id: snowflake(user_id),
let body = generated_types::AdminUserFlagsUpdateRequest {
add_flags: user_flags(add_flags)?,
remove_flags: user_flags(remove_flags)?,
};
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 +115,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 +138,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 +150,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 +162,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 +177,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 +198,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 +218,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 +232,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 +245,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 +258,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 +271,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 +301,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 +318,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 +334,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 +345,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 +361,14 @@ 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()))?,
.map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
username: generated_types::UsernameType::from(username.to_owned()),
};
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 +376,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())?;
@@ -436,27 +393,40 @@ impl AdminApiClient {
user_id: &str,
duration_hours: u32,
reason: Option<&str>,
notify_user: bool,
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()))?,
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
notify_user,
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),
pub async fn unban_user(
&self,
user_id: &str,
public_reason: Option<&str>,
notify_user: bool,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserUnbanRequest {
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
};
let response = self
.generated()
.unban_user(&body)
.generated_with_reason(private_reason)?
.unban_admin_user(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -469,46 +439,76 @@ impl AdminApiClient {
reason_code: i32,
public_reason: Option<&str>,
days_until_deletion: u32,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::ScheduleAccountDeletionRequest {
days_until_deletion: Some(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
.map_err(ApiError::Parse)?,
),
let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion,
"days_until_deletion",
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
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)?,
replace_pending_deletion_at: None,
};
let response = self
.generated()
.schedule_account_deletion(&body)
.generated_with_reason(audit_log_reason)?
.schedule_admin_user_deletion(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
.map_err(|error| self.generated_error(error))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
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)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
pub async fn cancel_deletion(
&self,
user_id: &str,
expected_pending_deletion_at: &str,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = serde_json::json!({
"expected_pending_deletion_at": expected_pending_deletion_at,
"notify_user": notify_user,
});
let resp: UserMutationResponse = self
.delete_with_reason(
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
Some(&body),
audit_log_reason,
)
.await?;
Ok(resp.user)
}
pub async fn annotate_ban(
&self,
user_id: &str,
ban_audit_log_id: &str,
note: &str,
) -> ApiResult<()> {
let body = serde_json::json!({
"ban_audit_log_id": ban_audit_log_id,
"note": note,
});
self.post_void(
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
Some(&body),
)
.await
}
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 +516,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 +529,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::RelationshipCategoryEnum::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?;
self.generated()
.remove_user_relationship(&body)
.remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
@@ -550,16 +543,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::RelationshipCategoryEnum::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 +558,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 +570,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 +583,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,21 +602,21 @@ 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 snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
.cloned()
.map(generated_types::UserFlags::from)
.map(|value| {
generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect()
}
+145 -48
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,30 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> {
let body =
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_voice_region(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let region_id = required_field(params, "id")?;
let body = voice_request_body(params, &["id"])?;
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
self.patch_with_reason(
&format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
Some(&body),
None,
)
.await
}
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
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 +88,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 +100,15 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<CreateVoiceServerResponse> {
let body =
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let region_id = required_field(params, "region_id")?;
paired_coordinates(params)?;
let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
voice_request_body(params, &["region_id"])?,
)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.create_voice_server(&body)
.create_admin_voice_server(&region_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
@@ -130,15 +118,21 @@ impl AdminApiClient {
&self,
params: &serde_json::Value,
) -> ApiResult<UpdateVoiceServerResponse> {
let body =
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self
.generated()
.update_voice_server(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?;
let body = voice_request_body(params, &["region_id", "server_id"])?;
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
self.patch_with_reason(
&format!(
"/admin/voice/regions/{}/servers/{}",
urlencoding::encode(&region_id),
urlencoding::encode(&server_id)
),
Some(&body),
None,
)
.await
}
pub async fn delete_voice_server(
@@ -146,15 +140,118 @@ impl AdminApiClient {
region_id: &str,
server_id: &str,
) -> ApiResult<DeleteVoiceResponse> {
let body = generated_types::DeleteVoiceServerRequest {
region_id: region_id.to_owned(),
server_id: server_id.to_owned(),
};
let response = self
.generated()
.delete_voice_server(&body)
.delete_admin_voice_server(region_id, server_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
serde_json::from_value::<T>(params.clone())
.map(drop)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn voice_request_body(
params: &serde_json::Value,
path_fields: &[&str],
) -> ApiResult<serde_json::Value> {
let mut body = params
.as_object()
.ok_or_else(|| ApiError::Parse("voice request body must be an object".to_owned()))?
.clone();
for field in path_fields {
body.remove(*field);
}
Ok(body.into())
}
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
if has_coordinate("latitude") == has_coordinate("longitude") {
Ok(())
} else {
Err(ApiError::Parse(
"latitude and longitude must both be set or both be left empty".to_owned(),
))
}
}
fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String> {
params
.get(field)
.and_then(serde_json::Value::as_str)
.map(std::borrow::ToOwned::to_owned)
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn region_update_body_preserves_explicit_restriction_clears() {
let expected = json!({
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
});
let mut params = expected.clone();
params["id"] = json!("eu");
let body = voice_request_body(&params, &["id"]).expect("region body");
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)
.expect("valid region body");
assert_eq!(body, expected);
}
#[test]
fn server_update_body_preserves_clears_and_omitted_restrictions() {
for expected in [
json!({
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"soft_connection_limit": null,
"latitude": null,
"longitude": null,
}),
json!({"is_active": false}),
] {
let mut params = expected.clone();
params["region_id"] = json!("eu");
params["server_id"] = json!("primary");
let body =
voice_request_body(&params, &["region_id", "server_id"]).expect("server body");
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)
.expect("valid server body");
assert_eq!(body, expected);
}
}
#[test]
fn create_server_body_keeps_the_server_id_and_rejects_missing_fields() {
let expected = json!({
"server_id": "primary",
"endpoint": "wss://voice.example.com",
"api_key": "key",
"api_secret": "secret",
});
let mut params = expected.clone();
params["region_id"] = json!("eu");
let body = voice_request_body(&params, &["region_id"]).expect("server body");
validate_against::<generated_types::CreateVoiceServerRequestBody>(&body)
.expect("valid server body");
assert_eq!(body, expected);
assert!(
validate_against::<generated_types::CreateVoiceServerRequestBody>(&json!({})).is_err()
);
}
}
+147 -87
View File
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::env;
use fluxer_common::config::{
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
read_first_env, trim_trailing_slash,
};
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
@@ -16,12 +19,10 @@ pub struct AdminConfig {
pub static_cdn_endpoint: String,
pub admin_endpoint: String,
pub web_app_endpoint: String,
pub kv_url: String,
pub oauth_client_id: String,
pub oauth_client_secret: String,
pub oauth_redirect_uri: String,
pub build_version: String,
pub release_channel: String,
pub self_hosted: bool,
pub proxy: ProxyConfig,
}
@@ -40,74 +41,65 @@ pub enum RuntimeEnv {
}
impl AdminConfig {
pub fn from_env() -> Self {
pub fn from_env() -> anyhow::Result<Self> {
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
let admin_endpoint = trim_trailing_slash(&read_env(
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_ADMIN_ENDPOINT",
"https://admin.fluxer.app",
));
let oauth_redirect_uri = read_env_preferred(
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
)));
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
&format!("{admin_endpoint}/oauth2_callback"),
));
let secret_key_base = read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "");
anyhow::ensure!(
!secret_key_base.trim().is_empty(),
"FLUXER_ADMIN_SECRET_KEY_BASE is required"
);
Self {
Ok(Self {
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
port: read_env("FLUXER_ADMIN_PORT", "3020")
.parse()
.unwrap_or(3020),
secret_key_base: read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "development-admin-secret"),
secret_key_base,
base_path,
api_endpoint: trim_trailing_slash(&read_env(
"FLUXER_API_ENDPOINT",
"https://api.fluxer.app",
)),
media_endpoint: trim_trailing_slash(&read_env(
media_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_MEDIA_ENDPOINT",
"https://media.fluxer.app",
))),
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
static_cdn_endpoint: trim_trailing_slash(&read_env("FLUXER_STATIC_CDN_ENDPOINT", "")),
admin_endpoint,
web_app_endpoint: trim_trailing_slash(&read_env(
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_APP_ENDPOINT",
"https://app.fluxer.app",
)),
kv_url: read_env("FLUXER_KV_URL", ""),
))),
oauth_client_id: read_env(
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
DEFAULT_ADMIN_OAUTH_CLIENT_ID,
),
oauth_client_secret: read_env("FLUXER_ADMIN_OAUTH_CLIENT_SECRET", ""),
oauth_redirect_uri,
build_version: read_env_preferred(
build_version: read_first_env(
&["BUILD_VERSION", "FLUXER_BUILD_VERSION"],
env!("CARGO_PKG_VERSION"),
),
release_channel: read_env_preferred(
&["RELEASE_CHANNEL", "FLUXER_RELEASE_CHANNEL"],
"stable",
),
self_hosted: read_bool_env(&["FLUXER_SELF_HOSTED"], false),
self_hosted: read_bool_env("FLUXER_SELF_HOSTED", false),
proxy: ProxyConfig {
trust_client_ip_header: read_bool_env(
&["FLUXER_TRUST_CLIENT_IP_HEADER", "TRUST_CLIENT_IP_HEADER"],
false,
),
client_ip_header_name: read_env_preferred(
&[
"FLUXER_CLIENT_IP_HEADER_NAME",
"FLUXER_CLIENT_IP_HEADER",
"CLIENT_IP_HEADER_NAME",
"CLIENT_IP_HEADER",
],
"x-forwarded-for",
)
.trim()
.to_ascii_lowercase(),
trust_client_ip_header: read_bool_env("FLUXER_TRUST_CLIENT_IP_HEADER", false),
client_ip_header_name: read_env("FLUXER_CLIENT_IP_HEADER_NAME", "x-forwarded-for")
.trim()
.to_ascii_lowercase(),
},
}
})
}
pub fn is_dev(&self) -> bool {
@@ -117,6 +109,15 @@ impl AdminConfig {
pub fn is_production(&self) -> bool {
self.env == RuntimeEnv::Production
}
pub fn secure_cookies(&self) -> bool {
self.admin_endpoint.starts_with("https://")
}
pub fn admin_origin(&self) -> Option<String> {
let origin = url::Url::parse(&self.admin_endpoint).ok()?.origin();
origin.is_tuple().then(|| origin.ascii_serialization())
}
}
impl RuntimeEnv {
@@ -129,43 +130,44 @@ impl RuntimeEnv {
}
}
pub fn normalize_base_path(value: &str) -> String {
let trimmed = value.trim().trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
pub fn trim_trailing_slash(value: &str) -> String {
value.trim_end_matches('/').to_owned()
}
pub(crate) fn read_env(name: &str, fallback: &str) -> String {
env::var(name).unwrap_or_else(|_| fallback.to_owned())
}
pub(crate) fn read_env_preferred(names: &[&str], fallback: &str) -> String {
names
.iter()
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
.unwrap_or_else(|| fallback.to_owned())
}
pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
let Some(value) = names.iter().find_map(|name| env::var(name).ok()) else {
return fallback;
};
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 10] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_APP_ENDPOINT",
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
let _guard = ENV_LOCK.lock().unwrap();
for name in MANAGED_ENV {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
unsafe { env::remove_var("FLUXER_PUBLIC_ORIGIN") };
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
for (name, value) in vars {
unsafe { env::set_var(name, value) };
}
let config = AdminConfig::from_env().expect("config loads with a secret");
for (name, _) in vars {
unsafe { env::remove_var(name) };
}
config
}
#[test]
fn normalize_base_path_strips_trailing_slashes() {
@@ -239,12 +241,10 @@ mod tests {
admin_endpoint: String::new(),
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: String::new(),
release_channel: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
@@ -269,12 +269,10 @@ mod tests {
admin_endpoint: String::new(),
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: String::new(),
release_channel: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
@@ -287,18 +285,7 @@ mod tests {
#[test]
fn from_env_uses_defaults() {
for var in &[
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
] {
unsafe { env::remove_var(var) };
}
let config = AdminConfig::from_env();
let config = config_from_env(&[]);
assert_eq!(config.env, RuntimeEnv::Development);
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3020);
@@ -308,4 +295,77 @@ mod tests {
"https://admin.fluxer.app/oauth2_callback"
);
}
#[test]
fn a_non_default_public_port_reaches_the_public_endpoints() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "http://fluxer.example:19080/admin");
assert_eq!(config.media_endpoint, "http://fluxer.example:19080/media");
assert_eq!(config.web_app_endpoint, "http://fluxer.example:19080");
assert_eq!(config.static_cdn_endpoint, "https://cdn.example.net");
assert_eq!(
config.oauth_redirect_uri,
format!("{}/oauth2_callback", config.admin_endpoint)
);
}
#[test]
fn a_default_public_port_leaves_the_public_endpoints_alone() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "443"),
("FLUXER_ADMIN_ENDPOINT", "https://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "https://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "https://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"https://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "https://fluxer.example/admin");
assert_eq!(config.media_endpoint, "https://fluxer.example/media");
assert_eq!(config.web_app_endpoint, "https://fluxer.example");
assert_eq!(config.static_cdn_endpoint, "https://fluxer.example");
assert_eq!(
config.oauth_redirect_uri,
"https://fluxer.example/admin/oauth2_callback"
);
}
#[test]
fn the_oauth_redirect_uri_matches_the_api_derived_admin_endpoint() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
let api_admin_endpoint = fluxer_common::config::normalize_public_endpoint(
"http://fluxer.example/admin",
"fluxer.example",
Some(19080),
);
assert_eq!(
config.oauth_redirect_uri,
format!("{api_admin_endpoint}/oauth2_callback")
);
}
}
+1
View File
@@ -2,6 +2,7 @@
pub mod acl;
pub mod admin_flags;
pub mod admin_hints;
pub mod api;
pub mod config;
pub mod fonts;
+2 -4
View File
@@ -8,13 +8,11 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.with(fluxer_common::config::env_filter("info"))
.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))
+226 -52
View File
@@ -2,24 +2,42 @@
use axum::{
body::{Body, to_bytes},
extract::Request,
extract::{Request, State},
http::{HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use rand::RngExt;
use crate::middleware::auth::AuthContext;
use crate::session::{create_csrf_token, verify_csrf_token};
use crate::state::AppState;
const CSRF_COOKIE_NAME: &str = "csrf_token";
pub const CSRF_FORM_FIELD: &str = "_csrf";
const CSRF_HEADER_NAME: &str = "x-csrf-token";
const TOKEN_LENGTH: usize = 32;
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
let existing_token = extract_csrf_cookie(&request);
let token = existing_token.unwrap_or_else(generate_csrf_token);
pub async fn csrf_protection(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let secure_cookies = config.secure_cookies();
let user_id = request
.extensions()
.get::<AuthContext>()
.map(|ctx| ctx.session.user_id.clone())
.unwrap_or_default();
let token = extract_csrf_cookie(&request)
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
request.extensions_mut().insert(CsrfToken(token.clone()));
if matches!(
@@ -31,6 +49,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, config.admin_origin().as_deref()) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
let query_token = extract_csrf_from_query(&request);
let mut submitted = query_token.or(header_token);
@@ -43,40 +64,58 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
request = restored_request;
submitted = body_token;
}
match submitted {
Some(ref submitted_token) if submitted_token == &token => {}
_ => {
return StatusCode::FORBIDDEN.into_response();
}
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
});
if !accepted {
return StatusCode::FORBIDDEN.into_response();
}
}
}
let mut response = next.run(request).await;
let cookie_value = format!(
"{}={}; Path=/; SameSite=Lax; HttpOnly",
CSRF_COOKIE_NAME, token
);
let cookie_name = if secure_cookies {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if secure_cookies { "; Secure" } else { "" };
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
if secure_cookies
&& let Ok(value) = HeaderValue::from_str(&format!(
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
))
{
response.headers_mut().append(header::SET_COOKIE, value);
}
response
}
fn extract_csrf_cookie(request: &Request) -> Option<String> {
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
let mut legacy = None;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("csrf_token=") {
if let Some(value) = pair.strip_prefix("__Host-csrf_token=") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
} else if let Some(value) = pair.strip_prefix("csrf_token=")
&& legacy.is_none()
{
let trimmed = value.trim();
if !trimmed.is_empty() {
legacy = Some(trimmed.to_owned());
}
}
}
None
legacy
}
fn extract_csrf_header(request: &Request) -> Option<String> {
@@ -127,18 +166,22 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn generate_csrf_token() -> String {
let mut rng = rand::rng();
let bytes: [u8; TOKEN_LENGTH] = rng.random();
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes {
s.push_str(&format!("{byte:02x}"));
fn is_same_site_request(request: &Request, admin_origin: Option<&str>) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
{
return matches!(site, "same-origin" | "same-site" | "none");
}
match request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => admin_origin.is_some_and(|expected| origin == expected),
None => true,
}
s
}
#[derive(Clone, Debug)]
@@ -155,39 +198,170 @@ pub fn get_csrf_token(request: &Request) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
use crate::state::AppState;
use axum::{Router, middleware::from_fn_with_state, routing::get};
use tower::ServiceExt;
#[test]
fn generate_csrf_token_correct_length() {
let token = generate_csrf_token();
assert_eq!(
token.len(),
TOKEN_LENGTH * 2,
"token must be {} hex chars",
TOKEN_LENGTH * 2
);
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
AppState::new(AdminConfig {
env: RuntimeEnv::Production,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: String::new(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
})
}
#[test]
fn generate_csrf_token_is_valid_hex() {
let token = generate_csrf_token();
async fn csrf_cookies(admin_endpoint: &str) -> Vec<String> {
let state = state_with_admin_endpoint(admin_endpoint);
let app = Router::new()
.route("/", get(|| async { "ok" }))
.layer(from_fn_with_state(state, csrf_protection));
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("router responds");
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.map(|value| value.to_owned())
.collect()
}
#[tokio::test]
async fn https_admin_endpoint_sets_a_host_prefixed_secure_cookie() {
let cookies = csrf_cookies("https://example.com/admin").await;
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must contain only hex chars: {token}"
cookies
.iter()
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
&& cookie.contains("; Secure")),
"expected a secure __Host- cookie, got {cookies:?}"
);
}
#[test]
fn generate_csrf_token_is_unique() {
let a = generate_csrf_token();
let b = generate_csrf_token();
assert_ne!(a, b, "consecutive tokens must differ");
#[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 hex_encode_produces_correct_output() {
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
assert_eq!(hex_encode(&[]), "");
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
async fn action_status(admin_endpoint: &str, origin: &str) -> StatusCode {
let state = state_with_admin_endpoint(admin_endpoint);
let app = Router::new()
.route("/", get(|| async { "ok" }).post(|| async { "ok" }))
.layer(from_fn_with_state(state, csrf_protection));
let issued = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.expect("router responds");
let cookie = issued
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.filter_map(|value| value.split(';').next())
.find(|pair| pair.contains("csrf_token=") && !pair.ends_with('='))
.expect("a csrf cookie is issued")
.to_owned();
let token = cookie.split_once('=').expect("a cookie value").1.to_owned();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/")
.header(header::COOKIE, cookie.as_str())
.header(header::ORIGIN, origin)
.header(CSRF_HEADER_NAME, token.as_str())
.body(Body::empty())
.unwrap(),
)
.await
.expect("router responds");
response.status()
}
#[tokio::test]
async fn a_matching_origin_passes_the_same_site_check() {
let status = action_status(
"https://admin.example.test/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_matching_origin_on_a_non_default_port_passes_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://admin.example.test:19080",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_foreign_origin_fails_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://evil.example.test:19080",
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn another_port_on_the_admin_host_fails_the_same_site_check() {
let status = action_status(
"https://admin.example.test:19080/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_unparseable_admin_endpoint_fails_closed() {
let status = action_status("not-an-endpoint", "https://admin.example.test").await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn an_explicit_default_port_matches_a_portless_origin() {
let status = action_status(
"https://admin.example.test:443/admin",
"https://admin.example.test",
)
.await;
assert_eq!(status, StatusCode::OK);
}
#[test]
+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}"
);
+31 -22
View File
@@ -81,7 +81,7 @@ async fn admin_api_keys_post(
"create" => {
let name = form.clean("name").unwrap_or_default();
let acls = form.list_values_any(&["acls[]", "acls"]);
return match client.create_api_key(&name, &acls).await {
match client.create_api_key(&name, &acls).await {
Ok(created) => {
let keys = client
.list_api_keys()
@@ -107,29 +107,38 @@ async fn admin_api_keys_post(
is_htmx,
)
}
};
}
"revoke" => {
if let Some(key_id) = form.clean("key_id") {
let result = client.revoke_api_key(&key_id).await;
let flash = match result.log_error("revoke API key") {
Some(_) => FlashData::success("API key revoked."),
None => FlashData::error("Failed to revoke API key"),
};
return flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
flash,
config.is_production(),
);
}
}
_ => {}
"revoke" => {
let Some(key_id) = form.clean("key_id") else {
return admin_api_key_flash_response(
config,
FlashData::error("API key ID is required"),
is_htmx,
);
};
let result = client.revoke_api_key(&key_id).await;
let flash = match result.log_error("revoke API key") {
Some(_) => FlashData::success("API key revoked."),
None => FlashData::error("Failed to revoke API key"),
};
flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
flash,
config.secure_cookies(),
)
}
"" => admin_api_key_flash_response(
config,
FlashData::error("API key action is required"),
is_htmx,
),
_ => admin_api_key_flash_response(
config,
FlashData::error("Unknown API key action"),
is_htmx,
),
}
flash::redirect_with_flash(
&format!("{base}/admin-api-keys"),
FlashData::success(format!("API key action '{action}' completed.")),
config.is_production(),
)
}
fn admin_api_key_flash_response(
@@ -143,7 +152,7 @@ fn admin_api_key_flash_response(
flash::redirect_with_flash(
&format!("{}/admin-api-keys", config.base_path),
flash_data,
config.is_production(),
config.secure_cookies(),
)
}
}
+2 -2
View File
@@ -151,7 +151,7 @@ async fn application_detail_post(
return flash::redirect_with_flash(
&format!("{base}/applications/{application_id}"),
FlashData::error("Invalid form data"),
config.is_production(),
config.secure_cookies(),
);
}
};
@@ -178,6 +178,6 @@ async fn application_detail_post(
flash::redirect_with_flash(
&format!("{base}/applications/{application_id}"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
+2 -2
View File
@@ -187,7 +187,7 @@ async fn oauth2_callback_finish(
let session_cookie_value =
session::create_session(&user.id, &token.access_token, &config.secret_key_base);
let secure = if config.is_production() {
let secure = if config.secure_cookies() {
"; Secure"
} else {
""
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
)
}
fn json_string(value: &str) -> String {
pub(crate) fn json_string(value: &str) -> String {
serde_json::to_string(value)
.expect("JSON string serialization cannot fail")
.replace('<', "\\u003c")
+22 -16
View File
@@ -16,17 +16,13 @@ use axum::{
use super::ActionQuery;
use super::bans_actions::{
BanFormData, custom_flash, execute_ban, extract_value, flash_response, htmx_flash,
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
};
pub fn router() -> Router<AppState> {
Router::new()
.route("/ip-bans", get(ip_bans).post(ip_bans_post))
.route("/email-bans", get(email_bans).post(email_bans_post))
.route(
"/suspicious-email-domains",
get(suspicious_email_domains).post(suspicious_email_domains_post),
)
.route("/phrase-bans", get(phrase_bans).post(phrase_bans_post))
.route("/url-bans", get(url_bans).post(url_bans_post))
.route(
@@ -72,7 +68,6 @@ macro_rules! ban_get {
ban_get!(ip_bans, "ip-bans");
ban_get!(email_bans, "email-bans");
ban_get!(suspicious_email_domains, "suspicious-email-domains");
ban_get!(phrase_bans, "phrase-bans");
ban_get!(url_bans, "url-bans");
ban_get!(file_sha_bans, "file-sha-bans");
@@ -105,7 +100,7 @@ async fn generic_ban_post(
form.audit_log_reason.as_deref(),
)
.await;
flash_response(config, auth, is_htmx, &level, &msg, ban_cfg, csrf_token)
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
}
macro_rules! ban_post {
@@ -141,7 +136,6 @@ macro_rules! ban_post {
ban_post!(ip_bans_post, "ip-bans");
ban_post!(email_bans_post, "email-bans");
ban_post!(suspicious_email_domains_post, "suspicious-email-domains");
ban_post!(phrase_bans_post, "phrase-bans");
ban_post!(url_bans_post, "url-bans");
ban_post!(file_sha_bans_post, "file-sha-bans");
@@ -171,19 +165,22 @@ async fn url_domain_bans_post(
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
Err(_) => return render_inline_flash("error", "Invalid form data"),
};
let is_htmx = htmx::is_htmx_request(&headers);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let domain = form.domain.as_deref().unwrap_or("").trim().to_owned();
if domain.is_empty() {
return htmx_flash("error", "Domain is required", &headers);
return render_inline_flash("error", "Domain is required");
}
let action = aq.action.as_deref().unwrap_or("");
let (level, msg) = match action {
"ban" => {
let m_sub = form.match_subdomains.as_deref() == Some("true");
match client.ban_url_domain(&domain, m_sub).await {
match client
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
@@ -191,7 +188,10 @@ async fn url_domain_bans_post(
}
}
}
"unban" => match client.unban_url_domain(&domain).await {
"unban" => match client
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} unbanned")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
@@ -247,25 +247,31 @@ async fn profile_substring_bans_post(
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
Err(_) => return render_inline_flash("error", "Invalid form data"),
};
let is_htmx = htmx::is_htmx_request(&headers);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let scope = form.scope.as_deref().unwrap_or("").trim().to_owned();
let substring = form.substring.as_deref().unwrap_or("").trim().to_owned();
if scope.is_empty() || substring.is_empty() {
return htmx_flash("error", "Scope and substring required", &headers);
return render_inline_flash("error", "Scope and substring required");
}
let action = aq.action.as_deref().unwrap_or("");
let (level, msg) = match action {
"ban" => match client.ban_profile_substring(&scope, &substring).await {
"ban" => match client
.ban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("\"{substring}\" banned for {scope}")),
Err(error) => {
tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring");
("error", format!("Failed to ban substring for {scope}"))
}
},
"unban" => match client.unban_profile_substring(&scope, &substring).await {
"unban" => match client
.unban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("\"{substring}\" unbanned for {scope}")),
Err(error) => {
tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring");
+53 -78
View File
@@ -1,17 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::client::AdminApiClient;
use crate::api::client::{AdminApiClient, ApiResult};
use crate::api::types::{FlashLevel, FlashMessage};
use crate::middleware::auth::AuthContext;
use crate::templates;
use axum::{
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct BanFormData {
#[serde(default)]
pub ip: Option<String>,
@@ -65,7 +61,7 @@ pub async fn execute_ban(
bulk_hashes: Option<&str>,
bulk_sha256_list: Option<&str>,
audit_log_reason: Option<&str>,
) -> (String, String) {
) -> (&'static str, String) {
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
let raw_hashes = if action == "bulk-ban-files" {
bulk_sha256_list
@@ -75,13 +71,13 @@ pub async fn execute_ban(
return execute_bulk_ban(client, raw_hashes, audit_log_reason).await;
}
if value.is_empty() {
return ("error".into(), "Value is required".into());
return ("error", "Value is required".into());
}
match action {
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
"check" => execute_check(client, ban_type, value).await,
_ => ("error".into(), "Unknown action".into()),
_ => ("error", "Unknown action".into()),
}
}
@@ -89,7 +85,7 @@ async fn execute_bulk_ban(
client: &AdminApiClient,
bulk_hashes: Option<&str>,
audit_log_reason: Option<&str>,
) -> (String, String) {
) -> (&'static str, String) {
let hashes: Vec<String> = bulk_hashes
.unwrap_or("")
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
@@ -98,18 +94,15 @@ async fn execute_bulk_ban(
.collect();
if hashes.is_empty() {
return (
"error".into(),
"error",
"No valid 64-character hex hashes found in input".into(),
);
}
match client.bulk_ban_file_shas(&hashes, audit_log_reason).await {
Ok(r) => (
"success".into(),
format!("Bulk ban job created: {}", r.job_id),
),
Ok(r) => ("success", format!("Bulk ban job created: {}", r.job_id)),
Err(error) => {
tracing::warn!(%error, "admin API request failed: bulk ban file SHAs");
("error".into(), "Failed to enqueue bulk ban job".into())
("error", "Failed to enqueue bulk ban job".into())
}
}
}
@@ -119,29 +112,21 @@ async fn execute_single_ban(
ban_type: &str,
value: &str,
audit_log_reason: Option<&str>,
) -> (String, String) {
let success = format!("{value} banned successfully");
let failure = format!("Failed to ban {value}");
match ban_type {
"ip-bans" => ban_action_result(client.ban_ip(value).await, success, failure),
"email-bans" => ban_action_result(client.ban_email(value).await, success, failure),
"suspicious-email-domains" => ban_action_result(
client.add_suspicious_email_domain(value).await,
success,
failure,
),
"phrase-bans" => ban_action_result(client.ban_phrase(value).await, success, failure),
"url-bans" => ban_action_result(client.ban_url(value).await, success, failure),
"file-sha-bans" => ban_action_result(
client.ban_file_sha(value, audit_log_reason).await,
success,
failure,
),
"avatar-hash-bans" => {
ban_action_result(client.ban_avatar_hash(value).await, success, failure)
}
_ => ("error".into(), "Unknown ban type".into()),
}
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
"email-bans" => client.ban_email(value, audit_log_reason).await,
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
"url-bans" => client.ban_url(value, audit_log_reason).await,
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
"avatar-hash-bans" => client.ban_avatar_hash(value, audit_log_reason).await,
_ => return ("error", "Unknown ban type".into()),
};
ban_action_result(
result,
format!("{value} banned successfully"),
format!("Failed to ban {value}"),
)
}
async fn execute_single_unban(
@@ -149,62 +134,57 @@ async fn execute_single_unban(
ban_type: &str,
value: &str,
audit_log_reason: Option<&str>,
) -> (String, String) {
let success = format!("{value} unbanned successfully");
let failure = format!("Failed to unban {value}");
match ban_type {
"ip-bans" => ban_action_result(client.unban_ip(value).await, success, failure),
"email-bans" => ban_action_result(client.unban_email(value).await, success, failure),
"suspicious-email-domains" => ban_action_result(
client.remove_suspicious_email_domain(value).await,
success,
failure,
),
"phrase-bans" => ban_action_result(client.unban_phrase(value).await, success, failure),
"url-bans" => ban_action_result(client.unban_url(value).await, success, failure),
"file-sha-bans" => ban_action_result(
client.unban_file_sha(value, audit_log_reason).await,
success,
failure,
),
"avatar-hash-bans" => {
ban_action_result(client.unban_avatar_hash(value).await, success, failure)
}
_ => ("error".into(), "Unknown ban type".into()),
}
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
"email-bans" => client.unban_email(value, audit_log_reason).await,
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
"url-bans" => client.unban_url(value, audit_log_reason).await,
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
"avatar-hash-bans" => client.unban_avatar_hash(value, audit_log_reason).await,
_ => return ("error", "Unknown ban type".into()),
};
ban_action_result(
result,
format!("{value} unbanned successfully"),
format!("Failed to unban {value}"),
)
}
async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) -> (String, String) {
async fn execute_check(
client: &AdminApiClient,
ban_type: &str,
value: &str,
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.check_ip_ban(value).await,
"email-bans" => client.check_email_ban(value).await,
"suspicious-email-domains" => client.check_suspicious_email_domain(value).await,
"phrase-bans" => client.check_phrase_ban(value).await,
"url-bans" => client.check_url_ban(value).await,
"file-sha-bans" => client.check_file_sha_ban(value).await,
"avatar-hash-bans" => client.check_avatar_hash_ban(value).await,
_ => return ("error".into(), "Unknown ban type".into()),
_ => return ("error", "Unknown ban type".into()),
};
match result {
Ok(r) if r.banned => ("info".into(), format!("{value} is banned")),
Ok(_) => ("info".into(), format!("{value} is NOT banned")),
Ok(r) if r.banned => ("info", format!("{value} is banned")),
Ok(_) => ("info", format!("{value} is NOT banned")),
Err(error) => {
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
("error".into(), "Error checking ban status".into())
("error", "Error checking ban status".into())
}
}
}
fn ban_action_result<T, E: std::fmt::Display>(
result: Result<T, E>,
fn ban_action_result(
result: ApiResult<()>,
success_message: String,
error_message: String,
) -> (String, String) {
) -> (&'static str, String) {
match result {
Ok(_) => ("success".into(), success_message),
Ok(()) => ("success", success_message),
Err(error) => {
tracing::warn!(%error, "admin API request failed: ban action");
("error".into(), error_message)
("error", error_message)
}
}
}
@@ -228,11 +208,6 @@ pub fn flash_response(
}
}
pub fn htmx_flash(level: &str, message: &str, headers: &HeaderMap) -> Response {
let _ = headers;
render_inline_flash(level, message)
}
pub fn render_inline_flash(level: &str, message: &str) -> Response {
let (border, bg, text) = match level {
"success" => ("border-green-300", "bg-green-50", "text-green-800"),
+597
View File
@@ -0,0 +1,597 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::{
client::ApiError,
types::{
AppBrandingConfigUpdateRequest, AppPublicConfigUpdateRequest,
BILLING_MAX_COUNTRY_CURRENCIES, BILLING_MAX_CURRENCIES,
BILLING_MAX_LEGACY_PRICES_PER_SLOT, BILLING_MAX_LEGACY_SLOTS, BILLING_PRICE_SLOTS,
BillingPriceSet, InstanceBillingUpdateRequest, InstanceConfigUpdateRequest,
PREMIUM_PRODUCT_NAME_MAX_CHARS, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
},
},
middleware::flash::FlashData,
utils::forms::MultiValueForm,
};
use std::collections::BTreeMap;
const PRICE_ID_MAX_CHARS: usize = 255;
const INFO_URL_MAX_CHARS: usize = 2048;
pub(super) fn build_billing_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let premium_product_name = if form.contains_key("billing_premium_product_name") {
Some(parse_premium_product_name(
form.clean("billing_premium_product_name"),
)?)
} else {
None
};
let premium_info_url = if form.contains_key("billing_premium_info_url") {
Some(parse_info_url(form.clean("billing_premium_info_url"))?)
} else {
None
};
let branding = (premium_product_name.is_some() || premium_info_url.is_some()).then(|| {
AppBrandingConfigUpdateRequest {
premium_product_name,
premium_info_url,
..Default::default()
}
});
let prices = if form.contains_key("billing_price_currency") {
Some(parse_price_rows(form)?)
} else {
None
};
let default_currency = if form.contains_key("billing_default_currency") {
Some(
form.clean("billing_default_currency")
.map(|value| parse_currency(&value))
.transpose()?,
)
} else {
None
};
let country_currencies = if form.contains_key("billing_country_currencies") {
Some(parse_country_currencies(
form.first("billing_country_currencies").unwrap_or(""),
)?)
} else {
None
};
let legacy_prices = if form.contains_key("billing_legacy_prices") {
Some(parse_legacy_prices(
form.first("billing_legacy_prices").unwrap_or(""),
)?)
} else {
None
};
if let Some(Some(prices)) = &prices {
if let Some(Some(currency)) = &default_currency
&& !prices.contains_key(currency)
{
return Err(format!(
"Default currency {currency} has no row in the price table"
));
}
if let Some(Some(countries)) = &country_currencies
&& let Some((country, currency)) = countries
.iter()
.find(|(_, currency)| !prices.contains_key(*currency))
{
return Err(format!(
"{country} maps to {currency}, which has no row in the price table"
));
}
}
Ok(InstanceConfigUpdateRequest {
app_public: branding.map(|branding| AppPublicConfigUpdateRequest {
branding: Some(branding),
..Default::default()
}),
billing: Some(InstanceBillingUpdateRequest {
enabled: parse_tri_state(form, "billing_enabled")?,
stripe_secret_key: secret_update(
form,
"billing_stripe_secret_key",
"billing_clear_stripe_secret_key",
),
stripe_webhook_secret: secret_update(
form,
"billing_stripe_webhook_secret",
"billing_clear_stripe_webhook_secret",
),
default_currency,
prices,
country_currencies,
legacy_prices,
automatic_tax: parse_tri_state(form, "billing_automatic_tax")?,
tax_id_collection: parse_tri_state(form, "billing_tax_id_collection")?,
terms_consent_required: parse_tri_state(form, "billing_terms_consent_required")?,
}),
..Default::default()
})
}
fn parse_tri_state(form: &MultiValueForm, key: &str) -> Result<Option<Option<bool>>, String> {
if !form.contains_key(key) {
return Ok(None);
}
match form.first(key).map(str::trim).unwrap_or("") {
TRI_STATE_DEFAULT => Ok(Some(None)),
TRI_STATE_ON => Ok(Some(Some(true))),
TRI_STATE_OFF => Ok(Some(Some(false))),
other => Err(format!("Invalid choice \"{other}\" for {key}")),
}
}
pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
match result {
Ok(_) => FlashData::success("Premium and billing settings updated"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: update billing config");
match validation_message(&error) {
Some(message) => FlashData::error(format!(
"Failed to update premium and billing settings: {message}"
)),
None => FlashData::error("Failed to update premium and billing settings"),
}
}
}
}
fn validation_message(error: &ApiError) -> Option<String> {
let ApiError::Http {
status: 400,
message,
} = error
else {
return None;
};
let body: serde_json::Value = serde_json::from_str(message).ok()?;
let first = body["errors"].as_array().and_then(|errors| errors.first());
let detail = first.and_then(|error| {
let message = error["message"].as_str()?;
Some(
match error["path"].as_str().filter(|path| !path.is_empty()) {
Some(path) => format!("{path}: {message}"),
None => message.to_owned(),
},
)
});
detail.or_else(|| body["message"].as_str().map(str::to_owned))
}
fn secret_update(form: &MultiValueForm, key: &str, clear_key: &str) -> Option<Option<String>> {
match form.clean(key) {
Some(secret) => Some(Some(secret)),
None if form.bool_value(clear_key) => Some(None),
None => None,
}
}
fn parse_premium_product_name(value: Option<String>) -> Result<Option<String>, String> {
match value {
Some(name) if name.encode_utf16().count() > PREMIUM_PRODUCT_NAME_MAX_CHARS => Err(format!(
"Premium name must be at most {PREMIUM_PRODUCT_NAME_MAX_CHARS} characters"
)),
other => Ok(other),
}
}
fn parse_info_url(value: Option<String>) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let valid = value.chars().count() <= INFO_URL_MAX_CHARS
&& url::Url::parse(&value).is_ok_and(|url| {
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some_and(|h| !h.is_empty())
});
if valid {
Ok(Some(value))
} else {
Err("Premium info URL must be an absolute http or https URL".to_owned())
}
}
fn parse_currency(value: &str) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(currency)
} else {
Err(format!(
"Invalid currency \"{}\": use a 3-letter ISO 4217 code such as GBP",
value.trim()
))
}
}
fn parse_country(value: &str) -> Result<String, String> {
let country = value.trim().to_ascii_uppercase();
if country.len() == 2 && country.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(country)
} else {
Err(format!(
"Invalid country \"{}\": use a 2-letter ISO 3166 code such as SE",
value.trim()
))
}
}
fn parse_price_id(value: &str) -> Result<String, String> {
let id = value.trim();
let valid = id.len() <= PRICE_ID_MAX_CHARS
&& id.strip_prefix("price_").is_some_and(|rest| {
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())
});
if valid {
Ok(id.to_owned())
} else {
Err(format!(
"Invalid Stripe price ID \"{id}\": it must look like price_1AbC"
))
}
}
fn parse_optional_price_id(value: Option<&String>) -> Result<Option<String>, String> {
match value
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(id) => parse_price_id(id).map(Some),
None => Ok(None),
}
}
fn parse_price_rows(
form: &MultiValueForm,
) -> Result<Option<BTreeMap<String, BillingPriceSet>>, String> {
let currencies = form.values("billing_price_currency");
let column = |key: &str, index: usize| form.values(key).get(index);
let mut prices = BTreeMap::new();
for (index, currency) in currencies.iter().enumerate() {
if currency.trim().is_empty() {
continue;
}
let currency = parse_currency(currency)?;
let set = BillingPriceSet {
monthly: parse_optional_price_id(column("billing_price_monthly", index))?,
yearly: parse_optional_price_id(column("billing_price_yearly", index))?,
gift_1_month: parse_optional_price_id(column("billing_price_gift_1_month", index))?,
gift_1_year: parse_optional_price_id(column("billing_price_gift_1_year", index))?,
};
if set.is_empty() {
return Err(format!("{currency} needs at least one price ID"));
}
if prices.insert(currency.clone(), set).is_some() {
return Err(format!(
"{currency} appears more than once in the price table"
));
}
}
if prices.len() > BILLING_MAX_CURRENCIES {
return Err(format!(
"The price table holds at most {BILLING_MAX_CURRENCIES} currencies"
));
}
Ok((!prices.is_empty()).then_some(prices))
}
fn key_value_lines(value: &str) -> impl Iterator<Item = Result<(&str, &str), String>> {
value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.split_once('=')
.map(|(key, value)| (key.trim(), value.trim()))
.ok_or_else(|| format!("Line \"{line}\" must use the form KEY=VALUE"))
})
}
fn parse_country_currencies(value: &str) -> Result<Option<BTreeMap<String, String>>, String> {
let mut countries = BTreeMap::new();
for line in key_value_lines(value) {
let (country, currency) = line?;
let country = parse_country(country)?;
let currency = parse_currency(currency)?;
if countries.insert(country.clone(), currency).is_some() {
return Err(format!("{country} is mapped more than once"));
}
}
if countries.len() > BILLING_MAX_COUNTRY_CURRENCIES {
return Err(format!(
"At most {BILLING_MAX_COUNTRY_CURRENCIES} country mappings are allowed"
));
}
Ok((!countries.is_empty()).then_some(countries))
}
fn parse_legacy_slot(value: &str) -> Result<String, String> {
let invalid = || {
format!(
"Invalid legacy price slot \"{value}\": use monthly, yearly, gift_1_month or gift_1_year followed by _ and a currency, such as monthly_GBP"
)
};
let (slot, currency) = value.rsplit_once('_').ok_or_else(invalid)?;
let slot = slot.to_ascii_lowercase();
if !BILLING_PRICE_SLOTS.contains(&slot.as_str()) {
return Err(invalid());
}
let currency = parse_currency(currency).map_err(|_| invalid())?;
Ok(format!("{slot}_{currency}"))
}
fn parse_legacy_prices(value: &str) -> Result<Option<BTreeMap<String, Vec<String>>>, String> {
let mut legacy: BTreeMap<String, Vec<String>> = BTreeMap::new();
for line in key_value_lines(value) {
let (slot, ids) = line?;
let slot = parse_legacy_slot(slot)?;
let entry = legacy.entry(slot.clone()).or_default();
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
let id = parse_price_id(id)?;
if !entry.contains(&id) {
entry.push(id);
}
}
if entry.is_empty() {
return Err(format!("{slot} needs at least one price ID"));
}
if entry.len() > BILLING_MAX_LEGACY_PRICES_PER_SLOT {
return Err(format!(
"{slot} holds at most {BILLING_MAX_LEGACY_PRICES_PER_SLOT} legacy price IDs"
));
}
}
if legacy.len() > BILLING_MAX_LEGACY_SLOTS {
return Err(format!(
"At most {BILLING_MAX_LEGACY_SLOTS} legacy price slots are allowed"
));
}
Ok((!legacy.is_empty()).then_some(legacy))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
fn full_form(extra: &str) -> MultiValueForm {
let base = "billing_premium_product_name=%20Gold%20\
&billing_premium_info_url=https%3A%2F%2Fexample.com%2Fgold\
&billing_enabled=on\
&billing_automatic_tax=default&billing_tax_id_collection=on&billing_terms_consent_required=off\
&billing_stripe_secret_key=\
&billing_stripe_webhook_secret=whsec_new\
&billing_default_currency=gbp\
&billing_price_currency=gbp&billing_price_monthly=price_1GbpM&billing_price_yearly=price_1GbpY\
&billing_price_gift_1_month=&billing_price_gift_1_year=price_1GbpG\
&billing_price_currency=SEK&billing_price_monthly=price_1SekM&billing_price_yearly=price_1SekY\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_price_currency=&billing_price_monthly=&billing_price_yearly=\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_country_currencies=se%3Dsek%0D%0AGB%20%3D%20GBP%0D%0A\
&billing_legacy_prices=monthly_GBP%3Dprice_1OldA%0Amonthly_gbp%3Dprice_1OldB%2Cprice_1OldA%0Ayearly_SEK%3Dprice_1OldC";
MultiValueForm::parse(format!("{base}{extra}").as_bytes())
}
#[test]
fn full_billing_form_builds_the_expected_patch() {
let update = build_billing_update(&full_form("")).expect("valid form");
let value = serde_json::to_value(&update).expect("serializable");
serde_json::from_value::<generated_types::InstanceConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"app_public": {
"branding": {
"premium_product_name": "Gold",
"premium_info_url": "https://example.com/gold"
}
},
"billing": {
"enabled": true,
"stripe_webhook_secret": "whsec_new",
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1GbpM",
"yearly": "price_1GbpY",
"gift_1_month": null,
"gift_1_year": "price_1GbpG"
},
"SEK": {
"monthly": "price_1SekM",
"yearly": "price_1SekY",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP", "SE": "SEK"},
"legacy_prices": {
"monthly_GBP": ["price_1OldA", "price_1OldB"],
"yearly_SEK": ["price_1OldC"]
},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
}
})
);
}
#[test]
fn blank_fields_clear_and_the_default_choice_sends_null() {
let form = MultiValueForm::parse(
b"billing_premium_product_name=&billing_premium_info_url=&billing_enabled=default\
&billing_stripe_secret_key=&billing_clear_stripe_secret_key=true\
&billing_stripe_webhook_secret=\
&billing_default_currency=\
&billing_price_currency=&billing_price_monthly=price_1Ignored\
&billing_country_currencies=&billing_legacy_prices=",
);
let value = serde_json::to_value(build_billing_update(&form).expect("valid form")).unwrap();
assert_eq!(
value,
json!({
"app_public": {
"branding": {"premium_product_name": null, "premium_info_url": null}
},
"billing": {
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": null,
"country_currencies": null,
"legacy_prices": null
}
})
);
}
#[test]
fn a_new_secret_wins_over_the_clear_checkbox() {
let form = MultiValueForm::parse(
b"billing_stripe_secret_key=%20sk_live_x%20&billing_clear_stripe_secret_key=true",
);
let billing = build_billing_update(&form)
.expect("valid form")
.billing
.expect("billing");
assert_eq!(
billing.stripe_secret_key,
Some(Some("sk_live_x".to_owned()))
);
assert_eq!(billing.stripe_webhook_secret, None);
}
#[test]
fn absent_form_keys_leave_their_fields_untouched() {
let update = build_billing_update(&MultiValueForm::parse(b"billing_enabled=off"))
.expect("valid form");
assert!(update.app_public.is_none());
assert_eq!(
serde_json::to_value(update.billing).unwrap(),
json!({"enabled": false})
);
let untouched = build_billing_update(&MultiValueForm::parse(b"")).expect("valid form");
assert_eq!(serde_json::to_value(untouched.billing).unwrap(), json!({}));
}
#[test]
fn invalid_input_is_rejected_with_a_message() {
let cases: &[(&str, &str)] = &[
(
"billing_premium_info_url=ftp%3A%2F%2Fexample.com",
"http or https",
),
("billing_premium_info_url=example.com", "http or https"),
("billing_default_currency=GB", "Invalid currency"),
("billing_enabled=true", "Invalid choice"),
("billing_automatic_tax=maybe", "Invalid choice"),
(
"billing_price_currency=GBPX&billing_price_monthly=price_1A",
"Invalid currency",
),
(
"billing_price_currency=GBP&billing_price_monthly=prod_1A",
"Invalid Stripe price ID",
),
(
"billing_price_currency=GBP&billing_price_monthly=price_1-A",
"Invalid Stripe price ID",
),
("billing_price_currency=GBP", "needs at least one price ID"),
(
"billing_price_currency=GBP&billing_price_monthly=price_1A&billing_price_currency=gbp&billing_price_monthly=price_1B",
"more than once",
),
("billing_country_currencies=SWE%3DSEK", "Invalid country"),
("billing_country_currencies=SE", "KEY=VALUE"),
(
"billing_country_currencies=SE%3DSEK%0ASE%3DEUR",
"mapped more than once",
),
(
"billing_legacy_prices=weekly_GBP%3Dprice_1A",
"Invalid legacy price slot",
),
(
"billing_legacy_prices=monthly_GBP%3D",
"needs at least one price ID",
),
(
"billing_default_currency=EUR&billing_price_currency=GBP&billing_price_monthly=price_1A",
"Default currency EUR has no row",
),
(
"billing_country_currencies=SE%3DSEK&billing_price_currency=GBP&billing_price_monthly=price_1A",
"SE maps to SEK",
),
];
let long_name = format!("billing_premium_product_name={}", "A".repeat(41));
let emoji_name = format!(
"billing_premium_product_name=Gold{}",
"%F0%9F%92%8E".repeat(20)
);
let long_case = [
(long_name.as_str(), "at most 40"),
(emoji_name.as_str(), "at most 40"),
];
for (body, expected) in long_case.iter().chain(cases.iter()) {
let error =
build_billing_update(&MultiValueForm::parse(body.as_bytes())).expect_err(body);
assert!(error.contains(expected), "{body}: {error}");
}
}
#[test]
fn premium_name_limit_counts_utf16_units() {
let name = format!("{}{}", "A".repeat(39), "\u{1F48E}");
assert_eq!(name.chars().count(), 40);
assert!(parse_premium_product_name(Some(name)).is_err());
let fits = format!("{}{}", "A".repeat(38), "\u{E9}\u{E9}");
assert_eq!(
parse_premium_product_name(Some(fits.clone())),
Ok(Some(fits))
);
}
#[test]
fn country_currencies_are_not_cross_checked_without_a_price_table() {
let form = MultiValueForm::parse(b"billing_country_currencies=SE%3DSEK");
let billing = build_billing_update(&form).unwrap().billing.unwrap();
assert_eq!(
billing.country_currencies,
Some(Some(BTreeMap::from([("SE".to_owned(), "SEK".to_owned())])))
);
}
#[test]
fn validation_errors_surface_the_first_api_message() {
let error = ApiError::Http {
status: 400,
message: json!({
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [{"path": "billing.enabled", "code": "X", "message": "Switch the premium model to mirror first"}]
})
.to_string(),
};
assert_eq!(
validation_message(&error).as_deref(),
Some("billing.enabled: Switch the premium model to mirror first")
);
let server_error = ApiError::Http {
status: 500,
message: "{}".to_owned(),
};
assert_eq!(validation_message(&server_error), None);
}
}
+28 -30
View File
@@ -1,19 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient,
api::{client::AdminApiClient, generated::types::GiftCodeDurationTypeSchema},
middleware::{
auth::AuthContext,
csrf::CsrfToken,
flash::{self, FlashData},
},
state::AppState,
templates,
templates::{
self,
pages::gift_codes::{GiftCodesPremium, MAX_GIFT_CODES},
},
};
use axum::{
Form, Router,
extract::{FromRequest, Query, Request, State},
response::{Html, IntoResponse, Redirect, Response},
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
@@ -28,11 +31,11 @@ struct GiftCodesForm {
#[serde(default)]
_csrf: Option<String>,
#[serde(default)]
count: Option<String>,
count: Option<u32>,
#[serde(default)]
duration_type: Option<String>,
duration_type: Option<GiftCodeDurationTypeSchema>,
#[serde(default)]
duration_quantity: Option<String>,
duration_quantity: Option<u32>,
}
pub fn router() -> Router<AppState> {
@@ -46,10 +49,11 @@ async fn gift_codes_page(
Query(query): Query<GiftCodesQuery>,
) -> Response {
let config = state.config();
if config.self_hosted {
return Redirect::to(&format!("{}/dashboard", config.base_path)).into_response();
}
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let premium = GiftCodesPremium::from_branding(
config.self_hosted,
state.premium_branding(&client).await.as_ref(),
);
let generated_codes: Option<Vec<String>> = query
.codes
@@ -60,6 +64,7 @@ async fn gift_codes_page(
config,
&auth.0,
&csrf.0.0,
&premium,
generated_codes.as_deref(),
);
Html(markup.into_string()).into_response()
@@ -72,9 +77,6 @@ async fn gift_codes_post(
) -> Response {
let config = state.config();
let base = &config.base_path;
if config.self_hosted {
return Redirect::to(&format!("{base}/dashboard")).into_response();
}
let form: GiftCodesForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
@@ -82,31 +84,27 @@ 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(),
);
}
};
let count = form
.count
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1)
.clamp(1, 100);
let dur_type = form.duration_type.as_deref().unwrap_or("month");
let dur_qty = form
.duration_quantity
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1);
let count = form.count.unwrap_or(1).clamp(1, MAX_GIFT_CODES);
let duration_type = form
.duration_type
.unwrap_or(GiftCodeDurationTypeSchema::Months);
let duration_quantity = form.duration_quantity.unwrap_or(1);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let is_prod = config.is_production();
match client.generate_gift_codes(count, dur_type, dur_qty).await {
let secure_cookies = config.secure_cookies();
match client
.generate_gift_codes(count, duration_type, duration_quantity)
.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 +112,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(),
)
}
+5 -20
View File
@@ -43,7 +43,7 @@ pub async fn render(
let page = query.members_page.unwrap_or(0);
let limit: u32 = 50;
let resp = client
.list_guild_members(guild_id, limit, page * limit)
.list_guild_members(guild_id, limit, u64::from(page) * u64::from(limit))
.await
.log_error("load guild members")?;
Some(tabs::members::members_tab(
@@ -57,7 +57,7 @@ pub async fn render(
let page = query.reports_page.unwrap_or(0);
let limit: u32 = 25;
let resp = client
.search_reports_by_guild(guild_id, limit, page * limit)
.search_reports_by_guild(guild_id, limit, u64::from(page) * u64::from(limit))
.await
.log_error("load guild reports")?;
Some(tabs::reports::reports_tab(
@@ -121,6 +121,7 @@ pub async fn render(
admin_user_id: None,
target_id: Some(guild_id.to_owned()),
target_type: Some("guild".to_owned()),
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()),
limit: 50,
@@ -134,7 +135,7 @@ pub async fn render(
@if let Some(resp) = resp {
@if resp.logs.is_empty() {
p class="text-sm text-neutral-500" {
"No admin audit log entries for this guild."
"No admin write actions have been recorded for this guild."
}
} @else {
(table_container(table(maud::html! {
@@ -150,22 +151,6 @@ pub async fn render(
},
))
}
"billing" => {
if config.self_hosted || !acl::has_permission(admin_acls, acl::BILLING_VIEW) {
return None;
}
let billing = client
.get_billing_overview(guild_id)
.await
.log_error("load guild billing overview")
.map(|b| b.data);
Some(tabs::billing::billing_tab(
config,
guild_id,
billing.as_ref(),
csrf_token,
))
}
"applications" => {
if !acl::has_any_permission(
admin_acls,
@@ -174,7 +159,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();
+20 -20
View File
@@ -82,23 +82,19 @@ async fn guilds_list(
}
}
}
Some((guilds, Some(params.requested_ids.len() as u64), false))
Some((guilds, params.requested_ids.len() as u64))
} else if params.has_search() {
let offset = params.page.saturating_mul(params.limit);
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_guilds(params.search_query(), params.limit, offset)
.await
.log_error("search guilds")
.map(|response| {
let has_more = u64::from(offset) + (response.guilds.len() as u64) < response.total;
(response.guilds, Some(response.total), has_more)
})
.map(|response| (response.guilds, response.total))
} else {
None
};
let result_guilds = results.as_ref().map(|result| result.0.as_slice());
let total = results.as_ref().and_then(|result| result.1);
let has_more = results.as_ref().is_some_and(|result| result.2);
let total = results.as_ref().map(|result| result.1);
let markup = templates::pages::guilds_list::guilds_list_page(
config,
@@ -106,7 +102,6 @@ async fn guilds_list(
&params,
result_guilds,
total,
has_more,
is_results_fragment,
);
Html(markup.into_string()).into_response()
@@ -191,7 +186,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 +198,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(
@@ -324,8 +319,12 @@ async fn dispatch_guild_action(
"default_message_notifications",
"disabled_operations",
] {
if let Some(v) = form.parse_i64(key) {
settings.insert(key.to_string(), serde_json::json!(v));
let value = match form.parse_value::<i64>(key) {
Ok(value) => value,
Err(_) => return FlashData::error(format!("Invalid {key} value")),
};
if let Some(value) = value {
settings.insert(key.to_string(), serde_json::json!(value));
}
}
if form.contains_key("nsfw_submitted") {
@@ -356,11 +355,12 @@ async fn dispatch_guild_action(
)
}
"update_disabled_operations" => {
let disabled_operations = form
.list_values_any(&["disabled_operations[]", "disabled_operations"])
.iter()
.filter_map(|value| value.parse::<i64>().ok())
.fold(0_i64, |acc, value| acc | value);
let Ok(operations) =
form.parse_list_values::<i64>(&["disabled_operations[]", "disabled_operations"])
else {
return FlashData::error("Invalid disabled operation value");
};
let disabled_operations = operations.into_iter().fold(0_i64, |acc, value| acc | value);
let settings = serde_json::json!({
"disabled_operations": disabled_operations,
});
@@ -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",
)
+33 -47
View File
@@ -21,7 +21,6 @@ use axum::{
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
struct JobsQuery {
status: Option<String>,
task_type: Option<String>,
@@ -187,7 +186,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,54 +213,41 @@ async fn job_detail_post(
flash::redirect_with_flash(
&format!("{base}/jobs/{job_id}"),
flash,
config.is_production(),
config.secure_cookies(),
)
}
fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String {
let mut params = Vec::new();
push_query(&mut params, "status", query.status.as_deref());
push_query(&mut params, "task_type", query.task_type.as_deref());
push_query(
&mut params,
"requested_by_user_id",
query.requested_by_user_id.as_deref(),
);
push_query(
&mut params,
"max_lookback_days",
query.max_lookback_days.as_deref(),
);
push_query(
&mut params,
"cursor_bucket_day",
query.cursor_bucket_day.as_deref(),
);
push_query(
&mut params,
"cursor_created_at",
query.cursor_created_at.as_deref(),
);
push_query(&mut params, "cursor_job_id", query.cursor_job_id.as_deref());
if params.is_empty() {
return format!("{}/jobs", config.base_path);
}
let query = params
.iter()
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
format!("{}/jobs?{query}", config.base_path)
}
fn push_query(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
if let Some(value) = value.filter(|value| !value.is_empty()) {
params.push((key.to_owned(), value.to_owned()));
let query = [
("status", query.status.as_deref()),
("task_type", query.task_type.as_deref()),
(
"requested_by_user_id",
query.requested_by_user_id.as_deref(),
),
("max_lookback_days", query.max_lookback_days.as_deref()),
("cursor_bucket_day", query.cursor_bucket_day.as_deref()),
("cursor_created_at", query.cursor_created_at.as_deref()),
("cursor_job_id", query.cursor_job_id.as_deref()),
]
.into_iter()
.filter_map(|(key, value)| {
value
.filter(|value| !value.is_empty())
.map(|value| (key, value))
})
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
if query.is_empty() {
format!("{}/jobs", config.base_path)
} else {
format!("{}/jobs?{query}", config.base_path)
}
}
+48 -37
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(),
);
}
};
@@ -59,53 +59,51 @@ pub(crate) async fn messages_post(
match action {
"lookup" => {
let context_limit = parse_context_limit(form.first("context_limit"));
return Redirect::to(&format!(
Redirect::to(&format!(
"{base}/messages?channel_id={}&message_id={}&context_limit={context_limit}",
encode_opt(&channel_id),
encode_opt(&message_id)
))
.into_response();
.into_response()
}
"lookup-by-attachment" => {
let attachment_id = form.clean("attachment_id");
let filename = form.clean("filename");
let context_limit = parse_context_limit(form.first("context_limit"));
return Redirect::to(&format!(
Redirect::to(&format!(
"{base}/messages?channel_id={}&attachment_id={}&filename={}&context_limit={context_limit}",
encode_opt(&channel_id),
encode_opt(&attachment_id),
encode_opt(&filename)
))
.into_response();
}
"browse" => {
return Redirect::to(&format!(
"{base}/messages?channel_id={}",
encode_opt(&channel_id)
))
.into_response();
.into_response()
}
"browse" => Redirect::to(&format!(
"{base}/messages?channel_id={}",
encode_opt(&channel_id)
))
.into_response(),
"search" => {
let search = form.clean("search");
return Redirect::to(&format!(
Redirect::to(&format!(
"{base}/messages?channel_id={}&search={}",
encode_opt(&channel_id),
encode_opt(&search)
))
.into_response();
.into_response()
}
"delete" => {
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
};
let audit_log_reason = form.clean("audit_log_reason");
return match client
match client
.delete_message(cid, mid, audit_log_reason.as_deref())
.await
{
Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
};
}
}
"report-to-ncmec" => {
let attachment_id = form.clean("attachment_id");
@@ -131,7 +129,7 @@ pub(crate) async fn messages_post(
"Missing required NCMEC report fields",
);
}
return match client
match client
.report_attachment_to_ncmec(
cid,
mid,
@@ -144,11 +142,10 @@ pub(crate) async fn messages_post(
{
Ok(resp) => Json(resp.data).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
};
}
}
_ => {}
_ => Redirect::to(&format!("{base}/messages")).into_response(),
}
Redirect::to(&format!("{base}/messages")).into_response()
}
pub(crate) async fn system_dms_post(
@@ -164,7 +161,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 +181,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 +202,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,32 +248,42 @@ 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);
let (Ok(reason_code), Ok(days)) = (
form.parse_value::<u32>("reason_code"),
form.parse_value::<u32>("days_until_deletion"),
) else {
return flash::redirect_with_flash(
&format!("{base}/bulk-actions"),
FlashData::error("Invalid deletion reason code or delay"),
config.secure_cookies(),
);
};
let public_reason = form.clean("public_reason");
let notify_user = form.opt_out_value("notify_user");
client
.bulk_schedule_user_deletion(
&user_ids,
reason_code,
days,
reason_code.unwrap_or(2),
days.unwrap_or(14),
public_reason.as_deref(),
notify_user,
audit_log_reason.as_deref(),
)
.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 +295,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 +303,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(),
)
}
}
@@ -353,7 +364,7 @@ pub(crate) async fn messages_browse_fragment(
}
}
fn parse_context_limit(value: Option<&str>) -> u32 {
pub(super) fn parse_context_limit(value: Option<&str>) -> u32 {
value
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| *n > 0)
@@ -399,14 +410,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(),
)
}
}
+1 -8
View File
@@ -16,7 +16,6 @@ use axum::{
use serde::Deserialize;
#[derive(Deserialize)]
#[allow(dead_code)]
struct MessagesQuery {
channel_id: Option<String>,
message_id: Option<String>,
@@ -82,13 +81,7 @@ async fn messages_page(
let before = query.before.as_deref().filter(|s| !s.is_empty());
let after = query.after.as_deref().filter(|s| !s.is_empty());
let search = query.search.as_deref().filter(|s| !s.is_empty());
let context_limit = query
.context_limit
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(50)
.min(100);
let context_limit = super::message_actions::parse_context_limit(query.context_limit.as_deref());
let mut lookup_result = None;
let mut browse_result = None;
let mut search_result = None;
+5 -1
View File
@@ -5,6 +5,7 @@ pub mod applications;
pub mod auth;
pub mod bans;
mod bans_actions;
mod billing_actions;
pub mod codes;
pub mod discovery;
mod guild_tabs;
@@ -73,7 +74,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn(middleware::csrf::csrf_protection))
.layer(from_fn_with_state(
state.clone(),
middleware::csrf::csrf_protection,
))
.layer(from_fn(middleware::self_hosted::self_hosted_override))
.layer(from_fn_with_state(
state.clone(),
+51 -19
View File
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::{AdminApiClient, ApiResultExt},
api::{
client::{AdminApiClient, ApiResultExt},
reports::SearchReportsParams,
},
config::AdminConfig,
middleware::{
auth::AuthContext,
csrf,
@@ -22,6 +26,8 @@ use axum::{
};
use serde::Deserialize;
const MAX_REPORT_OFFSET: u64 = 10_000;
#[derive(Deserialize)]
struct ReportsQuery {
q: Option<String>,
@@ -46,6 +52,10 @@ struct ResolveForm {
_csrf: Option<String>,
#[serde(default)]
resolution: Option<String>,
#[serde(default)]
notify_reporter: Option<String>,
#[serde(default)]
notify_reporter_present: Option<String>,
}
pub fn router() -> Router<AppState> {
@@ -69,7 +79,15 @@ async fn reports_list(
let config = state.config();
let page = query.page.unwrap_or(0);
let limit = query.limit.unwrap_or(25).clamp(1, 200);
let offset = page.saturating_mul(limit);
let offset = u64::from(page) * u64::from(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. Narrow the filters and start again.",
);
}
let search_query = query.q.as_deref().and_then(clean_string);
let (sort_by, sort_order) = decode_sort(query.sort.as_deref());
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let status = query.status.as_deref().and_then(|s| s.parse::<i32>().ok());
@@ -78,22 +96,22 @@ async fn reports_list(
.as_deref()
.and_then(|s| s.parse::<i32>().ok());
let reports = client
.search_reports(
query.q.as_deref(),
.search_reports(&SearchReportsParams {
query: search_query.as_deref(),
status,
report_type,
query.category.as_deref(),
query.reporter_id.as_deref(),
query.reported_user_id.as_deref(),
query.reported_guild_id.as_deref(),
query.reported_channel_id.as_deref(),
query.guild_context_id.as_deref(),
query.resolved_by_admin_id.as_deref(),
Some(sort_by),
Some(sort_order),
category: query.category.as_deref(),
reporter_id: query.reporter_id.as_deref(),
reported_user_id: query.reported_user_id.as_deref(),
reported_guild_id: query.reported_guild_id.as_deref(),
reported_channel_id: query.reported_channel_id.as_deref(),
guild_context_id: query.guild_context_id.as_deref(),
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
sort_by: Some(sort_by),
sort_order: Some(sort_order),
limit,
offset,
)
})
.await
.log_error("search reports");
@@ -102,7 +120,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 +138,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,14 +225,16 @@ 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(),
);
}
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
let notify_reporter =
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
let result = client
.resolve_report(&report_id, public_comment.as_deref(), None)
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
.await;
match result {
Ok(_) => {
@@ -212,7 +244,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 +255,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(),
)
}
}

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