Compare commits

..
Author SHA1 Message Date
Hampus eaee820216 feat(experiments): target rollouts by guild and premium status (#3012) 2026-09-28 14:34:19 +02:00
Hampus 564c5ae164 feat(profile): move profile timezone from staff to an experiment (#3011) 2026-09-28 12:57:26 +02:00
Hampus dd8ed6f205 fix(app): react at once when picking a +: autocomplete emoji (#3010) 2026-09-28 12:30:45 +02:00
Hampus ed8c412415 perf(gateway): make channel moves cheap in large guilds (#3008) 2026-09-28 02:07:47 +02:00
Hampus 12417a6942 fix(app): keep the caret after inserted emoji (#3007) 2026-09-28 01:56:08 +02:00
Hampus d05f6c9aaa fix(gateway): push held users whose sessions end during grace (#3006) 2026-09-28 01:47:55 +02:00
Hampus 0ca035c547 fix(messages): accept null version on legacy message rows (#3004) 2026-09-28 01:10:52 +02:00
Hampus dfd46ccc2c ci(gateway): drop cached gateway build output before compiling (#3003) 2026-09-28 01:08:52 +02:00
Hampus f6df3169ca fix(app): use +:shortcode: for reactions, no space before emoji (#3001) 2026-09-28 00:48:23 +02:00
Hampus 5b280898c5 refactor(push): retire the push service delivery experiment (#3000) 2026-09-28 00:45:22 +02:00
Hampus 2a9e25c788 fix(dev): drop the stray -- from the tunnel public URL hint (#2999) 2026-09-28 00:43:32 +02:00
Hampus 463c03fb6d feat(app): make +emoji react on send and target replies (#2998) 2026-09-28 00:08:40 +02:00
Hampus 153dad11e1 feat(installer): let upgrades copy the uploads uncompressed (#2995) 2026-09-27 23:51:24 +02:00
Hampus e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
Hampus 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
Hampus 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
Hampus fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
Hampus 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
Hampus 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
Hampus 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
Hampus 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
Hampus 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
Hampus 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
Hampus 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
Hampus 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
Hampus 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
Hampus 841fb7af41 feat(auth): migrate passkeys to fluxer.com (#2964) 2026-09-25 22:33:50 +02:00
Hampus 08e65d41c0 fix(api): clear the perks-sanitized latch when premium returns (#2963) 2026-09-25 20:13:00 +02:00
Hampus f76c4dc041 fix(api): cancel only the subscription the refund belongs to (#2962) 2026-09-25 20:10:54 +02:00
Hampus f1f8ba2031 fix(app): add copy link to link channel context menus (#2959) 2026-09-25 18:16:20 +02:00
Hampus 5ab8d745c0 fix(i18n): correct the fluxer.com migration translations (#2958) 2026-09-25 17:46:07 +02:00
Hampus ff62bc89a4 feat(app): add passkey popup bridge for password managers (#2957) 2026-09-25 17:43:19 +02:00
Hampus 838bbdb5ec fix(app): only start the domain migration when the app opens (#2956) 2026-09-25 16:44:58 +02:00
Hampus 1c36a59b2c feat(app): rework quick switcher ranking and show origin icons (#2953) 2026-09-25 13:59:25 +02:00
Hampus 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
Hampus e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
Hampus f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
Hampus 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
Hampus c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
Hampus bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
Hampus e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
Hampus 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
Hampus b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
Hampus 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot] 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot] 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
Hampus 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
Hampus 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
Hampus 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
Hampus a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
Hampus 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
Hampus 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
Hampus a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
Hampus 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
Hampus c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
Hampus dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
Hampus 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
Hampus 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
Hampus deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omster 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omster 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
Hampus f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
Hampus ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
Hampus bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
Hampus b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
Hampus b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
Hampus 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
Hampus b16989d567 feat(push): ring incoming calls on Apple PushKit devices (#2911) 2026-09-23 20:21:08 +02:00
Hampus c9754ac11a fix(api): exempt internal rpc from the client ip check (#2910) 2026-09-23 18:03:36 +02:00
fluxer-weblate[bot] f34e4a5115 chore(i18n): update translations from Weblate (#2903) 2026-09-23 17:28:25 +02:00
fluxer-weblate[bot] 44b3615298 chore(i18n): update translations from Weblate (#2904) 2026-09-23 17:27:59 +02:00
Hampus 211e98307d perf(push): cache endpoint guard dns verdicts (#2907) 2026-09-23 17:27:19 +02:00
Hampus 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
Jiralite 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
Wagner 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot] 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omster 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
Hampus 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
Hampus c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
Hampus 91340c5c84 fix(markdown): compile the parser wasm asynchronously (#2900) 2026-09-22 19:58:38 +02:00
Hampus 045dd5d027 test(api): make the harvest token tamper test deterministic (#2894) 2026-09-22 02:20:18 +02:00
Hampus a21b9c4659 docs(readme): clean up the download prose (#2893) 2026-09-22 02:08:42 +02:00
Hampus 4b1b869802 docs(readme): point Linux installs at Flathub (#2892) 2026-09-22 02:04:06 +02:00
Hampus 1ab7e7dfcc fix(api): unfurl links to a self-hosted instance's own domain (#2891) 2026-09-22 02:00:51 +02:00
Hampus 31c53d2dff fix(app): stop pending stickers from reloading the channel (#2890) 2026-09-22 02:00:26 +02:00
Hampus 412a1ae79d perf(api): stop ledgering session payment reconciliation (#2889) 2026-09-22 01:37:21 +02:00
Hampus 0b2306ec3d fix(api): honour default TTLs and expire stale job ledger rows (#2887) 2026-09-21 23:16:39 +02:00
Hampus 242ed3a934 fix(desktop): drop orphaned Squirrel uninstall entry (#2885) 2026-09-21 20:03:33 +02:00
Hampus 70e1ce682a feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883) 2026-09-21 16:26:06 +02:00
Hampus 7601bf98ee fix(channel): sync a cleared group DM name without a reload (#2882) 2026-09-21 15:34:18 +02:00
TarekandHampus c7ec2a0f58 chore(tooling): Ignore .vscode/ in .gitignore (#2868)
Co-authored-by: Hampus <[email protected]>
2026-09-21 13:29:33 +02:00
Xeon 6a5e0056a8 fix(flatpak): Add a release tag and make small corrections (#2872) 2026-09-21 13:28:36 +02:00
Hampus 78d105b46e fix(desktop): stop looping on an update that never installs (#2879) 2026-09-21 03:36:11 +02:00
Hampus c68d62b8a0 fix(voice): darken screen share source titles in light theme (#2878) 2026-09-21 01:20:54 +02:00
Hampus df58020f4c fix(api): keep premium paid for after a subscription cancels (#2875) 2026-09-20 23:50:49 +02:00
Hampus f052ce05aa fix(workspace): point the Erlang extension at the repo root (#2871) 2026-09-20 19:49:06 +02:00
Hampus eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
Hampus 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
Hampus 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
Hampus a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
Hampus 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
Hampus c157ab5752 feat(voice): rework screen share delivery behind an experiment (#2859) 2026-09-20 06:10:20 +02:00
Hampus 574a93257c docs(downloads): the pacman repository is signed (#2858) 2026-09-20 05:54:28 +02:00
Hampus ba7d8781cf feat(auth): make passkey two-factor authentication opt-in (#2857) 2026-09-20 05:06:50 +02:00
Hampus 3256af8d92 refactor(app-proxy): remove the stable time freeze (#2856) 2026-09-20 04:02:47 +02:00
Hampus 86043212f2 docs(downloads): one pacman repository holds both channels (#2855) 2026-09-20 02:50:08 +02:00
Hampus 5d85e88532 fix(search): suggest yourself in DM from: and mentions: filters (#2854) 2026-09-20 01:24:43 +02:00
Hampus e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
Hampus 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
Hampus a3454e8245 fix(installer): name the services that are not ready (#2851) 2026-09-19 23:34:08 +02:00
Hampus bf7567b768 fix(user): push guild member updates on profile field changes (#2850) 2026-09-19 23:30:25 +02:00
Hampus ac3450ab32 feat(ci): publish appimage zsync control files (#2849) 2026-09-19 22:22:54 +02:00
Hampus 5d034becb8 fix(installer): stop waiting for an absent bucket initialiser (#2848) 2026-09-19 22:14:13 +02:00
Hampus f9397d0db9 feat(ci): publish linux repositories from the desktop release (#2847) 2026-09-19 22:01:06 +02:00
Hampus 9005139dc8 fix(voice): stop stereo microphones publishing as mono (#2846) 2026-09-19 21:54:38 +02:00
Hampus d93604afa2 fix(voice): let screen shares use the hardware H.264 encoder (#2845) 2026-09-19 21:54:30 +02:00
Hampus c4f0b2ece0 feat(desktop): self-update appimages in place (#2843) 2026-09-19 19:06:53 +02:00
Hampus 98a42f612b fix(desktop): supersede the legacy linux packages on upgrade (#2842) 2026-09-19 18:50:37 +02:00
Hampus cc75e1318d fix(ci): raise the macos minimum to 13.0 (#2841) 2026-09-19 18:35:05 +02:00
Hampus 9027cbdf3e fix(voice): send screen shares at the quality the user picked (#2840) 2026-09-19 16:46:22 +02:00
Hampus 2119e10ed5 chore(static): update marketing screenshots and readme cover (#2839) 2026-09-19 16:44:32 +02:00
Hampus 87f3eb3c81 feat(desktop): add flatpak and arch packaging inputs (#2838) 2026-09-19 15:31:41 +02:00
Hampus f9bb8bd585 test(voice): remove the slow screen share delivery proof (#2836) 2026-09-19 02:33:32 +02:00
Hampus bc47a724af fix(voice): stop screen shares failing to reach their viewers (#2835) 2026-09-19 02:17:25 +02:00
Hampus f32356801d feat(api): make tor and breached password lookups opt-in (#2834) 2026-09-19 01:22:17 +02:00
Hampus efd677f32b feat(api): exempt configured ASNs from abusive IP auto-bans (#2833) 2026-09-18 23:01:58 +02:00
Hampus 3cec27ba57 fix(static): vendor the deepfilternet 1.3.0 assets (#2832) 2026-09-18 18:47:42 +02:00
Hampus 1f810ba04d fix(api): drop the upload segment signal and dead exports (#2831) 2026-09-18 17:35:58 +02:00
Hampus 522cf08e61 feat(media-proxy): sign attachment URLs and gate origins (#2830) 2026-09-18 15:57:32 +02:00
Hampus 025c01ab13 fix(api): chunk guild permission batch RPC over 100 guilds (#2829) 2026-09-18 12:54:03 +02:00
Hampus dc41b53d60 fix(desktop): drop redundant casts flagged by clippy 1.98 (#2826) 2026-09-17 21:28:48 +02:00
Hampus 3b552e00ef chore(deps): upgrade all dependencies, toolchains and images (#2825) 2026-09-17 21:08:56 +02:00
Hampus 56e04e7b53 test(backend): remove duplicate and useless tests (#2820) 2026-09-17 15:32:25 +02:00
Hampus deac653a9e test(app): remove useless frontend tests (#2819) 2026-09-17 15:05:36 +02:00
Hampus ed9528834d fix(gateway): stop dead sessions leaving voice states behind (#2818) 2026-09-17 14:55:18 +02:00
Hampus 4cecbf1f43 fix(auth): disable TOTP with one code instead of two (#2816) 2026-09-17 04:21:50 +02:00
Hampus b019f4a91f fix(gateway): act on voice states in the voice server (#2815) 2026-09-17 03:59:36 +02:00
Hampus 34b6ecfbd2 chore(admin): remove the heap snapshot endpoint (#2814) 2026-09-16 18:56:01 +02:00
Hampus 4ef9c4c65b fix(api): restore commas in geoip location labels (#2812) 2026-09-16 18:27:50 +02:00
Hampus 3276039e41 feat(admin): audit admin reads and filter the log by access (#2811) 2026-09-16 17:23:03 +02:00
Hampus 03d1354562 chore(voice): remove voice reconciliation leftovers (#2810) 2026-09-16 17:09:57 +02:00
Hampus 964845d7a7 chore(voice): remove the recon service (#2808) 2026-09-16 16:53:30 +02:00
Hampus 3bc5dd8e0f fix(gateway): always clear expired custom statuses (#2807) 2026-09-16 16:52:22 +02:00
Hampus 17292fd6a5 fix(app): stop plain unicode symbols rendering as color emoji (#2806) 2026-09-16 16:33:13 +02:00
Tarek f753659899 feat(instance): make the status page URL configurable (#1159) 2026-09-16 15:20:37 +02:00
Hampus 7412ec3395 refactor(api): purge cache by canonical media prefix (#2802) 2026-09-16 02:32:36 +02:00
Hampus 570c8776c4 fix(api): require manage messages to remove others' reactions (#2799) 2026-09-15 18:16:55 +02:00
Hampus 910db6734b feat(experiments): ship seven treatments to everyone (#2798) 2026-09-15 18:03:35 +02:00
Hampus 9e614026d7 fix(api): stop exporting the change feed stats type (#2797) 2026-09-15 17:27:09 +02:00
Hampus b38e7c6433 feat(api): publish object storage changes to a JetStream feed (#2796) 2026-09-15 17:20:26 +02:00
Hampus 83c8e91955 fix(app): fit the user area popout shadow to its card (#2795) 2026-09-15 17:11:43 +02:00
Hampus 0532dd0440 fix(app): stop guild banner jumps and restore hover animation (#2792) 2026-09-15 09:31:52 +02:00
Hampus a08615e306 fix(gateway): match member search on username and global name (#2791) 2026-09-15 08:48:33 +02:00
Hampus f4c5fee17e feat(app): rank forward destinations and preview the message (#2790) 2026-09-15 07:23:26 +02:00
Hampus c5aaf65a10 fix(app): list friends with closed DMs in the forward modal (#2787) 2026-09-15 00:39:18 +02:00
Hampus 951e39da3d feat(api): add expression source guild routes (#2786) 2026-09-15 00:31:47 +02:00
Hampus b693d84d2b fix(openapi): restore named discriminated union branches (#2785) 2026-09-14 23:42:26 +02:00
Hampus 9bbf6c513b fix(installer): say what the email prompt is for (#2784) 2026-09-14 23:07:03 +02:00
Hampus 50cec92738 fix(api): batch member user lookups on guild load (#2783) 2026-09-14 23:06:36 +02:00
Hampus c212d315f4 fix(app): gate reworked typing indicators behind an experiment (#2782) 2026-09-14 21:54:52 +02:00
Hampus 1861432a53 fix(app): scope message rings and reach the composer by key (#2781) 2026-09-14 21:44:12 +02:00
Hampus d0c6146429 feat(app): gate a collapsing guild header behind an experiment (#2779) 2026-09-14 21:15:31 +02:00
Hampus 550e6b05a1 fix(app): show hover highlight and inset the focus ring (#2778) 2026-09-14 20:51:59 +02:00
Hampus 5b6949170f chore(donations): drop the donor email case backfill script (#2776) 2026-09-14 20:45:38 +02:00
Hampus f32bc37794 fix(guild): correct activity log sentence presentation (#2777) 2026-09-14 20:43:08 +02:00
Hampus 8ee2279b4b feat(donations): add Nordic currencies, raise amount ceilings (#2775) 2026-09-14 20:27:24 +02:00
Hampus 6339c3b8ad feat(app): gate the expression info card behind an experiment (#2773) 2026-09-14 20:14:58 +02:00
Hampus 7c9274847f feat(gateway): list bot ready guilds as unavailable (#2774) 2026-09-14 20:08:26 +02:00
Hampus 5d1dddc093 fix(deps): update rustls for RUSTSEC-2026-0285 (#2772) 2026-09-14 19:01:22 +02:00
Hampus 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
Hampus ed10f9d323 fix(app): gate blocked group rendering behind an experiment (#2763) 2026-09-14 16:08:19 +02:00
Hampus 4b278a0da8 fix(app): gate one-Tab message focus behind an experiment (#2762) 2026-09-14 15:55:00 +02:00
Hampus 1281045648 fix(app): gate single-source message hover behind an experiment (#2761) 2026-09-14 15:37:37 +02:00
Hampus 69c42cff90 docs: reword vague sentences and fix wrong claims (#2760) 2026-09-14 15:18:53 +02:00
Hampus 7d56481aba fix(app): copy selected message text without markdown (#2759) 2026-09-14 14:53:53 +02:00
Hampus 91a2604e9e fix(admin): apply audit logs and side effects to bulk actions (#2758) 2026-09-14 14:34:43 +02:00
Hampus a9dc74a520 fix(app): hide unread channels in muted collapsed categories (#2752) 2026-09-13 23:59:28 +02:00
Hampus a9cc04d277 fix(media-proxy): retry relay uploads on dropped connections (#2751) 2026-09-13 23:35:09 +02:00
Hampus 8cb097f954 fix(i18n): review translations and fix i18n library misuse (#2750) 2026-09-13 22:58:09 +02:00
Hampus 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
Hampus ca7ddd9272 refactor(api): drop Bunny for pluggable cache purge adapters (#2742) 2026-09-13 20:28:56 +02:00
Hampus 84dcf6b8a8 refactor(imports): replace relative imports with path aliases (#2741) 2026-09-13 20:18:22 +02:00
Hampus a59b80ce11 docs(api): correct the synced preferences size limits (#2740) 2026-09-13 19:37:32 +02:00
Hampus 007f338823 feat(schema): add double tap reaction to synced preferences (#2739) 2026-09-13 18:47:21 +02:00
Hampus 7d34d25497 fix(ci): stop verifying published assets against the CDN (#2738) 2026-09-13 18:04:08 +02:00
Hampus 7375ac9d80 docs: simplify the reference and tighten the verifier (#2736) 2026-09-13 17:38:50 +02:00
Hampus 6af33c7188 refactor(svc): tidy the rust services and build tooling (#2735) 2026-09-13 17:38:32 +02:00
Hampus 33737e0f79 refactor(admin): tidy the admin routes and templates (#2734) 2026-09-13 17:38:15 +02:00
Hampus 5afbf67a70 refactor(api): tidy the api and shared packages (#2733) 2026-09-13 17:37:48 +02:00
Hampus 580401d2dc chore(marketing): remove the private marketing submodule (#2732) 2026-09-13 16:37:55 +02:00
Hampus 38b7c63431 fix(admin): declare gateway cluster_metrics in node stats (#2728) 2026-09-12 22:48:22 +02:00
Hampus 57d08cd091 fix(api): keep stickers on messages sent to personal notes (#2727) 2026-09-12 20:20:26 +02:00
Hampus 91e2d31614 style(voice): format the room_finished webhook test 2026-09-12 16:05:26 +02:00
Hampus d375dc7946 fix(ci): stop a new component blocking every other image promote 2026-09-12 16:01:47 +02:00
Hampus 3fffce2a4a fix(voice): correct the room_finished test harness types (#2723) 2026-09-12 15:49:47 +02:00
Hampus 376c509083 docs(self-host): tighten the env example comments (#2722) 2026-09-12 15:39:24 +02:00
Hampus 156315fd5a docs: drop exact counts that go stale when inventory changes (#2721) 2026-09-12 15:39:07 +02:00
Hampus c7b9e9b9bd fix(voice): stop room_finished evicting a whole channel (#2720) 2026-09-12 15:38:50 +02:00
Hampus 12397032e3 feat(voice): add the recon service and remove the old worker (#2719) 2026-09-12 15:38:32 +02:00
Hampus 4a285cbb11 fix(docs): drop the orphaned voice command footnote (#2718) 2026-09-12 01:45:27 +02:00
Hampus 6d600990fe fix(app): derive unread from an ack timestamp floor (#2717) 2026-09-12 01:40:36 +02:00
Hampus e1bc6c2f7e refactor(voice): remove the unused voice state ack (#2716) 2026-09-12 01:37:45 +02:00
Hampus 3e79530389 fix(app): defer non-critical gateway dispatches (#2715) 2026-09-12 01:36:04 +02:00
Hampus d0c84b3d9b fix(voice): apply noise suppression in the mic test (#2714) 2026-09-12 01:14:36 +02:00
Hampus 3affd295e8 feat(guild): require opt-in for emoji and sticker cloning (#2712) 2026-09-12 00:33:23 +02:00
Hampus 7b15e5be0f fix(admin): reject synthetic user ids on mutating routes (#2711) 2026-09-12 00:23:04 +02:00
Hampus adab646d1e fix(schema): preserve WebAuthn payloads and align fixtures (#2710) 2026-09-12 00:19:44 +02:00
Hampus de1fd95a99 refactor(schema): simplify validation and OpenAPI generation (#2709) 2026-09-11 23:24:26 +02:00
Hampus 4bc5593f9f chore(i18n): translate the voice quality catalog additions (#2707) 2026-09-11 22:59:12 +02:00
Hampus 172791316b feat(voice): add noise suppression backends and rollout (#2706) 2026-09-11 22:24:05 +02:00
Hampus b30a4f5d14 fix(admin): omit synthetic accounts from user lookup and search (#2705) 2026-09-11 22:06:29 +02:00
Hampus f254ed679b fix(app): never lower the read-state unread watermark (#2704) 2026-09-11 22:02:24 +02:00
Hampus 258fe6f742 feat(voice): add audio bitrate guild features and 96 kbps cap (#2703) 2026-09-11 22:00:15 +02:00
Hampus cfa20b7093 fix(admin): let voice restriction lists be cleared (#2701) 2026-09-11 21:28:26 +02:00
Hampus 569146c5bc fix(app): pick favorite GIF preview kind from content type (#2696) 2026-09-11 21:06:00 +02:00
Hampus 1b1d48b05e feat(voice): soft connection limits for voice servers (#2694) 2026-09-11 20:05:14 +02:00
Hampus cadca2c18e test(voice): build watch attempt keys from the shared builder (#2693) 2026-09-11 19:44:34 +02:00
Hampus 2e3f78b3c6 fix(app): stop restarting the read-state ack batch window (#2689) 2026-09-11 16:26:34 +02:00
Hampus b57545b1a4 refactor(api): replace stripe mock currency ternary chains (#2688) 2026-09-11 16:02:09 +02:00
Hampus e490be2f35 feat(app): prompt to delete when clearing a message edit (#2687) 2026-09-11 15:56:05 +02:00
fluxer-ci[bot] ab07fd23cf chore(i18n): update public marketing catalogs (#2686) 2026-09-11 15:50:16 +02:00
fluxer-ci[bot] c1fd2234b8 chore(marketing): advance pointer 7867cf8 → 23cd1c9 (#2685) 2026-09-11 15:50:05 +02:00
Hampus 3af43b3366 feat(api): add SEK, DKK and NOK as localized currencies (#2684) 2026-09-11 15:48:56 +02:00
Hampus 0e470f532e test(voice): rename the watch failure deadline test file (#2683) 2026-09-11 15:18:08 +02:00
Hampus c541b86c00 fix(voice): show buffering while screen share recovery runs (#2682) 2026-09-11 15:03:21 +02:00
Hampus 53b3fa2f4a fix(voice): key watch attempts by published track (#2681) 2026-09-11 15:01:14 +02:00
Hampus 67e01be34a fix(voice): judge H.264 hardware support by negotiated format (#2680) 2026-09-11 14:59:04 +02:00
Hampus 81fd8c9aad fix(gateway): skip empty dm partner registration casts (#2677) 2026-09-11 13:49:03 +02:00
Hampus bcef7b3123 feat(app): edit blockquote lines in the composer (#2676) 2026-09-11 13:27:50 +02:00
Hampus a98d8ef679 fix(app): wrap multiline selections in code blocks (#2675) 2026-09-11 13:22:03 +02:00
Hampus a5af857564 fix(app): insert a newline on Enter inside code blocks (#2674) 2026-09-11 13:20:26 +02:00
Hampus 2830221949 fix(desktop): download the version a linux update prompt names (#2673) 2026-09-11 13:19:25 +02:00
Hampus 84aa8880f5 fix(app): format typed @everyone and @here in the composer (#2672) 2026-09-11 13:18:48 +02:00
Hampus 395ec1d60f fix(ci): publish desktop update feeds only after the release (#2671) 2026-09-11 13:18:15 +02:00
Hampus e6ee3b8059 fix(api): only offer desktop builds whose release is published (#2670) 2026-09-11 13:17:41 +02:00
Hampus 61a13e1c1a fix(app): download the version a linux update prompt names (#2669) 2026-09-11 13:16:27 +02:00
Hampus fc0e2628a4 fix(app): honour @silent in the message composer (#2668) 2026-09-11 13:16:13 +02:00
Hampus 87fdfd9c34 fix(app): show DMs opened by an incoming message as unread (#2667) 2026-09-11 13:14:06 +02:00
Hampus 88a5ff9c45 feat(voice): record watch failures and decode counters (#2666) 2026-09-11 13:05:34 +02:00
Hampus 320949a79d fix(gateway): drop dead clauses in dm partner visibility (#2665) 2026-09-11 13:00:23 +02:00
Hampus 3a862f1484 fix(voice): record why a screen share stopped (#2664) 2026-09-11 12:59:51 +02:00
Hampus 5da256df12 fix(voice): poll the current video element for a first frame (#2663) 2026-09-11 12:57:52 +02:00
Hampus baf2cbf3fd fix(voice): rebind codec negotiation after a region hot swap (#2662) 2026-09-11 12:55:50 +02:00
Hampus 74782dc4f2 fix(voice): confirm a decode stall before withdrawing a codec (#2661) 2026-09-11 12:53:29 +02:00
Hampus 7d8778495f chore(desktop): drop Chromium switches that no longer exist (#2660) 2026-09-11 12:50:54 +02:00
Hampus 53399ffb44 fix(gateway): track dm partner presence in mutual guilds (#2658) 2026-09-11 04:23:20 +02:00
Hampus 35d73eae76 fix(voice): stop asking for camera and mic access on page load (#2657) 2026-09-11 02:00:48 +02:00
Hampus 54128e049a test(api): restore the stripe webhook secret after mocking it (#2656) 2026-09-11 01:36:26 +02:00
Hampus 7d8d0ff804 fix(ci): retry release publish after transient GitHub failures (#2655) 2026-09-11 01:35:24 +02:00
Hampus 2e8f381efc fix(gateway): keep ets tids opaque in the permission cache (#2654) 2026-09-11 01:31:58 +02:00
Hampus b29da84282 perf(gateway): trim large guild connect snapshots by default (#2653) 2026-09-11 01:03:22 +02:00
Hampus 2988c846c8 perf(gateway): read cached members from the guild member table (#2652) 2026-09-11 00:58:17 +02:00
fluxer-ci[bot] 8e91c1412b chore(marketing): advance pointer 5908507 → 7867cf8 (#2650) 2026-09-11 00:51:33 +02:00
fluxer-ci[bot] 5b2099c777 chore(i18n): update public marketing catalogs (#2651) 2026-09-11 00:51:25 +02:00
Hampus f97841a58f fix(installer): resolve the compose file name Compose loads (#2649) 2026-09-11 00:38:09 +02:00
Hampus 0421c86039 fix(api): price gifts in the base currency everywhere (#2648) 2026-09-11 00:28:14 +02:00
Hampus d17f320bd7 fix(app): tidy the Plutonium billing and pricing layout (#2647) 2026-09-10 23:06:18 +02:00
Hampus f708586c59 feat(api)!: always use localized pricing where it is offered (#2646) 2026-09-10 21:22:32 +02:00
Hampus 905af5dd5a fix(app): shrink stored favorite gifs and raise their budget (#2643) 2026-09-10 18:43:42 +02:00
Hampus 5fea319f4e fix(app): stop other youtube embeds when one starts playing (#2642) 2026-09-10 18:42:30 +02:00
Hampus 01fd11fea9 fix(api): unexport the search lookup result type (#2641) 2026-09-10 18:24:16 +02:00
Hampus d028679b90 fix(api): batch the message lookups behind message search (#2640) 2026-09-10 18:18:49 +02:00
4379 changed files with 479307 additions and 511805 deletions

No files matched your search

+9 -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 USERNAME=vscode
ARG USER_UID=1000 ARG USER_UID=1000
ARG USER_GID=1000 ARG USER_GID=1000
ARG NODE_MAJOR=24 ARG NODE_MAJOR=26
ARG ELP_VERSION=2026-02-27 ARG ELP_VERSION=2026-08-10
ARG PNPM_VERSION=10.29.3 ARG PNPM_VERSION=11.27.0
ARG WASM_BINDGEN_VERSION=0.2.123 ARG WASM_BINDGEN_VERSION=0.2.128
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
@@ -131,7 +131,8 @@ RUN apt-get update \
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \ 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 \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \ && 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 RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
@@ -167,7 +168,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
arm64) ELP_ARCH="aarch64" ;; \ arm64) ELP_ARCH="aarch64" ;; \
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \ *) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
esac \ 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 \ && tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
&& chmod +x /usr/local/bin/elp \ && chmod +x /usr/local/bin/elp \
&& rm /tmp/elp.tgz && rm /tmp/elp.tgz
@@ -194,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 \ && cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git" && rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
&& pnpm --version
WORKDIR /workspaces/fluxer WORKDIR /workspaces/fluxer
+6 -2
View File
@@ -14,7 +14,7 @@
"DOCKER_HOST": "unix:///var/run/docker.sock" "DOCKER_HOST": "unix:///var/run/docker.sock"
}, },
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"], "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": { "portsAttributes": {
"8088": { "8088": {
"label": "Fluxer dev proxy", "label": "Fluxer dev proxy",
@@ -38,7 +38,11 @@
"customizations": { "customizations": {
"vscode": { "vscode": {
"settings": { "settings": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome",
"erlang.includePaths": ["."],
"search.exclude": {
"**/_build/default/lib/fluxer_gateway": true
}
}, },
"extensions": [ "extensions": [
"biomejs.biome", "biomejs.biome",
+6 -6
View File
@@ -9,7 +9,7 @@ services:
init: true init: true
environment: environment:
DOCKER_HOST: unix:///var/run/docker.sock 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_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_PUBLIC_URL: "http://localhost:${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" FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
@@ -256,7 +256,6 @@ services:
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771" - "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_MEDIA_PROXY_PORT:-8082}:8082"
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773" - "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_ADMIN_PORT:-3020}:3020"
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333" - "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
depends_on: depends_on:
@@ -293,13 +292,13 @@ services:
start_period: 5s start_period: 5s
valkey: valkey:
image: valkey/valkey:8.1.7-alpine image: valkey/valkey:9.1.2-alpine
command: ["valkey-server", "--save", "", "--appendonly", "no"] command: ["valkey-server", "--save", "", "--appendonly", "no"]
ports: ports:
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379" - "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
nats: nats:
image: nats:2.14.2-alpine image: nats:2.14.7-alpine
command: ["-js", "-sd", "/data", "-m", "8222"] command: ["-js", "-sd", "/data", "-m", "8222"]
volumes: volumes:
- nats-data:/data - nats-data:/data
@@ -322,9 +321,10 @@ services:
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp" - "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
meilisearch: meilisearch:
image: getmeili/meilisearch:v1.12 image: getmeili/meilisearch:v1.53
environment: environment:
MEILI_NO_ANALYTICS: "true" MEILI_NO_ANALYTICS: "true"
MEILI_UPGRADE_DB: "true"
MEILI_MASTER_KEY: fluxer-dev-meilisearch MEILI_MASTER_KEY: fluxer-dev-meilisearch
volumes: volumes:
- meilisearch-data:/meili_data - meilisearch-data:/meili_data
@@ -338,7 +338,7 @@ services:
start_period: 5s start_period: 5s
mailpit: mailpit:
image: axllent/mailpit:v1.30 image: axllent/mailpit:v1.31
environment: environment:
MP_DATABASE: /data/mailpit.db MP_DATABASE: /data/mailpit.db
MP_MAX_MESSAGES: 5000 MP_MAX_MESSAGES: 5000
+1 -1
View File
@@ -9,7 +9,6 @@
**/.git/** **/.git/**
/.github/ /.github/
/.pnpm-store/ /.pnpm-store/
/fluxer_marketing
**/.env **/.env
**/.env.*.local **/.env.*.local
@@ -33,6 +32,7 @@
/fluxer_docs/.astro/ /fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json /fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/ /fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs /fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts /fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/ /fluxer_app/src/features/theme/styles/generated/
-5
View File
@@ -1,7 +1,2 @@
/.github/CODEOWNERS @fluxerapp/developers /.github/CODEOWNERS @fluxerapp/developers
/.github/workflows/ @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). 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. 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
```
+3 -5
View File
@@ -22,17 +22,15 @@ f:docs:
f:gateway: f:gateway:
- changed-files: - changed-files:
- any-glob-to-any-file: fluxer_gateway/**/* - any-glob-to-any-file: fluxer_gateway/**/*
f:marketing:
- changed-files:
- any-glob-to-any-file:
- fluxer_marketing
- packages/i18n/marketing/**/*
f:media_proxy: f:media_proxy:
- changed-files: - changed-files:
- any-glob-to-any-file: fluxer_media_proxy/**/* - any-glob-to-any-file: fluxer_media_proxy/**/*
f:messages: f:messages:
- changed-files: - changed-files:
- any-glob-to-any-file: fluxer_messages/**/* - any-glob-to-any-file: fluxer_messages/**/*
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes: f:snowflakes:
- changed-files: - changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/* - any-glob-to-any-file: fluxer_snowflakes/**/*
+12 -12
View File
@@ -58,13 +58,13 @@ jobs:
outputs: outputs:
build_version: ${{ steps.vars.outputs.build_version }} build_version: ${{ steps.vars.outputs.build_version }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Create token - name: Create token
id: create-token id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
@@ -101,19 +101,19 @@ jobs:
- platform: arm64 - platform: arm64
runner: ubuntu-24.04-arm runner: ubuntu-24.04-arm
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date - name: resolve source date
id: source 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" 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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ github.token }} password: ${{ github.token }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with: with:
context: ${{ inputs.context }} context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }} file: ${{ inputs.dockerfile }}
@@ -141,15 +141,15 @@ jobs:
contents: write contents: write
packages: write packages: write
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -43,13 +43,13 @@ jobs:
outputs: outputs:
build_version: ${{ steps.vars.outputs.build_version }} build_version: ${{ steps.vars.outputs.build_version }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: set variables - name: set variables
id: vars id: vars
run: >- run: >-
@@ -71,20 +71,19 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }} BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: "" PUBLIC_ASSET_BASE_URL: ""
BUNDLE_LOCAL_ASSETS: "true" BUNDLE_LOCAL_ASSETS: "true"
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: prepare docker config - name: prepare docker config
run: >- run: >-
tools/ci/run.sh build-app-proxy tools/ci/run.sh build-app-proxy
--step prepare_docker_config --step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth - name: configure ghcr auth
env: env:
GHCR_USERNAME: ${{ github.actor }} GHCR_USERNAME: ${{ github.actor }}
@@ -131,19 +130,19 @@ jobs:
- platform: arm64 - platform: arm64
runner: ubuntu-24.04-arm runner: ubuntu-24.04-arm
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date - name: resolve source date
id: source 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" 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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with: with:
context: . context: .
file: fluxer_app_proxy/Dockerfile file: fluxer_app_proxy/Dockerfile
@@ -155,7 +154,6 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }} BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }} SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }} SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64 APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }} cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
@@ -173,15 +171,15 @@ jobs:
contents: write contents: write
packages: write packages: write
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+20 -20
View File
@@ -43,13 +43,13 @@ jobs:
outputs: outputs:
build_version: ${{ steps.vars.outputs.build_version }} build_version: ${{ steps.vars.outputs.build_version }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: set variables - name: set variables
id: vars id: vars
run: >- run: >-
@@ -67,18 +67,18 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: prepare docker config - name: prepare docker config
run: >- run: >-
tools/ci/run.sh build-app-proxy tools/ci/run.sh build-app-proxy
--step prepare_docker_config --step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth - name: configure ghcr auth
env: env:
GHCR_USERNAME: ${{ github.actor }} GHCR_USERNAME: ${{ github.actor }}
@@ -131,13 +131,13 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: resolve source date - name: resolve source date
id: source 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" run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
@@ -145,7 +145,7 @@ jobs:
run: >- run: >-
tools/ci/run.sh build-app-proxy tools/ci/run.sh build-app-proxy
--step prepare_docker_config --step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth - name: configure ghcr auth
env: env:
GHCR_USERNAME: ${{ github.actor }} GHCR_USERNAME: ${{ github.actor }}
@@ -178,19 +178,19 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date - name: resolve source date
id: source 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" 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@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with: with:
context: . context: .
file: fluxer_app_proxy/Dockerfile file: fluxer_app_proxy/Dockerfile
@@ -219,15 +219,15 @@ jobs:
contents: write contents: write
packages: write packages: write
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+68 -101
View File
@@ -11,11 +11,6 @@ on:
- stable - stable
- canary - canary
default: stable 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: build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation. description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false required: false
@@ -32,13 +27,12 @@ permissions:
actions: read actions: read
concurrency: concurrency:
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }} group: desktop-${{ inputs.channel }}
cancel-in-progress: true cancel-in-progress: true
env: env:
CHANNEL: ${{ inputs.channel }} CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }} BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
jobs: jobs:
meta: meta:
@@ -53,19 +47,17 @@ jobs:
pub_date: ${{ steps.meta.outputs.pub_date }} pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }} channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_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 }} source_sha: ${{ steps.meta.outputs.source_sha }}
steps: steps:
- name: Checkout source - name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
ref: main ref: main
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Create token - name: Create token
id: create-token id: create-token
@@ -85,7 +77,6 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata --step set_metadata
--channel "${{ inputs.channel }}" --channel "${{ inputs.channel }}"
--test-build "${{ inputs.test_build }}"
matrix: matrix:
name: Resolve build matrix name: Resolve build matrix
@@ -98,12 +89,12 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }} matrix: ${{ steps.set-matrix.outputs.matrix }}
steps: steps:
- name: Checkout source - name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Build platform matrix - name: Build platform matrix
id: set-matrix id: set-matrix
@@ -113,7 +104,7 @@ jobs:
--skip-targets "${{ inputs.skip_targets }}" --skip-targets "${{ inputs.skip_targets }}"
build: build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }}) name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs: needs:
- meta - meta
- matrix - matrix
@@ -137,41 +128,34 @@ jobs:
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }} PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }} PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} 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_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }} DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
PLATFORM: ${{ matrix.platform }} PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }} ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }} ELECTRON_ARCH: ${{ matrix.electron_arch }}
steps: steps:
- name: Checkout CI helpers - name: Checkout CI helpers
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
ref: ${{ needs.meta.outputs.source_sha }} ref: ${{ needs.meta.outputs.source_sha }}
path: _ci path: _ci
- name: Checkout source - name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
ref: ${{ needs.meta.outputs.source_sha }} ref: ${{ needs.meta.outputs.source_sha }}
path: source path: source
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Set up Python (Windows) - name: Set up Python (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with: with:
python-version: "3.13" python-version: "3.14"
- name: Ensure python3 command (Windows) - name: Ensure python3 command (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -196,14 +180,14 @@ jobs:
--step set_workdir_unix --step set_workdir_unix
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: 24 node-version: 26
- name: Set up pnpm via corepack - name: Set up pnpm
run: >- run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop 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) - name: Resolve pnpm store path (Windows)
if: runner.os == 'Windows' if: runner.os == 'Windows'
@@ -247,9 +231,9 @@ jobs:
- name: Set up Rust toolchain (Unix) - name: Set up Rust toolchain (Unix)
if: matrix.platform != 'windows' if: matrix.platform != 'windows'
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: 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') }} 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 - name: Install MSVC ARM64 build tools
@@ -260,7 +244,7 @@ jobs:
- name: Set up MSVC env (Windows) - name: Set up MSVC env (Windows)
if: matrix.platform == 'windows' if: matrix.platform == 'windows'
uses: TheMrMilchmann/setup-msvc-dev@79dac248aac9d0059f86eae9d8b5bfab4e95e97c uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9
with: with:
arch: ${{ matrix.arch == 'arm64' && 'amd64_arm64' || 'amd64' }} arch: ${{ matrix.arch == 'arm64' && 'amd64_arm64' || 'amd64' }}
@@ -302,9 +286,9 @@ jobs:
- name: Set up .NET SDK (Windows) - name: Set up .NET SDK (Windows)
if: matrix.platform == 'windows' if: matrix.platform == 'windows'
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
with: with:
dotnet-version: "8.0.x" dotnet-version: "10.0.x"
- name: Install Velopack CLI - name: Install Velopack CLI
if: matrix.platform == 'windows' if: matrix.platform == 'windows'
@@ -363,7 +347,7 @@ jobs:
- name: Azure login for Artifact Signing - name: Azure login for Artifact Signing
if: matrix.platform == 'windows' if: matrix.platform == 'windows'
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906
with: with:
client-id: ${{ secrets.AZURE_CLIENT_ID }} client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_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 cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_artifacts_unix --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) - name: Normalize updater YAML (macOS)
if: matrix.platform == 'macos' if: matrix.platform == 'macos'
run: >- run: >-
@@ -495,13 +485,23 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step generate_checksums_windows --step generate_checksums_windows
- name: Upload artifacts to S3 handoff - name: Stage build artifacts
id: handoff
run: >- run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop 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: upload:
name: Upload to S3 name: Assemble desktop release assets
if: ${{ !cancelled() && needs.build.result == 'success' }} if: ${{ !cancelled() && needs.build.result == 'success' }}
needs: needs:
- meta - meta
@@ -520,33 +520,26 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.version }} BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }} PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }} PUB_DATE: ${{ needs.meta.outputs.pub_date }}
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }} 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 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: steps:
- name: Checkout source - name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
ref: ${{ needs.meta.outputs.source_sha }} ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Download S3 handoff artifacts - name: Download build artifacts
run: >- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop with:
--step download_handoff path: artifacts
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
- name: Build S3 payload layout (+ manifest.json) - name: Build payload layout (+ manifest.json)
env: env:
VERSION: ${{ needs.meta.outputs.version }} VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }} PUB_DATE: ${{ needs.meta.outputs.pub_date }}
@@ -555,42 +548,27 @@ jobs:
--step build_payload --step build_payload
- name: Prepare GitHub release assets - name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >- run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets --step prepare_release_assets
- name: Publish GitHub release descriptor - name: Upload GitHub release assets
if: needs.meta.outputs.test_build != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
run: >- with:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop name: fluxer-desktop-release-assets
--step publish_release_descriptor path: release_assets
if-no-files-found: error
- name: Upload payload to S3 retention-days: 1
run: >- compression-level: 0
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: Build summary - name: Build summary
run: >- run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_summary --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: publish_release:
name: Publish GitHub desktop 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: needs:
- meta - meta
- upload - upload
@@ -602,26 +580,22 @@ jobs:
env: env:
CHANNEL: ${{ needs.meta.outputs.build_channel }} CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }} 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: steps:
- name: Checkout source - name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
ref: ${{ needs.meta.outputs.source_sha }} ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Download GitHub release assets - name: Download GitHub release assets
run: >- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop with:
--step download_release_assets name: fluxer-desktop-release-assets
path: release_assets
- name: Create token - name: Create token
id: create-token id: create-token
@@ -653,10 +627,3 @@ jobs:
release_args+=(--prerelease) release_args+=(--prerelease)
fi fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}" 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
+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 timeout-minutes: 15
steps: steps:
- name: Checkout fluxer - name: Checkout fluxer
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - 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 permission-pull-requests: write
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
token: ${{ steps.create-token.outputs.token }} token: ${{ steps.create-token.outputs.token }}
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- name: Set up Rust toolchain - name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: "24" node-version: "26"
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
+8 -8
View File
@@ -40,7 +40,7 @@ jobs:
permission-pull-requests: write permission-pull-requests: write
- name: Checkout Weblate branch - name: Checkout Weblate branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with: with:
token: ${{ steps.create-token.outputs.token }} token: ${{ steps.create-token.outputs.token }}
ref: ${{ env.WEBLATE_BRANCH }} ref: ${{ env.WEBLATE_BRANCH }}
@@ -48,17 +48,17 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Rust toolchain - name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: "24" node-version: "26"
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
@@ -77,14 +77,14 @@ jobs:
GH_TOKEN: ${{ steps.create-token.outputs.token }} GH_TOKEN: ${{ steps.create-token.outputs.token }}
run: | run: |
set -euo pipefail 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." echo "No generated catalog changes."
exit 0 exit 0
fi fi
git config user.name "fluxer-ci[bot]" 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 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 commit -m "i18n: compile Weblate catalogs"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git push origin "HEAD:$WEBLATE_BRANCH" git push origin "HEAD:$WEBLATE_BRANCH"
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
permission-pull-requests: write permission-pull-requests: write
- name: Label pull request - name: Label pull request
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13
with: with:
repo-token: ${{ steps.create-token.outputs.token }} repo-token: ${{ steps.create-token.outputs.token }}
configuration-path: .github/labeller.yaml configuration-path: .github/labeller.yaml
+5 -5
View File
@@ -56,15 +56,15 @@ jobs:
contents: write contents: write
packages: read packages: read
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env: env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+81 -56
View File
@@ -28,12 +28,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
- name: Restore ci helper - name: Restore ci helper
@@ -42,7 +42,7 @@ jobs:
with: with:
path: target/debug/fluxer-ci path: target/debug/fluxer-ci
key: >- 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/**') }} 'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper - name: Build ci helper
@@ -55,16 +55,16 @@ jobs:
with: with:
path: target/debug/fluxer-ci path: target/debug/fluxer-ci
key: >- 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/**') }} 'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
@@ -83,12 +83,12 @@ jobs:
PNPM_TEST_WORKSPACE_CONCURRENCY: '2' PNPM_TEST_WORKSPACE_CONCURRENCY: '2'
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
- name: Restore ci helper - name: Restore ci helper
@@ -97,7 +97,7 @@ jobs:
with: with:
path: target/debug/fluxer-ci path: target/debug/fluxer-ci
key: >- 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/**') }} 'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper - name: Build ci helper
@@ -105,12 +105,12 @@ jobs:
run: cargo build --locked --package fluxer-ci run: cargo build --locked --package fluxer-ci
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
@@ -123,12 +123,16 @@ jobs:
with: with:
path: | path: |
fluxer_app/pkgs/libfluxcore fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >- 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', '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.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', '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/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }} 'packages/markdown_parser/rust/src/**') }}
@@ -142,12 +146,16 @@ jobs:
with: with:
path: | path: |
fluxer_app/pkgs/libfluxcore fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >- 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', '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.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', '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/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }} 'packages/markdown_parser/rust/src/**') }}
@@ -156,21 +164,21 @@ jobs:
timeout-minutes: 45 timeout-minutes: 45
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
components: clippy, rustfmt components: clippy, rustfmt
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Cache cargo - name: Cache cargo
@@ -185,11 +193,14 @@ jobs:
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}- rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
- name: Install cargo-deny - name: Install cargo-deny
run: cargo install cargo-deny --version 0.19.6 --locked run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check Rust dependencies - name: Check Rust dependencies
run: cargo deny --locked check -D warnings 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 - name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies run: tools/ci/check-desktop-native-workspaces.sh dependencies
@@ -242,6 +253,9 @@ jobs:
- name: Check formatting - name: Check formatting
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Check formatting (libfluxwebp)
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
- name: Check formatting (desktop native workspaces) - name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt run: tools/ci/check-desktop-native-workspaces.sh fmt
@@ -273,12 +287,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
- name: Cache cargo (gateway NIFs) - name: Cache cargo (gateway NIFs)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
@@ -294,7 +308,7 @@ jobs:
with: with:
path: target/debug/fluxer-ci path: target/debug/fluxer-ci
key: >- 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/**') }} 'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper - name: Build ci helper
@@ -305,7 +319,7 @@ jobs:
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
with: with:
otp-version: '28' otp-version: '28'
rebar3-version: '3.24.0' rebar3-version: '3.27.0'
- name: Restore rebar3 dependencies - name: Restore rebar3 dependencies
id: rebar3-cache id: rebar3-cache
@@ -317,10 +331,13 @@ jobs:
!fluxer_gateway/_build/default/lib/fluxer_gateway/** !fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/** !fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >- 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') }} 'fluxer_gateway/rebar.config') }}
restore-keys: | 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 - name: Check formatting
run: | run: |
@@ -348,7 +365,7 @@ jobs:
!fluxer_gateway/_build/default/lib/fluxer_gateway/** !fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/** !fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >- 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') }} 'fluxer_gateway/rebar.config') }}
knip: knip:
@@ -358,12 +375,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers) - name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with: with:
toolchain: "1.93.0" toolchain: "1.98.1"
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
- name: Restore ci helper - name: Restore ci helper
@@ -372,7 +389,7 @@ jobs:
with: with:
path: target/debug/fluxer-ci path: target/debug/fluxer-ci
key: >- 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/**') }} 'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper - name: Build ci helper
@@ -380,12 +397,12 @@ jobs:
run: cargo build --locked --package fluxer-ci run: cargo build --locked --package fluxer-ci
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
@@ -398,12 +415,16 @@ jobs:
with: with:
path: | path: |
fluxer_app/pkgs/libfluxcore fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >- 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', '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.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', '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/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }} 'packages/markdown_parser/rust/src/**') }}
@@ -417,12 +438,16 @@ jobs:
with: with:
path: | path: |
fluxer_app/pkgs/libfluxcore fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >- 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', '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.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', '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/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }} 'packages/markdown_parser/rust/src/**') }}
@@ -431,15 +456,15 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
@@ -456,15 +481,15 @@ jobs:
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
@@ -490,15 +515,15 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with: with:
node-version: '24' node-version: '26'
cache: 'pnpm' cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
@@ -515,12 +540,12 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with: with:
python-version: "3.13" python-version: "3.14"
- name: Install font tooling - name: Install font tooling
run: python3 -m pip install -r tools/fonts/requirements.txt run: python3 -m pip install -r tools/fonts/requirements.txt
+2
View File
@@ -10,6 +10,7 @@
/.direnv/ /.direnv/
/.fluxer/ /.fluxer/
/.pnpm-store/ /.pnpm-store/
/.vscode/
**/*.css.d.ts **/*.css.d.ts
**/*.tsbuildinfo **/*.tsbuildinfo
@@ -25,6 +26,7 @@
/fluxer_app/.devserver-cache.json /fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/ /fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs /fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts /fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/ /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
+878 -863
View File
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -7,9 +7,9 @@ members = [
"fluxer_gifs", "fluxer_gifs",
"fluxer_svc", "fluxer_svc",
"fluxer_messages", "fluxer_messages",
"fluxer_push",
"fluxer_snowflakes", "fluxer_snowflakes",
"tools/ci", "tools/ci",
"tools/content/update-frozen-snapshot",
"tools/dev", "tools/dev",
"tools/i18n_auto", "tools/i18n_auto",
"fluxer_users", "fluxer_users",
@@ -17,7 +17,6 @@ members = [
"packages/markdown_parser/rust", "packages/markdown_parser/rust",
] ]
exclude = [ exclude = [
"fluxer_marketing",
"packages/markdown_parser/rust/fuzz", "packages/markdown_parser/rust/fuzz",
] ]
resolver = "2" resolver = "2"
+158 -3
View File
@@ -6,18 +6,173 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://fluxer.app/donate"> <a href="https://fluxer.app/download">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a> <img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
<a href="https://docs.fluxer.app"> <a href="https://docs.fluxer.app">
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a> <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"> <a href="./LICENSE">
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a> <img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
</p> </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
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities. Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center"> <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> </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/
+3 -2
View File
@@ -48,7 +48,7 @@
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true, "preset": "recommended",
"complexity": { "complexity": {
"noForEach": "off", "noForEach": "off",
"noImportantStyles": "off", "noImportantStyles": "off",
@@ -83,6 +83,7 @@
} }
}, },
"useConst": "error", "useConst": "error",
"noDescendingSpecificity": "off",
"noNonNullAssertion": "off", "noNonNullAssertion": "off",
"noParameterAssign": "off", "noParameterAssign": "off",
"noRestrictedImports": { "noRestrictedImports": {
@@ -98,7 +99,7 @@
} }
}, },
"a11y": { "a11y": {
"recommended": true, "preset": "recommended",
"useAriaPropsForRole": "error", "useAriaPropsForRole": "error",
"useValidAriaRole": "error", "useValidAriaRole": "error",
"useValidAriaValues": "error", "useValidAriaValues": "error",
+1 -6
View File
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_S3_BUCKET_STATIC=fluxer-static FLUXER_S3_BUCKET_STATIC=fluxer-static
@@ -68,7 +67,6 @@ FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitud
FLUXER_API_PORT=8080 FLUXER_API_PORT=8080
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
FLUXER_API_WORKER_MODE=all_lanes FLUXER_API_WORKER_MODE=all_lanes
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION=true
FLUXER_APP_DEV_PORT=3000 FLUXER_APP_DEV_PORT=3000
FLUXER_APP_PROXY_PORT=8773 FLUXER_APP_PROXY_PORT=8773
FLUXER_STATIC_DIR=fluxer_app/dist FLUXER_STATIC_DIR=fluxer_app/dist
@@ -91,10 +89,6 @@ FLUXER_ADMIN_BASE_PATH=/admin
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback 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_SUDO_MODE_SECRET=dev-sudo-secret
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
@@ -135,6 +129,7 @@ PUBLIC_RELEASE_CHANNEL=canary
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA= FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
AWS_EC2_METADATA_DISABLED=true AWS_EC2_METADATA_DISABLED=true
AWS_ACCESS_KEY_ID=fluxer AWS_ACCESS_KEY_ID=fluxer
AWS_SECRET_ACCESS_KEY=fluxer-secret AWS_SECRET_ACCESS_KEY=fluxer-secret
+18 -10
View File
@@ -79,34 +79,42 @@ deny = [
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" }, { 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 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 crypto API" },
{ crate = "[email protected].7", reason = "transitive dependency requires the older digest API" }, { crate = "[email protected].6", 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 digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older 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 prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" }, { crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior hashbrown 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 HTTP API" }, { 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 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 WASI API" },
{ crate = "[email protected].6", reason = "transitive dependency requires the older randomness API" }, { crate = "[email protected].8", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected].4", reason = "transitive dependency requires the prior randomness API" }, { crate = "[email protected].5", 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 older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior 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 older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" }, { crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected].6", reason = "transitive dependency requires the older digest API" }, { crate = "[email protected].7", 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 digest API" },
{ crate = "s[email protected]0", reason = "transitive dependency requires the older socket API" }, { crate = "s[email protected].0", 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]+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" }, { crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI binding API" },
] ]
skip-tree = [] skip-tree = []
+118 -187
View File
@@ -1,114 +1,64 @@
# Every variable docker-compose.yml reads from this file is named here: # Every variable docker-compose.yml reads, uncommented when it has no default and
# uncommented when it has no default, commented with its default when it has one. # commented with its default when it has one. Compose expands top to bottom, so a
# A name absent from this file is one Compose does not forward, and it reaches a # line using ${...} must sit below every name it reads.
# service only through a Compose override file that adds it to that service's
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
# a Compose edit forgets the matching line here. Compose expands this file from
# top to bottom, so a line written with ${...} has to sit below every name it
# reads.
FLUXER_DOMAIN=chat.example.com FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443 FLUXER_PUBLIC_PORT=443
# The three lines above are the address browsers use, and every endpoint the # The address browsers use. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below decide
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move # which host ports Fluxer binds.
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
# do that, and a non-default port needs the matching one set as well. Both
# complete recipes are written out beside them.
# How browsers reach this instance. # 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
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate. # HTTP on 127.0.0.1:8080. Keep the scheme and port above describing the public
# Point DNS at this host and there is nothing else to configure. # address, not this one.
#
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
# proxy serves, not this local port.
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml #COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on # Where that plain-HTTP port binds. Use 0.0.0.0:8080 only when the proxy is on
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy # another machine, and firewall it to that machine.
# is on another machine, and firewall the port to that machine.
#FLUXER_EDGE_BIND=127.0.0.1:8080 #FLUXER_EDGE_BIND=127.0.0.1:8080
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from # Which hops may set X-Forwarded-For. The default covers private and loopback
# this to the real client address, so IP bans, rate limits and abuse detection # addresses. Set your proxy's address if it reaches Fluxer from a public IP.
# see the caller rather than the proxy. The default covers proxies on private or
# loopback addresses, which is every same-host setup. Set it to your proxy's
# address if it reaches Fluxer from a public IP.
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges #FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
# The origin browsers see, without a trailing slash. Leave it unset and each # The origin browsers see, no trailing slash. Set it when browsers reach the
# service builds one from the three values at the top of this file. Set it and it # instance on a host FLUXER_DOMAIN does not name, and it wins over the three
# wins: every service reads the host, the scheme and the port out of it and # values above. Scheme, host and optional port only. It does not move the
# ignores those three names. Use it when browsers reach the instance on a host # published ports.
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
# and an optional port and nothing after them, or the services refuse to start.
# It does not move the edge listener or the published ports either, so set the
# publish below to the port written here.
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com #FLUXER_PUBLIC_ORIGIN=https://chat.example.com
# Overrides the address the edge listens on inside its container. Compose builds # The address the edge listens on inside its container. Both proxy overlays set
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps # this themselves, so a value here is ignored under either. Include the scheme.
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
# site by host and ignores the port in the Host header, so a request arriving on
# a non-default published port still lands on this site. Put a port in this value
# only if you also publish that same container port below, or nothing will be
# listening where the publish points. Honoured in the default mode only:
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
# literal :80, and Compose lets the last file win, so a value here is discarded
# under either overlay with no warning. Set it for an unusual default-mode
# layout, such as serving several hostnames. Write the scheme into it: a bare
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com #FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
# The old name for the value above. It is read only when # The old name for the line above, read only when it is unset.
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
#FLUXER_CADDY_SITE_ADDRESS= #FLUXER_CADDY_SITE_ADDRESS=
# Host side of the edge's publishes, and the only two names that decide which # Host ports. Container 80 handles the redirect and the certificate challenge,
# host ports Fluxer binds. The container side is fixed. Container 80 carries the # container 443 the TLS site. FLUXER_HTTPS_PORT moves TCP and UDP together, since
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https # HTTP/3 needs both. Both accept a bind address. Give them different host ports.
# scheme, and the site itself under an http one. Container 443 carries the TLS
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
# HTTP/3 needs both on the same port. Both take an optional bind address in front
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
# them different host ports: the same host port on both is two publishes of one
# port and the edge refuses to start.
#FLUXER_HTTP_PORT=80 #FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443 #FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80 #FLUXER_HTTP_PORT=127.0.0.1:80
#FLUXER_HTTPS_PORT=127.0.0.1:443 #FLUXER_HTTPS_PORT=127.0.0.1:443
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME # HTTPS on 8443. Host 80 stays published for the certificate challenge, which
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the # only ever arrives on public 80 or 443. Serve your own certificate if nothing
# certificate is issued if a router in front forwards public 80 to this host and # forwards those.
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
# cannot.
#FLUXER_PUBLIC_PORT=8443 #FLUXER_PUBLIC_PORT=8443
#FLUXER_HTTPS_PORT=8443 #FLUXER_HTTPS_PORT=8443
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing # Plain HTTP on 19080. Nothing binds host 80, and the last line parks the idle
# binds host 80. Under an http scheme nothing listens on container 443, so the # 443 publish on loopback.
# last line parks that publish on loopback for a host that wants 443 for
# something else. Drop it and 443 is published and idle, which is what earlier
# releases did.
#FLUXER_PUBLIC_SCHEME=http #FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080 #FLUXER_PUBLIC_PORT=19080
#FLUXER_HTTP_PORT=19080 #FLUXER_HTTP_PORT=19080
#FLUXER_HTTPS_PORT=127.0.0.1:443 #FLUXER_HTTPS_PORT=127.0.0.1:443
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all. # A tunnel needs no HTTPS publish. tunnel.compose.yml ships beside this file and
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports # leaves one loopback HTTP publish. Needs Compose 2.24.4 or newer.
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
# still moves that one publish. Set the line below and plain docker compose
# commands pick the file up, or add it to your own -f flags if you pass any. The
# file uses the !override tag, which needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml #COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
FLUXER_REGISTRY_OWNER=fluxerapp FLUXER_REGISTRY_OWNER=fluxerapp
@@ -117,11 +67,8 @@ FLUXER_IMAGE_TAG=v1
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
MEILI_MASTER_KEY=CHANGE_ME MEILI_MASTER_KEY=CHANGE_ME
# The stack ships its own Postgres and its own object store, and points at both # Set these to run Postgres or the object store outside the stack. Backing up a
# by service name. Set these to run either one outside the stack. Leave them # store you moved out is yours to arrange, and an upgrade skips it.
# unset and the bundled services are used. Taking a service out of the stack
# means an upgrade skips the backup step that reaches into it, and backing that
# store up belongs to whoever runs it.
#FLUXER_POSTGRES_HOST=db.example.com #FLUXER_POSTGRES_HOST=db.example.com
#FLUXER_POSTGRES_PORT=5432 #FLUXER_POSTGRES_PORT=5432
#FLUXER_POSTGRES_DATABASE=fluxer #FLUXER_POSTGRES_DATABASE=fluxer
@@ -131,19 +78,15 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com #FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
#FLUXER_S3_REGION=eu-central-1 #FLUXER_S3_REGION=eu-central-1
#FLUXER_S3_FORCE_PATH_STYLE=false #FLUXER_S3_FORCE_PATH_STYLE=false
# Bucket names. The bundled object store creates whichever names these hold, so # Bucket names. The bundled store creates these. An outside store needs them to
# the two stay in step. An object store outside the stack needs the buckets to
# exist already. # exist already.
#FLUXER_S3_BUCKET_CDN=fluxer #FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads #FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports #FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests #FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
# The rest of the bundled services, pointed somewhere else the same way. Leave a # The other bundled services, pointed elsewhere. Removing a service from the
# line unset and the service in the stack is used. Taking a service out of the # stack belongs in an override file, since an upgrade replaces docker-compose.yml.
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
# replaces docker-compose.yml.
#FLUXER_KV_URL=redis://cache.example.com:6379/0 #FLUXER_KV_URL=redis://cache.example.com:6379/0
#FLUXER_NATS_URL=nats://mq.example.com:4222 #FLUXER_NATS_URL=nats://mq.example.com:4222
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222 #FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
@@ -151,24 +94,27 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_SEARCH_URL=https://search.example.com #FLUXER_SEARCH_URL=https://search.example.com
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880 #FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
# Voice off. The livekit service still runs until an override file takes it out. # Voice off. The livekit service still runs until an override removes it.
#FLUXER_LIVEKIT_ENABLED=false #FLUXER_LIVEKIT_ENABLED=false
# Optional systems, each off unless the instance is configured for it. # Optional systems, each off unless configured.
#FLUXER_SMS_ENABLED=false #FLUXER_SMS_ENABLED=false
#FLUXER_STRIPE_ENABLED=false #FLUXER_STRIPE_ENABLED=false
#FLUXER_NCMEC_ENABLED=false #FLUXER_NCMEC_ENABLED=false
#FLUXER_CLAMAV_ENABLED=false #FLUXER_CLAMAV_ENABLED=false
# The client address. Set the header name a proxy in front actually writes, and # Outside lookups, off unless turned on. The Tor exit list comes from
# turn the trust off when nothing sits in front, because a trusted header an # onionoo.torproject.org and the breached password check asks
# attacker can set is a spoofed client address. # api.pwnedpasswords.com.
#FLUXER_TOR_EXIT_LIST_ENABLED=true
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
# The client address. Name the header your proxy actually writes, and turn the
# trust off when nothing sits in front.
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip #FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
#FLUXER_TRUST_CLIENT_IP_HEADER=true #FLUXER_TRUST_CLIENT_IP_HEADER=true
# How much the services write. trace, debug, info, warn, error or fatal. Every # How much the services write. trace, debug, info, warn, error or fatal.
# service names the object storage endpoint and its addressing at info on start,
# so a bucket that answers 404 is visible without raising this.
#LOG_LEVEL=debug #LOG_LEVEL=debug
FLUXER_S3_ACCESS_KEY=fluxer FLUXER_S3_ACCESS_KEY=fluxer
@@ -183,32 +129,49 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
# The token every service sends to NATS. The bundled NATS runs without # The token every service sends to NATS. The bundled NATS needs none, so this
# authentication, so this stays empty unless a Compose override points the stack # stays empty unless an override points at an external one.
# at an external NATS that requires a token. Compose forwards the name to every
# container that connects.
#FLUXER_NATS_AUTH_TOKEN= #FLUXER_NATS_AUTH_TOKEN=
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it # Defaults to admin@ followed by FLUXER_DOMAIN. Set it if that mailbox does not
# only if that mailbox does not exist. # exist.
#[email protected] #[email protected]
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the # Passkeys follow FLUXER_DOMAIN. Set these only if browsers use another host.
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID # Changing the RP ID invalidates every passkey registered against the old value.
# invalidates every passkey already registered against the old value.
#FLUXER_PASSKEY_RP_ID=chat.example.com #FLUXER_PASSKEY_RP_ID=chat.example.com
#FLUXER_PASSKEY_RP_NAME=Fluxer #FLUXER_PASSKEY_RP_NAME=Fluxer
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com #FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080 #FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these # Notification jobs the push container holds at once, 1 to 1000000.
# only when a browser must reach an origin the defaults do not cover, such as a #FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several # Provider requests the push container sends at once, 1 to 65536.
# sources with spaces or commas. Every one of them is empty by default, and the #FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
# three carrying a value below are illustrations, not defaults.
# 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. Start at report. media-proxy reads these
# at start, so apply with docker compose up -d media-proxy.
#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_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881 #FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com #FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
@@ -220,39 +183,29 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_CSP_EXTRA_WORKER_SRC= #FLUXER_CSP_EXTRA_WORKER_SRC=
#FLUXER_CSP_EXTRA_MANIFEST_SRC= #FLUXER_CSP_EXTRA_MANIFEST_SRC=
# One report-uri for Content-Security-Policy violation reports. Empty leaves the # One report-uri for CSP violation reports. Empty leaves the directive off.
# directive off the header.
#FLUXER_CSP_REPORT_URI= #FLUXER_CSP_REPORT_URI=
# Allow the SSO identity provider to resolve to a private or internal address. # Let the SSO provider resolve to a private address. Off by default, so a
# Off by default: the API refuses to call non-public addresses so a misconfigured # misconfigured provider URL cannot reach internal services. Turn it on only for
# provider URL cannot be used to reach internal services. Turn it on only when the # a provider on your own network.
# 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.
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true #FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as # These reach both LiveKit and the api. Change them together.
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
LIVEKIT_API_KEY=fluxer LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME LIVEKIT_API_SECRET=CHANGE_ME
# The URL browsers use for voice signalling. Compose builds it from # The URL browsers use for voice signalling. Built from the public origin plus
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and # /livekit. Set it only when LiveKit is served from another host.
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
# leading http to ws itself. Set it only when LiveKit is served from another
# host.
#FLUXER_LIVEKIT_URL= #FLUXER_LIVEKIT_URL=
# Media ports. LiveKit advertises these in ICE candidates, so the host must # Media ports. LiveKit advertises these, so forward the same numbers.
# forward the same numbers.
#FLUXER_LIVEKIT_TCP_PORT=7881 #FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882 #FLUXER_LIVEKIT_UDP_PORT=7882
# LiveKit finds the address browsers dial by asking a STUN server. A host that # LiveKit finds its public address over STUN. A host that cannot reach one stops
# cannot reach one over UDP stops with "could not resolve external IP", and the # with "could not resolve external IP", so set the address by hand instead, or
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set # point STUN elsewhere.
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
# server to keep the lookup and leave Google out of it.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false #FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10 #FLUXER_LIVEKIT_NODE_IP=203.0.113.10
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302 #FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
@@ -279,11 +232,9 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY= FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true FLUXER_DISCOVERY_ENABLED=true
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and # Container memory. These are ceilings, not allocations, and the defaults suit a
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB. # 16 GB host. The reservations bias the kernel away from reclaiming from services
# The four reservations are cgroup memory.low, which biases the kernel away from # whose death takes the instance down. Lower the limits on a smaller host.
# reclaiming from the services whose death takes the whole instance down. They do
# not reserve anything. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb #FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb #FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb #FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
@@ -300,6 +251,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb #FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb #FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb #FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_PUSH_MEMORY_LIMIT=256mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb #FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb #FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb #FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
@@ -314,75 +266,54 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb #FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
#FLUXER_ADMIN_MEMORY_LIMIT=256mb #FLUXER_ADMIN_MEMORY_LIMIT=256mb
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT, # Meilisearch indexing memory. Keep it well under the container limit above.
# which is the container ceiling the indexer shares with the search process.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb #FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
# SeaweedFS heap ceiling. Go collects against this value instead of against the # SeaweedFS heap ceiling. Go cannot see the container limit, so without this an
# container limit, which it cannot see, so without it an upload burst grows the # upload burst gets the container OOM-killed. Keep it near three quarters of
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container # FLUXER_SEAWEEDFS_MEMORY_LIMIT and raise both together.
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
# together: the peak is the parts of one upload in flight at once, which is 25 MB
# times 20 for a 500 MB attachment.
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB #FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
# Node sizes its own heap from the container memory limit by default, at roughly # Node sizes its heap from the container limit by default. Leave these unset
# 55 percent of it, which always leaves room for the buffers and stacks that live # unless you need to pin it. A heap ceiling above the container limit gets the
# outside the heap. Leave these unset unless you have a reason to pin the value. # container OOM-killed instead of reporting a heap error.
# Any value set here must stay well below the container limit above: a heap ceiling
# above the container limit makes the kernel OOM-kill the container (exit 137, no
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
#FLUXER_API_NODE_HEAP_MB=1792 #FLUXER_API_NODE_HEAP_MB=1792
#FLUXER_WORKER_NODE_HEAP_MB=1792 #FLUXER_WORKER_NODE_HEAP_MB=1792
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT: # Bundled Postgres tuning. Keep it consistent with the memory limit above. This
# budget roughly shared_buffers + (server max_connections x 12 MB) + # is the server setting, not the per-service pool sizes.
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
# pool sizes used by the api, worker and shards.
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150 #FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB #FLUXER_POSTGRES_SHARED_BUFFERS=512MB
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB #FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
#FLUXER_POSTGRES_WORK_MEM=8MB #FLUXER_POSTGRES_WORK_MEM=8MB
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB #FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB #FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
#FLUXER_POSTGRES_SHM_SIZE=1gb
# The bundled Valkey holds durable state as well as cache. The bulk message # The bundled Valkey holds durable state as well as cache, so it runs with an
# deletion queue and the account deletion queue are sorted sets with no expiry, # append-only file and with noeviction, which fails an over-limit write instead
# and nothing else stores the first of the two. It therefore runs with an # of dropping queued work. Change the policy only if that state lives elsewhere.
# append-only file on a named volume and with noeviction, so an over-limit write
# fails loudly instead of silently deleting queued work. Distributed locks all
# carry a TTL and are not what the durability is for. Only change the policy if
# you have moved that durable state elsewhere.
#FLUXER_VALKEY_MAXMEMORY=192mb #FLUXER_VALKEY_MAXMEMORY=192mb
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction #FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
# The gateway derives its BEAM scheduler count from the container CPU quota, # The gateway derives its scheduler count from the CPU quota, clamped here. One
# clamped to this range. The floor matters: a single scheduler lets one blocking # scheduler lets a single blocking operation stall every websocket on the node.
# operation stall every websocket on the node. The ceiling stops a large host
# from starting far more schedulers than the container can actually use.
#FLUXER_ERLANG_SCHEDULERS_MIN=2 #FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16 #FLUXER_ERLANG_SCHEDULERS_MAX=16
# In-flight request ceiling for the four services Compose forwards it to: the # In-flight request ceiling for the users and messages routers and their shards.
# users and messages routers and their shards. The Rust built-in defaults are 192 # One value replaces the built-in default on all of them, so size it for the
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service # busiest. Too low a value rejects requests rather than slowing them, and the api
# Compose does not forward this to. # turns that into a 503.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20 #FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
# The api and the Rust services name their fixed Postgres statement shapes so the # Named prepared statements need a session that outlives the transaction, so set
# server can reuse their plans. Named prepared statements require a session that # this to false behind a transaction-pooling connection pooler. The bundled
# outlives the transaction, so set this to false if you put a transaction-pooling # compose talks to Postgres directly, where the default is correct.
# connection pooler such as PgBouncer in front of Postgres. One setting governs
# every service. The bundled compose talks to Postgres directly, where naming is
# a win and the default is correct.
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true #FLUXER_POSTGRES_PREPARED_STATEMENTS=true
# The api bounds how long a client may take to send a request. The header timeout # How long a client may take to send a request. The header timeout covers the
# covers the request line and headers only, while the request timeout covers the # request line and headers, the request timeout the whole exchange, and the first
# whole exchange, so a slow uploader is bounded by the second value and not by # is clamped down to the second. Milliseconds, 1000 to 3600000.
# the first. Raise both if you front large uploads or serve clients on high
# latency links. The header timeout is clamped down to the request timeout, so
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
#FLUXER_API_HEADERS_TIMEOUT_MS=30000 #FLUXER_API_HEADERS_TIMEOUT_MS=30000
#FLUXER_API_REQUEST_TIMEOUT_MS=120000 #FLUXER_API_REQUEST_TIMEOUT_MS=120000
+43 -15
View File
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for} FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000} FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000} FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0} FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222} FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
@@ -44,7 +46,6 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}" FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer} FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads} FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports} FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests} FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env} AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
@@ -97,6 +98,7 @@ x-fluxer-env: &fluxer-env
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env} FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env} FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env} FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ${FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64:-}
FLUXER_ADMIN_SECRET_KEY_BASE: ${FLUXER_ADMIN_SECRET_KEY_BASE:?set FLUXER_ADMIN_SECRET_KEY_BASE in .env} FLUXER_ADMIN_SECRET_KEY_BASE: ${FLUXER_ADMIN_SECRET_KEY_BASE:?set FLUXER_ADMIN_SECRET_KEY_BASE in .env}
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env} FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
@@ -122,7 +124,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
services: services:
edge: edge:
image: caddy:2.10-alpine image: caddy:2.11-alpine
deploy: deploy:
resources: resources:
limits: limits:
@@ -186,7 +188,7 @@ services:
-c autovacuum_vacuum_cost_limit=2000 -c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on -c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements -c shared_preload_libraries=pg_stat_statements
shm_size: 256mb shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
environment: environment:
POSTGRES_DB: fluxer POSTGRES_DB: fluxer
POSTGRES_USER: fluxer POSTGRES_USER: fluxer
@@ -200,7 +202,7 @@ services:
retries: 10 retries: 10
valkey: valkey:
image: valkey/valkey:8.1-alpine image: valkey/valkey:9.1-alpine
deploy: deploy:
resources: resources:
limits: limits:
@@ -236,7 +238,7 @@ services:
retries: 10 retries: 10
meilisearch: meilisearch:
image: getmeili/meilisearch:v1.12 image: getmeili/meilisearch:v1.53
deploy: deploy:
resources: resources:
limits: limits:
@@ -246,6 +248,7 @@ services:
environment: environment:
MEILI_ENV: production MEILI_ENV: production
MEILI_NO_ANALYTICS: "true" MEILI_NO_ANALYTICS: "true"
MEILI_UPGRADE_DB: "true"
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb} MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env} MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes: volumes:
@@ -257,7 +260,7 @@ services:
retries: 10 retries: 10
seaweedfs: seaweedfs:
image: chrislusf/seaweedfs:4.34 image: chrislusf/seaweedfs:4.47
deploy: deploy:
resources: resources:
limits: limits:
@@ -266,7 +269,7 @@ services:
networks: [fluxer] networks: [fluxer]
environment: environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB} GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
command: ["server", "-s3", "-dir=/data"] command: ["server", "-s3", "-dir=/data", "-master.telemetry=false"]
volumes: volumes:
- seaweedfs-data:/data - seaweedfs-data:/data
healthcheck: healthcheck:
@@ -277,7 +280,7 @@ services:
start_period: 60s start_period: 60s
seaweedfs-init: seaweedfs-init:
image: chrislusf/seaweedfs:4.34 image: chrislusf/seaweedfs:4.47
deploy: deploy:
resources: resources:
limits: limits:
@@ -291,14 +294,13 @@ services:
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env} FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer} FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads} FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports} FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests} FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
entrypoint: entrypoint:
- /bin/sh - /bin/sh
- -c - -c
- > - >
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS"; buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
missing="$$buckets"; missing="$$buckets";
for attempt in $$(seq 1 60); do for attempt in $$(seq 1 60); do
if ! nc -z seaweedfs 9333 2>/dev/null; then if ! nc -z seaweedfs 9333 2>/dev/null; then
@@ -413,7 +415,6 @@ services:
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB} NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
FLUXER_API_WORKER_MODE: all_lanes FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true" FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25" FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"] test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
@@ -473,11 +474,38 @@ services:
FLUXER_MEDIA_PROXY_MODE: upload FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3 FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_MEDIA_PROXY_CORS_MODE: ${FLUXER_MEDIA_PROXY_CORS_MODE:-off}
FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS: ${FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}}
FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE: ${FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE:-off}
FLUXER_S3_READ_SIGNED: "true" FLUXER_S3_READ_SIGNED: "true"
depends_on: depends_on:
seaweedfs-init: {condition: service_completed_successfully} seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_healthy} nats: {condition: service_healthy}
push:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-push:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_PUSH_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_PUSH_SERVICE_HOST: 0.0.0.0
FLUXER_PUSH_SERVICE_PORT: "8126"
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: "${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}"
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: "${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}"
healthcheck:
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
nats: {condition: service_healthy}
api: {condition: service_healthy}
static-proxy: static-proxy:
<<: *fluxer-service <<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1} image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
@@ -565,7 +593,7 @@ services:
<<: *fluxer-env <<: *fluxer-env
FLUXER_SVC_NAME: users FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: router FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}" FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck healthcheck: *fluxer-svc-healthcheck
depends_on: depends_on:
nats: {condition: service_healthy} nats: {condition: service_healthy}
@@ -583,7 +611,7 @@ services:
FLUXER_SVC_MODE: shard FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0" FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20" FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}" FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck healthcheck: *fluxer-svc-healthcheck
depends_on: depends_on:
nats: {condition: service_healthy} nats: {condition: service_healthy}
@@ -633,7 +661,7 @@ services:
<<: *fluxer-env <<: *fluxer-env
FLUXER_SVC_NAME: messages FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}" FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck healthcheck: *fluxer-svc-healthcheck
depends_on: depends_on:
nats: {condition: service_healthy} nats: {condition: service_healthy}
@@ -651,7 +679,7 @@ services:
FLUXER_SVC_MODE: shard FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0" FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20" FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}" FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck healthcheck: *fluxer-svc-healthcheck
depends_on: depends_on:
nats: {condition: service_healthy} nats: {condition: service_healthy}
+13 -13
View File
@@ -9,32 +9,32 @@ build = "build.rs"
[dependencies] [dependencies]
anyhow = "1.0.104" anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] } axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1" base64 = "0.23.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] } chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.1" cookie = "0.18.2"
fluxer_common = { path = "../fluxer_common" } fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0" hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] } maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10" rand = "0.10"
regress = "0.11" regress = "0.12"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.150" serde_json = "1.0.151"
sha2 = "0.11.0" sha2 = "0.11.0"
time = { version = "0.3.47", features = ["formatting", "parsing"] } time = { version = "0.3.55", features = ["formatting", "parsing"] }
tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal"] } tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal"] }
tower = { version = "0.5.3", features = ["util"] } 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 = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5" url = "2.5"
urlencoding = "2.1.3" urlencoding = "2.1.3"
progenitor-client = { version = "0.14.0", default-features = false } progenitor-client = { version = "0.15.0", default-features = false }
[build-dependencies] [build-dependencies]
openapiv3 = "2.2.0" openapiv3 = "2.2.0"
prettyplease = "0.2" prettyplease = "0.3"
progenitor = { version = "0.14.0", default-features = false } progenitor = { version = "0.15.0", default-features = false }
serde_json = "1" serde_json = "1"
sha2 = "0.11.0" sha2 = "0.11.0"
syn = "2" syn = "3"
+3 -3
View File
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
FROM rust:1-bookworm AS builder FROM rust:1-trixie AS builder
ARG BUILD_VERSION="" ARG BUILD_VERSION=""
ARG TARGETARCH ARG TARGETARCH
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \ && 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/* && rm -rf /var/lib/apt/lists/*
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected] RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
@@ -57,7 +57,7 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \ && ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
&& echo "Latin-core fonts bundled successfully" && echo "Latin-core fonts bundled successfully"
FROM debian:bookworm-slim AS runtime FROM debian:trixie-slim AS runtime
ARG BUILD_VERSION="" ARG BUILD_VERSION=""
ARG SOURCE_SHA="" ARG SOURCE_SHA=""
+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 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"); 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(); let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional); 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 mut generator = progenitor::Generator::new(&settings);
let tokens = generator let tokens = generator
.generate_tokens(&spec) .generate_tokens(&spec)
.expect("failed to generate admin API client"); .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"), &syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens"),
); ));
let output_path = out_dir.join("admin_api_generated.rs"); let output_path = out_dir.join("admin_api_generated.rs");
fs::write(&output_path, content).expect("failed to write generated API code"); 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 { struct Face {
css_family: String, css_family: String,
weight: u64, weight: u64,
File diff suppressed because it is too large. Load diff
-2
View File
@@ -79,7 +79,6 @@ pub const REPORT_RESOLVE: &str = "report:resolve";
pub const REPORT_VIEW: &str = "report:view"; pub const REPORT_VIEW: &str = "report:view";
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii"; pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send"; 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_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_DELETE: &str = "user:delete"; pub const USER_DELETE: &str = "user:delete";
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious"; pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
@@ -192,7 +191,6 @@ pub const ALL_ACLS: &[&str] = &[
REPORT_VIEW, REPORT_VIEW,
REPORT_VIEW_REPORTER_PII, REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND, SYSTEM_DM_SEND,
SYSTEM_HEAP_SNAPSHOT,
USER_CANCEL_BULK_MESSAGE_DELETION, USER_CANCEL_BULK_MESSAGE_DELETION,
USER_DELETE, USER_DELETE,
USER_DISABLE_SUSPICIOUS, USER_DISABLE_SUSPICIOUS,
+2 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry}; use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
@@ -35,7 +35,7 @@ impl AdminApiClient {
} }
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> { pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
let key_id = generated_types::SnowflakeType::from(key_id.to_owned()); let key_id = snowflake(key_id);
self.generated() self.generated()
.delete_admin_api_key(&key_id) .delete_admin_api_key(&key_id)
.await .await
+10 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse}; use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
@@ -12,7 +12,7 @@ impl AdminApiClient {
include_attachments: bool, include_attachments: bool,
) -> ApiResult<Archive> { ) -> ApiResult<Archive> {
let body = generated_types::AdminArchiveCreateRequest { let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true), include_attachments,
}; };
let response = self let response = self
.generated() .generated()
@@ -28,7 +28,7 @@ impl AdminApiClient {
include_attachments: bool, include_attachments: bool,
) -> ApiResult<Archive> { ) -> ApiResult<Archive> {
let body = generated_types::AdminArchiveCreateRequest { let body = generated_types::AdminArchiveCreateRequest {
include_attachments: include_attachments.then_some(true), include_attachments,
}; };
let response = self let response = self
.generated() .generated()
@@ -78,15 +78,17 @@ impl AdminApiClient {
subject_id: &str, subject_id: &str,
archive_id: &str, archive_id: &str,
) -> ApiResult<ArchiveDownloadUrlResponse> { ) -> ApiResult<ArchiveDownloadUrlResponse> {
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self let response = self
.generated() .generated()
.get_admin_archive_download(subject_type, subject_id, archive_id) .get_admin_archive_download(
subject_type,
&snowflake(subject_id),
&snowflake(archive_id),
)
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner()) self.generated_value(response.into_inner())
} }
} }
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
+2 -5
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types; use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult}; use super::client::{AdminApiClient, ApiResult};
@@ -13,10 +13,7 @@ impl AdminApiClient {
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() }; let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
let response = self let response = self
.generated() .generated()
.purge_admin_guild_assets( .purge_admin_guild_assets(&snowflake(guild_id), &body)
&generated_types::SnowflakeType::from(guild_id.to_owned()),
&body,
)
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner()) self.generated_value(response.into_inner())
+82 -62
View File
@@ -3,8 +3,6 @@
use crate::api::generated::types as generated_types; use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult}; use super::client::{AdminApiClient, ApiError, ApiResult};
#[cfg(test)]
use super::types::AuditLogEntry;
use super::types::AuditLogsListResponse; use super::types::AuditLogsListResponse;
pub struct SearchAuditLogsParams { pub struct SearchAuditLogsParams {
@@ -12,10 +10,11 @@ pub struct SearchAuditLogsParams {
pub admin_user_id: Option<String>, pub admin_user_id: Option<String>,
pub target_id: Option<String>, pub target_id: Option<String>,
pub target_type: Option<String>, pub target_type: Option<String>,
pub access: Option<String>,
pub sort_by: Option<String>, pub sort_by: Option<String>,
pub sort_order: Option<String>, pub sort_order: Option<String>,
pub limit: u32, pub limit: u32,
pub offset: u32, pub offset: u64,
} }
impl AdminApiClient { impl AdminApiClient {
@@ -23,6 +22,12 @@ impl AdminApiClient {
&self, &self,
params: &SearchAuditLogsParams, params: &SearchAuditLogsParams,
) -> ApiResult<AuditLogsListResponse> { ) -> ApiResult<AuditLogsListResponse> {
let access = params
.access
.as_deref()
.map(audit_access)
.transpose()?
.map(|value| value.to_string());
let sort_by = params let sort_by = params
.sort_by .sort_by
.as_deref() .as_deref()
@@ -38,65 +43,29 @@ impl AdminApiClient {
let limit = params.limit.to_string(); let limit = params.limit.to_string();
let offset = params.offset.to_string(); let offset = params.offset.to_string();
let query_params = [ let query_params = [
( ("q", params.query.as_deref().unwrap_or_default()),
"q",
nonempty_string(params.query.as_deref()).unwrap_or_default(),
),
( (
"admin_user_id", "admin_user_id",
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(), params.admin_user_id.as_deref().unwrap_or_default(),
), ),
( (
"target_type", "target_type",
nonempty_string(params.target_type.as_deref()).unwrap_or_default(), params.target_type.as_deref().unwrap_or_default(),
), ),
( ("target_id", params.target_id.as_deref().unwrap_or_default()),
"target_id", ("access", access.as_deref().unwrap_or_default()),
nonempty_string(params.target_id.as_deref()).unwrap_or_default(), ("sort_by", sort_by.as_deref().unwrap_or_default()),
), ("sort_order", sort_order.as_deref().unwrap_or_default()),
("sort_by", sort_by.unwrap_or_default()), ("limit", limit.as_str()),
("sort_order", sort_order.unwrap_or_default()), ("offset", offset.as_str()),
("limit", limit),
("offset", offset),
]; ];
let query_params: Vec<(&str, &str)> = query_params
.iter()
.map(|(key, value)| (*key, value.as_str()))
.collect();
self.get("/admin/audit-logs", Some(&query_params)).await self.get("/admin/audit-logs", Some(&query_params)).await
} }
} }
#[cfg(test)] fn audit_access(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsAccess> {
fn audit_logs_response( generated_types::ListAdminAuditLogsAccess::try_from(value)
response: generated_types::AuditLogsListResponseSchema, .map_err(|e| ApiError::Parse(e.to_string()))
) -> ApiResult<AuditLogsListResponse> {
Ok(AuditLogsListResponse {
logs: response.logs.into_iter().map(audit_log_entry).collect(),
total: crate::api::generated::number_to_u64(response.total, "total")
.map_err(ApiError::Parse)?,
})
}
#[cfg(test)]
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
AuditLogEntry {
log_id: String::from(entry.log_id),
admin_user_id: String::from(entry.admin_user_id),
admin_user: None,
action: entry.action,
target_id: entry.target_id,
target_type: entry.target_type,
target_user: None,
target_guild: None,
target_channel: None,
related_users: Default::default(),
related_guilds: Default::default(),
related_channels: Default::default(),
audit_log_reason: entry.audit_log_reason,
metadata: entry.metadata,
created_at: entry.created_at,
}
} }
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> { fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
@@ -113,12 +82,6 @@ fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLog
.map_err(|e| ApiError::Parse(e.to_string())) .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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -133,12 +96,69 @@ mod tests {
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc"); 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] #[test]
fn rejects_lossy_audit_totals() { fn rejects_lossy_audit_totals() {
let response = generated_types::AuditLogsListResponseSchema { for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
logs: Vec::new(), let response = serde_json::json!({"logs": [], "total": total});
total: 1.5, assert!(serde_json::from_value::<AuditLogsListResponse>(response).is_err());
}; }
assert!(audit_logs_response(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);
} }
} }
+167 -97
View File
@@ -1,56 +1,77 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult}; use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
impl AdminApiClient { impl AdminApiClient {
pub async fn ban_email(&self, email: &str) -> ApiResult<()> { pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
"email", "email",
generated_types::BanEmailRequest { generated_types::AdminBlocklistEntryCreateRequest {
email: generated_types::EmailType::from(email.to_owned()), subtype_1: Some(generated_types::BanEmailRequest {
} email: generated_types::EmailType::from(email.to_owned()),
.into(), }),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn unban_email(&self, email: &str) -> ApiResult<()> { pub async fn unban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("email", email, None).await self.delete_blocklist_entry("email", email, None, audit_log_reason)
.await
} }
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> { pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("email", email, None).await self.check_blocklist_entry("email", email, None).await
} }
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> { pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
"ip", "ip",
generated_types::BanIpRequest { ip: ip.to_owned() }.into(), generated_types::AdminBlocklistEntryCreateRequest {
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> { pub async fn unban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("ip", ip, None).await self.delete_blocklist_entry("ip", ip, None, audit_log_reason)
.await
} }
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> { pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("ip", ip, None).await self.check_blocklist_entry("ip", ip, None).await
} }
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> { pub async fn add_suspicious_email_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST, SUSPICIOUS_EMAIL_DOMAIN_LIST,
suspicious_email_domain_request(domain)?.into(), generated_types::AdminBlocklistEntryCreateRequest {
subtype_2: Some(suspicious_email_domain_request(domain)?),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> { pub async fn remove_suspicious_email_domain(
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None) &self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason)
.await .await
} }
@@ -59,66 +80,90 @@ impl AdminApiClient {
.await .await
} }
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> { pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
"phrase", "phrase",
generated_types::BanPhraseRequest { generated_types::AdminBlocklistEntryCreateRequest {
phrase: phrase.to_owned(), subtype_3: Some(generated_types::BanPhraseRequest {
} phrase: phrase.to_owned(),
.into(), }),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> { pub async fn unban_phrase(
self.delete_blocklist_entry("phrase", phrase, None).await &self,
phrase: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("phrase", phrase, None, audit_log_reason)
.await
} }
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> { pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("phrase", phrase, None).await self.check_blocklist_entry("phrase", phrase, None).await
} }
pub async fn ban_url(&self, url: &str) -> ApiResult<()> { pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
"url", "url",
generated_types::BanUrlRequest { generated_types::AdminBlocklistEntryCreateRequest {
category: None, subtype_4: Some(generated_types::BanUrlRequest {
notes: None, category: None,
severity: None, notes: None,
source_url: None, severity: None,
url: url.to_owned(), source_url: None,
} url: url.to_owned(),
.into(), }),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn unban_url(&self, url: &str) -> ApiResult<()> { pub async fn unban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.delete_blocklist_entry("url", url, None).await self.delete_blocklist_entry("url", url, None, audit_log_reason)
.await
} }
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> { pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry("url", url, None).await self.check_blocklist_entry("url", url, None).await
} }
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> { pub async fn ban_url_domain(
&self,
domain: &str,
match_subdomains: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
"url-domain", "url-domain",
generated_types::BanUrlDomainRequest { generated_types::AdminBlocklistEntryCreateRequest {
category: None, subtype_5: Some(generated_types::BanUrlDomainRequest {
domain: domain.to_owned(), category: None,
match_subdomains: Some(match_subdomains), domain: domain.to_owned(),
notes: None, match_subdomains,
severity: None, notes: None,
source_url: None, severity: None,
} source_url: None,
.into(), }),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> { pub async fn unban_url_domain(
self.delete_blocklist_entry("url-domain", domain, None) &self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("url-domain", domain, None, audit_log_reason)
.await .await
} }
@@ -131,19 +176,19 @@ impl AdminApiClient {
sha256_hex: &str, sha256_hex: &str,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<()> { ) -> ApiResult<()> {
let body = generated_types::AdminBlocklistEntryCreateRequest::from( self.create_blocklist_entry(
generated_types::BanFileShaRequest { "file-sha",
category: None, generated_types::AdminBlocklistEntryCreateRequest {
content_type: None, subtype_6: Some(generated_types::BanFileShaRequest {
notes: None, category: None,
severity: None, content_type: None,
sha256_hex: sha256_hex.to_owned(), notes: None,
source_url: None, severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
}),
..Default::default()
}, },
);
self.post_void_with_reason(
"/admin/blocklists/file-sha/entries",
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
audit_log_reason, audit_log_reason,
) )
.await .await
@@ -154,12 +199,8 @@ impl AdminApiClient {
sha256_hex: &str, sha256_hex: &str,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<()> { ) -> ApiResult<()> {
self.delete_void_with_reason( self.delete_blocklist_entry("file-sha", sha256_hex, None, audit_log_reason)
&blocklist_entry_path("file-sha", sha256_hex), .await
None,
audit_log_reason,
)
.await
} }
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> { pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
@@ -183,24 +224,35 @@ impl AdminApiClient {
.await .await
} }
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> { pub async fn ban_avatar_hash(
&self,
hash_short: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
"avatar-hash", "avatar-hash",
generated_types::BanAvatarHashRequest { generated_types::AdminBlocklistEntryCreateRequest {
category: None, subtype_7: Some(generated_types::BanAvatarHashRequest {
hashes: vec![hash_short.to_owned()], category: None,
notes: None, hashes: vec![hash_short.to_owned()],
reason: None, notes: None,
severity: None, reason: None,
source_url: None, severity: None,
} source_url: None,
.into(), }),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> { pub async fn unban_avatar_hash(
self.delete_blocklist_entry("avatar-hash", hash_short, None) &self,
hash_short: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry("avatar-hash", hash_short, None, audit_log_reason)
.await .await
} }
@@ -213,26 +265,42 @@ impl AdminApiClient {
let body = generated_types::BanUserAvatarRequest::default(); let body = generated_types::BanUserAvatarRequest::default();
let response = self let response = self
.generated() .generated()
.ban_admin_user_avatar( .ban_admin_user_avatar(&snowflake(user_id), &body)
&generated_types::SnowflakeType::from(user_id.to_owned()),
&body,
)
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner()) self.generated_value(response.into_inner())
} }
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> { pub async fn ban_profile_substring(
&self,
scope: &str,
substring: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry( self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST, PROFILE_SUBSTRING_LIST,
profile_substring_request(scope, substring)?.into(), generated_types::AdminBlocklistEntryCreateRequest {
subtype_8: Some(profile_substring_request(scope, substring)?),
..Default::default()
},
audit_log_reason,
) )
.await .await
} }
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> { pub async fn unban_profile_substring(
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope)) &self,
.await 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( pub async fn check_profile_substring_ban(
@@ -248,12 +316,14 @@ impl AdminApiClient {
&self, &self,
list_type: &str, list_type: &str,
body: generated_types::AdminBlocklistEntryCreateRequest, body: generated_types::AdminBlocklistEntryCreateRequest,
audit_log_reason: Option<&str>,
) -> ApiResult<()> { ) -> ApiResult<()> {
self.generated() let list_type = blocklist_list_type(list_type)?;
self.generated_with_reason(audit_log_reason)?
.create_admin_blocklist_entry(list_type, &body) .create_admin_blocklist_entry(list_type, &body)
.await .await
.map_err(|e| self.generated_error(e))?; .map(drop)
Ok(()) .map_err(|error| self.generated_error(error))
} }
async fn delete_blocklist_entry( async fn delete_blocklist_entry(
@@ -261,13 +331,15 @@ impl AdminApiClient {
list_type: &str, list_type: &str,
entry_value: &str, entry_value: &str,
scope: Option<&str>, scope: Option<&str>,
audit_log_reason: Option<&str>,
) -> ApiResult<()> { ) -> ApiResult<()> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_delete_scope).transpose()?; let scope = scope.map(blocklist_delete_scope).transpose()?;
self.generated() self.generated_with_reason(audit_log_reason)?
.delete_admin_blocklist_entry(list_type, entry_value, scope) .delete_admin_blocklist_entry(list_type, entry_value, scope)
.await .await
.map_err(|e| self.generated_error(e))?; .map(drop)
Ok(()) .map_err(|error| self.generated_error(error))
} }
async fn check_blocklist_entry( async fn check_blocklist_entry(
@@ -276,6 +348,7 @@ impl AdminApiClient {
entry_value: &str, entry_value: &str,
scope: Option<&str>, scope: Option<&str>,
) -> ApiResult<BanCheckResult> { ) -> ApiResult<BanCheckResult> {
let list_type = blocklist_list_type(list_type)?;
let scope = scope.map(blocklist_get_scope).transpose()?; let scope = scope.map(blocklist_get_scope).transpose()?;
let response = self let response = self
.generated() .generated()
@@ -290,12 +363,9 @@ const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
const PROFILE_SUBSTRING_LIST: &str = "profile-substring"; const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String { fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
format!( generated_types::AdminBlocklistListType::try_from(list_type)
"/admin/blocklists/{}/entries/{}", .map_err(|e| ApiError::Parse(e.to_string()))
urlencoding::encode(list_type),
urlencoding::encode(entry_value)
)
} }
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> { fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
+44 -69
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::BulkJobResponse; use super::types::BulkJobResponse;
@@ -13,15 +13,11 @@ impl AdminApiClient {
remove_flags: &[String], remove_flags: &[String],
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> { ) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from( let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest { add_flags: user_flags(add_flags)?,
add_flags: user_flags(add_flags), remove_flags: user_flags(remove_flags)?,
remove_flags: user_flags(remove_flags), user_ids: snowflakes(user_ids),
task: };
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await .await
} }
@@ -33,14 +29,11 @@ impl AdminApiClient {
remove_flags: &[String], remove_flags: &[String],
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> { ) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from( let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest { add_flags: add_flags.to_vec(),
add_flags: add_flags.to_vec(), remove_flags: remove_flags.to_vec(),
remove_flags: remove_flags.to_vec(), user_ids: snowflakes(user_ids),
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags, };
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await .await
} }
@@ -52,14 +45,11 @@ impl AdminApiClient {
remove_features: &[String], remove_features: &[String],
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> { ) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from( let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest { add_features: guild_features(add_features),
add_features: guild_features(add_features), guild_ids: snowflakes(guild_ids),
guild_ids: snowflakes(guild_ids), remove_features: guild_features(remove_features),
remove_features: guild_features(remove_features), };
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await .await
} }
@@ -70,14 +60,10 @@ impl AdminApiClient {
user_ids: &[String], user_ids: &[String],
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> { ) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from( let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
generated_types::AddGuildMembersAdminBulkJobCreateRequest { guild_id: snowflake(guild_id),
guild_id: snowflake(guild_id), user_ids: snowflakes(user_ids),
task: };
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await .await
} }
@@ -87,13 +73,9 @@ impl AdminApiClient {
user_ids: &[String], user_ids: &[String],
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> { ) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from( let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest { user_ids: snowflakes(user_ids),
task: };
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
user_ids: snowflakes(user_ids),
},
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await .await
} }
@@ -106,44 +88,37 @@ impl AdminApiClient {
public_reason: Option<&str>, public_reason: Option<&str>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> { ) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::from( let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest { days_until_deletion: crate::api::generated::nonzero_u32(
days_until_deletion: Some( days_until_deletion,
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion") "days_until_deletion",
.map_err(ApiError::Parse)?, )
), .map_err(ApiError::Parse)?
public_reason: public_reason.map(std::borrow::ToOwned::to_owned), .into(),
reason_code: crate::api::generated::deletion_reason_code( public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?, reason_code: crate::api::generated::deletion_reason_code(
"reason_code", i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
) "reason_code",
.map_err(ApiError::Parse)?, )
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion, .map_err(ApiError::Parse)?,
user_ids: snowflakes(user_ids), user_ids: snowflakes(user_ids),
}, };
);
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason) self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await .await
} }
} }
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> { fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
values values.iter().map(|value| snowflake(value)).collect()
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect()
} }
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> { fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values values
.iter() .iter()
.cloned() .map(|value| {
.map(generated_types::UserFlags::from) generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect() .collect()
} }
+215 -131
View File
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::GeneratedClient;
use crate::{config::AdminConfig, session::Session}; use crate::{config::AdminConfig, session::Session};
use progenitor_client::ClientInfo;
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
use reqwest::{Method, RequestBuilder}; use reqwest::{Method, RequestBuilder};
use serde::Serialize; use serde::Serialize;
@@ -34,46 +36,39 @@ impl<T> ApiResultExt<T> for ApiResult<T> {
} }
pub struct AdminApiClient { pub struct AdminApiClient {
http_client: reqwest::Client, generated: GeneratedClient,
generated: crate::api::generated::GeneratedClient,
base_url: String,
access_token: String,
proxy_client_ip_headers: HeaderMap,
} }
impl AdminApiClient { impl AdminApiClient {
pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self { pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self {
let generated_http_client = build_generated_http_client(config, session); let generated = GeneratedClient::new_with_client(
let generated = crate::api::generated::GeneratedClient::new_with_client(
&config.api_endpoint, &config.api_endpoint,
generated_http_client, http_client.clone(),
build_session_headers(config, session),
); );
Self { Self { generated }
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),
}
} }
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String { fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
let base = format!("{}{}", self.base_url, path); let mut url = format!("{}{}", self.generated.baseurl(), path);
match query_params { let query = query_params
None => base, .unwrap_or_default()
Some(params) => { .iter()
let filtered: Vec<_> = params.iter().filter(|(_, v)| !v.is_empty()).collect(); .filter(|(_, value)| !value.is_empty())
if filtered.is_empty() { .map(|(key, value)| {
return base; format!(
} "{}={}",
let query = filtered urlencoding::encode(key),
.iter() urlencoding::encode(value)
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) )
.collect::<Vec<_>>() })
.join("&"); .collect::<Vec<_>>()
format!("{base}?{query}") .join("&");
} if !query.is_empty() {
url.push('?');
url.push_str(&query);
} }
url
} }
fn request( fn request(
@@ -81,30 +76,26 @@ impl AdminApiClient {
method: Method, method: Method,
path: &str, path: &str,
query_params: Option<&[(&str, &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>, audit_log_reason: Option<&str>,
) -> RequestBuilder { ) -> ApiResult<RequestBuilder> {
match audit_log_reason { let url = self.build_url(path, query_params);
Some(reason) => builder.header("X-Audit-Log-Reason", reason), Ok(self
None => builder, .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 { fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
match body { let mut headers = self.generated.inner().clone();
Some(body) => builder.json(body), if let Some(reason) = audit_log_reason {
None => builder, let mut value = HeaderValue::from_str(reason)
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
value.set_sensitive(true);
headers.insert("x-audit-log-reason", value);
} }
Ok(headers)
} }
async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> { async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> {
@@ -114,13 +105,29 @@ impl AdminApiClient {
.map_err(|e| ApiError::Network(e.to_string())) .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>( pub async fn get<T: DeserializeOwned>(
&self, &self,
path: &str, path: &str,
query_params: Option<&[(&str, &str)]>, query_params: Option<&[(&str, &str)]>,
) -> ApiResult<T> { ) -> ApiResult<T> {
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?; let response =
self.parse_response(response).await Self::send_request(self.request(Method::GET, path, query_params, None)?).await?;
Self::parse_response(response).await
} }
pub async fn post<T: DeserializeOwned>( pub async fn post<T: DeserializeOwned>(
@@ -149,10 +156,10 @@ impl AdminApiClient {
T: DeserializeOwned, T: DeserializeOwned,
B: Serialize + ?Sized, B: Serialize + ?Sized,
{ {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason); .send_json(Method::POST, path, Some(body), audit_log_reason)
let response = Self::send_request(builder.json(body)).await?; .await?;
self.parse_response(response).await Self::parse_response(response).await
} }
pub async fn post_with_reason<T: DeserializeOwned>( pub async fn post_with_reason<T: DeserializeOwned>(
@@ -161,10 +168,10 @@ impl AdminApiClient {
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<T> { ) -> ApiResult<T> {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason); .send_json(Method::POST, path, body, audit_log_reason)
let response = Self::send_request(Self::with_json_body(builder, body)).await?; .await?;
self.parse_response(response).await Self::parse_response(response).await
} }
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> { pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
@@ -177,9 +184,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<()> { ) -> ApiResult<()> {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason); .send_json(Method::POST, path, body, audit_log_reason)
let response = Self::send_request(Self::with_json_body(builder, body)).await?; .await?;
Self::parse_void_response(response).await Self::parse_void_response(response).await
} }
@@ -197,10 +204,10 @@ impl AdminApiClient {
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<T> { ) -> ApiResult<T> {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason); .send_json(Method::PATCH, path, body, audit_log_reason)
let response = Self::send_request(Self::with_json_body(builder, body)).await?; .await?;
self.parse_response(response).await Self::parse_response(response).await
} }
pub async fn patch_typed_with_reason<T, B>( pub async fn patch_typed_with_reason<T, B>(
@@ -213,10 +220,10 @@ impl AdminApiClient {
T: DeserializeOwned, T: DeserializeOwned,
B: Serialize + ?Sized, B: Serialize + ?Sized,
{ {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason); .send_json(Method::PATCH, path, Some(body), audit_log_reason)
let response = Self::send_request(builder.json(body)).await?; .await?;
self.parse_response(response).await Self::parse_response(response).await
} }
pub async fn put_with_reason<T: DeserializeOwned>( pub async fn put_with_reason<T: DeserializeOwned>(
@@ -225,10 +232,10 @@ impl AdminApiClient {
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<T> { ) -> ApiResult<T> {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); .send_json(Method::PUT, path, body, audit_log_reason)
let response = Self::send_request(Self::with_json_body(builder, body)).await?; .await?;
self.parse_response(response).await Self::parse_response(response).await
} }
pub async fn put_typed_with_reason<T, B>( pub async fn put_typed_with_reason<T, B>(
@@ -241,10 +248,10 @@ impl AdminApiClient {
T: DeserializeOwned, T: DeserializeOwned,
B: Serialize + ?Sized, B: Serialize + ?Sized,
{ {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); .send_json(Method::PUT, path, Some(body), audit_log_reason)
let response = Self::send_request(builder.json(body)).await?; .await?;
self.parse_response(response).await Self::parse_response(response).await
} }
pub async fn put_void_with_reason( pub async fn put_void_with_reason(
@@ -253,9 +260,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<()> { ) -> ApiResult<()> {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason); .send_json(Method::PUT, path, body, audit_log_reason)
let response = Self::send_request(Self::with_json_body(builder, body)).await?; .await?;
Self::parse_void_response(response).await Self::parse_void_response(response).await
} }
@@ -269,9 +276,9 @@ impl AdminApiClient {
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<()> { ) -> ApiResult<()> {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason); .send_json(Method::DELETE, path, body, audit_log_reason)
let response = Self::send_request(Self::with_json_body(builder, body)).await?; .await?;
Self::parse_void_response(response).await Self::parse_void_response(response).await
} }
@@ -281,31 +288,42 @@ impl AdminApiClient {
body: Option<&serde_json::Value>, body: Option<&serde_json::Value>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> ApiResult<T> { ) -> ApiResult<T> {
let builder = let response = self
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason); .send_json(Method::DELETE, path, body, audit_log_reason)
let response = Self::send_request(Self::with_json_body(builder, body)).await?; .await?;
self.parse_response(response).await Self::parse_response(response).await
} }
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> { async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
if response.status().is_success() { Self::check_response_status(response).await.map(drop)
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(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient { 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 &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> pub(crate) fn generated_value<T, U>(&self, value: U) -> ApiResult<T>
where where
T: DeserializeOwned, T: DeserializeOwned,
@@ -328,28 +346,16 @@ impl AdminApiClient {
} }
} }
async fn parse_response<T: DeserializeOwned>( async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
&self, let response = Self::check_response_status(response).await?;
response: reqwest::Response, let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
) -> ApiResult<T> { String::new()
let status = response.status(); } else {
if status.as_u16() == 204 { response
return serde_json::from_value(serde_json::Value::Null) .text()
.map_err(|e| ApiError::Parse(e.to_string())); .await
} .map_err(|e| ApiError::Network(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()))?;
if text.is_empty() { if text.is_empty() {
return serde_json::from_value(serde_json::Value::Null) return serde_json::from_value(serde_json::Value::Null)
.map_err(|e| ApiError::Parse(e.to_string())); .map_err(|e| ApiError::Parse(e.to_string()));
@@ -358,17 +364,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 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"); .expect("failed to build generated API Authorization header");
auth_value.set_sensitive(true);
headers.insert(AUTHORIZATION, auth_value); headers.insert(AUTHORIZATION, auth_value);
headers.extend(build_proxy_client_ip_headers(config)); headers.extend(build_proxy_client_ip_headers(config));
reqwest::Client::builder() headers
.user_agent(format!("FluxerAdmin/{} (Rust)", config.build_version))
.default_headers(headers)
.build()
.expect("failed to create generated API HTTP client")
} }
pub(crate) fn with_proxy_client_ip_header( pub(crate) fn with_proxy_client_ip_header(
@@ -418,3 +421,84 @@ impl std::fmt::Display for ApiError {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn response(status: u16, body: &'static str) -> reqwest::Response {
axum::http::Response::builder()
.status(status)
.body(body)
.expect("valid response")
.into()
}
#[tokio::test]
async fn parses_successful_json_and_empty_responses() {
for (status, body, expected) in [
(200, r#"{"value":1}"#, json!({"value": 1})),
(201, "[1,2]", json!([1, 2])),
(202, "null", Value::Null),
(200, "", Value::Null),
(204, "ignored body", Value::Null),
] {
let actual: Value = AdminApiClient::parse_response(response(status, body))
.await
.expect("valid response body");
assert_eq!(actual, expected, "HTTP {status}: {body}");
}
}
#[tokio::test]
async fn empty_responses_preserve_null_deserialization_errors() {
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
.expect_err("null is not a list")
.to_string();
for (status, body) in [(200, ""), (204, "ignored body")] {
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
.await
.expect_err("missing list");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn malformed_json_preserves_deserialization_errors() {
for body in [" ", "{", "not JSON"] {
let expected = serde_json::from_str::<Value>(body)
.expect_err("malformed JSON")
.to_string();
let error = AdminApiClient::parse_response::<Value>(response(200, body))
.await
.expect_err("malformed response");
assert_eq!(error.to_string(), format!("parse error: {expected}"));
}
}
#[tokio::test]
async fn void_responses_do_not_parse_successful_bodies() {
for status in [200, 201, 202, 204] {
AdminApiClient::parse_void_response(response(status, "not JSON"))
.await
.expect("successful void response");
}
}
#[tokio::test]
async fn typed_and_void_responses_preserve_http_errors() {
for status in [302, 400, 403, 404, 500] {
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
.await
.map(drop);
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
for result in [typed, empty] {
let error = result.expect_err("unsuccessful response");
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
}
}
}
}
}
+7 -7
View File
@@ -9,20 +9,20 @@ impl AdminApiClient {
pub async fn generate_gift_codes( pub async fn generate_gift_codes(
&self, &self,
count: u32, count: u32,
duration_type: &str, duration_type: generated_types::GiftCodeDurationTypeSchema,
duration_quantity: u32, duration_quantity: u32,
) -> ApiResult<CodesResponse> { ) -> ApiResult<CodesResponse> {
let body = generated_types::GenerateGiftCodesRequest { 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: crate::api::generated::nonzero_u32(
duration_quantity, duration_quantity,
"duration_quantity", "duration_quantity",
) )
.map_err(ApiError::Parse)?, .map_err(ApiError::Parse)?
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from( .into(),
duration_type, duration_type,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
}; };
let response = self let response = self
.generated() .generated()
+16 -28
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::{ use super::types::{
@@ -16,8 +16,7 @@ impl AdminApiClient {
.list_admin_discovery_applications() .list_admin_discovery_applications()
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
response Vec::from(response.into_inner())
.into_inner()
.into_iter() .into_iter()
.map(pending_discovery_application) .map(pending_discovery_application)
.collect() .collect()
@@ -29,8 +28,7 @@ impl AdminApiClient {
.list_admin_discovery_listings() .list_admin_discovery_listings()
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
response Vec::from(response.into_inner())
.into_inner()
.into_iter() .into_iter()
.map(listed_guild) .map(listed_guild)
.collect() .collect()
@@ -41,16 +39,13 @@ impl AdminApiClient {
guild_id: &str, guild_id: &str,
reason: Option<&str>, reason: Option<&str>,
) -> ApiResult<DiscoveryApplicationResponse> { ) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from( let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest { reason: reason
reason: reason .map(generated_types::DiscoveryReviewReason::try_from)
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from) .transpose()
.transpose() .map_err(|e| ApiError::Parse(e.to_string()))?,
.map_err(|e| ApiError::Parse(e.to_string()))?, };
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
},
);
let response = self let response = self
.generated() .generated()
.update_admin_discovery_application(&guild_id, &body) .update_admin_discovery_application(&guild_id, &body)
@@ -64,18 +59,11 @@ impl AdminApiClient {
guild_id: &str, guild_id: &str,
reason: &str, reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> { ) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from( let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest { reason: generated_types::DiscoveryRejectionReason::try_from(reason)
reason: .map_err(|e| ApiError::Parse(e.to_string()))?,
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from( };
reason,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
status:
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
},
);
let response = self let response = self
.generated() .generated()
.update_admin_discovery_application(&guild_id, &body) .update_admin_discovery_application(&guild_id, &body)
@@ -89,7 +77,7 @@ impl AdminApiClient {
guild_id: &str, guild_id: &str,
reason: &str, reason: &str,
) -> ApiResult<DiscoveryApplicationResponse> { ) -> ApiResult<DiscoveryApplicationResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); let guild_id = snowflake(guild_id);
let body = generated_types::DiscoveryAdminRemoveRequest { let body = generated_types::DiscoveryAdminRemoveRequest {
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason) reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
.map_err(|e| ApiError::Parse(e.to_string()))?, .map_err(|e| ApiError::Parse(e.to_string()))?,
+23 -2
View File
@@ -1,5 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo};
use reqwest::header::HeaderMap;
#[allow( #[allow(
clippy::all, clippy::all,
unused_imports, unused_imports,
@@ -16,6 +19,24 @@ pub use inner::types;
pub use inner::Client as GeneratedClient; 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> { 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; 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 { if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
@@ -126,10 +147,10 @@ mod tests {
let response: SearchGuildsResponse = let response: SearchGuildsResponse =
serde_json::from_value(json).expect("failed to deserialize 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.len(), 1);
assert_eq!(response.guilds[0].name, "Test Guild"); 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] #[test]
+3 -3
View File
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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::client::{AdminApiClient, ApiResult};
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse}; use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
impl AdminApiClient { impl AdminApiClient {
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> { 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 let response = self
.generated() .generated()
.list_admin_guild_emojis(&guild_id) .list_admin_guild_emojis(&guild_id)
@@ -20,7 +20,7 @@ impl AdminApiClient {
&self, &self,
guild_id: &str, guild_id: &str,
) -> ApiResult<ListGuildStickersResponse> { ) -> ApiResult<ListGuildStickersResponse> {
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned()); let guild_id = snowflake(guild_id);
let response = self let response = self
.generated() .generated()
.list_admin_guild_stickers(&guild_id) .list_admin_guild_stickers(&guild_id)
+26 -45
View File
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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 serde::Deserialize;
use super::client::{AdminApiClient, ApiError, ApiResult}; use super::client::{AdminApiClient, ApiError, ApiResult};
use super::reports::SearchReportsParams;
use super::types::{ use super::types::{
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse, GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse, ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
@@ -15,7 +16,7 @@ impl AdminApiClient {
&self, &self,
query: &str, query: &str,
limit: u32, limit: u32,
offset: u32, offset: u64,
) -> ApiResult<SearchGuildsResponse> { ) -> ApiResult<SearchGuildsResponse> {
let limit = limit.to_string(); let limit = limit.to_string();
let offset = offset.to_string(); let offset = offset.to_string();
@@ -28,13 +29,8 @@ impl AdminApiClient {
} }
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> { pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
let response = self self.lookup_guild(guild_id)
.generated() .await?
.get_admin_guild(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
resp.guild
.map(GuildInfo::from) .map(GuildInfo::from)
.ok_or_else(|| super::client::ApiError::Http { .ok_or_else(|| super::client::ApiError::Http {
status: 404, status: 404,
@@ -101,7 +97,7 @@ impl AdminApiClient {
&self, &self,
guild_id: &str, guild_id: &str,
limit: u32, limit: u32,
offset: u32, offset: u64,
) -> ApiResult<ListGuildMembersResponse> { ) -> ApiResult<ListGuildMembersResponse> {
let limit = limit.to_string(); let limit = limit.to_string();
let offset = offset.to_string(); let offset = offset.to_string();
@@ -144,8 +140,7 @@ impl AdminApiClient {
let limit = limit let limit = limit
.map(i32::try_from) .map(i32::try_from)
.transpose() .transpose()
.map_err(|e| ApiError::Parse(e.to_string()))? .map_err(|e| ApiError::Parse(e.to_string()))?;
.map(generated_types::Int32Type::from);
let response = self let response = self
.generated() .generated()
.list_admin_guild_audit_logs( .list_admin_guild_audit_logs(
@@ -153,7 +148,7 @@ impl AdminApiClient {
None, None,
None, None,
before.as_ref(), before.as_ref(),
limit.as_ref(), limit,
None, None,
) )
.await .await
@@ -257,39 +252,29 @@ impl AdminApiClient {
&self, &self,
guild_id: &str, guild_id: &str,
limit: u32, limit: u32,
offset: u32, offset: u64,
) -> ApiResult<SearchReportsResponse> { ) -> ApiResult<SearchReportsResponse> {
self.search_reports( self.search_reports(&SearchReportsParams {
None, reported_guild_id: Some(guild_id),
None,
None,
None,
None,
None,
Some(guild_id),
None,
None,
None,
None,
None,
limit, limit,
offset, offset,
) ..Default::default()
})
.await .await
} }
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct GuildSettingsPatch { struct GuildSettingsPatch {
content_warning_level: Option<generated_types::ContentWarningLevel>, content_warning_level: Option<generated_types::ContentWarningLevelInput>,
content_warning_text: Option<String>, 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>, disabled_operations: Option<generated_types::GuildOperations>,
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>, explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
mfa_level: Option<generated_types::GuildMfaLevel>, mfa_level: Option<generated_types::GuildMfaLevelInput>,
nsfw: Option<bool>, nsfw: Option<bool>,
nsfw_level: Option<generated_types::NsfwLevel>, nsfw_level: Option<generated_types::NsfwLevelInput>,
verification_level: Option<generated_types::GuildVerificationLevel>, verification_level: Option<generated_types::GuildVerificationLevelInput>,
} }
fn search_guilds_response( fn search_guilds_response(
@@ -317,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
owner_global_name: response.owner_global_name, owner_global_name: response.owner_global_name,
owner_discriminator: response.owner_discriminator, owner_discriminator: response.owner_discriminator,
member_count: crate::api::generated::i64_to_u64( member_count: crate::api::generated::i64_to_u64(
i64::from(response.member_count), i64::from(i32::from(response.member_count)),
"member_count", "member_count",
) )
.map_err(ApiError::Parse)?, .map_err(ApiError::Parse)?,
@@ -325,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
nsfw_level: response.nsfw_level.map(i32::from), nsfw_level: response.nsfw_level.map(i32::from),
nsfw: response.nsfw, nsfw: response.nsfw,
content_warning_level: response.content_warning_level.map(i32::from), 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, description: None,
vanity_url_code: None, vanity_url_code: None,
}) })
@@ -338,9 +323,9 @@ fn guild_update_response(
Ok(GuildUpdateResponse { Ok(GuildUpdateResponse {
guild: GuildInfo { guild: GuildInfo {
id: String::from(guild.id), id: String::from(guild.id),
name: guild.name, name: String::from(guild.name),
icon: guild.icon, icon: guild.icon.map(String::from),
banner: guild.banner, banner: guild.banner.map(String::from),
owner_id: String::from(guild.owner_id), owner_id: String::from(guild.owner_id),
owner_username: None, owner_username: None,
owner_global_name: None, owner_global_name: None,
@@ -350,11 +335,11 @@ fn guild_update_response(
"member_count", "member_count",
) )
.map_err(ApiError::Parse)?, .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_level: guild.nsfw_level.map(i32::from),
nsfw: guild.nsfw, nsfw: guild.nsfw,
content_warning_level: guild.content_warning_level.map(i32::from), 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, description: None,
vanity_url_code: None, vanity_url_code: None,
}, },
@@ -380,10 +365,6 @@ fn guild_settings_request(
}) })
} }
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> { fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
values values
.iter() .iter()
+7 -6
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult}; use super::client::{AdminApiClient, ApiResult};
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse}; use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
@@ -22,9 +24,9 @@ impl AdminApiClient {
let max_lookback_days = params.max_lookback_days.to_string(); let max_lookback_days = params.max_lookback_days.to_string();
let query_params = [ let query_params = [
("limit", limit.as_str()), ("limit", limit.as_str()),
("cursor_bucket_day", cursor_bucket_day.as_str()), ("cursor_bucket_day", cursor_bucket_day),
("cursor_created_at", cursor_created_at.as_str()), ("cursor_created_at", cursor_created_at),
("cursor_job_id", cursor_job_id.as_str()), ("cursor_job_id", cursor_job_id),
("max_lookback_days", max_lookback_days.as_str()), ("max_lookback_days", max_lookback_days.as_str()),
("status", params.status.as_deref().unwrap_or_default()), ("status", params.status.as_deref().unwrap_or_default()),
("task_type", params.task_type.as_deref().unwrap_or_default()), ("task_type", params.task_type.as_deref().unwrap_or_default()),
@@ -39,7 +41,7 @@ impl AdminApiClient {
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> { pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
let response = self let response = self
.generated() .generated()
.get_admin_job(job_id) .get_admin_job(&snowflake(job_id))
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner()) self.generated_value(response.into_inner())
@@ -68,10 +70,9 @@ impl AdminApiClient {
} }
} }
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String { fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
cursor cursor
.and_then(|cursor| cursor.get(field)) .and_then(|cursor| cursor.get(field))
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_owned()
} }
+7 -8
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::{ use super::types::{
@@ -43,7 +43,8 @@ impl AdminApiClient {
attachment_id: snowflake(attachment_id), attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id), channel_id: snowflake(channel_id),
confirmed_viewed: true, 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), message_id: snowflake(message_id),
reporter_full_name: reporter_full_name:
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from( generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
@@ -119,7 +120,7 @@ impl AdminApiClient {
) -> ApiResult<MessageShredStatusResponse> { ) -> ApiResult<MessageShredStatusResponse> {
let response = self let response = self
.generated() .generated()
.get_admin_message_shred(job_id) .get_admin_message_shred(&snowflake(job_id))
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner()) self.generated_value(response.into_inner())
@@ -133,13 +134,15 @@ impl AdminApiClient {
context_limit: u32, context_limit: u32,
) -> ApiResult<LookupMessageResponse> { ) -> ApiResult<LookupMessageResponse> {
let context_limit = context_limit.to_string(); 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 let response = self
.generated() .generated()
.search_admin_messages( .search_admin_messages(
Some(&snowflake(attachment_id)), Some(&snowflake(attachment_id)),
&snowflake(channel_id), &snowflake(channel_id),
Some(context_limit.as_str()), Some(context_limit.as_str()),
Some(filename), Some(&filename),
None, None,
None, None,
None, None,
@@ -195,7 +198,3 @@ impl AdminApiClient {
self.generated_value(response.into_inner()) self.generated_value(response.into_inner())
} }
} }
fn snowflake(value: &str) -> generated_types::SnowflakeType {
generated_types::SnowflakeType::from(value.to_owned())
}
+122 -60
View File
@@ -1,10 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiError, ApiResult}; use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{ use super::types::{
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse, 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 { impl AdminApiClient {
pub async fn list_reports( pub async fn list_reports(
&self, &self,
@@ -26,7 +46,7 @@ impl AdminApiClient {
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> { pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
let response = self let response = self
.generated() .generated()
.get_admin_report(report_id) .get_admin_report(&snowflake(report_id))
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner()) self.generated_value(response.into_inner())
@@ -50,51 +70,55 @@ impl AdminApiClient {
.await .await
} }
#[allow(clippy::too_many_arguments)]
pub async fn search_reports( pub async fn search_reports(
&self, &self,
query: Option<&str>, params: &SearchReportsParams<'_>,
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,
) -> ApiResult<SearchReportsResponse> { ) -> ApiResult<SearchReportsResponse> {
let status = status.map(report_status).transpose()?.unwrap_or_default(); let status = params
let report_type = report_type .status
.map(report_status)
.transpose()?
.unwrap_or_default();
let report_type = params
.report_type
.map(report_type_name) .map(report_type_name)
.transpose()? .transpose()?
.unwrap_or_default(); .unwrap_or_default();
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default(); let sort_by = params
let limit = limit.to_string(); .sort_by
let offset = offset.to_string(); .map(report_sort_by)
.transpose()?
.unwrap_or_default();
let limit = params.limit.to_string();
let offset = params.offset.to_string();
let query_params = [ let query_params = [
("q", query.unwrap_or_default()), ("q", params.query.unwrap_or_default()),
("status", status), ("status", status),
("report_type", report_type), ("report_type", report_type),
("category", category.unwrap_or_default()), ("category", params.category.unwrap_or_default()),
("reporter_id", reporter_id.unwrap_or_default()), ("reporter_id", params.reporter_id.unwrap_or_default()),
("reported_user_id", reported_user_id.unwrap_or_default()), (
("reported_guild_id", reported_guild_id.unwrap_or_default()), "reported_user_id",
params.reported_user_id.unwrap_or_default(),
),
(
"reported_guild_id",
params.reported_guild_id.unwrap_or_default(),
),
( (
"reported_channel_id", "reported_channel_id",
reported_channel_id.unwrap_or_default(), params.reported_channel_id.unwrap_or_default(),
),
(
"guild_context_id",
params.guild_context_id.unwrap_or_default(),
), ),
("guild_context_id", guild_context_id.unwrap_or_default()),
( (
"resolved_by_admin_id", "resolved_by_admin_id",
resolved_by_admin_id.unwrap_or_default(), params.resolved_by_admin_id.unwrap_or_default(),
), ),
("sort_by", sort_by), ("sort_by", sort_by),
("sort_order", sort_order.unwrap_or_default()), ("sort_order", params.sort_order.unwrap_or_default()),
("limit", limit.as_str()), ("limit", limit.as_str()),
("offset", offset.as_str()), ("offset", offset.as_str()),
]; ];
@@ -105,24 +129,14 @@ impl AdminApiClient {
&self, &self,
reporter_id: &str, reporter_id: &str,
limit: u32, limit: u32,
offset: u32, offset: u64,
) -> ApiResult<SearchReportsResponse> { ) -> ApiResult<SearchReportsResponse> {
self.search_reports( self.search_reports(&SearchReportsParams {
None, reporter_id: Some(reporter_id),
None,
None,
None,
Some(reporter_id),
None,
None,
None,
None,
None,
None,
None,
limit, limit,
offset, offset,
) ..Default::default()
})
.await .await
} }
@@ -130,24 +144,14 @@ impl AdminApiClient {
&self, &self,
reported_user_id: &str, reported_user_id: &str,
limit: u32, limit: u32,
offset: u32, offset: u64,
) -> ApiResult<SearchReportsResponse> { ) -> ApiResult<SearchReportsResponse> {
self.search_reports( self.search_reports(&SearchReportsParams {
None, reported_user_id: Some(reported_user_id),
None,
None,
None,
None,
Some(reported_user_id),
None,
None,
None,
None,
None,
None,
limit, limit,
offset, offset,
) ..Default::default()
})
.await .await
} }
} }
@@ -179,3 +183,61 @@ fn report_sort_by(value: &str) -> ApiResult<&'static str> {
))), ))),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_statuses_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "pending"), (1, "resolved")] {
assert_eq!(report_status(value).expect("supported status"), expected);
}
for value in [-1, 2] {
assert_eq!(
report_status(value)
.expect_err("unknown status")
.to_string(),
format!("parse error: unknown report status: {value}")
);
}
}
#[test]
fn report_types_preserve_the_closed_wire_mapping() {
for (value, expected) in [(0, "message"), (1, "user"), (2, "guild")] {
assert_eq!(report_type_name(value).expect("supported type"), expected);
}
for value in [-1, 3] {
assert_eq!(
report_type_name(value)
.expect_err("unknown type")
.to_string(),
format!("parse error: unknown report type: {value}")
);
}
}
#[test]
fn report_sort_fields_accept_only_the_existing_aliases() {
for (field, expected) in [
("createdAt", "created_at"),
("created_at", "created_at"),
("reportedAt", "reported_at"),
("reported_at", "reported_at"),
("resolvedAt", "resolved_at"),
("resolved_at", "resolved_at"),
] {
assert_eq!(
report_sort_by(field).expect("supported sort field"),
expected
);
}
assert_eq!(
report_sort_by("unknown")
.expect_err("unknown sort field")
.to_string(),
"parse error: unknown report sort field: unknown"
);
}
}
+5 -2
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse}; use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
@@ -11,8 +11,11 @@ impl AdminApiClient {
index_type: &str, index_type: &str,
guild_id: Option<&str>, guild_id: Option<&str>,
) -> ApiResult<RefreshSearchIndexResponse> { ) -> 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 { let body = generated_types::RefreshSearchIndexRequest {
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())), guild_id: guild_id.map(snowflake),
user_id: None, user_id: None,
}; };
let response = self let response = self
+2 -6
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types; use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiResult}; use super::client::{AdminApiClient, ApiResult};
use super::types::{ use super::types::{
@@ -24,11 +24,7 @@ impl AdminApiClient {
guild_ids: &[String], guild_ids: &[String],
) -> ApiResult<ReloadAllGuildsResponse> { ) -> ApiResult<ReloadAllGuildsResponse> {
let body = generated_types::ReloadGuildsRequest { let body = generated_types::ReloadGuildsRequest {
guild_ids: guild_ids guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
}; };
let response = self let response = self
.generated() .generated()
+2 -6
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::SendSystemDmResponse; use super::types::SendSystemDmResponse;
@@ -14,11 +14,7 @@ impl AdminApiClient {
let body = generated_types::SendSystemDmRequest { let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content) content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?, .map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
.iter()
.cloned()
.map(generated_types::SnowflakeType::from)
.collect(),
}; };
let response = self let response = self
.generated() .generated()
+2
View File
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
#[serde(default)] #[serde(default)]
pub admin_user: Option<AuditLogUserSummary>, pub admin_user: Option<AuditLogUserSummary>,
pub action: String, pub action: String,
#[serde(default)]
pub access: Option<String>,
pub target_id: String, pub target_id: String,
pub target_type: String, pub target_type: String,
#[serde(default)] #[serde(default)]
+11 -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)?; let v: serde_json::Value = Deserialize::deserialize(d)?;
match v { match v {
serde_json::Value::String(s) => Ok(format!("{:0>4}", s)), serde_json::Value::String(s) => Ok(format!("{:0>4}", s)),
serde_json::Value::Number(n) => Ok(format!("{:04}", n.as_u64().unwrap_or(0))), serde_json::Value::Number(n) => n
_ => Ok("0000".to_owned()), .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)?; let v: serde_json::Value = Deserialize::deserialize(d)?;
match v { match v {
serde_json::Value::String(s) => s.parse::<u64>().map_err(serde::de::Error::custom), 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), serde_json::Value::Null => Ok(0),
_ => Err(serde::de::Error::custom("expected string or number")), _ => Err(serde::de::Error::custom("expected string or number")),
} }
@@ -2,6 +2,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse { pub struct InstanceConfigResponse {
pub sso: SsoConfigResponse, pub sso: SsoConfigResponse,
@@ -18,6 +20,18 @@ pub struct InstanceConfigResponse {
pub integrations: InstanceIntegrationsResponse, pub integrations: InstanceIntegrationsResponse,
#[serde(default)] #[serde(default)]
pub media: InstanceMediaResponse, pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub altcha_captcha: AltchaCaptchaConfigResponse,
#[serde(default)]
pub profile_timezone: ProfileTimezoneConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
} }
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
@@ -320,6 +334,8 @@ pub struct AppBrandingConfigResponse {
pub wordmark_url: Option<String>, pub wordmark_url: Option<String>,
pub favicon_url: Option<String>, pub favicon_url: Option<String>,
pub theme_color: Option<String>, pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
} }
impl Default for AppBrandingConfigResponse { impl Default for AppBrandingConfigResponse {
@@ -332,6 +348,8 @@ impl Default for AppBrandingConfigResponse {
wordmark_url: None, wordmark_url: None,
favicon_url: None, favicon_url: None,
theme_color: None, theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
} }
} }
} }
@@ -436,6 +454,306 @@ impl VoiceE2eeScope {
} }
} }
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
pub const ALL: [Self; 7] = [
Self::None,
Self::Standard,
Self::Gate,
Self::Speex,
Self::Rnnoise,
Self::Gtcrn,
Self::DeepFilter,
];
pub fn label(&self) -> &'static str {
match self {
Self::None => "None (pass-through)",
Self::Standard => "Standard (WebRTC)",
Self::Gate => "Noise gate",
Self::Speex => "Speex",
Self::Rnnoise => "RNNoise",
Self::Gtcrn => "GTCRN",
Self::DeepFilter => "DeepFilterNet",
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct VoiceNoiseSuppressionGuildOverride {
pub guild_id: String,
pub backend: NoiseSuppressionBackend,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceNoiseSuppressionConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub default_backend: NoiseSuppressionBackend,
pub enabled_backends: Vec<NoiseSuppressionBackend>,
pub allow_user_override: bool,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub suppression_strength: u32,
}
impl Default for VoiceNoiseSuppressionConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
default_backend: NoiseSuppressionBackend::Standard,
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
allow_user_override: true,
rollout_basis_points: 0,
rollout_salt: "voice-ns-v1".to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
suppression_strength: 80,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_backend: Option<NoiseSuppressionBackend>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_user_override: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub 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 guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
#[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 AltchaCaptchaConfigResponse {
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_enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for AltchaCaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
anonymous_enabled: false,
cost: 5_000,
max_counter: 10_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct AltchaCaptchaConfigUpdateRequest {
#[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_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 ProfileTimezoneConfigResponse {
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>,
}
impl Default for ProfileTimezoneConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ProfileTimezoneConfigUpdateRequest {
#[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>>,
}
#[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)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceRegistrationResponse { pub struct InstanceRegistrationResponse {
pub mode: RegistrationMode, pub mode: RegistrationMode,
@@ -525,6 +843,18 @@ pub struct InstanceConfigUpdateRequest {
pub integrations: Option<InstanceIntegrationsUpdateRequest>, pub integrations: Option<InstanceIntegrationsUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<InstanceMediaUpdateRequest>, pub media: Option<InstanceMediaUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_relay: Option<PushRelayConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
} }
#[derive(Clone, Debug, Default, Serialize)] #[derive(Clone, Debug, Default, Serialize)]
@@ -729,6 +1059,10 @@ pub struct AppBrandingConfigUpdateRequest {
pub favicon_url: Option<Option<String>>, pub favicon_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub theme_color: Option<Option<String>>, 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>>,
} }
#[derive(Clone, Debug, Default, Serialize)] #[derive(Clone, Debug, Default, Serialize)]
@@ -826,3 +1160,151 @@ pub struct CreateRegistrationUrlResponse {
pub code: String, pub code: String,
pub url: String, pub url: String,
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
assert_eq!(
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
json!([
"none",
"standard",
"gate",
"speex",
"rnnoise",
"gtcrn",
"deep_filter"
])
);
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
}
#[test]
fn default_instance_experiment_config_matches_the_published_contract() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../../openapi-admin.json"))
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
.expect("default altcha captcha config");
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
.expect("default profile timezone config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let altcha_captcha =
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
let profile_timezone =
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
serde_json::from_value(altcha_captcha.clone())
.expect("generated altcha captcha config contract");
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
serde_json::from_value(profile_timezone.clone())
.expect("generated profile timezone config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_altcha_captcha)
.expect("serializable generated altcha captcha config"),
altcha_captcha
);
assert_eq!(
serde_json::to_value(generated_profile_timezone)
.expect("serializable generated profile timezone config"),
profile_timezone
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("AltchaCaptchaConfigResponse", altcha_captcha),
("ProfileTimezoneConfigResponse", profile_timezone),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
assert_eq!(
value, &schema["components"]["schemas"][name]["properties"][field]["default"],
"{name}.{field}"
);
}
}
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
enabled_backends: Some(Vec::new()),
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
guild_overrides: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
);
assert_eq!(
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[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!({})
);
}
}
+1
View File
@@ -28,6 +28,7 @@ pub struct VoiceServer {
pub latitude: Option<f64>, pub latitude: Option<f64>,
pub longitude: Option<f64>, pub longitude: Option<f64>,
pub is_active: Option<bool>, pub is_active: Option<bool>,
pub soft_connection_limit: Option<i64>,
pub vip_only: Option<bool>, pub vip_only: Option<bool>,
#[serde(default)] #[serde(default)]
pub required_guild_features: Vec<String>, pub required_guild_features: Vec<String>,
+27 -27
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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, ApiError, ApiResult};
use super::types::{ use super::types::{
@@ -15,7 +15,7 @@ impl AdminApiClient {
email: Option<&str>, email: Option<&str>,
last_active_ip: Option<&str>, last_active_ip: Option<&str>,
limit: u32, limit: u32,
offset: u32, offset: u64,
) -> ApiResult<SearchUsersResponse> { ) -> ApiResult<SearchUsersResponse> {
let limit = limit.to_string(); let limit = limit.to_string();
let offset = offset.to_string(); let offset = offset.to_string();
@@ -35,7 +35,8 @@ impl AdminApiClient {
let response = response.into_inner(); let response = response.into_inner();
Ok(SearchUsersResponse { Ok(SearchUsersResponse {
users: self.generated_value(response.users)?, 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)?,
}) })
} }
@@ -94,8 +95,8 @@ impl AdminApiClient {
remove_flags: &[String], remove_flags: &[String],
) -> ApiResult<AdminUser> { ) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserFlagsUpdateRequest { let body = generated_types::AdminUserFlagsUpdateRequest {
add_flags: user_flags(add_flags), add_flags: user_flags(add_flags)?,
remove_flags: user_flags(remove_flags), remove_flags: user_flags(remove_flags)?,
}; };
let response = self let response = self
.generated() .generated()
@@ -362,11 +363,8 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> { ) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserUsernameUpdateRequest { let body = generated_types::AdminUserUsernameUpdateRequest {
discriminator: discriminator discriminator: discriminator
.map(generated_types::DiscriminatorType::try_from) .map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
.transpose() username: generated_types::UsernameType::from(username.to_owned()),
.map_err(|e| ApiError::Parse(e.to_string()))?,
username: generated_types::UsernameType::try_from(username)
.map_err(|e| ApiError::Parse(e.to_string()))?,
}; };
let response = self let response = self
.generated() .generated()
@@ -399,7 +397,8 @@ impl AdminApiClient {
) -> ApiResult<AdminUser> { ) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBanRequest { let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours) duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?, .map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
reason: reason.map(std::borrow::ToOwned::to_owned), reason: reason.map(std::borrow::ToOwned::to_owned),
}; };
let resp: UserMutationResponse = self let resp: UserMutationResponse = self
@@ -428,21 +427,24 @@ impl AdminApiClient {
reason_code: i32, reason_code: i32,
public_reason: Option<&str>, public_reason: Option<&str>,
days_until_deletion: u32, days_until_deletion: u32,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> { ) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDeletionScheduleRequest { let body = generated_types::AdminUserDeletionScheduleRequest {
days_until_deletion: Some( days_until_deletion: crate::api::generated::nonzero_u32(
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion") days_until_deletion,
.map_err(ApiError::Parse)?, "days_until_deletion",
), )
.map_err(ApiError::Parse)?
.into(),
public_reason: public_reason.map(std::borrow::ToOwned::to_owned), public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code") reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?, .map_err(ApiError::Parse)?,
}; };
let response = self let response = self
.generated() .generated_with_reason(audit_log_reason)?
.schedule_admin_user_deletion(&snowflake(user_id), &body) .schedule_admin_user_deletion(&snowflake(user_id), &body)
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|error| self.generated_error(error))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?; let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user) Ok(resp.user)
} }
@@ -484,10 +486,10 @@ impl AdminApiClient {
target_id: &str, target_id: &str,
category: &str, category: &str,
) -> ApiResult<()> { ) -> ApiResult<()> {
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category) let category = generated_types::RelationshipCategoryEnum::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?; .map_err(|e| ApiError::Parse(e.to_string()))?;
self.generated() self.generated()
.remove_admin_user_relationship(&snowflake(user_id), target_id, category) .remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
.await .await
.map_err(|e| self.generated_error(e))?; .map_err(|e| self.generated_error(e))?;
Ok(()) Ok(())
@@ -498,7 +500,7 @@ impl AdminApiClient {
user_id: &str, user_id: &str,
category: &str, category: &str,
) -> ApiResult<super::types::RemoveRelationshipsResponse> { ) -> ApiResult<super::types::RemoveRelationshipsResponse> {
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category) let category = generated_types::RelationshipCategoryEnum::try_from(category)
.map_err(|e| ApiError::Parse(e.to_string()))?; .map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self let response = self
.generated() .generated()
@@ -565,15 +567,13 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" } if value { "true" } else { "false" }
} }
fn snowflake(value: &str) -> generated_types::SnowflakeType { fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
generated_types::SnowflakeType::from(value.to_owned())
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
values values
.iter() .iter()
.cloned() .map(|value| {
.map(generated_types::UserFlags::from) generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect() .collect()
} }
+108 -21
View File
@@ -55,15 +55,14 @@ impl AdminApiClient {
params: &serde_json::Value, params: &serde_json::Value,
) -> ApiResult<UpdateVoiceRegionResponse> { ) -> ApiResult<UpdateVoiceRegionResponse> {
let region_id = required_field(params, "id")?; let region_id = required_field(params, "id")?;
let body = let body = voice_request_body(params, &["id"])?;
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone()) validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
.map_err(|e| ApiError::Parse(e.to_string()))?; self.patch_with_reason(
let response = self &format!("/admin/voice/regions/{}", urlencoding::encode(&region_id)),
.generated() Some(&body),
.update_admin_voice_region(&region_id, &body) None,
.await )
.map_err(|e| self.generated_error(e))?; .await
self.generated_value(response.into_inner())
} }
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> { pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
@@ -103,9 +102,10 @@ impl AdminApiClient {
) -> ApiResult<CreateVoiceServerResponse> { ) -> ApiResult<CreateVoiceServerResponse> {
let region_id = required_field(params, "region_id")?; let region_id = required_field(params, "region_id")?;
paired_coordinates(params)?; paired_coordinates(params)?;
let body = let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone()) voice_request_body(params, &["region_id"])?,
.map_err(|e| ApiError::Parse(e.to_string()))?; )
.map_err(|e| ApiError::Parse(e.to_string()))?;
let response = self let response = self
.generated() .generated()
.create_admin_voice_server(&region_id, &body) .create_admin_voice_server(&region_id, &body)
@@ -121,15 +121,18 @@ impl AdminApiClient {
let region_id = required_field(params, "region_id")?; let region_id = required_field(params, "region_id")?;
let server_id = required_field(params, "server_id")?; let server_id = required_field(params, "server_id")?;
paired_coordinates(params)?; paired_coordinates(params)?;
let body = let body = voice_request_body(params, &["region_id", "server_id"])?;
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone()) validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
.map_err(|e| ApiError::Parse(e.to_string()))?; self.patch_with_reason(
let response = self &format!(
.generated() "/admin/voice/regions/{}/servers/{}",
.update_admin_voice_server(&region_id, &server_id, &body) urlencoding::encode(&region_id),
.await urlencoding::encode(&server_id)
.map_err(|e| self.generated_error(e))?; ),
self.generated_value(response.into_inner()) Some(&body),
None,
)
.await
} }
pub async fn delete_voice_server( pub async fn delete_voice_server(
@@ -150,6 +153,26 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" } 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<()> { fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null()); let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
if has_coordinate("latitude") == has_coordinate("longitude") { if has_coordinate("latitude") == has_coordinate("longitude") {
@@ -168,3 +191,67 @@ fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String>
.map(std::borrow::ToOwned::to_owned) .map(std::borrow::ToOwned::to_owned)
.ok_or_else(|| ApiError::Parse(format!("{field} is required"))) .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()
);
}
}
+30 -21
View File
@@ -81,7 +81,7 @@ async fn admin_api_keys_post(
"create" => { "create" => {
let name = form.clean("name").unwrap_or_default(); let name = form.clean("name").unwrap_or_default();
let acls = form.list_values_any(&["acls[]", "acls"]); 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) => { Ok(created) => {
let keys = client let keys = client
.list_api_keys() .list_api_keys()
@@ -107,29 +107,38 @@ async fn admin_api_keys_post(
is_htmx, 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.secure_cookies(),
);
} }
} }
_ => {} "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.secure_cookies(),
)
} }
fn admin_api_key_flash_response( fn admin_api_key_flash_response(
+22 -10
View File
@@ -16,7 +16,7 @@ use axum::{
use super::ActionQuery; use super::ActionQuery;
use super::bans_actions::{ 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> { pub fn router() -> Router<AppState> {
@@ -105,7 +105,7 @@ async fn generic_ban_post(
form.audit_log_reason.as_deref(), form.audit_log_reason.as_deref(),
) )
.await; .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 { macro_rules! ban_post {
@@ -171,19 +171,22 @@ async fn url_domain_bans_post(
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None })); Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
let form: BanFormData = match Form::from_request(request, &state).await { let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f, 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 is_htmx = htmx::is_htmx_request(&headers);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let domain = form.domain.as_deref().unwrap_or("").trim().to_owned(); let domain = form.domain.as_deref().unwrap_or("").trim().to_owned();
if domain.is_empty() { 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 action = aq.action.as_deref().unwrap_or("");
let (level, msg) = match action { let (level, msg) = match action {
"ban" => { "ban" => {
let m_sub = form.match_subdomains.as_deref() == Some("true"); 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")), Ok(()) => ("success", format!("Domain {domain} banned successfully")),
Err(error) => { Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: ban URL domain"); tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
@@ -191,7 +194,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")), Ok(()) => ("success", format!("Domain {domain} unbanned")),
Err(error) => { Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: unban URL domain"); tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
@@ -247,25 +253,31 @@ async fn profile_substring_bans_post(
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None })); Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
let form: BanFormData = match Form::from_request(request, &state).await { let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f, 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 is_htmx = htmx::is_htmx_request(&headers);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let scope = form.scope.as_deref().unwrap_or("").trim().to_owned(); let scope = form.scope.as_deref().unwrap_or("").trim().to_owned();
let substring = form.substring.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() { 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 action = aq.action.as_deref().unwrap_or("");
let (level, msg) = match action { 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}")), Ok(()) => ("success", format!("\"{substring}\" banned for {scope}")),
Err(error) => { Err(error) => {
tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring"); tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring");
("error", format!("Failed to ban substring for {scope}")) ("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}")), Ok(()) => ("success", format!("\"{substring}\" unbanned for {scope}")),
Err(error) => { Err(error) => {
tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring"); tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring");
+61 -75
View File
@@ -1,17 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // 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::api::types::{FlashLevel, FlashMessage};
use crate::middleware::auth::AuthContext; use crate::middleware::auth::AuthContext;
use crate::templates; use crate::templates;
use axum::{ use axum::response::{Html, IntoResponse, Response};
http::HeaderMap,
response::{Html, IntoResponse, Response},
};
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
pub struct BanFormData { pub struct BanFormData {
#[serde(default)] #[serde(default)]
pub ip: Option<String>, pub ip: Option<String>,
@@ -65,7 +61,7 @@ pub async fn execute_ban(
bulk_hashes: Option<&str>, bulk_hashes: Option<&str>,
bulk_sha256_list: Option<&str>, bulk_sha256_list: Option<&str>,
audit_log_reason: 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" { if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
let raw_hashes = if action == "bulk-ban-files" { let raw_hashes = if action == "bulk-ban-files" {
bulk_sha256_list bulk_sha256_list
@@ -75,13 +71,13 @@ pub async fn execute_ban(
return execute_bulk_ban(client, raw_hashes, audit_log_reason).await; return execute_bulk_ban(client, raw_hashes, audit_log_reason).await;
} }
if value.is_empty() { if value.is_empty() {
return ("error".into(), "Value is required".into()); return ("error", "Value is required".into());
} }
match action { match action {
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await, "ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
"unban" => execute_single_unban(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, "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, client: &AdminApiClient,
bulk_hashes: Option<&str>, bulk_hashes: Option<&str>,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> (String, String) { ) -> (&'static str, String) {
let hashes: Vec<String> = bulk_hashes let hashes: Vec<String> = bulk_hashes
.unwrap_or("") .unwrap_or("")
.split(|c: char| c.is_whitespace() || c == ',' || c == ';') .split(|c: char| c.is_whitespace() || c == ',' || c == ';')
@@ -98,18 +94,15 @@ async fn execute_bulk_ban(
.collect(); .collect();
if hashes.is_empty() { if hashes.is_empty() {
return ( return (
"error".into(), "error",
"No valid 64-character hex hashes found in input".into(), "No valid 64-character hex hashes found in input".into(),
); );
} }
match client.bulk_ban_file_shas(&hashes, audit_log_reason).await { match client.bulk_ban_file_shas(&hashes, audit_log_reason).await {
Ok(r) => ( Ok(r) => ("success", format!("Bulk ban job created: {}", r.job_id)),
"success".into(),
format!("Bulk ban job created: {}", r.job_id),
),
Err(error) => { Err(error) => {
tracing::warn!(%error, "admin API request failed: bulk ban file SHAs"); 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,26 @@ async fn execute_single_ban(
ban_type: &str, ban_type: &str,
value: &str, value: &str,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> (String, String) { ) -> (&'static str, String) {
let success = format!("{value} banned successfully"); let result = match ban_type {
let failure = format!("Failed to ban {value}"); "ip-bans" => client.ban_ip(value, audit_log_reason).await,
match ban_type { "email-bans" => client.ban_email(value, audit_log_reason).await,
"ip-bans" => ban_action_result(client.ban_ip(value).await, success, failure), "suspicious-email-domains" => {
"email-bans" => ban_action_result(client.ban_email(value).await, success, failure), client
"suspicious-email-domains" => ban_action_result( .add_suspicious_email_domain(value, audit_log_reason)
client.add_suspicious_email_domain(value).await, .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()), "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( async fn execute_single_unban(
@@ -149,32 +139,33 @@ async fn execute_single_unban(
ban_type: &str, ban_type: &str,
value: &str, value: &str,
audit_log_reason: Option<&str>, audit_log_reason: Option<&str>,
) -> (String, String) { ) -> (&'static str, String) {
let success = format!("{value} unbanned successfully"); let result = match ban_type {
let failure = format!("Failed to unban {value}"); "ip-bans" => client.unban_ip(value, audit_log_reason).await,
match ban_type { "email-bans" => client.unban_email(value, audit_log_reason).await,
"ip-bans" => ban_action_result(client.unban_ip(value).await, success, failure), "suspicious-email-domains" => {
"email-bans" => ban_action_result(client.unban_email(value).await, success, failure), client
"suspicious-email-domains" => ban_action_result( .remove_suspicious_email_domain(value, audit_log_reason)
client.remove_suspicious_email_domain(value).await, .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()), "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 { let result = match ban_type {
"ip-bans" => client.check_ip_ban(value).await, "ip-bans" => client.check_ip_ban(value).await,
"email-bans" => client.check_email_ban(value).await, "email-bans" => client.check_email_ban(value).await,
@@ -183,28 +174,28 @@ async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) ->
"url-bans" => client.check_url_ban(value).await, "url-bans" => client.check_url_ban(value).await,
"file-sha-bans" => client.check_file_sha_ban(value).await, "file-sha-bans" => client.check_file_sha_ban(value).await,
"avatar-hash-bans" => client.check_avatar_hash_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 { match result {
Ok(r) if r.banned => ("info".into(), format!("{value} is banned")), Ok(r) if r.banned => ("info", format!("{value} is banned")),
Ok(_) => ("info".into(), format!("{value} is NOT banned")), Ok(_) => ("info", format!("{value} is NOT banned")),
Err(error) => { Err(error) => {
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status"); 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>( fn ban_action_result(
result: Result<T, E>, result: ApiResult<()>,
success_message: String, success_message: String,
error_message: String, error_message: String,
) -> (String, String) { ) -> (&'static str, String) {
match result { match result {
Ok(_) => ("success".into(), success_message), Ok(()) => ("success", success_message),
Err(error) => { Err(error) => {
tracing::warn!(%error, "admin API request failed: ban action"); tracing::warn!(%error, "admin API request failed: ban action");
("error".into(), error_message) ("error", error_message)
} }
} }
} }
@@ -228,11 +219,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 { pub fn render_inline_flash(level: &str, message: &str) -> Response {
let (border, bg, text) = match level { let (border, bg, text) = match level {
"success" => ("border-green-300", "bg-green-50", "text-green-800"), "success" => ("border-green-300", "bg-green-50", "text-green-800"),
+14 -18
View File
@@ -1,14 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{ use crate::{
api::client::AdminApiClient, api::{client::AdminApiClient, generated::types::GiftCodeDurationTypeSchema},
middleware::{ middleware::{
auth::AuthContext, auth::AuthContext,
csrf::CsrfToken, csrf::CsrfToken,
flash::{self, FlashData}, flash::{self, FlashData},
}, },
state::AppState, state::AppState,
templates, templates::{self, pages::gift_codes::MAX_GIFT_CODES},
}; };
use axum::{ use axum::{
Form, Router, Form, Router,
@@ -28,11 +28,11 @@ struct GiftCodesForm {
#[serde(default)] #[serde(default)]
_csrf: Option<String>, _csrf: Option<String>,
#[serde(default)] #[serde(default)]
count: Option<String>, count: Option<u32>,
#[serde(default)] #[serde(default)]
duration_type: Option<String>, duration_type: Option<GiftCodeDurationTypeSchema>,
#[serde(default)] #[serde(default)]
duration_quantity: Option<String>, duration_quantity: Option<u32>,
} }
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
@@ -86,21 +86,17 @@ async fn gift_codes_post(
); );
} }
}; };
let count = form let count = form.count.unwrap_or(1).clamp(1, MAX_GIFT_CODES);
.count let duration_type = form
.as_deref() .duration_type
.and_then(|s| s.parse::<u32>().ok()) .unwrap_or(GiftCodeDurationTypeSchema::Months);
.unwrap_or(1) let duration_quantity = form.duration_quantity.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 client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let secure_cookies = config.secure_cookies(); let secure_cookies = config.secure_cookies();
match client.generate_gift_codes(count, dur_type, dur_qty).await { match client
.generate_gift_codes(count, duration_type, duration_quantity)
.await
{
Ok(result) => { Ok(result) => {
let codes = result.codes.join(","); let codes = result.codes.join(",");
flash::redirect_with_flash( flash::redirect_with_flash(
+4 -3
View File
@@ -43,7 +43,7 @@ pub async fn render(
let page = query.members_page.unwrap_or(0); let page = query.members_page.unwrap_or(0);
let limit: u32 = 50; let limit: u32 = 50;
let resp = client let resp = client
.list_guild_members(guild_id, limit, page * limit) .list_guild_members(guild_id, limit, u64::from(page) * u64::from(limit))
.await .await
.log_error("load guild members")?; .log_error("load guild members")?;
Some(tabs::members::members_tab( Some(tabs::members::members_tab(
@@ -57,7 +57,7 @@ pub async fn render(
let page = query.reports_page.unwrap_or(0); let page = query.reports_page.unwrap_or(0);
let limit: u32 = 25; let limit: u32 = 25;
let resp = client 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 .await
.log_error("load guild reports")?; .log_error("load guild reports")?;
Some(tabs::reports::reports_tab( Some(tabs::reports::reports_tab(
@@ -121,6 +121,7 @@ pub async fn render(
admin_user_id: None, admin_user_id: None,
target_id: Some(guild_id.to_owned()), target_id: Some(guild_id.to_owned()),
target_type: Some("guild".to_owned()), target_type: Some("guild".to_owned()),
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()), sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()), sort_order: Some("desc".to_owned()),
limit: 50, limit: 50,
@@ -134,7 +135,7 @@ pub async fn render(
@if let Some(resp) = resp { @if let Some(resp) = resp {
@if resp.logs.is_empty() { @if resp.logs.is_empty() {
p class="text-sm text-neutral-500" { 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 { } @else {
(table_container(table(maud::html! { (table_container(table(maud::html! {
+16 -16
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() { } else if params.has_search() {
let offset = params.page.saturating_mul(params.limit); let offset = u64::from(params.page) * u64::from(params.limit);
client client
.search_guilds(params.search_query(), params.limit, offset) .search_guilds(params.search_query(), params.limit, offset)
.await .await
.log_error("search guilds") .log_error("search guilds")
.map(|response| { .map(|response| (response.guilds, response.total))
let has_more = u64::from(offset) + (response.guilds.len() as u64) < response.total;
(response.guilds, Some(response.total), has_more)
})
} else { } else {
None None
}; };
let result_guilds = results.as_ref().map(|result| result.0.as_slice()); let result_guilds = results.as_ref().map(|result| result.0.as_slice());
let total = results.as_ref().and_then(|result| result.1); let total = results.as_ref().map(|result| result.1);
let has_more = results.as_ref().is_some_and(|result| result.2);
let markup = templates::pages::guilds_list::guilds_list_page( let markup = templates::pages::guilds_list::guilds_list_page(
config, config,
@@ -106,7 +102,6 @@ async fn guilds_list(
&params, &params,
result_guilds, result_guilds,
total, total,
has_more,
is_results_fragment, is_results_fragment,
); );
Html(markup.into_string()).into_response() Html(markup.into_string()).into_response()
@@ -324,8 +319,12 @@ async fn dispatch_guild_action(
"default_message_notifications", "default_message_notifications",
"disabled_operations", "disabled_operations",
] { ] {
if let Some(v) = form.parse_i64(key) { let value = match form.parse_value::<i64>(key) {
settings.insert(key.to_string(), serde_json::json!(v)); 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") { if form.contains_key("nsfw_submitted") {
@@ -356,11 +355,12 @@ async fn dispatch_guild_action(
) )
} }
"update_disabled_operations" => { "update_disabled_operations" => {
let disabled_operations = form let Ok(operations) =
.list_values_any(&["disabled_operations[]", "disabled_operations"]) form.parse_list_values::<i64>(&["disabled_operations[]", "disabled_operations"])
.iter() else {
.filter_map(|value| value.parse::<i64>().ok()) return FlashData::error("Invalid disabled operation value");
.fold(0_i64, |acc, value| acc | value); };
let disabled_operations = operations.into_iter().fold(0_i64, |acc, value| acc | value);
let settings = serde_json::json!({ let settings = serde_json::json!({
"disabled_operations": disabled_operations, "disabled_operations": disabled_operations,
}); });
+31 -45
View File
@@ -21,7 +21,6 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
struct JobsQuery { struct JobsQuery {
status: Option<String>, status: Option<String>,
task_type: Option<String>, task_type: Option<String>,
@@ -219,49 +218,36 @@ async fn job_detail_post(
} }
fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String { fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String {
let mut params = Vec::new(); let query = [
push_query(&mut params, "status", query.status.as_deref()); ("status", query.status.as_deref()),
push_query(&mut params, "task_type", query.task_type.as_deref()); ("task_type", query.task_type.as_deref()),
push_query( (
&mut params, "requested_by_user_id",
"requested_by_user_id", query.requested_by_user_id.as_deref(),
query.requested_by_user_id.as_deref(), ),
); ("max_lookback_days", query.max_lookback_days.as_deref()),
push_query( ("cursor_bucket_day", query.cursor_bucket_day.as_deref()),
&mut params, ("cursor_created_at", query.cursor_created_at.as_deref()),
"max_lookback_days", ("cursor_job_id", query.cursor_job_id.as_deref()),
query.max_lookback_days.as_deref(), ]
); .into_iter()
push_query( .filter_map(|(key, value)| {
&mut params, value
"cursor_bucket_day", .filter(|value| !value.is_empty())
query.cursor_bucket_day.as_deref(), .map(|value| (key, value))
); })
push_query( .map(|(key, value)| {
&mut params, format!(
"cursor_created_at", "{}={}",
query.cursor_created_at.as_deref(), urlencoding::encode(key),
); urlencoding::encode(value)
push_query(&mut params, "cursor_job_id", query.cursor_job_id.as_deref()); )
if params.is_empty() { })
return format!("{}/jobs", config.base_path); .collect::<Vec<_>>()
} .join("&");
let query = params if query.is_empty() {
.iter() format!("{}/jobs", config.base_path)
.map(|(key, value)| { } else {
format!( format!("{}/jobs?{query}", config.base_path)
"{}={}",
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()));
} }
} }
+29 -24
View File
@@ -59,53 +59,51 @@ pub(crate) async fn messages_post(
match action { match action {
"lookup" => { "lookup" => {
let context_limit = parse_context_limit(form.first("context_limit")); 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}", "{base}/messages?channel_id={}&message_id={}&context_limit={context_limit}",
encode_opt(&channel_id), encode_opt(&channel_id),
encode_opt(&message_id) encode_opt(&message_id)
)) ))
.into_response(); .into_response()
} }
"lookup-by-attachment" => { "lookup-by-attachment" => {
let attachment_id = form.clean("attachment_id"); let attachment_id = form.clean("attachment_id");
let filename = form.clean("filename"); let filename = form.clean("filename");
let context_limit = parse_context_limit(form.first("context_limit")); 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}", "{base}/messages?channel_id={}&attachment_id={}&filename={}&context_limit={context_limit}",
encode_opt(&channel_id), encode_opt(&channel_id),
encode_opt(&attachment_id), encode_opt(&attachment_id),
encode_opt(&filename) encode_opt(&filename)
)) ))
.into_response(); .into_response()
}
"browse" => {
return Redirect::to(&format!(
"{base}/messages?channel_id={}",
encode_opt(&channel_id)
))
.into_response();
} }
"browse" => Redirect::to(&format!(
"{base}/messages?channel_id={}",
encode_opt(&channel_id)
))
.into_response(),
"search" => { "search" => {
let search = form.clean("search"); let search = form.clean("search");
return Redirect::to(&format!( Redirect::to(&format!(
"{base}/messages?channel_id={}&search={}", "{base}/messages?channel_id={}&search={}",
encode_opt(&channel_id), encode_opt(&channel_id),
encode_opt(&search) encode_opt(&search)
)) ))
.into_response(); .into_response()
} }
"delete" => { "delete" => {
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else { let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id"); return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
}; };
let audit_log_reason = form.clean("audit_log_reason"); let audit_log_reason = form.clean("audit_log_reason");
return match client match client
.delete_message(cid, mid, audit_log_reason.as_deref()) .delete_message(cid, mid, audit_log_reason.as_deref())
.await .await
{ {
Ok(()) => Json(serde_json::json!({"success": true})).into_response(), Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")), Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
}; }
} }
"report-to-ncmec" => { "report-to-ncmec" => {
let attachment_id = form.clean("attachment_id"); let attachment_id = form.clean("attachment_id");
@@ -131,7 +129,7 @@ pub(crate) async fn messages_post(
"Missing required NCMEC report fields", "Missing required NCMEC report fields",
); );
} }
return match client match client
.report_attachment_to_ncmec( .report_attachment_to_ncmec(
cid, cid,
mid, mid,
@@ -144,11 +142,10 @@ pub(crate) async fn messages_post(
{ {
Ok(resp) => Json(resp.data).into_response(), Ok(resp) => Json(resp.data).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")), 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( pub(crate) async fn system_dms_post(
@@ -253,14 +250,22 @@ pub(crate) async fn bulk_actions_post(
} }
"bulk-schedule-user-deletion" | "bulk_delete_users" => { "bulk-schedule-user-deletion" | "bulk_delete_users" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]); let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
let reason_code = form.parse_u32("reason_code").unwrap_or(2); let (Ok(reason_code), Ok(days)) = (
let days = form.parse_u32("days_until_deletion").unwrap_or(14); 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 public_reason = form.clean("public_reason");
client client
.bulk_schedule_user_deletion( .bulk_schedule_user_deletion(
&user_ids, &user_ids,
reason_code, reason_code.unwrap_or(2),
days, days.unwrap_or(14),
public_reason.as_deref(), public_reason.as_deref(),
audit_log_reason.as_deref(), audit_log_reason.as_deref(),
) )
@@ -357,7 +362,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 value
.and_then(|s| s.parse::<u32>().ok()) .and_then(|s| s.parse::<u32>().ok())
.filter(|n| *n > 0) .filter(|n| *n > 0)
+1 -8
View File
@@ -16,7 +16,6 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
struct MessagesQuery { struct MessagesQuery {
channel_id: Option<String>, channel_id: Option<String>,
message_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 before = query.before.as_deref().filter(|s| !s.is_empty());
let after = query.after.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 search = query.search.as_deref().filter(|s| !s.is_empty());
let context_limit = query let context_limit = super::message_actions::parse_context_limit(query.context_limit.as_deref());
.context_limit
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(50)
.min(100);
let mut lookup_result = None; let mut lookup_result = None;
let mut browse_result = None; let mut browse_result = None;
let mut search_result = None; let mut search_result = None;
+19 -16
View File
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{ use crate::{
api::client::{AdminApiClient, ApiResultExt}, api::{
client::{AdminApiClient, ApiResultExt},
reports::SearchReportsParams,
},
config::AdminConfig, config::AdminConfig,
middleware::{ middleware::{
auth::AuthContext, auth::AuthContext,
@@ -23,7 +26,7 @@ use axum::{
}; };
use serde::Deserialize; use serde::Deserialize;
const MAX_REPORT_OFFSET: u32 = 10_000; const MAX_REPORT_OFFSET: u64 = 10_000;
#[derive(Deserialize)] #[derive(Deserialize)]
struct ReportsQuery { struct ReportsQuery {
@@ -72,12 +75,12 @@ async fn reports_list(
let config = state.config(); let config = state.config();
let page = query.page.unwrap_or(0); let page = query.page.unwrap_or(0);
let limit = query.limit.unwrap_or(25).clamp(1, 200); 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 { if offset > MAX_REPORT_OFFSET {
return reports_error_page( return reports_error_page(
config, config,
&auth.0, &auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.", "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 search_query = query.q.as_deref().and_then(clean_string);
@@ -89,22 +92,22 @@ async fn reports_list(
.as_deref() .as_deref()
.and_then(|s| s.parse::<i32>().ok()); .and_then(|s| s.parse::<i32>().ok());
let reports = client let reports = client
.search_reports( .search_reports(&SearchReportsParams {
search_query.as_deref(), query: search_query.as_deref(),
status, status,
report_type, report_type,
query.category.as_deref(), category: query.category.as_deref(),
query.reporter_id.as_deref(), reporter_id: query.reporter_id.as_deref(),
query.reported_user_id.as_deref(), reported_user_id: query.reported_user_id.as_deref(),
query.reported_guild_id.as_deref(), reported_guild_id: query.reported_guild_id.as_deref(),
query.reported_channel_id.as_deref(), reported_channel_id: query.reported_channel_id.as_deref(),
query.guild_context_id.as_deref(), guild_context_id: query.guild_context_id.as_deref(),
query.resolved_by_admin_id.as_deref(), resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
Some(sort_by), sort_by: Some(sort_by),
Some(sort_order), sort_order: Some(sort_order),
limit, limit,
offset, offset,
) })
.await .await
.log_error("search reports"); .log_error("search reports");
+4 -3
View File
@@ -17,19 +17,18 @@ use serde::Deserialize;
use super::system_actions; use super::system_actions;
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
struct GatewayQuery { struct GatewayQuery {
leaderboard_limit: Option<String>, leaderboard_limit: Option<String>,
node_stats: Option<String>, node_stats: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
struct AuditLogsQuery { struct AuditLogsQuery {
q: Option<String>, q: Option<String>,
admin_user_id: Option<String>, admin_user_id: Option<String>,
target_id: Option<String>, target_id: Option<String>,
target_type: Option<String>, target_type: Option<String>,
access: Option<String>,
sort_by: Option<String>, sort_by: Option<String>,
sort_order: Option<String>, sort_order: Option<String>,
limit: Option<u32>, limit: Option<u32>,
@@ -121,6 +120,7 @@ async fn audit_logs_page(
admin_user_id: query.admin_user_id.as_deref().unwrap_or(""), admin_user_id: query.admin_user_id.as_deref().unwrap_or(""),
target_id: query.target_id.as_deref().unwrap_or(""), target_id: query.target_id.as_deref().unwrap_or(""),
target_type: query.target_type.as_deref().unwrap_or(""), target_type: query.target_type.as_deref().unwrap_or(""),
access: query.access.as_deref().unwrap_or(""),
sort_by: query.sort_by.as_deref().unwrap_or("createdAt"), sort_by: query.sort_by.as_deref().unwrap_or("createdAt"),
sort_order: query.sort_order.as_deref().unwrap_or("desc"), sort_order: query.sort_order.as_deref().unwrap_or("desc"),
limit, limit,
@@ -133,10 +133,11 @@ async fn audit_logs_page(
admin_user_id: nonempty(params.admin_user_id), admin_user_id: nonempty(params.admin_user_id),
target_id: nonempty(params.target_id), target_id: nonempty(params.target_id),
target_type: nonempty(params.target_type), target_type: nonempty(params.target_type),
access: nonempty(params.access),
sort_by: Some(params.sort_by.to_owned()), sort_by: Some(params.sort_by.to_owned()),
sort_order: Some(params.sort_order.to_owned()), sort_order: Some(params.sort_order.to_owned()),
limit, limit,
offset: current_page * limit, offset: u64::from(current_page) * u64::from(limit),
}; };
let result = client let result = client
.search_audit_logs(&search_params) .search_audit_logs(&search_params)
File diff suppressed because it is too large. Load diff
+44 -31
View File
@@ -60,7 +60,9 @@ pub async fn dispatch(
"Failed to update user flags", "Failed to update user flags",
); );
} }
let submitted = parse_u64_list(form, &["flags[]", "flags"]); let Ok(submitted) = form.parse_list_values::<u64>(&["flags[]", "flags"]) else {
return DispatchOutcome::error("Invalid user flag value");
};
let selected = submitted.iter().copied().collect::<HashSet<_>>(); let selected = submitted.iter().copied().collect::<HashSet<_>>();
let user = match client.get_user_by_id(user_id).await { let user = match client.get_user_by_id(user_id).await {
Ok(user) => user, Ok(user) => user,
@@ -89,15 +91,22 @@ pub async fn dispatch(
} }
"update_premium_flags" => { "update_premium_flags" => {
if has_legacy_flag_delta_fields(form) { if has_legacy_flag_delta_fields(form) {
let add = parse_i32_list(form, &["add_flags[]", "add_flags"]); let Ok(add) = form.parse_list_values::<i32>(&["add_flags[]", "add_flags"]) else {
let remove = parse_i32_list(form, &["remove_flags[]", "remove_flags"]); return DispatchOutcome::error("Invalid premium flag value to add");
};
let Ok(remove) = form.parse_list_values::<i32>(&["remove_flags[]", "remove_flags"])
else {
return DispatchOutcome::error("Invalid premium flag value to remove");
};
return DispatchOutcome::from_result( return DispatchOutcome::from_result(
client.update_premium_flags(user_id, &add, &remove).await, client.update_premium_flags(user_id, &add, &remove).await,
"Premium flags updated successfully", "Premium flags updated successfully",
"Failed to update premium flags", "Failed to update premium flags",
); );
} }
let submitted = parse_i32_list(form, &["flags[]", "flags"]); let Ok(submitted) = form.parse_list_values::<i32>(&["flags[]", "flags"]) else {
return DispatchOutcome::error("Invalid premium flag value");
};
let selected = submitted.iter().copied().collect::<HashSet<_>>(); let selected = submitted.iter().copied().collect::<HashSet<_>>();
let user = match client.get_user_by_id(user_id).await { let user = match client.get_user_by_id(user_id).await {
Ok(user) => user, Ok(user) => user,
@@ -124,9 +133,12 @@ pub async fn dispatch(
) )
} }
"update_suspicious_flags" => { "update_suspicious_flags" => {
let flags = parse_i32_list(form, &["suspicious_flags[]", "suspicious_flags"]) let Ok(submitted) =
.into_iter() form.parse_list_values::<i32>(&["suspicious_flags[]", "suspicious_flags"])
.fold(0, |acc, flag| acc | flag); else {
return DispatchOutcome::error("Invalid suspicious activity flag value");
};
let flags = submitted.into_iter().fold(0, |acc, flag| acc | flag);
DispatchOutcome::from_result( DispatchOutcome::from_result(
client.update_suspicious_flags(user_id, flags).await, client.update_suspicious_flags(user_id, flags).await,
"Suspicious activity flags updated successfully", "Suspicious activity flags updated successfully",
@@ -225,15 +237,19 @@ pub async fn dispatch(
) )
} }
"temp_ban" => { "temp_ban" => {
let dur = form let Ok(duration) = form.parse_value_any::<u32>(&["duration_hours", "duration"]) else {
.parse_u32("duration_hours") return DispatchOutcome::error("Invalid ban duration");
.or_else(|| form.parse_u32("duration")) };
.unwrap_or(24);
let reason = get("reason"); let reason = get("reason");
let private = get("private_reason"); let private = get("private_reason");
DispatchOutcome::from_result( DispatchOutcome::from_result(
client client
.temp_ban_user(user_id, dur, reason.as_deref(), private.as_deref()) .temp_ban_user(
user_id,
duration.unwrap_or(24),
reason.as_deref(),
private.as_deref(),
)
.await, .await,
"User temporarily banned successfully", "User temporarily banned successfully",
"Failed to temporarily ban user", "Failed to temporarily ban user",
@@ -249,7 +265,7 @@ pub async fn dispatch(
return DispatchOutcome::error("IP address is required"); return DispatchOutcome::error("IP address is required");
}; };
DispatchOutcome::from_result( DispatchOutcome::from_result(
client.ban_ip(&ip).await, client.ban_ip(&ip, None).await,
"IP banned successfully", "IP banned successfully",
"Failed to ban IP", "Failed to ban IP",
) )
@@ -259,18 +275,29 @@ pub async fn dispatch(
return DispatchOutcome::error("Avatar hash is required"); return DispatchOutcome::error("Avatar hash is required");
}; };
DispatchOutcome::from_result( DispatchOutcome::from_result(
client.ban_avatar_hash(&hash).await, client.ban_avatar_hash(&hash, None).await,
"Avatar hash banned successfully", "Avatar hash banned successfully",
"Failed to ban avatar hash", "Failed to ban avatar hash",
) )
} }
"schedule_deletion" => { "schedule_deletion" => {
let reason_code = form.parse_i32("reason_code").unwrap_or(0); let Ok(reason_code) = form.parse_value::<i32>("reason_code") else {
return DispatchOutcome::error("Invalid deletion reason code");
};
let public_reason = get("public_reason"); let public_reason = get("public_reason");
let days = form.parse_u32("days_until_deletion").unwrap_or(60); let private_reason = get("private_reason");
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
return DispatchOutcome::error("Invalid deletion delay");
};
DispatchOutcome::from_result( DispatchOutcome::from_result(
client client
.schedule_deletion(user_id, reason_code, public_reason.as_deref(), days) .schedule_deletion(
user_id,
reason_code.unwrap_or(0),
public_reason.as_deref(),
days.unwrap_or(60),
private_reason.as_deref(),
)
.await, .await,
"User deletion scheduled successfully", "User deletion scheduled successfully",
"Failed to schedule user deletion", "Failed to schedule user deletion",
@@ -441,20 +468,6 @@ fn is_relationship_category(category: &str) -> bool {
) )
} }
fn parse_u64_list(form: &MultiValueForm, keys: &[&str]) -> Vec<u64> {
form.list_values_any(keys)
.iter()
.filter_map(|value| value.parse().ok())
.collect()
}
fn parse_i32_list(form: &MultiValueForm, keys: &[&str]) -> Vec<i32> {
form.list_values_any(keys)
.iter()
.filter_map(|value| value.parse().ok())
.collect()
}
fn parse_dry_run(value: Option<&str>) -> bool { fn parse_dry_run(value: Option<&str>) -> bool {
!matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0") !matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0")
} }
+22 -21
View File
@@ -73,15 +73,11 @@ pub async fn render(
.map(|r| r.sessions) .map(|r| r.sessions)
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions")) .map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
.unwrap_or_default(); .unwrap_or_default();
let webauthn_credentials = if u.authenticator_types.contains(&2) { let webauthn_credentials = client
client .list_webauthn_credentials(user_id)
.list_webauthn_credentials(user_id) .await
.await .map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials")) .unwrap_or_default();
.unwrap_or_default()
} else {
Vec::new()
};
Some(tabs::account::account_tab( Some(tabs::account::account_tab(
config, config,
&u, &u,
@@ -164,25 +160,29 @@ pub async fn render(
Some(tabs::guilds::guilds_tab(config, user_id, &g)) Some(tabs::guilds::guilds_tab(config, user_id, &g))
} }
"reports" => { "reports" => {
let lim = query.reports_limit.unwrap_or(25); let limit = query.reports_limit.unwrap_or(25);
let sp = query.reports_sent_page.unwrap_or(0); let sent_page = query.reports_sent_page.unwrap_or(0);
let rp = query.reports_received_page.unwrap_or(0); let received_page = query.reports_received_page.unwrap_or(0);
let sent = client let sent = client
.search_reports_by_reporter(user_id, lim, sp * lim) .search_reports_by_reporter(user_id, limit, u64::from(sent_page) * u64::from(limit))
.await .await
.log_error("load reports sent by user"); .log_error("load reports sent by user");
let recv = client let received = client
.search_reports_by_reported_user(user_id, lim, rp * lim) .search_reports_by_reported_user(
user_id,
limit,
u64::from(received_page) * u64::from(limit),
)
.await .await
.log_error("load reports against user"); .log_error("load reports against user");
Some(tabs::reports::reports_tab( Some(tabs::reports::reports_tab(
config, config,
user_id, user_id,
sent.as_ref(), sent.as_ref(),
recv.as_ref(), received.as_ref(),
sp, sent_page,
rp, received_page,
lim, limit,
)) ))
} }
"relationships" => { "relationships" => {
@@ -240,11 +240,12 @@ pub async fn render(
query: None, query: None,
admin_user_id: None, admin_user_id: None,
target_id: Some(user_id.to_owned()), target_id: Some(user_id.to_owned()),
target_type: Some("user".to_owned()), target_type: None,
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()), sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()), sort_order: Some("desc".to_owned()),
limit, limit,
offset: page * limit, offset: u64::from(page) * u64::from(limit),
}) })
.await .await
.log_error("load user admin audit logs")?; .log_error("load user admin audit logs")?;
+2 -2
View File
@@ -93,7 +93,7 @@ async fn users_list(
.log_error("lookup users by ids") .log_error("lookup users by ids")
.map(|users| (users, false)) .map(|users| (users, false))
} else if params.has_search() { } else if params.has_search() {
let offset = params.page.saturating_mul(params.limit); let offset = u64::from(params.page) * u64::from(params.limit);
client client
.search_users( .search_users(
params.search_query(), params.search_query(),
@@ -105,7 +105,7 @@ async fn users_list(
.await .await
.log_error("search users") .log_error("search users")
.map(|r| { .map(|r| {
let has_more = u64::from(offset) + (r.users.len() as u64) < r.total; let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more) (r.users, has_more)
}) })
} else { } else {
+48 -16
View File
@@ -4,7 +4,7 @@ use crate::{
api::client::AdminApiClient, api::client::AdminApiClient,
middleware::{auth::AuthContext, csrf}, middleware::{auth::AuthContext, csrf},
state::AppState, state::AppState,
templates, templates::{self, pages::voice_servers::VoiceServersPageParams},
}; };
use axum::{ use axum::{
Router, Router,
@@ -13,12 +13,34 @@ use axum::{
routing::get, routing::get,
}; };
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)] #[derive(Deserialize)]
struct VoiceServersQuery { struct VoiceServersQuery {
region_id: Option<String>, region_id: Option<String>,
} }
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
let response = match client.get_gateway_voice_state_counts().await {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "admin API request failed: load voice state counts");
return HashMap::new();
}
};
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
return HashMap::new();
};
servers
.iter()
.filter_map(|entry| {
let server_id = entry.get("server_id")?.as_str()?.to_owned();
let count = entry.get("voice_state_count")?.as_i64()?;
Some((server_id, count))
})
.collect()
}
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route( .route(
@@ -79,17 +101,21 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page( let markup = templates::pages::voice_servers::voice_servers_page(
config, config,
&auth.0, &auth.0,
None, &VoiceServersPageParams {
None, region_id: None,
None, region_name: None,
None, servers: None,
&csrf_token, connection_counts: &HashMap::new(),
error: None,
csrf_token: &csrf_token,
},
); );
return Html(markup.into_string()).into_response(); return Html(markup.into_string()).into_response();
} }
}; };
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session); let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let connection_counts = load_server_connection_counts(&client).await;
let region_name = match client.get_voice_region(region_id, false).await { let region_name = match client.get_voice_region(region_id, false).await {
Ok(resp) => resp Ok(resp) => resp
@@ -107,11 +133,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page( let markup = templates::pages::voice_servers::voice_servers_page(
config, config,
&auth.0, &auth.0,
Some(region_id), &VoiceServersPageParams {
Some(&region_name), region_id: Some(region_id),
Some(&resp.servers), region_name: Some(&region_name),
None, servers: Some(&resp.servers),
&csrf_token, connection_counts: &connection_counts,
error: None,
csrf_token: &csrf_token,
},
); );
Html(markup.into_string()).into_response() Html(markup.into_string()).into_response()
} }
@@ -120,11 +149,14 @@ async fn voice_servers_page(
let markup = templates::pages::voice_servers::voice_servers_page( let markup = templates::pages::voice_servers::voice_servers_page(
config, config,
&auth.0, &auth.0,
Some(region_id), &VoiceServersPageParams {
Some(&region_name), region_id: Some(region_id),
None, region_name: Some(&region_name),
Some(&msg), servers: None,
&csrf_token, connection_counts: &connection_counts,
error: Some(&msg),
csrf_token: &csrf_token,
},
); );
Html(markup.into_string()).into_response() Html(markup.into_string()).into_response()
} }
+109 -20
View File
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
} }
body.insert("is_default".into(), form.bool_value("is_default").into()); body.insert("is_default".into(), form.bool_value("is_default").into());
body.insert("vip_only".into(), form.bool_value("vip_only").into()); body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert( insert_submitted_list(&mut body, form, "required_guild_features");
"required_guild_features".into(), insert_submitted_list(&mut body, form, "allowed_guild_ids");
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
serde_json::Value::Object(body) serde_json::Value::Object(body)
} }
fn insert_submitted_list(
body: &mut serde_json::Map<String, serde_json::Value>,
form: &MultiValueForm,
field: &str,
) {
let repeated = format!("{field}[]");
if !form.contains_key(&repeated) && !form.contains_key(field) {
return;
}
body.insert(
field.to_owned(),
form.list_values_any(&[repeated.as_str(), field]).into(),
);
}
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value { pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
let mut body = serde_json::Map::new(); let mut body = serde_json::Map::new();
if let Some(v) = form.clean("region_id") { if let Some(v) = form.clean("region_id") {
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
body.insert("longitude".into(), lng.into()); body.insert("longitude".into(), lng.into());
} }
body.insert("is_active".into(), form.bool_value("is_active").into()); body.insert("is_active".into(), form.bool_value("is_active").into());
if let Some(raw) = form.first("soft_connection_limit") {
let trimmed = raw.trim();
if trimmed.is_empty() {
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
} else if let Ok(limit) = trimmed.parse::<i64>()
&& limit > 0
{
body.insert("soft_connection_limit".into(), limit.into());
}
}
body.insert("vip_only".into(), form.bool_value("vip_only").into()); body.insert("vip_only".into(), form.bool_value("vip_only").into());
body.insert( insert_submitted_list(&mut body, form, "required_guild_features");
"required_guild_features".into(), insert_submitted_list(&mut body, form, "allowed_guild_ids");
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
.into(),
);
body.insert(
"allowed_guild_ids".into(),
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
.into(),
);
serde_json::Value::Object(body) serde_json::Value::Object(body)
} }
@@ -255,6 +264,86 @@ mod tests {
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"])); assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
} }
#[test]
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
);
let body = build_server_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
);
let body = build_server_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
assert_eq!(body["is_active"], serde_json::json!(false));
}
#[test]
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
let body = build_region_body(&form);
assert_eq!(body["required_guild_features"], serde_json::json!([]));
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
}
#[test]
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
let body = build_region_body(&form);
let object = body.as_object().unwrap();
assert!(!object.contains_key("required_guild_features"));
assert!(!object.contains_key("allowed_guild_ids"));
}
#[test]
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
let form =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
}
#[test]
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
let body = build_server_body(&form);
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
}
#[test]
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
assert!(
!build_server_body(&absent)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let invalid =
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
assert!(
!build_server_body(&invalid)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
assert!(
!build_server_body(&zero)
.as_object()
.unwrap()
.contains_key("soft_connection_limit")
);
}
#[test] #[test]
fn build_server_body_preserves_single_and_repeated_values() { fn build_server_body_preserves_single_and_repeated_values() {
let form = MultiValueForm::parse( let form = MultiValueForm::parse(
@@ -41,17 +41,14 @@ pub fn archives_tab(
} }
} }
fn status_text(archive: &Archive) -> String { fn status_text(archive: &Archive) -> &str {
if archive.failed_at.is_some() { if archive.failed_at.is_some() {
return "Failed".to_owned(); return "Failed";
} }
if archive.completed_at.is_some() { if archive.completed_at.is_some() {
return "Completed".to_owned(); return "Completed";
} }
archive archive.progress_step.as_deref().unwrap_or("In Progress")
.progress_step
.clone()
.unwrap_or_else(|| "In Progress".to_owned())
} }
fn archives_table(base: &str, archives: &[Archive]) -> Markup { fn archives_table(base: &str, archives: &[Archive]) -> Markup {
@@ -66,9 +66,15 @@ pub fn audit_logs_for_target(
) -> Markup { ) -> Markup {
let has_previous = current_page > 0; let has_previous = current_page > 0;
let offset = (current_page as u64) * (PAGE_SIZE as u64); let offset = (current_page as u64) * (PAGE_SIZE as u64);
let has_next = offset + (entries.len() as u64) < total; let next_page = current_page
let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil().max(1.0) as u64; .checked_add(1)
let all_logs_href = format!("{base_path}/audit-logs?target_id={target_id}"); .filter(|_| offset + (entries.len() as u64) < total);
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
let page_number = u64::from(current_page) + 1;
let all_logs_href = format!(
"{base_path}/audit-logs?target_id={}&access=write",
urlencoding::encode(target_id)
);
html! { html! {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" { div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" {
@@ -88,7 +94,7 @@ pub fn audit_logs_for_target(
} }
} }
@if entries.is_empty() { @if entries.is_empty() {
(empty_state("No admin actions have been recorded against this entity.")) (empty_state("No admin write actions have been recorded against this entity."))
} @else { } @else {
(table_container(html! { (table_container(html! {
(table(html! { (table(html! {
@@ -108,7 +114,7 @@ pub fn audit_logs_for_target(
})) }))
})) }))
} }
@if has_previous || has_next { @if has_previous || next_page.is_some() {
div class="flex items-center justify-center gap-2" { div class="flex items-center justify-center gap-2" {
@if has_previous { @if has_previous {
a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)} a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)}
@@ -119,10 +125,10 @@ pub fn audit_logs_for_target(
} }
} }
span class="text-sm text-neutral-500" { span class="text-sm text-neutral-500" {
"Page " (current_page + 1) " of " (total_pages) "Page " (page_number) " of " (total_pages)
} }
@if has_next { @if let Some(page) = next_page {
a href={(tab_href_base) "&audit_logs_page=" (current_page + 1)} a href={(tab_href_base) "&audit_logs_page=" (page)}
class="inline-flex items-center justify-center gap-2 font-medium \ class="inline-flex items-center justify-center gap-2 font-medium \
rounded-lg bg-neutral-50 text-neutral-700 border \ rounded-lg bg-neutral-50 text-neutral-700 border \
border-neutral-300 px-3 py-1.5 text-sm" { border-neutral-300 px-3 py-1.5 text-sm" {
+9 -14
View File
@@ -81,20 +81,15 @@ pub fn guild_asset_url(
} }
pub fn initials(name: &str) -> String { pub fn initials(name: &str) -> String {
let parts = name let mut parts = name.split_whitespace();
.split_whitespace() let Some(first) = parts.next() else {
.filter(|part| !part.is_empty()) return "?".to_owned();
.collect::<Vec<_>>(); };
match parts.as_slice() { std::iter::once(first)
[] => "?".to_owned(), .chain(parts.next_back())
[part] => part.chars().next().unwrap_or('?').to_uppercase().collect(), .flat_map(|part| part.chars().take(1))
[first, .., last] => { .flat_map(char::to_uppercase)
let mut value = String::new(); .collect()
value.extend(first.chars().next().unwrap_or('?').to_uppercase());
value.extend(last.chars().next().unwrap_or('?').to_uppercase());
value
}
}
} }
fn media_asset_url( fn media_asset_url(
@@ -0,0 +1,236 @@
use std::cmp::Ordering;
use serde_json::Value;
#[derive(Debug, PartialEq)]
pub struct Attachment {
pub id: String,
pub url: String,
pub filename: String,
pub nsfw: Option<bool>,
pub content_type: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
#[derive(Debug, PartialEq)]
pub struct Message {
pub id: String,
pub content: String,
pub timestamp: String,
pub author_id: String,
pub author_username: String,
pub author_global_name: Option<String>,
pub author_discriminator: String,
pub author_avatar: Option<String>,
pub channel_id: String,
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
}
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value["attachments"]
.as_array()
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value_id(&value["id"]).unwrap_or_default(),
content: value["content"].as_str().unwrap_or("").to_owned(),
timestamp: value["timestamp"].as_str().unwrap_or("").to_owned(),
author_id: value_id(&value["author_id"]).unwrap_or_default(),
author_username: value["author_username"]
.as_str()
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value["author_global_name"].as_str().map(ToOwned::to_owned),
author_discriminator: value_id(&value["author_discriminator"])
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
channel_nsfw: value["channel_nsfw"].as_bool(),
channel_content_warning_level: value["channel_content_warning_level"]
.as_i64()
.map(|n| n as i32),
channel_content_warning_text: value["channel_content_warning_text"]
.as_str()
.map(ToOwned::to_owned),
guild_nsfw: value["guild_nsfw"].as_bool(),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value_id(&value["id"]).unwrap_or_default(),
url: value["url"].as_str().unwrap_or("").to_owned(),
filename: value["filename"].as_str().unwrap_or("").to_owned(),
nsfw: value["nsfw"].as_bool(),
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
width: value["width"].as_u64().map(|n| n as u32),
height: value["height"].as_u64().map(|n| n as u32),
size: value["size"].as_u64(),
ncmec_status: value["ncmec_status"]
.as_str()
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
ncmec_failure_reason: value["ncmec_failure_reason"]
.as_str()
.map(ToOwned::to_owned),
}
}
pub(crate) fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn maps_message_and_attachment_fields() {
let value = json!({
"id": "9007199254740993",
"content": "Message content",
"timestamp": "2026-09-11T12:00:00Z",
"author_id": 42,
"author_username": "alice",
"author_global_name": "Alice",
"author_discriminator": 1234,
"author_avatar": "avatar-hash",
"channel_id": "100",
"channel_nsfw": false,
"channel_content_warning_level": 2,
"channel_content_warning_text": "Content warning",
"guild_nsfw": true,
"attachments": [{
"id": 9007199254740993_u64,
"url": "https://cdn.example.com/image.png",
"filename": "image.png",
"nsfw": true,
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096,
"ncmec_status": "submitted",
"ncmec_report_id": "report-id",
"ncmec_failure_reason": "previous failure"
}]
});
assert_eq!(
message_from_value(&value),
Message {
id: "9007199254740993".into(),
content: "Message content".into(),
timestamp: "2026-09-11T12:00:00Z".into(),
author_id: "42".into(),
author_username: "alice".into(),
author_global_name: Some("Alice".into()),
author_discriminator: "1234".into(),
author_avatar: Some("avatar-hash".into()),
channel_id: "100".into(),
channel_nsfw: Some(false),
channel_content_warning_level: Some(2),
channel_content_warning_text: Some("Content warning".into()),
guild_nsfw: Some(true),
attachments: vec![Attachment {
id: "9007199254740993".into(),
url: "https://cdn.example.com/image.png".into(),
filename: "image.png".into(),
nsfw: Some(true),
content_type: Some("image/png".into()),
width: Some(640),
height: Some(480),
size: Some(4096),
ncmec_status: "submitted".into(),
ncmec_report_id: Some("report-id".into()),
ncmec_failure_reason: Some("previous failure".into()),
}],
}
);
}
#[test]
fn retains_display_defaults_for_incomplete_snapshots() {
let message = message_from_value(&json!({"attachments": [{}]}));
assert_eq!(message.author_username, "Unknown");
assert_eq!(message.author_discriminator, "0000");
assert_eq!(message.content, "");
assert_eq!(message.author_global_name, None);
assert_eq!(message.channel_nsfw, None);
assert_eq!(
message.attachments,
vec![Attachment {
id: String::new(),
url: String::new(),
filename: String::new(),
nsfw: None,
content_type: None,
width: None,
height: None,
size: None,
ncmec_status: "not_submitted".into(),
ncmec_report_id: None,
ncmec_failure_reason: None,
}]
);
}
#[test]
fn orders_string_and_numeric_snowflakes_without_rounding() {
let values = vec![
json!({"id": "9007199254740993"}),
json!({"id": "10"}),
json!({"id": 2}),
json!({"id": 9007199254740992_u64}),
];
let messages = ordered_messages(&values);
let ids: Vec<&str> = messages.iter().map(|message| message.id.as_str()).collect();
assert_eq!(ids, ["2", "10", "9007199254740992", "9007199254740993"]);
assert_eq!(values[0]["id"], "9007199254740993");
assert!(ordered_messages(&[]).is_empty());
}
#[test]
fn preserves_message_order_when_ids_are_equal() {
let values = [
json!({"id": "10", "content": "first"}),
json!({"id": 10, "content": "second"}),
];
let messages = ordered_messages(&values);
assert_eq!(messages[0].content, "first");
assert_eq!(messages[1].content, "second");
}
}
@@ -9,36 +9,7 @@ use super::user_display::format_user_display;
use crate::config::AdminConfig; use crate::config::AdminConfig;
use crate::routes::auth::json_string; use crate::routes::auth::json_string;
pub struct Attachment { use super::message_data::{Attachment, Message};
pub id: String,
pub url: String,
pub filename: String,
pub nsfw: Option<bool>,
pub content_type: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
pub struct Message {
pub id: String,
pub content: String,
pub timestamp: String,
pub author_id: String,
pub author_username: String,
pub author_global_name: Option<String>,
pub author_discriminator: String,
pub author_avatar: Option<String>,
pub channel_id: String,
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
}
fn is_image(att: &Attachment) -> bool { fn is_image(att: &Attachment) -> bool {
att.content_type att.content_type
@@ -74,8 +45,8 @@ fn ncmec_badge(att: &Attachment) -> Markup {
} }
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup { fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
let images: Vec<&Attachment> = msg.attachments.iter().filter(|a| is_image(a)).collect(); let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
if images.is_empty() { if images.peek().is_none() {
return html! {}; return html! {};
} }
let spacer = if !msg.content.is_empty() { let spacer = if !msg.content.is_empty() {
@@ -85,7 +56,7 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
}; };
html! { html! {
div class=(spacer) { div class=(spacer) {
@for att in &images { @for att in images {
div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" { div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" {
a href=(att.url) target="_blank" rel="noopener noreferrer" a href=(att.url) target="_blank" rel="noopener noreferrer"
class="block overflow-hidden bg-neutral-100" { class="block overflow-hidden bg-neutral-100" {
@@ -145,8 +116,8 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
} }
fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup { fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup {
let others: Vec<&Attachment> = msg.attachments.iter().filter(|a| !is_image(a)).collect(); let mut others = msg.attachments.iter().filter(|a| !is_image(a)).peekable();
if others.is_empty() { if others.peek().is_none() {
return html! {}; return html! {};
} }
let spacer = if has_content_or_images { let spacer = if has_content_or_images {
@@ -156,7 +127,7 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
}; };
html! { html! {
div class=(spacer) { div class=(spacer) {
@for att in &others { @for att in others {
div class="flex flex-wrap items-center gap-2 text-xs" { div class="flex flex-wrap items-center gap-2 text-xs" {
(paperclip_icon("text-neutral-400")) (paperclip_icon("text-neutral-400"))
a href=(att.url) target="_blank" rel="noopener noreferrer" a href=(att.url) target="_blank" rel="noopener noreferrer"
+1 -1
View File
@@ -13,10 +13,10 @@ pub mod error_display;
pub mod form; pub mod form;
pub mod icons; pub mod icons;
pub mod media; pub mod media;
pub mod message_data;
pub mod message_list; pub mod message_list;
pub mod nsfw_indicators; pub mod nsfw_indicators;
pub mod page_container; pub mod page_container;
pub mod pagination;
pub mod resource_link; pub mod resource_link;
pub mod section_card; pub mod section_card;
pub mod stack; pub mod stack;
@@ -19,7 +19,7 @@ pub fn adult_content_badge(is_adult: bool, label: Option<&str>) -> Markup {
} }
fn truncate(text: &str, max: usize) -> String { fn truncate(text: &str, max: usize) -> String {
if text.len() <= max { if text.chars().nth(max).is_none() {
text.to_owned() text.to_owned()
} else { } else {
let boundary = max.saturating_sub(1); let boundary = max.saturating_sub(1);
@@ -29,10 +29,7 @@ fn truncate(text: &str, max: usize) -> String {
} }
pub fn content_warning_badge(level: Option<i32>, text: Option<&str>, inline_text: bool) -> Markup { pub fn content_warning_badge(level: Option<i32>, text: Option<&str>, inline_text: bool) -> Markup {
let Some(lvl) = level else { if level != Some(CONTENT_WARNING_LEVEL) {
return html! {};
};
if lvl != CONTENT_WARNING_LEVEL {
return html! {}; return html! {};
} }
let default_text = "This contains sensitive content."; let default_text = "This contains sensitive content.";
@@ -82,13 +79,8 @@ fn resolve_nsfw_source(
fn source_label(source: NsfwSource, explicit: Option<bool>) -> &'static str { fn source_label(source: NsfwSource, explicit: Option<bool>) -> &'static str {
match source { match source {
NsfwSource::Channel => { NsfwSource::Channel if explicit == Some(true) => "(override on)",
if explicit == Some(true) { NsfwSource::Channel => "(override off)",
"(override on)"
} else {
"(override off)"
}
}
NsfwSource::Category => "(from category)", NsfwSource::Category => "(from category)",
NsfwSource::Community => "(from community)", NsfwSource::Community => "(from community)",
NsfwSource::None => "", NsfwSource::None => "",
@@ -110,11 +102,7 @@ pub fn channel_nsfw_state_badge(
} }
let has_context = let has_context =
nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some(); nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some();
let (source, explicit) = if has_context { let (source, explicit) = resolve_nsfw_source(nsfw_override, category_override, guild_nsfw);
resolve_nsfw_source(nsfw_override, category_override, guild_nsfw)
} else {
(NsfwSource::None, None)
};
html! { html! {
span class="inline-flex flex-wrap items-center gap-1" { span class="inline-flex flex-wrap items-center gap-1" {
@if is_nsfw { @if is_nsfw {
@@ -1,34 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, PreEscaped, html};
pub fn pagination(base_url: &str, current_page: u32, has_more: bool, extra_params: &str) -> Markup {
let sep = if base_url.contains('?') { "&" } else { "?" };
let has_previous = current_page > 1;
html! {
div class="mt-4 flex items-center justify-between" {
@if has_previous {
p class="text-sm font-normal text-neutral-500" {
a href={
(base_url) (sep) "page=" (current_page - 1) (extra_params)
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
(PreEscaped("&larr; Previous"))
}
}
} @else {
span {}
}
@if has_more {
p class="text-sm font-normal text-neutral-500" {
a href={
(base_url) (sep) "page=" (current_page + 1) (extra_params)
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
(PreEscaped("Next &rarr;"))
}
}
} @else {
span {}
}
}
}
}
@@ -12,8 +12,6 @@ const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral
hover:text-neutral-600 hover:decoration-neutral-500 text-sm"; hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \ const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50"; px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
const TEXT_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
hover:text-neutral-600 hover:decoration-neutral-500";
impl ResourceType { impl ResourceType {
fn path_segment(self) -> &'static str { fn path_segment(self) -> &'static str {
@@ -91,18 +89,3 @@ pub fn nav_link(href: &str, content: Markup) -> Markup {
a href=(href) class=(NAV_LINK_CLASS) { (content) } a href=(href) class=(NAV_LINK_CLASS) { (content) }
} }
} }
pub fn text_link(href: &str, content: Markup, external: bool, _mono: bool) -> Markup {
if external {
html! {
a href=(href) class=(TEXT_LINK_CLASS)
target="_blank" rel="noopener noreferrer" {
(content)
}
}
} else {
html! {
a href=(href) class=(TEXT_LINK_CLASS) { (content) }
}
}
}
@@ -54,6 +54,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
"bulk-actions", "bulk-actions",
[ [
acl::BULK_UPDATE_USER_FLAGS, acl::BULK_UPDATE_USER_FLAGS,
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
acl::BULK_UPDATE_GUILD_FEATURES, acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS, acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS, acl::BULK_DELETE_USERS,
@@ -264,3 +265,27 @@ pub const NAV_SECTIONS: &[NavSection] = &[
)], )],
}, },
]; ];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
let item = NAV_SECTIONS
.iter()
.flat_map(|section| section.items)
.find(|item| item.active_key == "bulk-actions")
.expect("bulk actions nav item");
for required in [
acl::BULK_UPDATE_USER_FLAGS,
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
acl::BULK_DELETE_USER_MESSAGES,
] {
assert!(item.required_acls.contains(&required), "{required}");
}
}
}
+79 -25
View File
@@ -22,6 +22,7 @@ pub struct AuditLogsParams<'a> {
pub admin_user_id: &'a str, pub admin_user_id: &'a str,
pub target_id: &'a str, pub target_id: &'a str,
pub target_type: &'a str, pub target_type: &'a str,
pub access: &'a str,
pub sort_by: &'a str, pub sort_by: &'a str,
pub sort_order: &'a str, pub sort_order: &'a str,
pub limit: u32, pub limit: u32,
@@ -33,6 +34,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
("", "Any"), ("", "Any"),
("user", "User"), ("user", "User"),
("guild", "Guild"), ("guild", "Guild"),
("bulk_job", "Bulk job"),
("email_domain", "Email domain"), ("email_domain", "Email domain"),
("ip", "IP"), ("ip", "IP"),
("phrase", "Phrase"), ("phrase", "Phrase"),
@@ -41,6 +43,11 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
("file_sha", "File SHA"), ("file_sha", "File SHA"),
("email", "Email"), ("email", "Email"),
]; ];
let access_options: &[(&str, &str)] = &[
("", "All entries"),
("write", "Writes only"),
("read", "Reads only"),
];
let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")]; let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")];
let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")]; let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")];
let limit_options: &[(&str, &str)] = let limit_options: &[(&str, &str)] =
@@ -59,6 +66,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
"Filter by admin user ID...")) "Filter by admin user ID..."))
(select_input("target_type", "Target type", (select_input("target_type", "Target type",
target_type_options, params.target_type)) target_type_options, params.target_type))
(select_input("access", "Access", access_options, params.access))
(select_input("sort_by", "Sort by", sort_options, params.sort_by)) (select_input("sort_by", "Sort by", sort_options, params.sort_by))
(select_input("sort_order", "Order", order_options, params.sort_order)) (select_input("sort_order", "Order", order_options, params.sort_order))
(select_input("limit", "Page size", limit_options, &limit_str)) (select_input("limit", "Page size", limit_options, &limit_str))
@@ -72,27 +80,23 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
} }
fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String { fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String {
let mut url = format!("{base}/audit-logs?page={page}"); let mut query = url::form_urlencoded::Serializer::new(String::new());
if !params.query.is_empty() { query.append_pair("page", &page.to_string());
url.push_str(&format!("&q={}", params.query)); query.extend_pairs(
} [
if !params.admin_user_id.is_empty() { ("q", params.query),
url.push_str(&format!("&admin_user_id={}", params.admin_user_id)); ("admin_user_id", params.admin_user_id),
} ("target_id", params.target_id),
if !params.target_id.is_empty() { ("target_type", params.target_type),
url.push_str(&format!("&target_id={}", params.target_id)); ("access", params.access),
} ("sort_by", params.sort_by),
if !params.target_type.is_empty() { ("sort_order", params.sort_order),
url.push_str(&format!("&target_type={}", params.target_type)); ]
} .into_iter()
if !params.sort_by.is_empty() { .filter(|(_, value)| !value.is_empty()),
url.push_str(&format!("&sort_by={}", params.sort_by)); );
} query.append_pair("limit", &params.limit.to_string());
if !params.sort_order.is_empty() { format!("{base}/audit-logs?{}", query.finish())
url.push_str(&format!("&sort_order={}", params.sort_order));
}
url.push_str(&format!("&limit={}", params.limit));
url
} }
pub fn audit_logs_page( pub fn audit_logs_page(
@@ -107,10 +111,15 @@ pub fn audit_logs_page(
let total = data.total; let total = data.total;
let entries = &data.logs; let entries = &data.logs;
let total_pages = if params.limit > 0 { let total_pages = if params.limit > 0 {
((total as f64) / (params.limit as f64)).ceil().max(1.0) as u64 total.div_ceil(u64::from(params.limit)).max(1)
} else { } else {
1 1
}; };
let page_number = u64::from(params.current_page) + 1;
let next_page = params
.current_page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
let showing = format!("Showing {} of {} entries", entries.len(), total); let showing = format!("Showing {} of {} entries", entries.len(), total);
html! { html! {
(page_header_with_actions("Audit Logs", None, html! { (page_header_with_actions("Audit Logs", None, html! {
@@ -134,10 +143,10 @@ pub fn audit_logs_page(
} }
} @else { span {} } } @else { span {} }
span class="text-sm text-neutral-500" { span class="text-sm text-neutral-500" {
"Page " (params.current_page + 1) " of " (total_pages) "Page " (page_number) " of " (total_pages)
} }
@if (params.current_page + 1) < total_pages as u32 { @if let Some(page) = next_page {
a href=(build_pagination_url(base, params.current_page + 1, params)) a href=(build_pagination_url(base, page, params))
class="text-sm text-neutral-900 underline" { class="text-sm text-neutral-900 underline" {
(PreEscaped("Next &rarr;")) (PreEscaped("Next &rarr;"))
} }
@@ -156,3 +165,48 @@ pub fn audit_logs_page(
}; };
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content) admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_type_filter_offers_bulk_jobs() {
let params = AuditLogsParams {
query: "",
admin_user_id: "",
target_id: "",
target_type: "bulk_job",
access: "",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
current_page: 0,
};
let markup = filters_section("/admin", &params).into_string();
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
}
#[test]
fn access_filter_survives_form_and_pagination() {
let params = AuditLogsParams {
query: "",
admin_user_id: "",
target_id: "1500000000000000001",
target_type: "",
access: "read",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
current_page: 0,
};
let markup = filters_section("/admin", &params).into_string();
assert!(markup.contains(r#"<select id="access" name="access""#));
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
assert_eq!(
build_pagination_url("/admin", 1, &params),
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
);
}
}
@@ -14,12 +14,7 @@ use crate::{
use maud::{Markup, html}; use maud::{Markup, html};
pub fn format_action(action: &str) -> String { pub fn format_action(action: &str) -> String {
let replaced = action.replace('_', " "); capitalise(&action.replace('_', " "))
let mut chars = replaced.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
} }
pub fn action_badge_variant(action: &str) -> BadgeVariant { pub fn action_badge_variant(action: &str) -> BadgeVariant {
@@ -92,10 +87,8 @@ pub fn admin_user_cell(base: &str, entry: &AuditLogEntry) -> Markup {
} }
} }
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> String { fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> &str {
guild guild.map(|guild| guild.name.as_str()).unwrap_or("Guild")
.map(|guild| guild.name.clone())
.unwrap_or_else(|| "Guild".to_owned())
} }
pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup { pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
@@ -133,6 +126,14 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
} }
})) }))
}, },
"bulk_job" => html! {
(job_link(base, &entry.target_id, html! {
div class="flex flex-col" {
span class="text-sm font-medium" { "Bulk job" }
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
}
}))
},
"message" => html! { "message" => html! {
div class="flex flex-col" { div class="flex flex-col" {
span class="text-sm font-medium" { "Message" } span class="text-sm font-medium" { "Message" }
@@ -154,6 +155,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
} }
} }
fn job_link(base: &str, job_id: &str, display: Markup) -> Markup {
html! {
a href={(base) "/jobs/" (job_id)} title={"Job " (job_id)}
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
(display)
}
}
}
fn channel_href(base: &str, channel_id: &str) -> String { fn channel_href(base: &str, channel_id: &str) -> String {
format!( format!(
"{base}/messages?channel_id={}&context_limit=50", "{base}/messages?channel_id={}&context_limit=50",
@@ -317,3 +327,36 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
} }
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
serde_json::from_value(serde_json::json!({
"log_id": "1900000000000000001",
"admin_user_id": "1500000000000000001",
"action": "bulk_schedule_deletion",
"target_id": target_id,
"target_type": target_type,
"audit_log_reason": "Raid cleanup",
"created_at": "2026-09-14T12:00:00.000Z"
}))
.expect("audit log fixture must deserialize")
}
#[test]
fn bulk_job_targets_link_to_the_job_detail_page() {
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
assert!(markup.contains("Bulk job"));
assert!(!markup.contains("/admin/users/"));
}
#[test]
fn unknown_target_types_stay_unlinked() {
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
assert!(!markup.contains("<a "));
assert!(markup.contains("Email domain"));
}
}
@@ -7,8 +7,8 @@ use crate::{
templates::{ templates::{
components::{ components::{
form::{ form::{
checkbox, csrf_input, danger_button, form_actions, form_field_group, select_input, FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
submit_button, text_input, textarea_input, form_field_group, select_chevron, submit_button, text_input, textarea_input,
}, },
page_container::page_header, page_container::page_header,
section_card::section_card_simple, section_card::section_card_simple,
@@ -147,9 +147,14 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
const GUILD_FEATURES: &[&str] = &[ const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON", "ANIMATED_ICON",
"ANIMATED_BANNER", "ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER", "BANNER",
"CLONE_EMOJI_DISABLED", "CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED", "CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER", "DETACHED_BANNER",
"INVITE_SPLASH", "INVITE_SPLASH",
"INVITES_DISABLED", "INVITES_DISABLED",
@@ -175,6 +180,16 @@ const GUILD_FEATURES: &[&str] = &[
"VERY_LARGE_GUILD", "VERY_LARGE_GUILD",
]; ];
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn guild_feature_label(feature: &str) -> String {
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
format!("{feature} (deprecated, removal only)")
} else {
feature.to_owned()
}
}
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup { pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
let base = &config.base_path; let base = &config.base_path;
let admin_acls = auth let admin_acls = auth
@@ -219,6 +234,18 @@ fn flag_checkbox_grid(prefix: &str, flags: &[&str]) -> Markup {
} }
} }
fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup {
html! {
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
@for feature in GUILD_FEATURES {
@if include_deprecated || !DEPRECATED_GUILD_FEATURES.contains(feature) {
(checkbox(prefix, feature, &guild_feature_label(feature), false, true))
}
}
}
}
}
fn user_flag_checkbox_grid(prefix: &str) -> Markup { fn user_flag_checkbox_grid(prefix: &str) -> Markup {
html! { html! {
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" { div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
@@ -301,13 +328,13 @@ fn bulk_update_guild_features_section(base: &str, csrf_token: &str) -> Markup {
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" { p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
"Features to Add" "Features to Add"
} }
(flag_checkbox_grid("add_features[]", GUILD_FEATURES)) (guild_feature_checkbox_grid("add_features[]", false))
} }
div { div {
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" { p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
"Features to Remove" "Features to Remove"
} }
(flag_checkbox_grid("remove_features[]", GUILD_FEATURES)) (guild_feature_checkbox_grid("remove_features[]", true))
} }
(form_field_group("Custom features to add", "custom_add_features", false, None, (form_field_group("Custom features to add", "custom_add_features", false, None,
Some("Comma-separated list of custom features not in the standard set."), Some("Comma-separated list of custom features not in the standard set."),
@@ -368,16 +395,33 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
(csrf_input(csrf_token)) (csrf_input(csrf_token))
div class="space-y-4" { div class="space-y-4" {
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true)) (textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
(select_input("reason_code", "Deletion Reason", DELETION_REASONS, "1")) (form_field_group("Deletion Reason", "reason_code", true, None,
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
html! {
div class="relative" {
select id="reason_code" name="reason_code" required
class=(FORM_SELECT_CLASS) {
option value="" selected { "Select a reason" }
@for &(value, label) in DELETION_REASONS {
option value=(value) { (label) }
}
}
(select_chevron())
}
},
))
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation")) (text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
(form_field_group("Days Until Deletion", "days_until_deletion", true, None, None, html! { (form_field_group("Days Until Deletion", "days_until_deletion", true, None,
input type="number" id="days_until_deletion" name="days_until_deletion" Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
value="14" min="14" required html! {
class="w-full rounded-lg border border-neutral-300 bg-white \ input type="number" id="days_until_deletion" name="days_until_deletion"
text-neutral-900 text-sm h-8 px-3 py-1.5 \ value="60" min="14" max="365" required
focus:border-brand-primary focus:outline-none \ class="w-full rounded-lg border border-neutral-300 bg-white \
focus:ring-2 focus:ring-brand-primary/20"; text-neutral-900 text-sm h-8 px-3 py-1.5 \
})) focus:border-brand-primary focus:outline-none \
focus:ring-2 focus:ring-brand-primary/20";
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation")) (text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(form_actions(html! { (form_actions(html! {
(danger_button("Schedule Deletion")) (danger_button("Schedule Deletion"))
@@ -408,3 +452,41 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
}, },
) )
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_grid_offers_only_the_opt_in_clone_features() {
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
}
#[test]
fn deletion_form_has_no_preselected_reason() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
for (value, _) in DELETION_REASONS {
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
}
}
#[test]
fn deletion_form_defaults_to_the_moderation_retention_floor() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
assert!(markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
assert!(markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, removal only)"));
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
}
}
@@ -16,7 +16,7 @@ use crate::{
}; };
use maud::{Markup, html}; use maud::{Markup, html};
const MAX_GIFT_CODES: u32 = 100; pub const MAX_GIFT_CODES: u32 = 100;
const DEFAULT_GIFT_COUNT: u32 = 10; const DEFAULT_GIFT_COUNT: u32 = 10;
pub fn gift_codes_page( pub fn gift_codes_page(
@@ -44,12 +44,12 @@ pub fn gift_codes_page(
(csrf_input(csrf_token)) (csrf_input(csrf_token))
div class="flex flex-col gap-4" { div class="flex flex-col gap-4" {
(form_field_group( (form_field_group(
"Number of codes", "gift-count-slider", false, None, "Number of codes", "gift-count-slider", true, None,
Some(&format!("Range: 1-{MAX_GIFT_CODES}")), Some(&format!("Range: 1-{MAX_GIFT_CODES}")),
html! { html! {
input type="number" id="gift-count-slider" name="count" input type="number" id="gift-count-slider" name="count"
value=(DEFAULT_GIFT_COUNT) min="1" value=(DEFAULT_GIFT_COUNT) min="1"
max=(MAX_GIFT_CODES) max=(MAX_GIFT_CODES) required
class=(FORM_INPUT_CLASS); class=(FORM_INPUT_CLASS);
}, },
)) ))
@@ -14,9 +14,14 @@ use maud::{Markup, html};
const GUILD_FEATURES: &[&str] = &[ const GUILD_FEATURES: &[&str] = &[
"ANIMATED_ICON", "ANIMATED_ICON",
"ANIMATED_BANNER", "ANIMATED_BANNER",
"AUDIO_BITRATE_128_KBPS",
"AUDIO_BITRATE_256_KBPS",
"AUDIO_BITRATE_384_KBPS",
"BANNER", "BANNER",
"CLONE_EMOJI_DISABLED", "CLONE_EMOJI_DISABLED",
"CLONE_EMOJI_ENABLED",
"CLONE_STICKER_DISABLED", "CLONE_STICKER_DISABLED",
"CLONE_STICKER_ENABLED",
"DETACHED_BANNER", "DETACHED_BANNER",
"INVITE_SPLASH", "INVITE_SPLASH",
"INVITES_DISABLED", "INVITES_DISABLED",
@@ -44,6 +49,16 @@ const GUILD_FEATURES: &[&str] = &[
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"]; const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
const DEPRECATED_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
fn feature_label(feature: &str) -> String {
if DEPRECATED_FEATURES.contains(&feature) {
format!("{feature} (deprecated, no longer enforced)")
} else {
feature.to_owned()
}
}
pub fn features_tab( pub fn features_tab(
config: &AdminConfig, config: &AdminConfig,
guild: &GuildInfo, guild: &GuildInfo,
@@ -85,7 +100,7 @@ pub fn features_tab(
(checkbox( (checkbox(
"features[]", "features[]",
feature, feature,
feature, &feature_label(feature),
guild.features.iter().any(|f| f == feature), guild.features.iter().any(|f| f == feature),
true, true,
)) ))
@@ -139,7 +154,7 @@ fn features_tab_readonly(guild: &GuildInfo, features_list: &[&str]) -> Markup {
@for feature in &enabled { @for feature in &enabled {
span class="inline-flex items-center rounded-full bg-green-100 \ span class="inline-flex items-center rounded-full bg-green-100 \
px-2.5 py-0.5 text-xs font-medium text-green-800" { px-2.5 py-0.5 text-xs font-medium text-green-800" {
(feature) (feature_label(feature))
} }
} }
@for feature in &custom { @for feature in &custom {
@@ -166,3 +181,56 @@ fn filtered_features() -> Vec<&'static str> {
.copied() .copied()
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use super::*;
fn guild_with_features(features: &[&str]) -> GuildInfo {
serde_json::from_value(serde_json::json!({
"id": "1600000000000000001",
"name": "Test Guild",
"icon": null,
"banner": null,
"owner_id": "1500000000000000001",
"owner_username": null,
"owner_global_name": null,
"owner_discriminator": null,
"features": features,
"nsfw_level": null,
"nsfw": null,
"content_warning_level": null,
"content_warning_text": null,
"description": null,
"vanity_url_code": null,
}))
.expect("guild fixture")
}
#[test]
fn editor_offers_the_opt_in_clone_features() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
}
#[test]
fn editor_keeps_the_deprecated_clone_features_clearable() {
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_DISABLED"));
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_DISABLED"));
assert_eq!(
feature_label("CLONE_EMOJI_DISABLED"),
"CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"
);
assert_eq!(feature_label("CLONE_EMOJI_ENABLED"), "CLONE_EMOJI_ENABLED");
}
#[test]
fn readonly_view_marks_a_stale_flag_as_deprecated() {
let guild = guild_with_features(&["CLONE_EMOJI_DISABLED", "CLONE_STICKER_ENABLED"]);
let markup = features_tab_readonly(&guild, GUILD_FEATURES).into_string();
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"));
assert!(markup.contains("CLONE_STICKER_ENABLED"));
}
}
@@ -25,7 +25,9 @@ pub fn members_tab(
let total = response.total; let total = response.total;
let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 }; let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 };
let has_previous = page > 0; let has_previous = page > 0;
let has_next = (page as u64) < total_pages.saturating_sub(1); let next_page = page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! { html! {
div class="space-y-4" { div class="space-y-4" {
@@ -34,12 +36,16 @@ pub fn members_tab(
"Guild Members (" (total) ")" "Guild Members (" (total) ")"
} }
p class="text-sm text-neutral-500" { p class="text-sm text-neutral-500" {
@let start = response.offset + 1; @if response.members.is_empty() {
@let end = std::cmp::min( "Showing 0 of " (total)
response.offset + response.members.len() as u64, } @else {
total, @let start = u128::from(response.offset) + 1;
); @let end = std::cmp::min(
"Showing " (start) "-" (end) " of " (total) u128::from(response.offset) + response.members.len() as u128,
u128::from(total),
);
"Showing " (start) "-" (end) " of " (total)
}
} }
} }
@@ -70,10 +76,10 @@ pub fn members_tab(
} }
} }
p class="text-sm text-neutral-500" { p class="text-sm text-neutral-500" {
"Page " (page + 1) " of " (total_pages) "Page " (u64::from(page) + 1) " of " (total_pages)
} }
@if has_next { @if let Some(next_page) = next_page {
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page + 1)} a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (next_page)}
class="inline-flex items-center rounded-md bg-brand-primary px-3 \ class="inline-flex items-center rounded-md bg-brand-primary px-3 \
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" { py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
"Next \u{2192}" "Next \u{2192}"
@@ -40,13 +40,13 @@ fn channel_type_label(channel_type: i32) -> &'static str {
pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup { pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup {
let base = &config.base_path; let base = &config.base_path;
let mut sorted_channels = guild.channels.clone(); let mut sorted_channels: Vec<_> = guild.channels.iter().collect();
sorted_channels.sort_by_key(|c| c.position); sorted_channels.sort_by_key(|c| c.position);
let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> = let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> =
guild.channels.iter().map(|c| (c.id.as_str(), c)).collect(); guild.channels.iter().map(|c| (c.id.as_str(), c)).collect();
let mut sorted_roles = guild.roles.clone(); let mut sorted_roles: Vec<_> = guild.roles.iter().collect();
sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position)); sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position));
let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true); let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true);
@@ -102,16 +102,10 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
p class="text-sm font-semibold text-neutral-500" { p class="text-sm font-semibold text-neutral-500" {
"Custom warning text" "Custom warning text"
} }
@if let Some(ref text) = guild.content_warning_text { @if let Some(text) = guild.content_warning_text.as_deref().filter(|text| !text.trim().is_empty()) {
@if !text.trim().is_empty() { blockquote class="border-amber-300 border-l-2 bg-amber-50 \
blockquote class="border-amber-300 border-l-2 bg-amber-50 \ px-3 py-2 text-neutral-800 text-sm italic" {
px-3 py-2 text-neutral-800 text-sm italic" { (text)
(text)
}
} @else {
p class="text-sm text-neutral-500" {
"\u{2014} (default fallback shown to users)"
}
} }
} @else { } @else {
p class="text-sm text-neutral-500" { p class="text-sm text-neutral-500" {
@@ -17,8 +17,9 @@ pub fn reports_tab(
let base = &config.base_path; let base = &config.base_path;
let page_size: u64 = 25; let page_size: u64 = 25;
let has_previous = page > 0; let has_previous = page > 0;
let has_next = (page as u64) * page_size + reports.len() as u64 > 0 let next_page = page.checked_add(1).filter(|next_page| {
&& (page as u64 + 1) * page_size < total; (page > 0 || !reports.is_empty()) && u64::from(*next_page) * page_size < total
});
html! { html! {
div class="space-y-4" { div class="space-y-4" {
div class="flex items-center justify-between" { div class="flex items-center justify-between" {
@@ -48,7 +49,7 @@ pub fn reports_tab(
)) ))
} }
@if has_previous || has_next { @if has_previous || next_page.is_some() {
div class="flex justify-center gap-2" { div class="flex justify-center gap-2" {
@if has_previous { @if has_previous {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)} a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)}
@@ -58,8 +59,8 @@ pub fn reports_tab(
"\u{2190} Newer" "\u{2190} Newer"
} }
} }
@if has_next { @if let Some(next_page) = next_page {
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page + 1)} a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (next_page)}
class="inline-flex items-center rounded-md border border-neutral-300 \ class="inline-flex items-center rounded-md border border-neutral-300 \
bg-white px-3 py-2 text-sm font-medium text-neutral-700 \ bg-white px-3 py-2 text-sm font-medium text-neutral-700 \
hover:bg-neutral-50" { hover:bg-neutral-50" {
@@ -64,7 +64,6 @@ impl GuildsListParams {
pub struct GuildsListResults<'a> { pub struct GuildsListResults<'a> {
pub guilds: Option<&'a [GuildInfo]>, pub guilds: Option<&'a [GuildInfo]>,
pub total: Option<u64>, pub total: Option<u64>,
pub has_more: bool,
} }
pub fn guilds_list_page( pub fn guilds_list_page(
@@ -73,14 +72,12 @@ pub fn guilds_list_page(
params: &GuildsListParams, params: &GuildsListParams,
results: Option<&[GuildInfo]>, results: Option<&[GuildInfo]>,
total: Option<u64>, total: Option<u64>,
has_more: bool,
is_htmx: bool, is_htmx: bool,
) -> Markup { ) -> Markup {
let base = &config.base_path; let base = &config.base_path;
let result_state = GuildsListResults { let result_state = GuildsListResults {
guilds: results, guilds: results,
total, total,
has_more,
}; };
let has_results = results.is_some_and(|guilds| !guilds.is_empty()); let has_results = results.is_some_and(|guilds| !guilds.is_empty());
let header = html! { let header = html! {
@@ -164,7 +161,10 @@ fn render_results(
fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup { fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup {
let total_pages = total.div_ceil(u64::from(params.limit)).max(1); let total_pages = total.div_ceil(u64::from(params.limit)).max(1);
let has_previous = params.page > 0; let has_previous = params.page > 0;
let has_next = u64::from(params.page) < total_pages.saturating_sub(1); let next_page = params
.page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! { html! {
div class="mt-6 flex items-center justify-center gap-3" { div class="mt-6 flex items-center justify-center gap-3" {
@if has_previous { @if has_previous {
@@ -181,10 +181,10 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
} }
} }
span class="text-neutral-600 text-sm" { span class="text-neutral-600 text-sm" {
"Page " (params.page + 1) " of " (total_pages) "Page " (u64::from(params.page) + 1) " of " (total_pages)
} }
@if has_next { @if let Some(next_page) = next_page {
a href=(pagination_url(&config.base_path, params, params.page + 1)) a href=(pagination_url(&config.base_path, params, next_page))
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \ class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \
text-white no-underline transition-colors hover:bg-neutral-800" { text-white no-underline transition-colors hover:bg-neutral-800" {
"Next \u{2192}" "Next \u{2192}"
@@ -200,8 +200,7 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
} }
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String { fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
let mut parts = Vec::new(); let mut parts = vec![format!("page={page}"), format!("limit={}", params.limit)];
parts.push(format!("page={page}"));
if !params.q.is_empty() { if !params.q.is_empty() {
parts.push(format!("q={}", urlencoding::encode(&params.q))); parts.push(format!("q={}", urlencoding::encode(&params.q)));
} }
@@ -2,10 +2,15 @@
use crate::{ use crate::{
api::types::{ api::types::{
AppPublicConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse, InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse, InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
RegistrationUrlResponse, SsoConfigResponse, PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
}, },
config::AdminConfig, config::AdminConfig,
middleware::auth::AuthContext, middleware::auth::AuthContext,
@@ -42,6 +47,17 @@ fn format_decimal(value: f64) -> String {
} }
} }
fn entry_count_hint(count: usize, cap: usize) -> Markup {
html! {
p class="text-xs text-neutral-500" {
(count) " of " (cap) " stored"
@if count >= cap {
" (at the cap; remove an entry before adding another)"
}
}
}
}
fn number_field( fn number_field(
name: &str, name: &str,
label: &str, label: &str,
@@ -122,6 +138,13 @@ pub fn instance_config_page(
(integrations_config_section(base, csrf_token, &instance_config.integrations)) (integrations_config_section(base, csrf_token, &instance_config.integrations))
}, },
)) ))
(config_group(
"Push notifications",
"Consent for the relay that delivers official mobile app notifications.",
html! {
(push_relay_section(base, csrf_token, &instance_config.push_relay))
},
))
(config_group( (config_group(
"Media & retention", "Media & retention",
"Attachment expiry rules that can be changed without editing environment variables.", "Attachment expiry rules that can be changed without editing environment variables.",
@@ -134,6 +157,11 @@ pub fn instance_config_page(
"Gateway rollout behavior and the limit rules applied to users and guilds.", "Gateway rollout behavior and the limit rules applied to users and guilds.",
html! { html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout)) (gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config { @if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config)) (limit_config_section(base, limit_config))
} @else { } @else {
@@ -833,6 +861,18 @@ fn app_public_config_section(
app_public.branding.favicon_url.as_deref().unwrap_or(""), app_public.branding.favicon_url.as_deref().unwrap_or(""),
"https://example.com/favicon.ico", "https://example.com/favicon.ico",
)) ))
(text_input(
"app_status_page_url",
"Status page URL",
app_public.branding.status_page_url.as_deref().unwrap_or(""),
"https://fluxerstatus.com",
))
(text_input(
"app_status_page_incident_history_url",
"Status page history URL",
app_public.branding.status_page_incident_history_url.as_deref().unwrap_or(""),
"https://fluxerstatus.com/history",
))
} }
div class="space-y-2" { div class="space-y-2" {
(checkbox( (checkbox(
@@ -953,6 +993,818 @@ fn gateway_rollout_section(
) )
} }
fn voice_noise_suppression_section(
base: &str,
csrf_token: &str,
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> Markup {
let status = if voice_noise_suppression.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let backend_labels =
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
let backend_options = backend_labels
.iter()
.map(|(value, label)| (value.as_str(), *label))
.collect::<Vec<_>>();
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
let guild_overrides = voice_noise_suppression
.guild_overrides
.iter()
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
.collect::<Vec<_>>()
.join("\n");
section_card_with_description(
"Voice Noise Suppression",
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
of them are targeted. While the master switch below is off nothing on this form reaches \
any client: every user keeps the audio pipeline they have today, whatever the rest of \
these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (voice_noise_suppression.config_version)
}
}
(checkbox(
"voice_ns_enabled",
"true",
"Serve noise suppression assignments to clients",
voice_noise_suppression.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
feature is inert and keeps its current behavior, so the rollout, targeting \
and override fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
(select_input(
"voice_ns_default_backend",
"Default Backend",
&backend_options,
&voice_noise_suppression.default_backend.to_string(),
))
p class="text-xs text-neutral-500" {
"The backend assigned by always-on user rules and the canary. A default \
that is not ticked below is unavailable, but per-guild overrides can \
still target users."
}
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
@for backend in NoiseSuppressionBackend::ALL {
(checkbox(
"voice_ns_enabled_backends[]",
&backend.to_string(),
backend.label(),
voice_noise_suppression.enabled_backends.contains(&backend),
true,
))
}
}
p class="text-xs text-neutral-500" {
"Backends clients are allowed to load. Unticking one withdraws it from \
every user, including anyone who picked it themselves."
}
(checkbox(
"voice_ns_allow_user_override",
"true",
"Let users pick their own backend from the ticked list",
voice_noise_suppression.allow_user_override,
true,
))
p class="text-xs text-neutral-500" {
"Applies only to users who are already targeted. It never pulls anyone \
into the rollout."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"voice_ns_rollout_basis_points",
"Rollout (basis points)",
&voice_noise_suppression.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"voice_ns_rollout_salt",
"Rollout Salt",
&voice_noise_suppression.rollout_salt,
"voice-ns-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_ns_include_premium_users",
"true",
"Include premium users",
voice_noise_suppression.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_noise_suppression.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_noise_suppression.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-user kill switch."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
div class="flex flex-col gap-2" {
(textarea_input(
"voice_ns_guild_overrides",
"Guild Overrides",
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
&guild_overrides,
4,
false,
))
(entry_count_hint(
voice_noise_suppression.guild_overrides.len(),
VOICE_NS_MAX_GUILD_OVERRIDES,
))
p class="text-xs text-neutral-500" {
"One per line as guild_id=backend. A guild \
rule targets callers even outside the canary. Always-on user rules \
take precedence, and excluded users stay off. Invalid lines and \
conflicting rules for the same guild prevent the save. \
Unticked backends stay stored but are inactive."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
"Suppression Strength",
&voice_noise_suppression.suppression_strength.to_string(),
Some(0), Some(100), "1",
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
))
}
(form_actions(html! {
(submit_button("Save Voice Noise Suppression Configuration"))
}))
}
}
},
)
}
fn push_relay_section(
base: &str,
csrf_token: &str,
push_relay: &PushRelayConfigResponse,
) -> Markup {
let status = if push_relay.relay_consent_accepted {
("Accepted", BadgeVariant::Success)
} else {
("Not accepted", BadgeVariant::Default)
};
let accepted_at =
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
let accepted_by = push_relay
.relay_consent_accepted_by
.as_deref()
.unwrap_or("Nobody");
section_card_with_description(
"Push Relay",
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
The relay delivers them only after an operator accepts its privacy notice.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
(badge(status.0, status.1))
}
(checkbox(
"push_relay_consent_accepted",
"true",
"Accept the push relay supplemental privacy notice",
push_relay.relay_consent_accepted,
true,
))
p class="text-xs text-neutral-500" {
"Until this is accepted official mobile app notifications are dropped. \
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
unaffected. "
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
"Read the notice"
}
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_at"
value=(accepted_at)
disabled class=(FORM_INPUT_CLASS);
},
))
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
html! {
input type="text" id="push_relay_consent_accepted_by"
value=(accepted_by)
disabled class=(FORM_INPUT_CLASS);
},
))
}
(form_actions(html! {
(submit_button("Save Push Relay Settings"))
}))
}
}
},
)
}
fn domain_migration_section(
base: &str,
csrf_token: &str,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = domain_migration.included_user_ids.join("\n");
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
section_card_with_description(
"Domain Migration",
"Moves web clients of the official instance from the legacy web app origin to the new \
one. Selected accounts copy their local data across and continue on the new origin. \
Clients of other instances read this configuration and ignore it.",
html! {
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"domain_migration_enabled",
"true",
"Move selected web clients to the new origin",
domain_migration.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked no client \
starts a migration and clients that already migrated stop forwarding the \
legacy origin, so the rollout and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
(checkbox(
"domain_migration_standalone_forwarding",
"true",
"Forward installed desktop web apps to the new origin",
domain_migration.standalone_forwarding,
true,
))
p class="text-xs text-neutral-500" {
"Leave this off until the manifest scope extension and the association file \
are live and verified. While it is off, installed Chromium desktop apps copy \
their data across but stay on the legacy origin and offer to install the new \
app. Installed mobile and Safari apps never forward either way."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&domain_migration.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
(number_field(
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout (basis points)",
&domain_migration.anonymous_rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
))
div class="flex flex-col gap-2" {
(text_input(
"domain_migration_rollout_salt",
"Rollout Salt",
&domain_migration.rollout_salt,
DOMAIN_MIGRATION_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
which users and devices fall inside the percentages above. Leave it \
alone to keep the current cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
domain_migration.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"domain_migration_include_premium_users",
"true",
"Include premium users",
domain_migration.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&domain_migration.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
domain_migration.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
domain_migration.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. It stops new migrations only. A user who already moved \
stays on the new origin."
}
}
(form_actions(html! {
(submit_button("Save Domain Migration Configuration"))
}))
}
}
},
)
}
fn altcha_captcha_section(
base: &str,
csrf_token: &str,
altcha_captcha: &AltchaCaptchaConfigResponse,
) -> Markup {
let status = if altcha_captcha.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
section_card_with_description(
"ALTCHA Captcha",
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
selected requesters. The API issues and verifies every challenge itself, so no third \
party is involved. Requests only need a captcha where one is already required, so this \
does nothing while captcha is off for the instance.",
html! {
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (altcha_captcha.config_version)
}
}
(checkbox(
"altcha_captcha_enabled",
"true",
"Serve ALTCHA to the selected requesters",
altcha_captcha.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every \
requester gets the configured provider and ALTCHA answers are rejected."
}
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
(checkbox(
"altcha_captcha_anonymous_enabled",
"true",
"Serve ALTCHA to logged-out requests",
altcha_captcha.anonymous_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Covers registration, login and password reset. These requests have no \
account to bucket, so this switch applies to all of them at once."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"altcha_captcha_rollout_basis_points",
"Rollout (basis points)",
&altcha_captcha.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"altcha_captcha_rollout_salt",
"Rollout Salt",
&altcha_captcha.rollout_salt,
ALTCHA_CAPTCHA_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get ALTCHA \
regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"altcha_captcha_include_premium_users",
"true",
"Include premium users",
altcha_captcha.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&altcha_captcha.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
altcha_captcha.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"altcha_captcha_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
altcha_captcha.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
(number_field(
"altcha_captcha_cost",
"Cost (PBKDF2 iterations per attempt)",
&altcha_captcha.cost.to_string(),
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
"1",
Some("The API spends one attempt at this cost to issue each challenge."),
))
(number_field(
"altcha_captcha_max_counter",
"Maximum counter",
&altcha_captcha.max_counter.to_string(),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
"1",
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
))
(form_actions(html! {
(submit_button("Save ALTCHA Configuration"))
}))
}
}
},
)
}
fn profile_timezone_section(
base: &str,
csrf_token: &str,
profile_timezone: &ProfileTimezoneConfigResponse,
) -> Markup {
let status = if profile_timezone.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = profile_timezone.included_user_ids.join("\n");
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
section_card_with_description(
"Profile Timezone",
"Lets the selected users save a time zone in profile settings and show their local time \
on their profile. Users outside the rollout cannot change it, and a saved time zone \
stays hidden from everyone while its owner is outside the rollout.",
html! {
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (profile_timezone.config_version)
}
}
(checkbox(
"profile_timezone_enabled",
"true",
"Serve profile timezone to the selected users",
profile_timezone.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked nobody \
sees the setting and every saved time zone is hidden."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"profile_timezone_rollout_basis_points",
"Rollout (basis points)",
&profile_timezone.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"profile_timezone_rollout_salt",
"Rollout Salt",
&profile_timezone.rollout_salt,
PROFILE_TIMEZONE_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users get profile \
timezone regardless of the percentage above. Invalid entries prevent the save."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"profile_timezone_include_premium_users",
"true",
"Include premium users",
profile_timezone.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&profile_timezone.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
profile_timezone.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"profile_timezone_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
profile_timezone.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the percentage."
}
}
(form_actions(html! {
(submit_button("Save Profile Timezone Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
experiment_delivery: &ExperimentDeliveryConfigResponse,
) -> Markup {
section_card_with_description(
"Experiment Delivery",
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the ones above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
html! {
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"experiment_delivery_poll_interval_seconds",
"Assignment Poll Interval (s)",
&experiment_delivery.poll_interval_seconds.to_string(),
Some(60), Some(86400), "1",
Some("How often a client re-reads its assignments, 60 to 86400 seconds. Lower values pick up changes sooner at the cost of more requests."),
))
(number_field(
"experiment_delivery_poll_jitter_percent",
"Assignment Poll Jitter (%)",
&experiment_delivery.poll_jitter_percent.to_string(),
Some(0), Some(50), "1",
Some("How far each client spreads its poll around the interval, 0 to 50 percent. Raise it to break up a fleet that polls on the same tick, set it to 0 for an exact interval."),
))
}
(form_actions(html! {
(submit_button("Save Experiment Delivery Configuration"))
}))
}
}
},
)
}
fn registration_config_section( fn registration_config_section(
config: &AdminConfig, config: &AdminConfig,
csrf_token: &str, csrf_token: &str,
@@ -1522,3 +2374,96 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
}, },
) )
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
fn rendered_voice_noise_suppression_section(
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
) -> String {
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
}
#[test]
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
}],
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(markup.contains("1 of 200 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
let domain_migration = DomainMigrationConfigResponse {
anonymous_rollout_basis_points: 250,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..DomainMigrationConfigResponse::default()
};
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
assert!(markup.contains("action=update_domain_migration"));
assert!(markup.contains("domain_migration_enabled"));
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
relay_consent_accepted: true,
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
};
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
assert!(markup.contains("action=update_push_relay"));
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
assert!(markup.contains("https://fluxer.com/push-relay"));
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
assert!(markup.contains("value=\"1130650140672000000\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
assert!(!markup.to_lowercase().contains("rollout"));
let unaccepted =
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
assert!(unaccepted.contains("Not accepted"));
assert!(unaccepted.contains("value=\"Never\""));
assert!(unaccepted.contains("value=\"Nobody\""));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect(),
..VoiceNoiseSuppressionConfigResponse::default()
};
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
assert!(markup.contains("1000 of 1000 stored"));
assert!(markup.contains("at the cap"));
}
}
+2 -13
View File
@@ -7,7 +7,6 @@ use crate::{
templates::{ templates::{
components::{ components::{
auto_refresh::auto_refresh, auto_refresh::auto_refresh,
badge::{BadgeVariant, badge},
data_field::{data_field_mono, data_field_text, data_grid}, data_field::{data_field_mono, data_field_text, data_grid},
form::{csrf_input, danger_button, form_field_group}, form::{csrf_input, danger_button, form_field_group},
page_container::{page_header_with_actions, page_header_with_back}, page_container::{page_header_with_actions, page_header_with_back},
@@ -18,17 +17,7 @@ use crate::{
}; };
use maud::{Markup, html}; use maud::{Markup, html};
fn status_badge(status: &str) -> Markup { use super::jobs_list_helpers::status_badge;
let variant = match status {
"queued" => BadgeVariant::Default,
"running" => BadgeVariant::Info,
"succeeded" => BadgeVariant::Success,
"failed" | "deadletter" => BadgeVariant::Danger,
"cancelled" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
};
badge(status, variant)
}
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str { fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
job.get(key).and_then(|v| v.as_str()).unwrap_or("") job.get(key).and_then(|v| v.as_str()).unwrap_or("")
@@ -71,7 +60,7 @@ fn progress_bar(job: &serde_json::Value) -> Markup {
} }
(cur, Some(tot)) => { (cur, Some(tot)) => {
let c = cur.unwrap_or(0); let c = cur.unwrap_or(0);
let pct = (c * 100 / tot).min(100); let pct = (u128::from(c) * 100 / u128::from(tot)).min(100);
html! { html! {
div role="progressbar" aria-valuenow=(pct) aria-valuemin="0" div role="progressbar" aria-valuenow=(pct) aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
@@ -33,25 +33,26 @@ pub(crate) fn format_progress(job: &serde_json::Value) -> String {
(Some(cur), None | Some(0)) => format!("{cur}"), (Some(cur), None | Some(0)) => format!("{cur}"),
(cur, Some(tot)) => { (cur, Some(tot)) => {
let c = cur.unwrap_or(0); let c = cur.unwrap_or(0);
let pct = c.saturating_mul(100).checked_div(tot).unwrap_or(0); let pct = (u128::from(c) * 100)
.checked_div(u128::from(tot))
.unwrap_or(0);
format!("{c} / {tot} ({pct}%)") format!("{c} / {tot} ({pct}%)")
} }
} }
} }
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup { fn job_id_text(value: &serde_json::Value) -> String {
let job_id = job value
.get("job_id")
.and_then(|v| v.as_str().or_else(|| v.as_u64().map(|_| "")))
.unwrap_or("");
let job_id_display = job
.get("job_id") .get("job_id")
.map(|v| match v { .map(|v| match v {
serde_json::Value::String(s) => s.clone(), serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => v.to_string(), _ => v.to_string(),
}) })
.unwrap_or_default(); .unwrap_or_default()
}
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
let job_id = job_id_text(job);
let task_type = job let task_type = job
.get("task_type") .get("task_type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -79,23 +80,17 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
.unwrap_or_else(|| "cron".to_owned()); .unwrap_or_else(|| "cron".to_owned());
let progress = format_progress(job); let progress = format_progress(job);
let link_id = if job_id.is_empty() {
&job_id_display
} else {
job_id
};
table_row(html! { table_row(html! {
(table_cell(true, html! { (table_cell(true, html! {
span class="whitespace-nowrap text-sm" { (created_at) } span class="whitespace-nowrap text-sm" { (created_at) }
})) }))
(table_cell(false, html! { (table_cell(false, html! {
a href={(base) "/jobs/" (link_id)} a href={(base) "/jobs/" (job_id)}
hx-target="#main-content" hx-target="#main-content"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-push-url="true" hx-push-url="true"
class="text-blue-600 text-sm hover:underline" { class="text-blue-600 text-sm hover:underline" {
(job_id_display) (job_id)
} }
})) }))
(table_cell(false, html! { (table_cell(false, html! {
@@ -103,7 +98,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
})) }))
(table_cell(false, html! { (status_badge(status)) })) (table_cell(false, html! { (status_badge(status)) }))
(table_cell(false, html! { (table_cell(false, html! {
span class="text-sm" data-job-progress=(link_id) { (progress) } span class="text-sm" data-job-progress=(job_id) { (progress) }
})) }))
(table_cell(true, html! { (table_cell(true, html! {
span class="text-sm" { (attempts) "/" (max_attempts) } span class="text-sm" { (attempts) "/" (max_attempts) }
@@ -112,7 +107,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
span class="text-sm" { (requester) } span class="text-sm" { (requester) }
})) }))
(table_cell(false, html! { (table_cell(false, html! {
a href={(base) "/jobs/" (link_id)} a href={(base) "/jobs/" (job_id)}
hx-target="#main-content" hx-target="#main-content"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-push-url="true" hx-push-url="true"
@@ -159,14 +154,7 @@ pub(crate) fn next_page_link(
.get("created_at") .get("created_at")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let job_id = cursor let job_id = job_id_text(cursor);
.get("job_id")
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => v.to_string(),
})
.unwrap_or_default();
let mut params = vec![ let mut params = vec![
format!("cursor_bucket_day={bucket_day}"), format!("cursor_bucket_day={bucket_day}"),
+12 -130
View File
@@ -10,7 +10,8 @@ use crate::{
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group, FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
submit_button, submit_button,
}, },
message_list::{Attachment, Message, message_deletion_script, message_list}, message_data::{Message, ordered_messages, value_id},
message_list::{message_deletion_script, message_list},
page_container::{card, page_header}, page_container::{card, page_header},
}, },
layout::LayoutOptions, layout::LayoutOptions,
@@ -19,7 +20,6 @@ use crate::{
}; };
use maud::{Markup, html}; use maud::{Markup, html};
use serde_json::Value; use serde_json::Value;
use std::cmp::Ordering;
const MESSAGE_BROWSE_SCRIPT: &str = r#" const MESSAGE_BROWSE_SCRIPT: &str = r#"
(function () { (function () {
@@ -263,7 +263,7 @@ pub fn browse_messages_fragment(
show_delete: bool, show_delete: bool,
highlight_message_id: Option<&str>, highlight_message_id: Option<&str>,
) -> Markup { ) -> Markup {
let messages = ordered_messages(result); let messages = response_messages(result);
let has_more = result let has_more = result
.get("has_more") .get("has_more")
.and_then(Value::as_bool) .and_then(Value::as_bool)
@@ -295,7 +295,7 @@ fn browse_result_card(
csrf_token: &str, csrf_token: &str,
context_limit: u32, context_limit: u32,
) -> Markup { ) -> Markup {
let messages = ordered_messages(result); let messages = response_messages(result);
let has_more = result let has_more = result
.get("has_more") .get("has_more")
.and_then(Value::as_bool) .and_then(Value::as_bool)
@@ -345,7 +345,7 @@ fn search_result_card(
query_text: &str, query_text: &str,
show_delete: bool, show_delete: bool,
) -> Markup { ) -> Markup {
let messages = ordered_messages(result); let messages = response_messages(result);
let total = result let total = result
.get("total") .get("total")
.and_then(Value::as_u64) .and_then(Value::as_u64)
@@ -382,7 +382,7 @@ fn lookup_result_card(
show_delete: bool, show_delete: bool,
context_limit: u32, context_limit: u32,
) -> Markup { ) -> Markup {
let messages = ordered_messages(result); let messages = response_messages(result);
let channel_id = messages let channel_id = messages
.first() .first()
.map(|m| m.channel_id.as_str()) .map(|m| m.channel_id.as_str())
@@ -508,130 +508,12 @@ fn empty_state(text: &str) -> Markup {
} }
} }
fn ordered_messages(result: &Value) -> Vec<Message> { fn response_messages(result: &Value) -> Vec<Message> {
let mut messages: Vec<Message> = result let values = result["messages"]
.get("messages") .as_array()
.and_then(Value::as_array) .map(Vec::as_slice)
.into_iter() .unwrap_or_default();
.flatten() ordered_messages(values)
.map(message_from_value)
.collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value
.get("attachments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value.get("id").and_then(value_id).unwrap_or_default(),
content: value
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
author_id: value
.get("author_id")
.and_then(value_id)
.unwrap_or_default(),
author_username: value
.get("author_username")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value
.get("author_global_name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author_discriminator: value
.get("author_discriminator")
.and_then(value_id)
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value
.get("author_avatar")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
channel_id: value
.get("channel_id")
.and_then(value_id)
.unwrap_or_default(),
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
channel_content_warning_level: value
.get("channel_content_warning_level")
.and_then(Value::as_i64)
.map(|n| n as i32),
channel_content_warning_text: value
.get("channel_content_warning_text")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value.get("id").and_then(value_id).unwrap_or_default(),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
filename: value
.get("filename")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
nsfw: value.get("nsfw").and_then(Value::as_bool),
content_type: value
.get("content_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
height: value
.get("height")
.and_then(Value::as_u64)
.map(|n| n as u32),
size: value.get("size").and_then(Value::as_u64),
ncmec_status: value
.get("ncmec_status")
.and_then(Value::as_str)
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value
.get("ncmec_report_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
ncmec_failure_reason: value
.get("ncmec_failure_reason")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
}
}
fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
} }
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup { fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use std::cmp::Ordering;
use crate::{ use crate::{
acl, acl,
api::types::ReportEntry, api::types::ReportEntry,
@@ -13,7 +11,8 @@ use crate::{
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text}, data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
form::csrf_input, form::csrf_input,
media::{guild_icon_url, initials, user_avatar_url}, media::{guild_icon_url, initials, user_avatar_url},
message_list::{Attachment, Message, message_deletion_script, message_list}, message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
nsfw_indicators::{ nsfw_indicators::{
adult_content_badge, channel_nsfw_state_badge, content_warning_badge, adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
}, },
@@ -26,7 +25,6 @@ use crate::{
utils::timestamps::format_admin_timestamp, utils::timestamps::format_admin_timestamp,
}; };
use maud::{Markup, html}; use maud::{Markup, html};
use serde_json::Value;
fn status_badge(status: i32) -> Markup { fn status_badge(status: i32) -> Markup {
let (label, variant) = match status { let (label, variant) = match status {
@@ -534,123 +532,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
})) }))
} }
} }
fn ordered_messages(values: &[Value]) -> Vec<Message> {
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
messages.sort_by(compare_message_ids);
messages
}
fn message_from_value(value: &Value) -> Message {
let attachments = value
.get("attachments")
.and_then(Value::as_array)
.into_iter()
.flatten()
.map(attachment_from_value)
.collect();
Message {
id: value.get("id").and_then(value_id).unwrap_or_default(),
content: value
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
timestamp: value
.get("timestamp")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
author_id: value
.get("author_id")
.and_then(value_id)
.unwrap_or_default(),
author_username: value
.get("author_username")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned(),
author_global_name: value
.get("author_global_name")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
author_discriminator: value
.get("author_discriminator")
.and_then(value_id)
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value
.get("author_avatar")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
channel_id: value
.get("channel_id")
.and_then(value_id)
.unwrap_or_default(),
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
channel_content_warning_level: value
.get("channel_content_warning_level")
.and_then(Value::as_i64)
.map(|n| n as i32),
channel_content_warning_text: value
.get("channel_content_warning_text")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
attachments,
}
}
fn attachment_from_value(value: &Value) -> Attachment {
Attachment {
id: value.get("id").and_then(value_id).unwrap_or_default(),
url: value
.get("url")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
filename: value
.get("filename")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
nsfw: value.get("nsfw").and_then(Value::as_bool),
content_type: value
.get("content_type")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
height: value
.get("height")
.and_then(Value::as_u64)
.map(|n| n as u32),
size: value.get("size").and_then(Value::as_u64),
ncmec_status: value
.get("ncmec_status")
.and_then(Value::as_str)
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value
.get("ncmec_report_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
ncmec_failure_reason: value
.get("ncmec_failure_reason")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
}
}
fn value_id(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
(Ok(l), Ok(r)) => l.cmp(&r),
_ => left.id.cmp(&right.id),
}
}
@@ -466,7 +466,10 @@ fn reports_pagination(
limit: u32, limit: u32,
total: u64, total: u64,
) -> Markup { ) -> Markup {
let total_pages = ((total + u64::from(limit).saturating_sub(1)) / u64::from(limit)).max(1); let total_pages = total.div_ceil(u64::from(limit)).max(1);
let next_page = page
.checked_add(1)
.filter(|page| u64::from(*page) < total_pages);
html! { html! {
div class="mt-4 flex items-center justify-between" { div class="mt-4 flex items-center justify-between" {
@if page > 0 { @if page > 0 {
@@ -478,10 +481,10 @@ fn reports_pagination(
span {} span {}
} }
span class="text-neutral-500 text-sm" { span class="text-neutral-500 text-sm" {
"Page " (page + 1) " of " (total_pages) "Page " (u64::from(page) + 1) " of " (total_pages)
} }
@if u64::from(page + 1) < total_pages { @if let Some(next_page) = next_page {
a href=(reports_url(config, filters, page + 1, limit)) a href=(reports_url(config, filters, next_page, limit))
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" { class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
(PreEscaped("Next &rarr;")) (PreEscaped("Next &rarr;"))
} }
Loaded 100 of 4379 files, more files were not shown because too many files have changed in this diff. Show more