Compare commits

...
Author SHA1 Message Date
HampusandGitHub 4e6b837ccc fix: tighten edge cases across services (#3158) 2026-10-03 13:04:29 +02:00
HampusandGitHub a9f7a23c0d fix(voice): point the corner volume at the focused stream (#3157) 2026-10-03 12:37:24 +02:00
HampusandGitHub 07301adc6d fix(messages): keep mention highlight on hover in blocked groups (#3156) 2026-10-03 12:37:20 +02:00
HampusandGitHub c6630008b5 fix(voice): enlarge participant avatars in the voice panel (#3155) 2026-10-03 12:19:37 +02:00
HampusandGitHub cdcaba34ce fix(self-hosting): cap meilisearch indexing threads by default (#3153) 2026-10-03 02:15:16 +02:00
HampusandGitHub 09b9a57e38 fix(guild): match the verification discovery note to filtering (#3152) 2026-10-03 02:14:54 +02:00
HampusandGitHub 79d7c85832 fix(app): retry emoji picker images that fail to load (#3151) 2026-10-03 02:14:28 +02:00
HampusandGitHub eb0e8366bc fix(voice): keep saved linux audio apps in the source picker (#3150) 2026-10-03 02:14:06 +02:00
HampusandGitHub cf9752db4f fix(voice): release call modals when fullscreen ends (#3149) 2026-10-03 02:13:46 +02:00
HampusandGitHub d6fb3b2c50 fix(apps): stop bot permission labels overlapping (#3148) 2026-10-03 02:11:57 +02:00
HampusandGitHub b04fdc68df fix(storage): fall back when cross-bucket copy is rejected (#3147) 2026-10-03 02:11:34 +02:00
HampusandGitHub 706c41aad9 fix(auth): check the TOTP setup code before asking for sudo (#3146) 2026-10-03 02:11:14 +02:00
HampusandGitHub db9ec0605e fix(media-proxy): stop rejecting large storage transport chunks (#3144) 2026-10-03 02:10:55 +02:00
omsterandGitHub a95172bf88 fix(app-call): utilise popout window opened by manager (#2960) 2026-10-03 01:11:23 +02:00
HampusandGitHub 597116a0b4 fix(app): stop blurring reactions and stickers in CW channels (#3141) 2026-10-02 23:52:17 +02:00
HampusandGitHub 811341bc2f feat(email): configurable reply-to address (#3140) 2026-10-02 23:26:01 +02:00
HampusandGitHub 98fa41dcf0 fix(voice): avoid capped software h264 for auto screen shares (#3139) 2026-10-02 22:52:03 +02:00
HampusandGitHub b52a0b5d5f fix: friendlier wording for paused messaging (#3138) 2026-10-02 22:50:34 +02:00
HampusandGitHub effeaaa435 fix(app): make web update detection survive flaky networks (#3135) 2026-10-02 21:39:52 +02:00
HampusandGitHub 27fc634bc9 perf(app): stop preloading channels and guilds on hover (#3133) 2026-10-02 19:04:14 +02:00
HampusandGitHub 69d93f9fee fix(premium): serve the Plutonium page at /channels/@premium (#3132) 2026-10-02 18:20:01 +02:00
HampusandGitHub 00620715da fix(api): drop leftover node stats logging (#3131) 2026-10-02 18:19:07 +02:00
HampusandGitHub 1ec8f31253 refactor: simplify account standing and verification levels (#3130) 2026-10-02 18:17:41 +02:00
HampusandGitHub 1abde06824 feat(admin): accept domain entries in the email blocklist (#3129) 2026-10-02 18:00:38 +02:00
HampusandGitHub b54016653b fix(app): show active incidents on the reconnecting banner (#3128) 2026-10-02 17:27:21 +02:00
HampusandGitHub ee74d61f27 fix(channel): track the member list width with its divider (#3127) 2026-10-02 17:26:52 +02:00
HampusandGitHub 1f18d3262d fix(composer): keep emoji autocomplete open on tilde names (#3126) 2026-10-02 17:26:30 +02:00
HampusandGitHub 8ea7707b37 fix(guild): clear guild header menu highlight on pointer leave (#3125) 2026-10-02 17:26:09 +02:00
HampusandGitHub 7b40df5d6c fix(messages): keep spoilers on forwarded link embeds (#3124) 2026-10-02 17:25:33 +02:00
HampusandGitHub 6f98de33f7 fix(ui): portal combobox menus into the fullscreen call host (#3123) 2026-10-02 17:25:08 +02:00
HampusandGitHub efe94ed094 fix(voice): stop offering h264 to firefox on linux (#3122) 2026-10-02 17:24:45 +02:00
HampusandGitHub 603b936536 fix(installer): point Fedora at podman with docker-compose (#3121) 2026-10-02 17:24:20 +02:00
HampusandGitHub d87351eefe fix(privacy): let minors block media in DMs from others (#3120) 2026-10-02 17:23:50 +02:00
HampusandGitHub 76e6891f5b fix(api): credit self-hosted gift codes to the issuing admin (#3119) 2026-10-02 17:23:26 +02:00
HampusandGitHub 5040ae2c10 fix(sso): join provisioned users to the single community (#3118) 2026-10-02 17:23:03 +02:00
HampusandGitHub 87df92e2c2 fix(gifs): fetch featured category previews concurrently (#3117) 2026-10-02 17:22:30 +02:00
HampusandGitHub 237aff666d perf(app-proxy): skip disk reads for absent static prefixes (#3115) 2026-10-02 16:09:34 +02:00
HampusandGitHub 11645cbf28 fix(ci): keep published source maps when a rebuild differs (#3113) 2026-10-02 15:25:05 +02:00
HampusandGitHub e297a6a653 fix(app-proxy): make the SPA shell identical for every visitor (#3112) 2026-10-02 15:13:36 +02:00
HampusandGitHub 1eed347ffb fix(premium): follow the light theme on the Plutonium page (#3111) 2026-10-02 14:15:24 +02:00
HampusandGitHub 4aa7a3e181 fix(voice): allow stereo mics at 64 kbps and in the mic test (#3110) 2026-10-02 14:11:19 +02:00
HampusandGitHub 69786d3b49 fix(voice): prefer vp8 for automatic screen shares in firefox (#3109) 2026-10-02 14:09:55 +02:00
HampusandGitHub 2fb5fb1abb fix(voice): allow av1 and vp9 screen shares in firefox (#3108) 2026-10-02 14:08:41 +02:00
HampusandGitHub a9265cbb39 perf(gateway): speed up reconnects and pin guilds to nodes (#3107) 2026-10-02 14:02:22 +02:00
HampusandGitHub ee2d11ee0a fix(voice): prefer vp9 over software h264 for screen shares (#3106) 2026-10-02 13:29:29 +02:00
TarekandGitHub 632067b552 feat(gateway,admin): Expand stats for metrics (#3064) 2026-10-02 12:58:16 +02:00
HampusandGitHub 840dc3dfa5 feat(premium): match the Plutonium page to the new site look (#3104) 2026-10-02 12:20:44 +02:00
HampusandGitHub 4e6f9b539c fix(voice): make RNNoise the default noise suppression (#3103) 2026-10-02 11:31:40 +02:00
HampusandGitHub 98cce4815d feat(users): add temporary new conversation limits (#3100) 2026-10-02 01:28:35 +02:00
HampusandGitHub b375abc20a feat(desktop): live-reload linked css theme files (#3099) 2026-10-02 01:02:30 +02:00
HampusandGitHub 21cb7ba69c feat(premium): show App Store and Google Play subs on web (#3098) 2026-10-01 22:51:14 +02:00
HampusandGitHub be69333eaf feat(premium): add the Plutonium page behind an experiment (#3097) 2026-10-01 21:45:44 +02:00
HampusandGitHub 9a074adb11 fix(app): make disabling built-in shortcuts take effect live (#3096) 2026-10-01 20:37:28 +02:00
HampusandGitHub 2df82b2b5e fix(guild): treat very high as high without phone verification (#3095) 2026-10-01 20:33:02 +02:00
HampusandGitHub d691047884 feat(desktop): add start minimized option for launch at login (#3094) 2026-10-01 19:51:43 +02:00
HampusandGitHub c2e7fde5bc test(api): isolate crosspost tests that mock constants (#3091) 2026-10-01 17:13:49 +02:00
HampusandGitHub 7e4d5137f8 feat: add announcement channels, publishing and following (#3090) 2026-10-01 16:57:21 +02:00
HampusandGitHub 376afd2ad6 fix(voice): keep mic publish state in sync with voice state (#3088) 2026-10-01 14:03:04 +02:00
HampusandGitHub e3fcedbec5 fix(voice): stabilize voice input and noise suppression (#3087) 2026-10-01 14:02:12 +02:00
HampusandGitHub 7c9564bcad feat(deploy): add helm charts for the fluxer services (#3082) 2026-10-01 04:11:30 +02:00
HampusandGitHub cfed6cc4e0 perf(media-proxy): gzip static assets on the fly (#3079) 2026-09-30 23:41:16 +02:00
HampusandGitHub c7bd1be3e4 fix(auth): offer every transport for passkeys stored without any (#3077) 2026-09-30 23:01:37 +02:00
HampusandGitHub 2161d84701 fix(self-hosting): grow seaweedfs one volume at a time (#3076) 2026-09-30 22:55:12 +02:00
HampusandGitHub eaeeb3b502 fix(api): report final system DM progress (#3074) 2026-09-30 22:11:34 +02:00
HampusandGitHub dc32a7c70e feat(admin): allow system DMs to all users (#3073) 2026-09-30 21:29:05 +02:00
HampusandGitHub ab0b483fbe perf(gateway): speed up presence and harden guild queries (#3072) 2026-09-30 21:12:13 +02:00
HampusandGitHub 6e2f90b03c fix(premium): drop the grace period after a voluntary cancel (#3071) 2026-09-30 21:03:25 +02:00
HampusandGitHub 5e0806f479 fix(voice): preserve microphone channels during screen sharing (#3070) 2026-09-30 20:47:30 +02:00
HampusandGitHub dfdfffe5de feat(premium): give failed renewals a billing-cycle grace period (#3066) 2026-09-30 18:48:01 +02:00
HampusandGitHub f5e32aed31 fix(ci): correct TTL fixtures and unused dependencies (#3065) 2026-09-30 17:45:07 +02:00
HampusandGitHub 710c1aeaa8 fix(deps): bump yanked yoke-derive to 0.8.4 (#3063) 2026-09-30 16:59:59 +02:00
HampusandGitHub af49cd6cc4 refactor(ban): drop ipinfo cgnat blast-radius guard (#3062) 2026-09-30 16:54:43 +02:00
omsterandGitHub ca719e7b5e feat(admin,api): restrict community creation on self-hosted (#3055) 2026-09-30 16:32:45 +02:00
HampusandGitHub ab4069ed0e fix(app): let hidden sidebar buttons be shown again (#3061) 2026-09-30 15:03:44 +02:00
HampusandGitHub 12bfaa83ba fix(sso): route mobile sign-in through the web callback (#3060) 2026-09-30 14:48:24 +02:00
HampusandGitHub 1076728241 perf(gateway): keep large guilds responsive under floods (#3058) 2026-09-30 12:26:21 +02:00
HampusandGitHub 360b984adc fix(ci): repair admin test config and a ttl race in api tests (#3054) 2026-09-30 02:39:46 +02:00
HampusandGitHub dcdf7e1d93 fix(api): let new channels inherit the adult-only setting (#3053) 2026-09-30 02:31:47 +02:00
HampusandGitHub e8cb167dbf feat(premium): add App Store and Google Play purchases (#3052) 2026-09-30 01:55:19 +02:00
HampusandGitHub 0b3418dcbe fix(app): make unchecked checkbox border visible (#3050) 2026-09-30 01:23:43 +02:00
HampusandGitHub ec7649193c docs(admin): document notify_reporter on report resolve (#3049) 2026-09-30 01:01:22 +02:00
HampusandGitHub 2b8a743dc5 refactor(self-hosting): forward every setting, drop dead config (#3047) 2026-09-30 00:58:43 +02:00
HampusandGitHub 39f9beda5a fix(api): send correct staff emails and allow suppressing them (#3048) 2026-09-29 23:55:54 +02:00
HampusandGitHub f0b3c82cfd fix(app): scroll quick switcher selection after typing (#3044) 2026-09-29 20:54:08 +02:00
HampusandGitHub 2808edf6d0 fix(push): keep notification images within the web push budget (#3043) 2026-09-29 20:35:56 +02:00
HampusandGitHub 071263188a fix(push): run the stale DM read check on presence nodes (#3042) 2026-09-29 19:51:36 +02:00
HampusandGitHub f9108f24ce feat(self-host): add an overlay that turns off bundled seaweedfs (#3041) 2026-09-29 19:49:02 +02:00
HampusandGitHub e98b77a54a fix(api): stop treating users without a birth date as minors (#3040) 2026-09-29 18:27:15 +02:00
HampusandGitHub 2636e9cc13 fix(voice): lower DeepFilterNet attenuation limit to 30 dB (#3039) 2026-09-29 18:16:13 +02:00
JiraliteandGitHub 944b586f22 fix(UseForwardDestinations): hide system user (#3038) 2026-09-29 18:14:19 +02:00
HampusandGitHub 4f968bbc47 feat(captcha): make ALTCHA the only captcha (#3035) 2026-09-29 17:00:15 +02:00
HampusandGitHub d433a039b5 feat(profile): ship profile timezone to everyone (#3034) 2026-09-29 16:28:40 +02:00
HampusandGitHub b30ea361d3 fix(push): stop pushes for read, silent and muted messages (#3033) 2026-09-29 15:58:46 +02:00
HampusandGitHub 9908518f5b feat(app): add quick reply and edit keybinds (#3032) 2026-09-29 15:50:56 +02:00
HampusandGitHub 364084c819 refactor(api): emit moderation events and apply account actions (#3031) 2026-09-29 12:24:15 +02:00
HampusandGitHub c488906131 feat(voice): ship noise suppression treatment to everyone (#3029) 2026-09-29 03:43:58 +02:00
HampusandGitHub 39c72f0fb0 fix(desktop): require readable keyboards for Linux input access (#3026) 2026-09-28 22:27:20 +02:00
HampusandGitHub 3736d94d73 feat(premium): let self-hosted instances sell premium and gifts (#3025) 2026-09-28 21:21:51 +02:00
HampusandGitHub 192cec689a fix(app): keep voice connections of one session across channels (#3023) 2026-09-28 19:50:47 +02:00
HampusandGitHub e895c41bf0 fix(app): tighten the composer status row (#3022) 2026-09-28 19:50:00 +02:00
HampusandGitHub 997d98c65c fix(app): fade messages behind the composer status row (#3020) 2026-09-28 18:47:42 +02:00
HampusandGitHub c9ae5b6ee8 fix(app): smooth the fluxer.com migration and expired re-login (#3019) 2026-09-28 18:11:18 +02:00
HampusandGitHub a728be4062 fix(app): respect time format setting in profile local time (#3018) 2026-09-28 17:48:55 +02:00
HampusandGitHub fce81367fb fix(app): stop message text showing through the slowmode hint (#3017) 2026-09-28 17:29:15 +02:00
HampusandGitHub 5a4edc0b59 fix(api): make read state clear endpoint a no-op (#3015) 2026-09-28 16:31:30 +02:00
HampusandGitHub 713ae5f7f5 feat(api): restrict dms to friends by default for new users (#3013) 2026-09-28 15:02:20 +02:00
HampusandGitHub eaee820216 feat(experiments): target rollouts by guild and premium status (#3012) 2026-09-28 14:34:19 +02:00
HampusandGitHub 564c5ae164 feat(profile): move profile timezone from staff to an experiment (#3011) 2026-09-28 12:57:26 +02:00
HampusandGitHub dd8ed6f205 fix(app): react at once when picking a +: autocomplete emoji (#3010) 2026-09-28 12:30:45 +02:00
HampusandGitHub ed8c412415 perf(gateway): make channel moves cheap in large guilds (#3008) 2026-09-28 02:07:47 +02:00
HampusandGitHub 12417a6942 fix(app): keep the caret after inserted emoji (#3007) 2026-09-28 01:56:08 +02:00
HampusandGitHub d05f6c9aaa fix(gateway): push held users whose sessions end during grace (#3006) 2026-09-28 01:47:55 +02:00
HampusandGitHub 0ca035c547 fix(messages): accept null version on legacy message rows (#3004) 2026-09-28 01:10:52 +02:00
HampusandGitHub dfd46ccc2c ci(gateway): drop cached gateway build output before compiling (#3003) 2026-09-28 01:08:52 +02:00
HampusandGitHub f6df3169ca fix(app): use +:shortcode: for reactions, no space before emoji (#3001) 2026-09-28 00:48:23 +02:00
HampusandGitHub 5b280898c5 refactor(push): retire the push service delivery experiment (#3000) 2026-09-28 00:45:22 +02:00
HampusandGitHub 2a9e25c788 fix(dev): drop the stray -- from the tunnel public URL hint (#2999) 2026-09-28 00:43:32 +02:00
HampusandGitHub 463c03fb6d feat(app): make +emoji react on send and target replies (#2998) 2026-09-28 00:08:40 +02:00
HampusandGitHub 153dad11e1 feat(installer): let upgrades copy the uploads uncompressed (#2995) 2026-09-27 23:51:24 +02:00
HampusandGitHub e2d05a44a8 fix(push): stop retrying relay rate limit refusals (#2993) 2026-09-27 23:29:27 +02:00
HampusandGitHub 30ba55bd4d fix(gateway): parse push relay hosts as binaries (#2989) 2026-09-27 21:22:45 +02:00
HampusandGitHub 9def9fbef6 feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) 2026-09-27 21:19:35 +02:00
HampusandGitHub fa3fd0027c fix(i18n): translate the push relay notice strings (#2987) 2026-09-27 21:15:33 +02:00
HampusandGitHub 7e1b934637 feat(captcha): add ALTCHA proof-of-work captcha experiment (#2986) 2026-09-27 21:02:55 +02:00
HampusandGitHub 33a118d12a docs(readme): list the Google Play beta first for Android (#2985) 2026-09-27 20:49:39 +02:00
HampusandGitHub 01f53a168d feat(push): gate relay delivery on operator consent (#2984) 2026-09-27 20:33:10 +02:00
HampusandGitHub 336b8b7dcd fix(forward): make an @silent comment silence the forward too (#2983) 2026-09-27 20:13:14 +02:00
HampusandGitHub 48d0034239 fix(app-proxy): trust the Play app signing certificate (#2982) 2026-09-27 19:37:40 +02:00
HampusandGitHub 677ef8491e fix(desktop): back off failed app loads and offer a retry (#2980) 2026-09-27 16:18:01 +02:00
HampusandGitHub 6a6119ed1e fix(push): preview forwarded message content (#2979) 2026-09-27 13:33:22 +02:00
HampusandGitHub 931327d1dc fix(push): stop sending notifications for system messages (#2978) 2026-09-27 13:33:18 +02:00
HampusandGitHub 858a2d9e2b fix(oauth): stop granting scopes the user turned off (#2968) 2026-09-26 13:48:23 +02:00
HampusandGitHub 841fb7af41 feat(auth): migrate passkeys to fluxer.com (#2964) 2026-09-25 22:33:50 +02:00
HampusandGitHub 08e65d41c0 fix(api): clear the perks-sanitized latch when premium returns (#2963) 2026-09-25 20:13:00 +02:00
HampusandGitHub f76c4dc041 fix(api): cancel only the subscription the refund belongs to (#2962) 2026-09-25 20:10:54 +02:00
HampusandGitHub f1f8ba2031 fix(app): add copy link to link channel context menus (#2959) 2026-09-25 18:16:20 +02:00
HampusandGitHub 5ab8d745c0 fix(i18n): correct the fluxer.com migration translations (#2958) 2026-09-25 17:46:07 +02:00
HampusandGitHub ff62bc89a4 feat(app): add passkey popup bridge for password managers (#2957) 2026-09-25 17:43:19 +02:00
HampusandGitHub 838bbdb5ec fix(app): only start the domain migration when the app opens (#2956) 2026-09-25 16:44:58 +02:00
HampusandGitHub 1c36a59b2c feat(app): rework quick switcher ranking and show origin icons (#2953) 2026-09-25 13:59:25 +02:00
HampusandGitHub 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
HampusandGitHub e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
HampusandGitHub f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
HampusandGitHub bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
HampusandGitHub e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
HampusandGitHub 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
HampusandGitHub b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
HampusandGitHub 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot]andGitHub 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot]andGitHub 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
HampusandGitHub 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
HampusandGitHub 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
HampusandGitHub 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
HampusandGitHub a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
HampusandGitHub 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
HampusandGitHub 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
HampusandGitHub a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
HampusandGitHub 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
HampusandGitHub c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
HampusandGitHub dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
HampusandGitHub 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
HampusandGitHub 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
HampusandGitHub deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omsterandGitHub 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omsterandGitHub 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
HampusandGitHub f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
HampusandGitHub ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
HampusandGitHub bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
HampusandGitHub b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
HampusandGitHub b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
HampusandGitHub 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
HampusandGitHub b16989d567 feat(push): ring incoming calls on Apple PushKit devices (#2911) 2026-09-23 20:21:08 +02:00
HampusandGitHub c9754ac11a fix(api): exempt internal rpc from the client ip check (#2910) 2026-09-23 18:03:36 +02:00
fluxer-weblate[bot]andGitHub f34e4a5115 chore(i18n): update translations from Weblate (#2903) 2026-09-23 17:28:25 +02:00
fluxer-weblate[bot]andGitHub 44b3615298 chore(i18n): update translations from Weblate (#2904) 2026-09-23 17:27:59 +02:00
HampusandGitHub 211e98307d perf(push): cache endpoint guard dns verdicts (#2907) 2026-09-23 17:27:19 +02:00
HampusandGitHub 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
JiraliteandGitHub 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
WagnerandGitHub 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot]andGitHub 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omsterandGitHub a1e2bf2c8d feat(dev/linux): select the wayland backend when reachable in the native desktop app (#2899)
Signed-off-by: omstr <[email protected]>
2026-09-22 21:27:59 +02:00
HampusandGitHub 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
HampusandGitHub c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
HampusandGitHub 91340c5c84 fix(markdown): compile the parser wasm asynchronously (#2900) 2026-09-22 19:58:38 +02:00
HampusandGitHub 045dd5d027 test(api): make the harvest token tamper test deterministic (#2894) 2026-09-22 02:20:18 +02:00
HampusandGitHub a21b9c4659 docs(readme): clean up the download prose (#2893) 2026-09-22 02:08:42 +02:00
HampusandGitHub 4b1b869802 docs(readme): point Linux installs at Flathub (#2892) 2026-09-22 02:04:06 +02:00
HampusandGitHub 1ab7e7dfcc fix(api): unfurl links to a self-hosted instance's own domain (#2891) 2026-09-22 02:00:51 +02:00
HampusandGitHub 31c53d2dff fix(app): stop pending stickers from reloading the channel (#2890) 2026-09-22 02:00:26 +02:00
HampusandGitHub 412a1ae79d perf(api): stop ledgering session payment reconciliation (#2889) 2026-09-22 01:37:21 +02:00
HampusandGitHub 0b2306ec3d fix(api): honour default TTLs and expire stale job ledger rows (#2887) 2026-09-21 23:16:39 +02:00
HampusandGitHub 242ed3a934 fix(desktop): drop orphaned Squirrel uninstall entry (#2885) 2026-09-21 20:03:33 +02:00
HampusandGitHub 70e1ce682a feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883) 2026-09-21 16:26:06 +02:00
HampusandGitHub 7601bf98ee fix(channel): sync a cleared group DM name without a reload (#2882) 2026-09-21 15:34:18 +02:00
c7ec2a0f58 chore(tooling): Ignore .vscode/ in .gitignore (#2868)
Co-authored-by: Hampus <[email protected]>
2026-09-21 13:29:33 +02:00
XeonandGitHub 6a5e0056a8 fix(flatpak): Add a release tag and make small corrections (#2872) 2026-09-21 13:28:36 +02:00
HampusandGitHub 78d105b46e fix(desktop): stop looping on an update that never installs (#2879) 2026-09-21 03:36:11 +02:00
HampusandGitHub c68d62b8a0 fix(voice): darken screen share source titles in light theme (#2878) 2026-09-21 01:20:54 +02:00
HampusandGitHub df58020f4c fix(api): keep premium paid for after a subscription cancels (#2875) 2026-09-20 23:50:49 +02:00
HampusandGitHub f052ce05aa fix(workspace): point the Erlang extension at the repo root (#2871) 2026-09-20 19:49:06 +02:00
HampusandGitHub eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
HampusandGitHub 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
HampusandGitHub 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
HampusandGitHub a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
HampusandGitHub 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
HampusandGitHub c157ab5752 feat(voice): rework screen share delivery behind an experiment (#2859) 2026-09-20 06:10:20 +02:00
HampusandGitHub 574a93257c docs(downloads): the pacman repository is signed (#2858) 2026-09-20 05:54:28 +02:00
HampusandGitHub ba7d8781cf feat(auth): make passkey two-factor authentication opt-in (#2857) 2026-09-20 05:06:50 +02:00
HampusandGitHub 3256af8d92 refactor(app-proxy): remove the stable time freeze (#2856) 2026-09-20 04:02:47 +02:00
HampusandGitHub 86043212f2 docs(downloads): one pacman repository holds both channels (#2855) 2026-09-20 02:50:08 +02:00
HampusandGitHub 5d85e88532 fix(search): suggest yourself in DM from: and mentions: filters (#2854) 2026-09-20 01:24:43 +02:00
HampusandGitHub e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
HampusandGitHub 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
2318 changed files with 242973 additions and 154739 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ ARG USER_UID=1000
ARG USER_GID=1000
ARG NODE_MAJOR=26
ARG ELP_VERSION=2026-08-10
ARG PNPM_VERSION=12.4.2
ARG PNPM_VERSION=11.27.0
ARG WASM_BINDGEN_VERSION=0.2.128
ENV DEBIAN_FRONTEND=noninteractive
+5 -1
View File
@@ -38,7 +38,11 @@
"customizations": {
"vscode": {
"settings": {
"editor.defaultFormatter": "biomejs.biome"
"editor.defaultFormatter": "biomejs.biome",
"erlang.includePaths": ["."],
"search.exclude": {
"**/_build/default/lib/fluxer_gateway": true
}
},
"extensions": [
"biomejs.biome",
-7
View File
@@ -23,7 +23,6 @@ services:
FLUXER_S3_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_LIVEKIT_URL: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/livekit"
FLUXER_LIVEKIT_INTERNAL_URL: "http://livekit:7880"
FLUXER_LIVEKIT_WEBHOOK_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api/webhooks/livekit"
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
@@ -202,11 +201,6 @@ services:
target: /workspaces/fluxer/fluxer_api/pkgs/rate_limit/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-sms-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/sms/node_modules
volume:
nocopy: true
- type: volume
source: fluxer-api-virus-scan-node-modules
target: /workspaces/fluxer/fluxer_api/pkgs/virus_scan/node_modules
@@ -384,7 +378,6 @@ volumes:
fluxer-api-mime-utils-node-modules:
fluxer-api-nats-node-modules:
fluxer-api-rate-limit-node-modules:
fluxer-api-sms-node-modules:
fluxer-api-virus-scan-node-modules:
fluxer-api-worker-node-modules:
fluxer-app-list-utils-node-modules:
+1
View File
@@ -32,6 +32,7 @@
/fluxer_docs/.astro/
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
+2
View File
@@ -2,3 +2,5 @@
fluxer_static/** -text -diff
fluxer_static/**/*.md text diff
packages/fonts/files/** -text -diff
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.wasm -text -diff
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.tar.gz -text -diff
+3
View File
@@ -28,6 +28,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
@@ -71,7 +71,6 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: ""
BUNDLE_LOCAL_ASSETS: "true"
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
@@ -155,7 +154,6 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
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_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
+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'] }}
+25
View File
@@ -123,12 +123,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -142,12 +146,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -190,6 +198,9 @@ jobs:
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check libfluxwebp dependencies
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
@@ -242,6 +253,9 @@ jobs:
- name: Check formatting
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)
run: tools/ci/check-desktop-native-workspaces.sh fmt
@@ -322,6 +336,9 @@ jobs:
restore-keys: |
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
- name: Drop restored gateway build output
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
- name: Check formatting
run: |
"$FLUXER_CI_BIN" ci --step gateway_fmt
@@ -398,12 +415,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -417,12 +438,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
+2
View File
@@ -10,6 +10,7 @@
/.direnv/
/.fluxer/
/.pnpm-store/
/.vscode/
**/*.css.d.ts
**/*.tsbuildinfo
@@ -25,6 +26,7 @@
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
Generated
+44 -12
View File
@@ -1607,6 +1607,7 @@ dependencies = [
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
@@ -1755,16 +1756,6 @@ dependencies = [
"zip",
]
[[package]]
name = "fluxer-content-update-frozen-snapshot"
version = "0.1.0"
dependencies = [
"anyhow",
"base64 0.23.1",
"sha2 0.11.0",
"tempfile",
]
[[package]]
name = "fluxer-dev"
version = "0.1.0"
@@ -1794,8 +1785,10 @@ name = "fluxer-gifs"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"fluxer-svc",
"fluxer_common",
"futures",
"hmac 0.13.0",
"moka",
"reqwest",
@@ -1832,6 +1825,7 @@ dependencies = [
"cc",
"clap",
"criterion",
"flate2",
"fluxer_common",
"futures-util",
"hex",
@@ -1859,6 +1853,7 @@ dependencies = [
"tokio",
"tokio-util",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
"url",
@@ -1891,6 +1886,31 @@ dependencies = [
"url",
]
[[package]]
name = "fluxer-push"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"base64 0.23.1",
"clap",
"fluxer-svc",
"futures",
"hmac 0.13.0",
"p256",
"percent-encoding",
"rand 0.10.2",
"reqwest",
"ring",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror",
"tokio",
"tracing",
"url",
]
[[package]]
name = "fluxer-snowflakes"
version = "0.1.0"
@@ -2022,6 +2042,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tokio-util",
"tower",
@@ -2050,6 +2071,7 @@ dependencies = [
"thiserror",
"time",
"tracing",
"tracing-subscriber",
"url",
"urlencoding",
]
@@ -2318,6 +2340,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac 0.12.1",
]
[[package]]
name = "hmac"
version = "0.12.1"
@@ -3903,6 +3934,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
@@ -5803,9 +5835,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
+1 -1
View File
@@ -7,9 +7,9 @@ members = [
"fluxer_gifs",
"fluxer_svc",
"fluxer_messages",
"fluxer_push",
"fluxer_snowflakes",
"tools/ci",
"tools/content/update-frozen-snapshot",
"tools/dev",
"tools/i18n_auto",
"fluxer_users",
+158 -3
View File
@@ -6,18 +6,173 @@
</p>
<p align="center">
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="https://fluxer.app/download">
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
<a href="https://docs.fluxer.app">
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
<a href="https://fluxer.app/donate">
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
<a href="./LICENSE">
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
</p>
<p align="center">
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
</p>
# Fluxer
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
</p>
## Download
| Windows | macOS | Linux | Android | iOS |
| --- | --- | --- | --- | --- |
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
| | | [AppImage (x64)][linux-appimage-x64] | | |
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
| | | [tar.gz (x64)][linux-targz-x64] | | |
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
## Linux package repositories
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
### Flatpak
Stable is on [Flathub][flathub], the easiest route on most desktops:
```sh
flatpak install flathub app.fluxer.Fluxer
```
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
From a terminal:
```sh
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
```
### Debian and Ubuntu
```sh
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
sudo apt update && sudo apt install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
sudo apt update && sudo apt install fluxer-canary
```
A `.deb` installed from a download only updates once its channel's entry is added.
### Fedora and RHEL
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
sudo dnf install fluxer
```
For canary, use the canary entry file and package.
```sh
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
sudo dnf install fluxer-canary
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
### Arch Linux
The repository is signed, so pacman needs the key once:
```sh
sudo pacman-key --init
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
```
`--lsign-key` is what makes pacman trust it. Then add the repository:
```sh
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
[fluxer]
SigLevel = Required TrustedOnly
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
REPO
sudo pacman -Syu fluxer
```
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
## Other ways to run it
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
- [Host your own instance][docs-selfhost] from this repository.
## Documentation
- [Documentation home][docs]
- [Downloads][docs-downloads]
- [Self-hosting][docs-selfhost]
## License
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
imagery are copyright Fluxer, all rights reserved, as set out in
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
terms, listed in
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
Public availability of this repository does not grant trademark, brand, or
endorsement rights.
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
[docs]: https://docs.fluxer.app
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
+4
View File
@@ -143,6 +143,10 @@
],
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
},
{
"includes": ["fluxer_app/src/**/*.worklet.js"],
"javascript": {"globals": ["AudioWorkletProcessor", "registerProcessor", "sampleRate", "currentTime"]}
},
{
"includes": ["**/*.astro"],
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
-10
View File
@@ -34,7 +34,6 @@ FLUXER_KV_URL=redis://valkey:6379/0
FLUXER_NATS_URL=nats://nats:4222
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
FLUXER_INTERNAL_API_ENDPOINT=http://127.0.0.1:8080
FLUXER_INTERNAL_GATEWAY_ENDPOINT=http://127.0.0.1:8771
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT=http://127.0.0.1:8082
FLUXER_MEDIA_PROXY_ENDPOINT=http://127.0.0.1:8082
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=http://localhost:8088/media
@@ -42,7 +41,6 @@ FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=http://localhost:8088/media
FLUXER_SVC_NATS_URL=nats://nats:4222
FLUXER_SVC_SHARD_COUNT=1
FLUXER_SVC_CACHE_TTL_MS=30000
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
@@ -52,7 +50,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_BUCKET_CDN=fluxer
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
FLUXER_S3_BUCKET_STATIC=fluxer-static
@@ -62,7 +59,6 @@ FLUXER_LIVEKIT_URL=ws://localhost:8088/livekit
FLUXER_LIVEKIT_INTERNAL_URL=http://localhost:7880
FLUXER_LIVEKIT_API_KEY=devkey
FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
FLUXER_API_PORT=8080
@@ -108,9 +104,6 @@ FLUXER_EMAIL_SMTP_PORT=1025
FLUXER_EMAIL_SMTP_USERNAME=dev
FLUXER_EMAIL_SMTP_PASSWORD=dev
FLUXER_EMAIL_SMTP_SECURE=false
FLUXER_SMS_ENABLED=false
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_SEARCH_ENGINE=meilisearch
FLUXER_SEARCH_URL=http://meilisearch:7700
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
@@ -132,6 +125,3 @@ PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
AWS_EC2_METADATA_DISABLED=true
AWS_ACCESS_KEY_ID=fluxer
AWS_SECRET_ACCESS_KEY=fluxer-secret
AWS_DEFAULT_REGION=us-east-1
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-api
description: Fluxer HTTP API and background job workers
type: application
version: 0.1.0
appVersion: "v1"
@@ -0,0 +1,244 @@
{{- define "fluxer-api.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "fluxer-api.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
{{- end }}
{{- define "fluxer-api.labels" -}}
{{ include "fluxer-api.selectorLabels" . }}
app.kubernetes.io/component: {{ .component }}
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
helm.sh/chart: {{ include "fluxer-api.chart" .root }}
{{- end }}
{{- define "fluxer-api.image" -}}
{{- $g := .root.Values.image | default dict -}}
{{- $i := .w.image | default dict -}}
{{- $repo := $i.repository -}}
{{- if not $repo -}}
{{- $repo = printf "%s/%s" (required "image.registry is required" $g.registry) ($i.name | default "fluxer-api") -}}
{{- end -}}
{{- $tag := required "image.tag is required" ($i.tag | default $g.tag) -}}
{{- if $i.digest -}}
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
{{- else -}}
{{- printf "%s:%s" $repo $tag | quote -}}
{{- end -}}
{{- end }}
{{- define "fluxer-api.pick" -}}
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
{{- if $v }}
{{- toYaml $v }}
{{- end }}
{{- end }}
{{- define "fluxer-api.str" -}}
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
{{- int64 . | toString | quote -}}
{{- else -}}
{{- toString . | quote -}}
{{- end -}}
{{- end }}
{{- define "fluxer-api.env" -}}
{{- $env := dict -}}
{{- range $k, $val := .root.Values.env | default dict }}
{{- $_ := set $env $k $val }}
{{- end }}
{{- range $k, $val := .w.env | default dict }}
{{- $_ := set $env $k $val }}
{{- end }}
{{- range $k, $val := $env }}
{{- if not (kindIs "invalid" $val) }}
- name: {{ $k }}
value: {{ include "fluxer-api.str" $val }}
{{- end }}
{{- end }}
{{- with .w.buildVersion }}
- name: BUILD_VERSION
value: {{ include "fluxer-api.str" . }}
{{- end }}
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
{{ toYaml . }}
{{- end }}
{{- end }}
{{- define "fluxer-api.topologySpread" -}}
{{- $tscs := ternary .w.topologySpreadConstraints .root.Values.topologySpreadConstraints (hasKey .w "topologySpreadConstraints") -}}
{{- range $tscs }}
{{- $c := deepCopy . }}
{{- if not $c.labelSelector }}
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-api.selectorLabels" $ | fromYaml)) }}
{{- end }}
- {{- toYaml $c | nindent 2 }}
{{- end }}
{{- end }}
{{- define "fluxer-api.pdb" -}}
{{- with .w.pdb }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $.name }}-pdb
namespace: {{ $.root.Release.Namespace }}
labels:
{{- include "fluxer-api.labels" $ | nindent 4 }}
spec:
{{- toYaml . | nindent 2 }}
selector:
matchLabels:
{{- include "fluxer-api.selectorLabels" $ | nindent 6 }}
{{- end }}
{{- end }}
{{- define "fluxer-api.hpa" -}}
{{- with .w.hpa }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ $.name }}
namespace: {{ $.root.Release.Namespace }}
labels:
{{- include "fluxer-api.labels" $ | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ $.name }}
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $.name) .minReplicas }}
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $.name) .maxReplicas }}
{{- with .targetCPUUtilizationPercentage }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ . }}
{{- end }}
{{- with .behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
{{- define "fluxer-api.deployment" -}}
{{- $root := .root -}}
{{- $v := $root.Values -}}
{{- $w := .w -}}
{{- $envFrom := concat ($v.envFrom | default list) ($w.envFrom | default list) -}}
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($v.podAnnotations | default dict) -}}
{{- $wProbes := $w.probes | default dict -}}
{{- $gProbes := .probes | default dict -}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .name }}
namespace: {{ $root.Release.Namespace }}
labels:
{{- include "fluxer-api.labels" . | nindent 4 }}
spec:
{{- if not $w.hpa }}
replicas: {{ if kindIs "invalid" $w.replicas }}1{{ else }}{{ int $w.replicas }}{{ end }}
{{- end }}
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
minReadySeconds: {{ int $w.minReadySeconds }}
{{- end }}
selector:
matchLabels:
{{- include "fluxer-api.selectorLabels" . | nindent 6 }}
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "strategy") }}
strategy:
{{- . | nindent 4 }}
{{- end }}
template:
metadata:
labels:
{{- include "fluxer-api.labels" . | nindent 8 }}
{{- with $podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
imagePullSecrets:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
securityContext:
{{- . | nindent 8 }}
{{- end }}
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
terminationGracePeriodSeconds: {{ int $w.terminationGracePeriodSeconds }}
{{- end }}
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
nodeSelector:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "affinity") }}
affinity:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "tolerations") }}
tolerations:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-api.topologySpread" . | trim }}
topologySpreadConstraints:
{{- . | nindent 8 }}
{{- end }}
containers:
- name: {{ .name }}
image: {{ include "fluxer-api.image" . }}
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($v.image | default dict).pullPolicy | default "IfNotPresent" }}
{{- with .command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with include "fluxer-api.env" . | trim }}
env:
{{- . | nindent 12 }}
{{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: 8080
{{- with $w.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- range $probe := list "startup" "liveness" "readiness" }}
{{- with hasKey $wProbes $probe | ternary (get $wProbes $probe) (get $gProbes $probe) }}
{{ $probe }}Probe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
{{- with $w.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with include "fluxer-api.pick" (dict "root" $root "w" $w "key" "securityContext") }}
securityContext:
{{- . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
+24
View File
@@ -0,0 +1,24 @@
{{- range $name, $w := .Values.api }}
{{- if not (kindIs "invalid" $w) }}
{{- $ctx := dict "root" $ "name" $name "w" $w "component" "api" "probes" ($.Values.probes | default dict) }}
{{ include "fluxer-api.deployment" $ctx }}
{{ include "fluxer-api.hpa" $ctx }}
{{ include "fluxer-api.pdb" $ctx }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-api.labels" $ctx | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- include "fluxer-api.selectorLabels" $ctx | nindent 4 }}
ports:
- name: http
port: 8080
targetPort: http
{{- end }}
{{- end }}
@@ -0,0 +1,8 @@
{{- range $name, $w := .Values.workers }}
{{- if not (kindIs "invalid" $w) }}
{{- $ctx := dict "root" $ "name" $name "w" $w "component" "worker" "command" (list "node" "dist/WorkerEntrypoint.js") "probes" (dict) }}
{{ include "fluxer-api.deployment" $ctx }}
{{ include "fluxer-api.hpa" $ctx }}
{{ include "fluxer-api.pdb" $ctx }}
{{- end }}
{{- end }}
+86
View File
@@ -0,0 +1,86 @@
image:
registry: ghcr.io/fluxerapp
tag: v1
pullPolicy: IfNotPresent
imagePullSecrets: []
env:
NODE_ENV: production
FLUXER_ENV: production
FLUXER_PUBLIC_ORIGIN: https://web.example.com
FLUXER_API_ENDPOINT: https://api.example.com
FLUXER_GATEWAY_ENDPOINT: wss://gateway.example.com
FLUXER_MEDIA_ENDPOINT: https://media.example.com
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: https://uploads.example.com
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_KV_URL: redis://valkey:6379/0
FLUXER_NATS_URL: nats://nats:4222
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
extraEnv: []
envFrom:
- secretRef:
name: fluxer-env
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
probes:
startup:
httpGet:
path: /_health
port: http
periodSeconds: 10
failureThreshold: 30
liveness:
httpGet:
path: /_health
port: http
readiness:
httpGet:
path: /_health
port: http
strategy:
type: RollingUpdate
topologySpreadConstraints: []
nodeSelector: {}
tolerations: []
affinity: {}
api:
api:
replicas: 1
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
memory: 2560Mi
workers:
worker:
replicas: 1
env:
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
resources:
requests:
cpu: 250m
memory: 1Gi
limits:
memory: 2560Mi
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-gateway
description: A Helm chart for the Fluxer realtime gateway.
type: application
version: 0.1.0
appVersion: "v1"
@@ -0,0 +1,280 @@
{{- define "gateway.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
{{- end }}
{{- define "gateway.labels" -}}
{{ include "gateway.selectorLabels" . }}
{{- with .component }}
app.kubernetes.io/component: {{ . }}
{{- end }}
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .root.Chart.Name .root.Chart.Version | replace "+" "_" }}
{{- end }}
{{- define "gateway.headlessName" -}}
{{ printf "%s-headless" .Release.Name }}
{{- end }}
{{- define "gateway.pick" -}}
{{- $v := get .root.Values .key }}
{{- if hasKey .w .key }}
{{- $v = get .w .key }}
{{- end }}
{{- with $v }}
{{- toYaml . }}
{{- end }}
{{- end }}
{{- define "gateway.string" -}}
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) }}
{{- int64 . | toString }}
{{- else }}
{{- toString . }}
{{- end }}
{{- end }}
{{- define "gateway.envList" -}}
{{- $env := deepCopy (.root.Values.env | default dict) }}
{{- range $k, $v := .w.env | default dict }}
{{- if kindIs "invalid" $v }}
{{- $_ := unset $env $k }}
{{- else }}
{{- $_ := set $env $k $v }}
{{- end }}
{{- end }}
{{- range $k, $v := $env }}
{{- if not (kindIs "invalid" $v) }}
- name: {{ $k }}
value: {{ include "gateway.string" $v | quote }}
{{- end }}
{{- end }}
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
{{ toYaml . }}
{{- end }}
{{- end }}
{{- define "gateway.envFrom" -}}
{{- with concat (.root.Values.envFrom | default list) (.w.envFrom | default list) }}
{{- toYaml . }}
{{- end }}
{{- end }}
{{- define "gateway.podAnnotations" -}}
{{- with merge (deepCopy (.w.podAnnotations | default dict)) (deepCopy (.root.Values.podAnnotations | default dict)) }}
{{- toYaml . }}
{{- end }}
{{- end }}
{{- define "gateway.probes" -}}
{{- $global := .root.Values.probes | default dict }}
{{- $own := .w.probes | default dict }}
{{- range $probe := list "startup" "liveness" "readiness" }}
{{- $p := get $global $probe }}
{{- if hasKey $own $probe }}
{{- $p = get $own $probe }}
{{- end }}
{{- with $p }}
{{ $probe }}Probe:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}
{{- end }}
{{- define "gateway.topologySpreadConstraints" -}}
{{- $out := list }}
{{- range include "gateway.pick" (dict "root" .root "w" .w "key" "topologySpreadConstraints") | fromYamlArray }}
{{- $c := deepCopy . }}
{{- if not (hasKey $c "labelSelector") }}
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "gateway.selectorLabels" $ | fromYaml)) }}
{{- end }}
{{- $out = append $out $c }}
{{- end }}
{{- with $out }}
{{- toYaml . }}
{{- end }}
{{- end }}
{{- define "gateway.image" -}}
{{- $img := .w.image | default dict }}
{{- $v := .root.Values.image }}
{{- $repo := $img.repository | default (printf "%s/%s" $v.registry ($img.name | default "fluxer-gateway")) }}
{{- $ref := printf "%s:%s" $repo ($img.tag | default $v.tag) }}
{{- with $img.digest }}
{{- $ref = printf "%s@%s" $ref . }}
{{- end }}
{{- $ref | quote }}
{{- end }}
{{- define "gateway.replicas" -}}
{{- if kindIs "invalid" .w.replicas }}1{{ else }}{{ .w.replicas }}{{ end }}
{{- end }}
{{- define "gateway.env" -}}
{{- $root := .root }}
{{- $w := .w -}}
{{- with $w.role }}
- name: FLUXER_GATEWAY_ROLE
value: {{ . | quote }}
{{- end }}
{{- if not (kindIs "invalid" $w.buildVersion) }}
- name: BUILD_VERSION
value: {{ include "gateway.string" $w.buildVersion | quote }}
{{- end }}
- name: POD_IP
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: status.podIP
- name: FLUXER_ERLANG_NODE_NAME
value: fluxer_gateway@$(POD_IP)
- name: FLUXER_ERLANG_DIST_PORT
value: "8081"
- name: FLUXER_GATEWAY_CLUSTER_ENABLED
value: "true"
- name: FLUXER_GATEWAY_CLUSTER_DISCOVERY_DNS_NAME
value: {{ printf "%s.%s.svc.%s" (include "gateway.headlessName" $root) $root.Release.Namespace $root.Values.clusterDomain | quote }}
- name: FLUXER_GATEWAY_CLUSTER_DISCOVERY_NODE_BASENAME
value: fluxer_gateway
{{- include "gateway.envList" . }}
{{- end }}
{{- define "gateway.pod" -}}
{{- $root := .root }}
{{- $w := .w -}}
metadata:
labels:
{{- include "gateway.labels" . | nindent 4 }}
{{- with include "gateway.podAnnotations" . }}
annotations:
{{- . | nindent 4 }}
{{- end }}
spec:
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "affinity") }}
affinity:
{{- . | nindent 4 }}
{{- end }}
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
imagePullSecrets:
{{- . | nindent 4 }}
{{- end }}
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
nodeSelector:
{{- . | nindent 4 }}
{{- end }}
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "tolerations") }}
tolerations:
{{- . | nindent 4 }}
{{- end }}
{{- with include "gateway.topologySpreadConstraints" . }}
topologySpreadConstraints:
{{- . | nindent 4 }}
{{- end }}
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
securityContext:
{{- . | nindent 4 }}
{{- end }}
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds }}
{{- end }}
containers:
- name: gateway
image: {{ include "gateway.image" . }}
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default $root.Values.image.pullPolicy }}
env:
{{- include "gateway.env" . | trim | nindent 6 }}
{{- with include "gateway.envFrom" . }}
envFrom:
{{- . | nindent 6 }}
{{- end }}
{{- with $w.lifecycle }}
lifecycle:
{{- toYaml . | nindent 6 }}
{{- end }}
ports:
- name: http
containerPort: 8080
protocol: TCP
- name: epmd
containerPort: 4369
protocol: TCP
- name: erl-dist
containerPort: 8081
protocol: TCP
{{- with include "gateway.probes" . | trim }}
{{- . | nindent 4 }}
{{- end }}
{{- with $w.resources }}
resources:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with include "gateway.pick" (dict "root" $root "w" $w "key" "securityContext") }}
securityContext:
{{- . | nindent 6 }}
{{- end }}
{{- with $w.extraVolumeMounts }}
volumeMounts:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- with $w.extraVolumes }}
volumes:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- define "gateway.pdb" -}}
{{- with .w.pdb }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $.name }}-pdb
namespace: {{ $.root.Release.Namespace }}
labels:
{{- include "gateway.labels" $ | nindent 4 }}
spec:
{{- if not (kindIs "invalid" .minAvailable) }}
minAvailable: {{ .minAvailable }}
{{- end }}
{{- if not (kindIs "invalid" .maxUnavailable) }}
maxUnavailable: {{ .maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "gateway.selectorLabels" $ | nindent 6 }}
{{- end }}
{{- end }}
{{- define "gateway.hpa" -}}
{{- with .w.hpa }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ $.name }}
namespace: {{ $.root.Release.Namespace }}
labels:
{{- include "gateway.labels" $ | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ $.name }}
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $.name) .minReplicas }}
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $.name) .maxReplicas }}
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .targetCPUUtilizationPercentage }}
{{- end }}
{{- with .behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,48 @@
{{- range $name, $w := .Values.deployments }}
{{- if not (kindIs "invalid" $w) }}
{{- $ctx := dict "root" $ "name" $name "component" $w.role "w" $w }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "gateway.labels" $ctx | nindent 4 }}
spec:
{{- if not $w.hpa }}
replicas: {{ include "gateway.replicas" $ctx }}
{{- end }}
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
minReadySeconds: {{ $w.minReadySeconds }}
{{- end }}
selector:
matchLabels:
{{- include "gateway.selectorLabels" $ctx | nindent 6 }}
{{- with include "gateway.pick" (dict "root" $ "w" $w "key" "strategy") }}
strategy:
{{- . | nindent 4 }}
{{- end }}
template:
{{- include "gateway.pod" $ctx | nindent 4 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "gateway.labels" $ctx | nindent 4 }}
spec:
type: ClusterIP
ports:
- name: http
port: 8080
protocol: TCP
targetPort: http
selector:
{{- include "gateway.selectorLabels" $ctx | nindent 4 }}
{{- include "gateway.hpa" $ctx }}
{{- include "gateway.pdb" $ctx }}
{{- end }}
{{- end }}
@@ -0,0 +1,26 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "gateway.headlessName" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "gateway.labels" (dict "root" . "name" "gateway" "component" "discovery") | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
ports:
- name: http
port: 8080
protocol: TCP
targetPort: http
- name: epmd
port: 4369
protocol: TCP
targetPort: epmd
- name: erl-dist
port: 8081
protocol: TCP
targetPort: erl-dist
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/part-of: fluxer
@@ -0,0 +1,53 @@
{{- $np := .Values.networkPolicy | default dict }}
{{- if $np.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: gateway
namespace: {{ .Release.Namespace }}
labels:
{{- include "gateway.labels" (dict "root" . "name" "gateway") | nindent 4 }}
spec:
podSelector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/part-of: fluxer
policyTypes:
- Ingress
- Egress
egress:
- {}
ingress:
{{- with $np.ingressNamespace }}
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ . }}
ports:
- port: 8080
protocol: TCP
{{- end }}
{{- with $np.clients }}
- from:
{{- range . }}
- podSelector:
matchLabels:
{{- toYaml . | nindent 10 }}
{{- end }}
ports:
- port: 8080
protocol: TCP
{{- end }}
- from:
- podSelector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/part-of: fluxer
ports:
- port: 8080
protocol: TCP
- port: 4369
protocol: TCP
- port: 8081
protocol: TCP
{{- end }}
@@ -0,0 +1,29 @@
{{- range $name, $w := .Values.statefulsets }}
{{- if not (kindIs "invalid" $w) }}
{{- $ctx := dict "root" $ "name" $name "component" $w.role "w" $w }}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "gateway.labels" $ctx | nindent 4 }}
spec:
replicas: {{ include "gateway.replicas" $ctx }}
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
minReadySeconds: {{ $w.minReadySeconds }}
{{- end }}
serviceName: {{ include "gateway.headlessName" $ }}
selector:
matchLabels:
{{- include "gateway.selectorLabels" $ctx | nindent 6 }}
{{- with include "gateway.pick" (dict "root" $ "w" $w "key" "updateStrategy") }}
updateStrategy:
{{- . | nindent 4 }}
{{- end }}
template:
{{- include "gateway.pod" $ctx | nindent 4 }}
{{- include "gateway.pdb" $ctx }}
{{- end }}
{{- end }}
+86
View File
@@ -0,0 +1,86 @@
image:
registry: ghcr.io/fluxerapp
tag: v1
pullPolicy: IfNotPresent
imagePullSecrets: []
clusterDomain: cluster.local
env:
FLUXER_ENV: production
FLUXER_GATEWAY_PORT: "8080"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: https://media.example.com
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
extraEnv: []
envFrom:
- secretRef:
name: fluxer-env
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
probes:
startup:
httpGet:
path: /_health
port: http
failureThreshold: 30
liveness:
httpGet:
path: /_health
port: http
readiness:
exec:
command:
- curl
- -fsS
- -o
- /dev/null
- --max-time
- "2"
- http://127.0.0.1:8080/_health/ready
timeoutSeconds: 3
strategy: {}
updateStrategy: {}
topologySpreadConstraints: []
nodeSelector: {}
tolerations: []
affinity: {}
networkPolicy:
enabled: false
ingressNamespace: ingress-nginx
clients:
- app.kubernetes.io/part-of: fluxer
deployments:
gateway:
role: all
replicas: 1
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- curl -fsS -o /dev/null --max-time 2 http://127.0.0.1:8080/_health/drain; sleep 5
resources:
requests:
cpu: 100m
memory: 384Mi
limits:
memory: 1Gi
statefulsets: {}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-infra
description: NATS and Valkey for a Fluxer installation.
type: application
version: 0.1.0
appVersion: "v1"
@@ -0,0 +1,282 @@
{{- define "fluxer-infra.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "fluxer-infra.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
{{- end }}
{{- define "fluxer-infra.labels" -}}
{{ include "fluxer-infra.selectorLabels" . }}
app.kubernetes.io/component: {{ .component }}
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
helm.sh/chart: {{ include "fluxer-infra.chart" .root }}
{{- end }}
{{- define "fluxer-infra.pick" -}}
{{- $v := get .root.Values .key }}
{{- if hasKey .w .key }}
{{- $v = get .w .key }}
{{- end }}
{{- with $v }}
{{- toYaml . }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.string" -}}
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) }}
{{- int64 . | toString }}
{{- else }}
{{- toString . }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.envList" -}}
{{- $env := deepCopy (.root.Values.env | default dict) }}
{{- range $k, $v := .w.env | default dict }}
{{- if kindIs "invalid" $v }}
{{- $_ := unset $env $k }}
{{- else }}
{{- $_ := set $env $k $v }}
{{- end }}
{{- end }}
{{- range $k, $v := $env }}
{{- if not (kindIs "invalid" $v) }}
- name: {{ $k }}
value: {{ include "fluxer-infra.string" $v | quote }}
{{- end }}
{{- end }}
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
{{ toYaml . }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.envFrom" -}}
{{- with concat (.root.Values.envFrom | default list) (.w.envFrom | default list) }}
{{- toYaml . }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.probes" -}}
{{- $global := .root.Values.probes | default dict }}
{{- $own := .w.probes | default dict }}
{{- range $probe := list "startup" "liveness" "readiness" }}
{{- $p := get $global $probe }}
{{- if hasKey $own $probe }}
{{- $p = get $own $probe }}
{{- end }}
{{- with $p }}
{{ $probe }}Probe:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.topologySpreadConstraints" -}}
{{- $out := list }}
{{- range include "fluxer-infra.pick" (dict "root" .root "w" .w "key" "topologySpreadConstraints") | fromYamlArray }}
{{- $c := deepCopy . }}
{{- if not (hasKey $c "labelSelector") }}
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-infra.selectorLabels" $ | fromYaml)) }}
{{- end }}
{{- $out = append $out $c }}
{{- end }}
{{- with $out }}
{{- toYaml . }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.replicas" -}}
{{- if kindIs "invalid" .w.replicas }}1{{ else }}{{ .w.replicas }}{{ end }}
{{- end }}
{{- define "fluxer-infra.image" -}}
{{- $ref := printf "%s:%s" .repository .tag }}
{{- with .digest }}
{{- $ref = printf "%s@%s" $ref . }}
{{- end }}
{{- $ref | quote }}
{{- end }}
{{- define "fluxer-infra.podAnnotations" -}}
{{- with merge (deepCopy (.extra | default dict)) (deepCopy (.w.podAnnotations | default dict)) (deepCopy (.root.Values.podAnnotations | default dict)) }}
annotations:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.podSpec" -}}
{{- $root := .root }}
{{- $w := .w }}
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "affinity") }}
affinity:
{{- . | nindent 2 }}
{{- end }}
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "imagePullSecrets") }}
imagePullSecrets:
{{- . | nindent 2 }}
{{- end }}
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "nodeSelector") }}
nodeSelector:
{{- . | nindent 2 }}
{{- end }}
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "tolerations") }}
tolerations:
{{- . | nindent 2 }}
{{- end }}
{{- with include "fluxer-infra.topologySpreadConstraints" . }}
topologySpreadConstraints:
{{- . | nindent 2 }}
{{- end }}
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "podSecurityContext") }}
securityContext:
{{- . | nindent 2 }}
{{- end }}
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.containerCommon" -}}
{{- $root := .root }}
{{- $w := .w }}
{{- $img := $w.image | default dict }}
image: {{ include "fluxer-infra.image" $img }}
imagePullPolicy: {{ $img.pullPolicy }}
{{- $env := include "fluxer-infra.envList" . | trim }}
{{- if or .env $env }}
env:
{{- with .env }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{- with $env }}
{{- . | nindent 2 }}
{{- end }}
{{- end }}
{{- with include "fluxer-infra.envFrom" . }}
envFrom:
{{- . | nindent 2 }}
{{- end }}
{{- with $w.lifecycle }}
lifecycle:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- include "fluxer-infra.probes" . }}
{{- with $w.resources }}
resources:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- with include "fluxer-infra.pick" (dict "root" $root "w" $w "key" "securityContext") }}
securityContext:
{{- . | nindent 2 }}
{{- end }}
{{- with concat .mounts ($w.extraVolumeMounts | default list) }}
volumeMounts:
{{- toYaml . | nindent 2 }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.statefulSetSpec" -}}
{{- $w := .w }}
{{- with include "fluxer-infra.pick" (dict "root" .root "w" $w "key" "updateStrategy") }}
updateStrategy:
{{- . | nindent 2 }}
{{- end }}
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
minReadySeconds: {{ $w.minReadySeconds }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.volumeClaim" -}}
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
{{- with .storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .size }}
{{- end }}
{{- define "fluxer-infra.pdb" -}}
{{- with .w.pdb }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $.name }}-pdb
namespace: {{ $.root.Release.Namespace }}
labels:
{{- include "fluxer-infra.labels" $ | nindent 4 }}
spec:
{{- if not (kindIs "invalid" .minAvailable) }}
minAvailable: {{ .minAvailable }}
{{- end }}
{{- if not (kindIs "invalid" .maxUnavailable) }}
maxUnavailable: {{ .maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "fluxer-infra.selectorLabels" $ | nindent 6 }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.service" }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .svcName }}
namespace: {{ .root.Release.Namespace }}
labels:
{{- include "fluxer-infra.labels" . | nindent 4 }}
spec:
{{- if .headless }}
clusterIP: None
{{- end }}
{{- if .publishNotReady }}
publishNotReadyAddresses: true
{{- end }}
selector:
{{- include "fluxer-infra.selectorLabels" . | nindent 4 }}
ports:
{{- range .ports }}
- name: {{ index . 0 }}
port: {{ index . 1 }}
targetPort: {{ index . 0 }}
{{- end }}
{{- end }}
{{- define "fluxer-infra.natsConf" -}}
{{- $w := .Values.nats -}}
{{- with $w.config -}}
listen: 0.0.0.0:4222
http: 0.0.0.0:8222
max_payload: {{ .maxPayload }}
max_pending: {{ .maxPending }}
max_connections: {{ .maxConnections }}
{{- if $w.jetstream.enabled }}
server_name: $POD_NAME
jetstream {
store_dir: /data
}
{{- end }}
cluster {
name: {{ .clusterName }}
listen: 0.0.0.0:6222
routes = [
{{- range $i := until (int (include "fluxer-infra.replicas" (dict "w" $w))) }}
nats-route://nats-{{ $i }}.nats-headless.{{ $.Release.Namespace }}.svc.{{ $.Values.clusterDomain }}:6222
{{- end }}
]
}
{{ end }}
{{- end }}
@@ -0,0 +1,71 @@
{{- with .Values.nats }}
{{- $ctx := dict "root" $ "w" . "name" "nats" "component" "messaging" }}
apiVersion: v1
kind: ConfigMap
metadata:
name: nats-config
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
data:
nats.conf: {{ include "fluxer-infra.natsConf" $ | toJson }}
{{- include "fluxer-infra.pdb" $ctx }}
{{- include "fluxer-infra.service" (merge (dict "svcName" "nats" "ports" (list (list "client" 4222))) $ctx) }}
{{- include "fluxer-infra.service" (merge (dict "svcName" "nats-headless" "headless" true "ports" (list (list "client" 4222) (list "cluster" 6222) (list "monitor" 8222))) $ctx) }}
{{- $mounts := list (dict "name" "config" "mountPath" "/etc/nats") }}
{{- $env := list }}
{{- if .jetstream.enabled }}
{{- $mounts = append $mounts (dict "name" "data" "mountPath" "/data") }}
{{- $env = append $env (dict "name" "POD_NAME" "valueFrom" (dict "fieldRef" (dict "fieldPath" "metadata.name"))) }}
{{- end }}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nats
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
spec:
replicas: {{ include "fluxer-infra.replicas" $ctx }}
serviceName: nats-headless
{{- with include "fluxer-infra.statefulSetSpec" $ctx | trim }}
{{- . | nindent 2 }}
{{- end }}
selector:
matchLabels:
{{- include "fluxer-infra.selectorLabels" $ctx | nindent 6 }}
template:
metadata:
labels:
{{- include "fluxer-infra.labels" $ctx | nindent 8 }}
{{- with include "fluxer-infra.podAnnotations" (merge (dict "extra" (dict "checksum/config" (include "fluxer-infra.natsConf" $ | sha256sum))) $ctx) | trim }}
{{- . | nindent 6 }}
{{- end }}
spec:
{{- include "fluxer-infra.podSpec" $ctx | trim | nindent 6 }}
containers:
- name: nats
{{- include "fluxer-infra.containerCommon" (merge (dict "env" $env "mounts" $mounts) $ctx) | trim | nindent 10 }}
args:
- -c
- /etc/nats/nats.conf
ports:
- name: client
containerPort: 4222
- name: cluster
containerPort: 6222
- name: monitor
containerPort: 8222
volumes:
- name: config
configMap:
name: nats-config
{{- with .extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .jetstream.enabled }}
volumeClaimTemplates:
{{- include "fluxer-infra.volumeClaim" .jetstream.storage | nindent 4 }}
{{- end }}
{{- end }}
@@ -0,0 +1,67 @@
{{- with .Values.valkey }}
{{- $ctx := dict "root" $ "w" . "name" "valkey" "component" "cache" }}
{{- include "fluxer-infra.pdb" $ctx }}
{{- include "fluxer-infra.service" (merge (dict "svcName" "valkey" "ports" (list (list "valkey" 6379))) $ctx) }}
{{- include "fluxer-infra.service" (merge (dict "svcName" "valkey-headless" "headless" true "publishNotReady" true "ports" (list (list "valkey" 6379))) $ctx) }}
{{- $mounts := list }}
{{- if .persistence.enabled }}
{{- $mounts = append $mounts (dict "name" "data" "mountPath" "/data") }}
{{- end }}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: valkey
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-infra.labels" $ctx | nindent 4 }}
spec:
replicas: 1
serviceName: valkey-headless
{{- with include "fluxer-infra.statefulSetSpec" $ctx | trim }}
{{- . | nindent 2 }}
{{- end }}
selector:
matchLabels:
{{- include "fluxer-infra.selectorLabels" $ctx | nindent 6 }}
template:
metadata:
labels:
{{- include "fluxer-infra.labels" $ctx | nindent 8 }}
{{- with include "fluxer-infra.podAnnotations" $ctx | trim }}
{{- . | nindent 6 }}
{{- end }}
spec:
{{- include "fluxer-infra.podSpec" $ctx | trim | nindent 6 }}
containers:
- name: valkey
{{- include "fluxer-infra.containerCommon" (merge (dict "env" list "mounts" $mounts) $ctx) | trim | nindent 10 }}
command:
- valkey-server
{{- if .persistence.enabled }}
- --appendonly
- "yes"
- --dir
- /data
{{- else }}
- --save
- ""
- --appendonly
- "no"
{{- end }}
- --maxmemory
- {{ .maxmemory | quote }}
- --maxmemory-policy
- {{ .maxmemoryPolicy | quote }}
ports:
- name: valkey
containerPort: 6379
{{- with .extraVolumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .persistence.enabled }}
volumeClaimTemplates:
{{- include "fluxer-infra.volumeClaim" .persistence | nindent 4 }}
{{- end }}
{{- end }}
+108
View File
@@ -0,0 +1,108 @@
imagePullSecrets: []
clusterDomain: cluster.local
env: {}
extraEnv: []
envFrom: []
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
probes: {}
updateStrategy: {}
topologySpreadConstraints: []
nodeSelector: {}
tolerations: []
affinity: {}
nats:
image:
repository: nats
tag: 2.14-alpine
pullPolicy: IfNotPresent
replicas: 3
config:
clusterName: nats
maxPayload: 1MB
maxPending: 64MB
maxConnections: 65536
jetstream:
enabled: true
storage:
size: 10Gi
storageClassName: ""
podSecurityContext:
fsGroup: 65534
runAsGroup: 65534
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
probes:
liveness:
httpGet:
path: /healthz
port: monitor
initialDelaySeconds: 10
readiness:
httpGet:
path: /healthz?js-enabled-only=true
port: monitor
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
valkey:
image:
repository: valkey/valkey
tag: 9.1-alpine
pullPolicy: IfNotPresent
maxmemory: 192mb
maxmemoryPolicy: noeviction
persistence:
enabled: true
size: 1Gi
storageClassName: ""
podSecurityContext:
fsGroup: 999
runAsGroup: 999
runAsNonRoot: true
runAsUser: 999
seccompProfile:
type: RuntimeDefault
probes:
liveness:
exec:
command:
- valkey-cli
- ping
initialDelaySeconds: 10
readiness:
exec:
command:
- valkey-cli
- ping
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-ingress
description: Ingress routing for the public Fluxer endpoints.
type: application
version: 0.1.0
appVersion: "v1"
@@ -0,0 +1,27 @@
{{- define "fluxer-ingress.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "fluxer-ingress.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ include "fluxer-ingress.chart" . }}
{{- end }}
{{- define "fluxer-ingress.annotationKey" -}}
{{- if or (contains "/" .key) (not .prefix) -}}
{{- .key -}}
{{- else -}}
{{- printf "%s/%s" .prefix .key -}}
{{- end -}}
{{- end }}
{{- define "fluxer-ingress.string" -}}
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
{{- . | int64 | toString -}}
{{- else -}}
{{- . | toString -}}
{{- end -}}
{{- end }}
@@ -0,0 +1,20 @@
{{- with .Values.clusterIssuer }}
{{- if .enabled }}
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: {{ required "clusterIssuer.name is required" .name }}
labels:
{{- include "fluxer-ingress.labels" $ | nindent 4 }}
spec:
acme:
email: {{ required "clusterIssuer.email is required" .email | quote }}
privateKeySecretRef:
name: {{ required "clusterIssuer.privateKeySecretName is required" .privateKeySecretName }}
server: {{ required "clusterIssuer.server is required" .server }}
solvers:
- http01:
ingress:
class: {{ required "clusterIssuer.solverIngressClass is required" .solverIngressClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,58 @@
{{- $v := .Values }}
{{- $presets := $v.annotationPresets | default dict }}
{{- $issuer := $v.clusterIssuer | default dict }}
{{- range $name, $spec := ($v.ingresses | default dict) }}
{{- if not (kindIs "invalid" $spec) }}
{{- $ann := deepCopy ($v.commonAnnotations | default dict) }}
{{- range ($spec.presets | default list) }}
{{- $ann = mergeOverwrite $ann (deepCopy (required (printf "unknown annotation preset %s" .) (index $presets .))) }}
{{- end }}
{{- if and $spec.tls $issuer.enabled }}
{{- $_ := set $ann "cert-manager.io/cluster-issuer" (required "clusterIssuer.name is required" $issuer.name) }}
{{- end }}
{{- $ann = mergeOverwrite $ann (deepCopy ($spec.annotations | default dict)) }}
{{- range $k, $val := $ann }}
{{- if kindIs "invalid" $val }}
{{- $_ := unset $ann $k }}
{{- end }}
{{- end }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-ingress.labels" $ | nindent 4 }}
{{- with $ann }}
annotations:
{{- range $k, $val := . }}
{{ include "fluxer-ingress.annotationKey" (dict "key" $k "prefix" $v.annotationPrefix) }}: {{ include "fluxer-ingress.string" $val | quote }}
{{- end }}
{{- end }}
spec:
{{- with $spec.ingressClassName | default $v.ingressClassName }}
ingressClassName: {{ . }}
{{- end }}
{{- with $spec.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range $rule := required (printf "ingress %s needs rules" $name) $spec.rules }}
- host: {{ required (printf "ingress %s has a rule without a host" $name) $rule.host | quote }}
http:
paths:
{{- range $p := $rule.paths | default (list dict) }}
{{- $p = $p | default dict }}
- path: {{ $p.path | default "/" | quote }}
pathType: {{ $p.pathType | default "Prefix" }}
backend:
service:
name: {{ required (printf "ingress %s host %s needs a service" $name $rule.host) ($p.service | default $rule.service) }}
port:
number: {{ required (printf "ingress %s host %s needs a port or servicePort" $name $rule.host) ($p.port | default $rule.port | default $v.servicePort) | int64 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+53
View File
@@ -0,0 +1,53 @@
ingressClassName: nginx
annotationPrefix: nginx.ingress.kubernetes.io
servicePort: 8080
commonAnnotations: {}
annotationPresets:
websocket:
proxy-read-timeout: "3600"
proxy-send-timeout: "3600"
stripPrefix:
use-regex: "true"
rewrite-target: /$2
ingresses:
fluxer:
rules:
- host: web.example.com
service: app-proxy
- host: api.example.com
service: api
- host: admin.example.com
service: admin
- host: media.example.com
service: media-proxy
fluxer-web-api:
presets: [stripPrefix]
rules:
- host: web.example.com
service: api
paths:
- path: /api(/(.*))?$
pathType: ImplementationSpecific
fluxer-gateway:
presets: [websocket]
rules:
- host: gateway.example.com
service: gateway
fluxer-uploads:
annotations:
proxy-body-size: 100m
proxy-request-buffering: "off"
rules:
- host: uploads.example.com
service: uploads
clusterIssuer:
enabled: false
name: letsencrypt
email: ""
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretName: letsencrypt-account-key
solverIngressClass: nginx
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-media-proxy
description: Fluxer media proxy and upload relay workloads.
type: application
version: 0.1.0
appVersion: "v1"
@@ -0,0 +1,87 @@
{{- define "fluxer-media-proxy.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "fluxer-media-proxy.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
{{- end }}
{{- define "fluxer-media-proxy.labels" -}}
{{ include "fluxer-media-proxy.selectorLabels" . }}
app.kubernetes.io/component: {{ include "fluxer-media-proxy.mode" . }}
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
helm.sh/chart: {{ include "fluxer-media-proxy.chart" .root }}
{{- end }}
{{- define "fluxer-media-proxy.image" -}}
{{- $g := .root.Values.image -}}
{{- $i := .w.image | default dict -}}
{{- $repo := $i.repository | default (printf "%s/%s" $g.registry ($i.name | default "fluxer-media-proxy")) -}}
{{- $tag := $i.tag | default $g.tag -}}
{{- if $i.digest -}}
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
{{- else -}}
{{- printf "%s:%s" $repo $tag | quote -}}
{{- end -}}
{{- end }}
{{- define "fluxer-media-proxy.pick" -}}
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
{{- if $v }}
{{- toYaml $v }}
{{- end }}
{{- end }}
{{- define "fluxer-media-proxy.mode" -}}
{{- $mode := required (printf "workloads.%s.mode is required" .name) .w.mode -}}
{{- if not (has $mode (list "mp" "static" "upload" "relay")) -}}
{{- fail (printf "workloads.%s.mode must be mp, static, upload or relay" .name) -}}
{{- end -}}
{{- $mode -}}
{{- end }}
{{- define "fluxer-media-proxy.envValue" -}}
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) -}}
{{- int64 . | toString -}}
{{- else -}}
{{- toString . -}}
{{- end -}}
{{- end }}
{{- define "fluxer-media-proxy.mergeEnv" -}}
{{- $out := dict -}}
{{- range $layer := . -}}
{{- range $k, $v := ($layer | default dict) -}}
{{- if kindIs "invalid" $v -}}
{{- $_ := unset $out $k -}}
{{- else -}}
{{- $_ := set $out $k $v -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end }}
{{- define "fluxer-media-proxy.topologySpreadConstraints" -}}
{{- $out := list -}}
{{- range .constraints -}}
{{- if .labelSelector -}}
{{- $out = append $out . -}}
{{- else -}}
{{- $out = append $out (merge (dict "labelSelector" (dict "matchLabels" $.selector)) .) -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end }}
{{- define "fluxer-media-proxy.pdb" -}}
{{- $out := dict -}}
{{- range $k := list "minAvailable" "maxUnavailable" -}}
{{- if and (hasKey $ $k) (not (kindIs "invalid" (index $ $k))) -}}
{{- $_ := set $out $k (index $ $k) -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end }}
@@ -0,0 +1,191 @@
{{- range $name, $w := .Values.workloads }}
{{- if not (kindIs "invalid" $w) }}
{{- $ctx := dict "root" $ "name" $name "w" $w }}
{{- $mode := include "fluxer-media-proxy.mode" $ctx }}
{{- $sel := include "fluxer-media-proxy.selectorLabels" $ctx | fromYaml }}
{{- $env := include "fluxer-media-proxy.mergeEnv" (list $.Values.env $w.env) | fromYaml }}
{{- $extraEnv := concat ($.Values.extraEnv | default list) ($w.extraEnv | default list) }}
{{- $envFrom := concat ($.Values.envFrom | default list) ($w.envFrom | default list) }}
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($.Values.podAnnotations | default dict) }}
{{- $probes := dict }}
{{- range $k, $v := ($.Values.probes | default dict) }}
{{- $_ := set $probes $k $v }}
{{- end }}
{{- range $k, $v := ($w.probes | default dict) }}
{{- $_ := set $probes $k $v }}
{{- end }}
{{- $pick := dict "root" $ "w" $w }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
spec:
{{- if not $w.hpa }}
replicas: {{ ternary $w.replicas 1 (hasKey $w "replicas") | int64 }}
{{- end }}
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
minReadySeconds: {{ $w.minReadySeconds | int64 }}
{{- end }}
selector:
matchLabels:
{{- toYaml $sel | nindent 6 }}
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "strategy") }}
strategy:
{{- . | nindent 4 }}
{{- end }}
template:
metadata:
{{- with $podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "fluxer-media-proxy.labels" $ctx | nindent 8 }}
spec:
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "imagePullSecrets") }}
imagePullSecrets:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "podSecurityContext") }}
securityContext:
{{- . | nindent 8 }}
{{- end }}
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds | int64 }}
{{- end }}
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "nodeSelector") }}
nodeSelector:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "tolerations") }}
tolerations:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "affinity") }}
affinity:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "topologySpreadConstraints") | fromYamlArray }}
topologySpreadConstraints:
{{- include "fluxer-media-proxy.topologySpreadConstraints" (dict "constraints" . "selector" $sel) | nindent 8 }}
{{- end }}
containers:
- name: {{ $name }}
image: {{ include "fluxer-media-proxy.image" $ctx }}
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default $.Values.image.pullPolicy }}
env:
{{- if not (kindIs "invalid" $w.buildVersion) }}
- name: BUILD_VERSION
value: {{ include "fluxer-media-proxy.envValue" $w.buildVersion | quote }}
{{- end }}
- name: FLUXER_MEDIA_PROXY_MODE
value: {{ $mode | quote }}
{{- range $k, $v := $env }}
- name: {{ $k }}
value: {{ include "fluxer-media-proxy.envValue" $v | quote }}
{{- end }}
{{- with $extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: 8080
protocol: TCP
{{- with $w.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- range $k := list "startup" "liveness" "readiness" }}
{{- with get $probes $k }}
{{ $k }}Probe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
{{- with $w.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with include "fluxer-media-proxy.pick" (set (deepCopy $pick) "key" "securityContext") }}
securityContext:
{{- . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- toYaml $sel | nindent 4 }}
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
{{- with include "fluxer-media-proxy.pdb" ($w.pdb | default dict) | fromYaml }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $name }}-pdb
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
spec:
{{- toYaml . | nindent 2 }}
selector:
matchLabels:
{{- toYaml $sel | nindent 6 }}
{{- end }}
{{- with $w.hpa }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-media-proxy.labels" $ctx | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ $name }}
minReplicas: {{ required (printf "workloads.%s.hpa.minReplicas is required" $name) .minReplicas | int64 }}
maxReplicas: {{ required (printf "workloads.%s.hpa.maxReplicas is required" $name) .maxReplicas | int64 }}
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .targetCPUUtilizationPercentage | int64 }}
{{- end }}
{{- with .behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,72 @@
image:
registry: ghcr.io/fluxerapp
tag: v1
pullPolicy: IfNotPresent
imagePullSecrets: []
env: {}
extraEnv: []
envFrom:
- secretRef:
name: fluxer-env
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
probes:
liveness:
httpGet:
path: /_health
port: http
readiness:
httpGet:
path: /_health
port: http
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
topologySpreadConstraints: []
nodeSelector: {}
tolerations: []
affinity: {}
workloads:
media-proxy:
mode: mp
replicas: 1
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
uploads:
mode: relay
replicas: 1
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 512Mi
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-push
description: Fluxer push notification delivery service
type: application
version: 0.1.0
appVersion: "v1"
@@ -0,0 +1,71 @@
{{- define "fluxer-push.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
{{- end }}
{{- define "fluxer-push.labels" -}}
{{ include "fluxer-push.selectorLabels" . }}
app.kubernetes.io/component: {{ include "fluxer-push.mode" . }}
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .root.Chart.Name .root.Chart.Version | replace "+" "_" }}
{{- end }}
{{- define "fluxer-push.mode" -}}
{{- $mode := .w.mode | default "delivery" -}}
{{- if not (has $mode (list "delivery" "relay")) -}}
{{- fail (printf "workloads.%s.mode must be delivery or relay" .name) -}}
{{- end -}}
{{- $mode -}}
{{- end }}
{{- define "fluxer-push.port" -}}
{{- .w.port | default (ternary 8127 8126 (eq (include "fluxer-push.mode" .) "relay")) -}}
{{- end }}
{{- define "fluxer-push.image" -}}
{{- $global := .root.Values.image | default dict -}}
{{- $img := .w.image | default dict -}}
{{- $repo := $img.repository -}}
{{- if not $repo -}}
{{- $repo = printf "%s/%s" (required "image.registry is required" $global.registry) ($img.name | default "fluxer-push") -}}
{{- end -}}
{{- $ref := printf "%s:%s" $repo (include "fluxer-push.string" (required "image.tag is required" ($img.tag | default $global.tag))) -}}
{{- with $img.digest }}{{ $ref = printf "%s@%s" $ref . }}{{ end -}}
{{- $ref -}}
{{- end }}
{{- define "fluxer-push.string" -}}
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
{{- . | int64 | toString -}}
{{- else -}}
{{- . | toString -}}
{{- end -}}
{{- end }}
{{- define "fluxer-push.env" -}}
{{- $env := deepCopy (.root.Values.env | default dict) -}}
{{- range $k, $v := (.w.env | default dict) -}}
{{- if kindIs "invalid" $v -}}
{{- $_ := unset $env $k -}}
{{- else -}}
{{- $_ := set $env $k $v -}}
{{- end -}}
{{- end -}}
{{- if not (kindIs "invalid" .w.port) -}}
{{- $_ := set $env "FLUXER_PUSH_SERVICE_PORT" .w.port -}}
{{- end -}}
{{- if not (kindIs "invalid" .w.buildVersion) }}
- name: BUILD_VERSION
value: {{ include "fluxer-push.string" .w.buildVersion | quote }}
{{- end }}
{{- range $k, $v := $env }}
{{- if not (kindIs "invalid" $v) }}
- name: {{ $k }}
value: {{ include "fluxer-push.string" $v | quote }}
{{- end }}
{{- end }}
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
{{ toYaml . }}
{{- end }}
{{- end }}
@@ -0,0 +1,205 @@
{{- range $name, $w := .Values.workloads }}
{{- if not (kindIs "invalid" $w) }}
{{- $ctx := dict "root" $ "name" $name "w" $w }}
{{- $mode := include "fluxer-push.mode" $ctx }}
{{- $port := include "fluxer-push.port" $ctx | int }}
{{- $globalProbes := $.Values.probes | default dict }}
{{- $workloadProbes := $w.probes | default dict }}
{{- $probes := dict }}
{{- range $probe := list "startup" "liveness" "readiness" }}
{{- $_ := set $probes $probe (ternary (index $workloadProbes $probe) (index $globalProbes $probe) (hasKey $workloadProbes $probe)) }}
{{- end }}
{{- $annotations := mergeOverwrite (deepCopy ($.Values.podAnnotations | default dict)) (deepCopy ($w.podAnnotations | default dict)) }}
{{- $pullSecrets := ternary $w.imagePullSecrets $.Values.imagePullSecrets (hasKey $w "imagePullSecrets") }}
{{- $podSecurityContext := ternary $w.podSecurityContext $.Values.podSecurityContext (hasKey $w "podSecurityContext") }}
{{- $securityContext := ternary $w.securityContext $.Values.securityContext (hasKey $w "securityContext") }}
{{- $strategy := ternary $w.strategy $.Values.strategy (hasKey $w "strategy") }}
{{- $tsc := ternary $w.topologySpreadConstraints $.Values.topologySpreadConstraints (hasKey $w "topologySpreadConstraints") }}
{{- $nodeSelector := ternary $w.nodeSelector $.Values.nodeSelector (hasKey $w "nodeSelector") }}
{{- $tolerations := ternary $w.tolerations $.Values.tolerations (hasKey $w "tolerations") }}
{{- $affinity := ternary $w.affinity $.Values.affinity (hasKey $w "affinity") }}
{{- $envFrom := concat ($.Values.envFrom | default list) ($w.envFrom | default list) }}
{{- $env := include "fluxer-push.env" $ctx }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
spec:
{{- if not $w.hpa }}
replicas: {{ ternary $w.replicas 1 (hasKey $w "replicas") | int }}
{{- end }}
{{- if hasKey $w "minReadySeconds" }}
minReadySeconds: {{ $w.minReadySeconds | int }}
{{- end }}
selector:
matchLabels:
{{- include "fluxer-push.selectorLabels" $ctx | nindent 6 }}
{{- with $strategy }}
strategy:
{{- toYaml . | nindent 4 }}
{{- end }}
template:
metadata:
{{- with $annotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "fluxer-push.labels" $ctx | nindent 8 }}
spec:
{{- with $pullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if hasKey $w "terminationGracePeriodSeconds" }}
terminationGracePeriodSeconds: {{ $w.terminationGracePeriodSeconds | int }}
{{- end }}
{{- with $nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $tsc }}
topologySpreadConstraints:
{{- range . }}
{{- $c := deepCopy . }}
{{- if not $c.labelSelector }}
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-push.selectorLabels" $ctx | fromYaml)) }}
{{- end }}
{{- toYaml (list $c) | nindent 8 }}
{{- end }}
{{- end }}
containers:
- name: {{ $name }}
image: {{ include "fluxer-push.image" $ctx | quote }}
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($.Values.image | default dict).pullPolicy | default "IfNotPresent" }}
command:
- /usr/local/bin/fluxer-push
{{- if eq $mode "relay" }}
args:
- --mode
- relay
{{- end }}
{{- with trim $env }}
env:
{{- . | nindent 12 }}
{{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ $port }}
protocol: TCP
{{- with $probes.startup }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $probes.liveness }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $probes.readiness }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $w.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $w.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- include "fluxer-push.selectorLabels" $ctx | nindent 4 }}
ports:
- name: http
port: {{ $port }}
protocol: TCP
targetPort: http
{{- with $w.pdb }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $name }}-pdb
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
spec:
{{- toYaml . | nindent 2 }}
selector:
matchLabels:
{{- include "fluxer-push.selectorLabels" $ctx | nindent 6 }}
{{- end }}
{{- with $w.hpa }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-push.labels" $ctx | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ $name }}
minReplicas: {{ required (printf "workloads.%s.hpa.minReplicas is required" $name) .minReplicas | int }}
maxReplicas: {{ required (printf "workloads.%s.hpa.maxReplicas is required" $name) .maxReplicas | int }}
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .targetCPUUtilizationPercentage | int }}
{{- end }}
{{- with .behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+65
View File
@@ -0,0 +1,65 @@
image:
registry: ghcr.io/fluxerapp
tag: v1
pullPolicy: IfNotPresent
imagePullSecrets: []
env: {}
extraEnv: []
envFrom:
- secretRef:
name: fluxer-env
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
probes:
liveness:
httpGet:
path: /_healthz
port: http
readiness:
httpGet:
path: /_healthz
port: http
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
topologySpreadConstraints: []
nodeSelector: {}
tolerations: []
affinity: {}
workloads:
push:
mode: delivery
replicas: 1
env:
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
FLUXER_SVC_NATS_URL: nats://nats:4222
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-svc
description: Fluxer internal services, each a router Deployment and a shard StatefulSet
type: application
version: 0.1.0
appVersion: v1
@@ -0,0 +1,203 @@
{{- define "fluxer-svc.chart" -}}
{{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{- end }}
{{- define "fluxer-svc.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
{{- end }}
{{- define "fluxer-svc.labels" -}}
{{ include "fluxer-svc.selectorLabels" . }}
app.kubernetes.io/component: {{ .mode }}
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
helm.sh/chart: {{ include "fluxer-svc.chart" .root }}
{{- end }}
{{- define "fluxer-svc.envValue" -}}
{{- if and (kindIs "float64" .) (eq . (float64 (int64 .))) -}}
{{- int64 . | toString -}}
{{- else -}}
{{- toString . -}}
{{- end -}}
{{- end }}
{{- define "fluxer-svc.mergeEnv" -}}
{{- $out := dict -}}
{{- range $layer := . -}}
{{- range $k, $v := ($layer | default dict) -}}
{{- if kindIs "invalid" $v -}}
{{- $_ := unset $out $k -}}
{{- else -}}
{{- $_ := set $out $k $v -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end }}
{{- define "fluxer-svc.topologySpreadConstraints" -}}
{{- $out := list -}}
{{- range .constraints -}}
{{- if .labelSelector -}}
{{- $out = append $out . -}}
{{- else -}}
{{- $out = append $out (merge (dict "labelSelector" (dict "matchLabels" $.selector)) .) -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end }}
{{- define "fluxer-svc.pdb" -}}
{{- $out := dict -}}
{{- range $k := list "minAvailable" "maxUnavailable" -}}
{{- if and (hasKey $ $k) (not (kindIs "invalid" (index $ $k))) -}}
{{- $_ := set $out $k (index $ $k) -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end }}
{{- define "fluxer-svc.config" -}}
{{- $v := .root.Values -}}
{{- $levels := list (index $v .mode) (index .svc .mode) -}}
{{- $c := dict "extraEnv" ($v.extraEnv | default list) "envFrom" ($v.envFrom | default list) "podAnnotations" (deepCopy ($v.podAnnotations | default dict)) "probes" (deepCopy ($v.probes | default dict)) "image" (deepCopy (.svc.image | default dict)) -}}
{{- range $k := list "imagePullSecrets" "podSecurityContext" "securityContext" "topologySpreadConstraints" "nodeSelector" "tolerations" "affinity" (ternary "updateStrategy" "strategy" (eq .mode "shard")) -}}
{{- $_ := set $c $k (index $v $k) -}}
{{- end -}}
{{- $envLayers := list $v.env -}}
{{- range $level := $levels -}}
{{- range $k, $x := ($level | default dict) -}}
{{- if eq $k "env" -}}
{{- $envLayers = append $envLayers $x -}}
{{- else if has $k (list "podAnnotations" "image") -}}
{{- $_ := set $c $k (mergeOverwrite (index $c $k) (deepCopy ($x | default dict))) -}}
{{- else if has $k (list "extraEnv" "envFrom") -}}
{{- $_ := set $c $k (concat (index $c $k) ($x | default list)) -}}
{{- else if eq $k "probes" -}}
{{- range $name, $p := ($x | default dict) -}}
{{- $_ := set $c.probes $name $p -}}
{{- end -}}
{{- else -}}
{{- $_ := set $c $k $x -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- $_ := set $c "env" (include "fluxer-svc.mergeEnv" $envLayers | fromYaml) -}}
{{- toYaml $c }}
{{- end }}
{{- define "fluxer-svc.image" -}}
{{- $g := .root.Values.image -}}
{{- $i := .c.image -}}
{{- $repo := $i.repository | default (printf "%s/%s" $g.registry ($i.name | default (printf "fluxer-%s" .service))) -}}
{{- $ref := printf "%s:%s" $repo ($i.tag | default $g.tag) -}}
{{- with $i.digest }}{{ $ref = printf "%s@%s" $ref . }}{{ end -}}
{{- $ref -}}
{{- end }}
{{- define "fluxer-svc.pod" -}}
{{- $v := .root.Values -}}
{{- $c := .c -}}
metadata:
{{- with $c.podAnnotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
labels:
{{- include "fluxer-svc.labels" . | nindent 4 }}
spec:
{{- with $c.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with $c.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if not (kindIs "invalid" $c.terminationGracePeriodSeconds) }}
terminationGracePeriodSeconds: {{ $c.terminationGracePeriodSeconds | int64 }}
{{- end }}
{{- with $c.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with $c.tolerations }}
tolerations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with $c.affinity }}
affinity:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with $c.topologySpreadConstraints }}
topologySpreadConstraints:
{{- include "fluxer-svc.topologySpreadConstraints" (dict "constraints" . "selector" (include "fluxer-svc.selectorLabels" $ | fromYaml)) | nindent 4 }}
{{- end }}
containers:
- name: {{ .mode }}
image: {{ include "fluxer-svc.image" . | quote }}
imagePullPolicy: {{ $c.image.pullPolicy | default $v.image.pullPolicy }}
env:
- name: FLUXER_SVC_MODE
value: {{ .mode | quote }}
- name: FLUXER_SVC_NAME
value: {{ .service | quote }}
- name: FLUXER_SVC_SHARD_COUNT
value: {{ .shardCount | quote }}
- name: FLUXER_SVC_PORT
value: {{ include "fluxer-svc.envValue" $v.port | quote }}
{{- if not (kindIs "invalid" $c.buildVersion) }}
- name: BUILD_VERSION
value: {{ include "fluxer-svc.envValue" $c.buildVersion | quote }}
{{- end }}
{{- if eq .mode "shard" }}
- name: POD_NAME
valueFrom:
fieldRef:
apiVersion: v1
fieldPath: metadata.name
{{- end }}
{{- range $name, $value := $c.env }}
- name: {{ $name }}
value: {{ include "fluxer-svc.envValue" $value | quote }}
{{- end }}
{{- with $c.extraEnv }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $c.envFrom }}
envFrom:
{{- toYaml . | nindent 8 }}
{{- end }}
ports:
- name: http
containerPort: {{ $v.port }}
protocol: TCP
{{- with $c.lifecycle }}
lifecycle:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- range $name := list "startup" "liveness" "readiness" }}
{{- with index $c.probes $name }}
{{ $name }}Probe:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- with $c.resources }}
resources:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $c.securityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $c.extraVolumeMounts }}
volumeMounts:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $c.extraVolumes }}
volumes:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
@@ -0,0 +1,145 @@
{{- range $service, $svc := .Values.services }}
{{- if not (kindIs "invalid" $svc) }}
{{- $svc = $svc | default dict }}
{{- $rc := fromYaml (include "fluxer-svc.config" (dict "root" $ "svc" $svc "mode" "router")) }}
{{- $sc := fromYaml (include "fluxer-svc.config" (dict "root" $ "svc" $svc "mode" "shard")) }}
{{- $routerReplicas := ternary $rc.replicas 1 (hasKey $rc "replicas") | int64 }}
{{- $shardCount := ternary $sc.replicas 1 (hasKey $sc "replicas") | int64 }}
{{- if lt $shardCount 1 }}
{{- fail (printf "services.%s shard replicas must be at least 1" $service) }}
{{- end }}
{{- $router := dict "root" $ "service" $service "svc" $svc "mode" "router" "name" $service "c" $rc "shardCount" (toString $shardCount) }}
{{- $shard := dict "root" $ "service" $service "svc" $svc "mode" "shard" "name" (printf "%s-shard" $service) "c" $sc "shardCount" (toString $shardCount) }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $service }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-svc.labels" $router | nindent 4 }}
spec:
{{- if not $rc.hpa }}
replicas: {{ $routerReplicas }}
{{- end }}
{{- if not (kindIs "invalid" $rc.minReadySeconds) }}
minReadySeconds: {{ $rc.minReadySeconds | int64 }}
{{- end }}
selector:
matchLabels:
{{- include "fluxer-svc.selectorLabels" $router | nindent 6 }}
{{- with $rc.strategy }}
strategy:
{{- toYaml . | nindent 4 }}
{{- end }}
template:
{{- include "fluxer-svc.pod" $router | nindent 4 }}
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ $service }}-shard
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-svc.labels" $shard | nindent 4 }}
spec:
replicas: {{ $shardCount }}
{{- if not (kindIs "invalid" $sc.minReadySeconds) }}
minReadySeconds: {{ $sc.minReadySeconds | int64 }}
{{- end }}
podManagementPolicy: Parallel
serviceName: {{ $service }}-shard-headless
selector:
matchLabels:
{{- include "fluxer-svc.selectorLabels" $shard | nindent 6 }}
{{- with $sc.updateStrategy }}
updateStrategy:
{{- toYaml . | nindent 4 }}
{{- end }}
template:
{{- include "fluxer-svc.pod" $shard | nindent 4 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $service }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-svc.labels" $router | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- include "fluxer-svc.selectorLabels" $router | nindent 4 }}
ports:
- name: http
port: {{ $.Values.port }}
targetPort: {{ $.Values.port }}
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: {{ $service }}-shard-headless
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-svc.labels" $shard | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
publishNotReadyAddresses: true
selector:
{{- include "fluxer-svc.selectorLabels" $shard | nindent 4 }}
ports:
- name: http
port: {{ $.Values.port }}
targetPort: {{ $.Values.port }}
protocol: TCP
{{- with $rc.hpa }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ $service }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-svc.labels" $router | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ $service }}
minReplicas: {{ required (printf "services.%s router hpa.minReplicas is required" $service) .minReplicas | int64 }}
maxReplicas: {{ required (printf "services.%s router hpa.maxReplicas is required" $service) .maxReplicas | int64 }}
{{- if not (kindIs "invalid" .targetCPUUtilizationPercentage) }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .targetCPUUtilizationPercentage | int64 }}
{{- end }}
{{- with .behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- range $ctx := list $router $shard }}
{{- with include "fluxer-svc.pdb" ($ctx.c.pdb | default dict) | fromYaml }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $ctx.name }}-pdb
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-svc.labels" $ctx | nindent 4 }}
spec:
{{- toYaml . | nindent 2 }}
selector:
matchLabels:
{{- include "fluxer-svc.selectorLabels" $ctx | nindent 6 }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+89
View File
@@ -0,0 +1,89 @@
image:
registry: ghcr.io/fluxerapp
tag: v1
pullPolicy: IfNotPresent
imagePullSecrets: []
env:
FLUXER_SVC_NATS_URL: nats://nats:4222
extraEnv: []
envFrom:
- secretRef:
name: fluxer-env
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
probes:
liveness:
httpGet:
path: /_healthz
port: http
readiness:
httpGet:
path: /_health
port: http
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
updateStrategy:
type: RollingUpdate
topologySpreadConstraints: []
nodeSelector: {}
tolerations: []
affinity: {}
port: 8090
router:
replicas: 1
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 192Mi
shard:
replicas: 2
probes:
startup:
httpGet:
path: /_healthz
port: http
periodSeconds: 10
failureThreshold: 30
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
memory: 384Mi
services:
gifs:
shard:
env:
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: https://media.example.com
messages: {}
snowflakes: {}
unfurl:
shard:
env:
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
users: {}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: fluxer-web
description: Fluxer web app proxy and admin dashboard.
type: application
version: 0.1.0
appVersion: "v1"
@@ -0,0 +1,80 @@
{{- define "fluxer-web.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{- define "fluxer-web.selectorLabels" -}}
app.kubernetes.io/name: {{ .name }}
app.kubernetes.io/instance: {{ .root.Release.Name }}
{{- end }}
{{- define "fluxer-web.labels" -}}
{{ include "fluxer-web.selectorLabels" . }}
app.kubernetes.io/component: web
app.kubernetes.io/part-of: fluxer
app.kubernetes.io/managed-by: {{ .root.Release.Service }}
helm.sh/chart: {{ include "fluxer-web.chart" .root }}
{{- end }}
{{- define "fluxer-web.image" -}}
{{- $g := .root.Values.image | default dict -}}
{{- $i := .w.image | default dict -}}
{{- $repo := $i.repository -}}
{{- if not $repo -}}
{{- $repo = printf "%s/%s" (required "image.registry is required" $g.registry) ($i.name | default (printf "fluxer-%s" .name)) -}}
{{- end -}}
{{- $tag := required "image.tag is required" ($i.tag | default $g.tag) -}}
{{- if $i.digest -}}
{{- printf "%s:%s@%s" $repo $tag $i.digest | quote -}}
{{- else -}}
{{- printf "%s:%s" $repo $tag | quote -}}
{{- end -}}
{{- end }}
{{- define "fluxer-web.pick" -}}
{{- $v := ternary (get .w .key) (get .root.Values .key) (hasKey .w .key) -}}
{{- if $v }}
{{- toYaml $v }}
{{- end }}
{{- end }}
{{- define "fluxer-web.str" -}}
{{- if and (kindIs "float64" .) (eq . (floor .)) -}}
{{- int64 . | toString | quote -}}
{{- else -}}
{{- toString . | quote -}}
{{- end -}}
{{- end }}
{{- define "fluxer-web.env" -}}
{{- $env := dict -}}
{{- range $k, $val := .root.Values.env | default dict }}
{{- $_ := set $env $k $val }}
{{- end }}
{{- range $k, $val := .w.env | default dict }}
{{- $_ := set $env $k $val }}
{{- end }}
{{- range $k, $val := $env }}
{{- if not (kindIs "invalid" $val) }}
- name: {{ $k }}
value: {{ include "fluxer-web.str" $val }}
{{- end }}
{{- end }}
{{- with .w.buildVersion }}
- name: BUILD_VERSION
value: {{ include "fluxer-web.str" . }}
{{- end }}
{{- with concat (.root.Values.extraEnv | default list) (.w.extraEnv | default list) }}
{{ toYaml . }}
{{- end }}
{{- end }}
{{- define "fluxer-web.topologySpread" -}}
{{- $tscs := ternary .w.topologySpreadConstraints .root.Values.topologySpreadConstraints (hasKey .w "topologySpreadConstraints") -}}
{{- range $tscs }}
{{- $c := deepCopy . }}
{{- if not $c.labelSelector }}
{{- $_ := set $c "labelSelector" (dict "matchLabels" (include "fluxer-web.selectorLabels" $ | fromYaml)) }}
{{- end }}
- {{- toYaml $c | nindent 2 }}
{{- end }}
{{- end }}
@@ -0,0 +1,172 @@
{{- $v := .Values }}
{{- range $name, $w := .Values.workloads }}
{{- if not (kindIs "invalid" $w) }}
{{- $ctx := dict "root" $ "name" $name "w" $w }}
{{- $envFrom := concat ($v.envFrom | default list) ($w.envFrom | default list) }}
{{- $podAnnotations := merge (dict) ($w.podAnnotations | default dict) ($v.podAnnotations | default dict) }}
{{- $wProbes := $w.probes | default dict }}
{{- $gProbes := $v.probes | default dict }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
spec:
{{- if not $w.hpa }}
replicas: {{ if kindIs "invalid" $w.replicas }}1{{ else }}{{ int $w.replicas }}{{ end }}
{{- end }}
{{- if not (kindIs "invalid" $w.minReadySeconds) }}
minReadySeconds: {{ int $w.minReadySeconds }}
{{- end }}
selector:
matchLabels:
{{- include "fluxer-web.selectorLabels" $ctx | nindent 6 }}
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "strategy") }}
strategy:
{{- . | nindent 4 }}
{{- end }}
template:
metadata:
labels:
{{- include "fluxer-web.labels" $ctx | nindent 8 }}
{{- with $podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "imagePullSecrets") }}
imagePullSecrets:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "podSecurityContext") }}
securityContext:
{{- . | nindent 8 }}
{{- end }}
{{- if not (kindIs "invalid" $w.terminationGracePeriodSeconds) }}
terminationGracePeriodSeconds: {{ int $w.terminationGracePeriodSeconds }}
{{- end }}
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "nodeSelector") }}
nodeSelector:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "affinity") }}
affinity:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "tolerations") }}
tolerations:
{{- . | nindent 8 }}
{{- end }}
{{- with include "fluxer-web.topologySpread" $ctx | trim }}
topologySpreadConstraints:
{{- . | nindent 8 }}
{{- end }}
containers:
- name: {{ $name }}
image: {{ include "fluxer-web.image" $ctx }}
imagePullPolicy: {{ ($w.image | default dict).pullPolicy | default ($v.image | default dict).pullPolicy | default "IfNotPresent" }}
{{- with include "fluxer-web.env" $ctx | trim }}
env:
{{- . | nindent 12 }}
{{- end }}
{{- with $envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: 8080
protocol: TCP
{{- with $w.lifecycle }}
lifecycle:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- range $probe := list "startup" "liveness" "readiness" }}
{{- with hasKey $wProbes $probe | ternary (get $wProbes $probe) (get $gProbes $probe) }}
{{ $probe }}Probe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
{{- with $w.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with include "fluxer-web.pick" (dict "root" $ "w" $w "key" "securityContext") }}
securityContext:
{{- . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with $w.extraVolumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- include "fluxer-web.selectorLabels" $ctx | nindent 4 }}
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP
{{- with $w.hpa }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ $name }}
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ $name }}
minReplicas: {{ required (printf "%s.hpa.minReplicas is required" $name) .minReplicas }}
maxReplicas: {{ required (printf "%s.hpa.maxReplicas is required" $name) .maxReplicas }}
{{- with .targetCPUUtilizationPercentage }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ . }}
{{- end }}
{{- with .behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- with $w.pdb }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $name }}-pdb
namespace: {{ $.Release.Namespace }}
labels:
{{- include "fluxer-web.labels" $ctx | nindent 4 }}
spec:
{{- toYaml . | nindent 2 }}
selector:
matchLabels:
{{- include "fluxer-web.selectorLabels" $ctx | nindent 6 }}
{{- end }}
{{- end }}
{{- end }}
+83
View File
@@ -0,0 +1,83 @@
image:
registry: ghcr.io/fluxerapp
tag: v1
pullPolicy: IfNotPresent
imagePullSecrets: []
env: {}
extraEnv: []
envFrom:
- secretRef:
name: fluxer-env
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
probes:
startup:
httpGet:
path: /_health
port: http
periodSeconds: 10
failureThreshold: 30
liveness:
httpGet:
path: /_health
port: http
readiness:
httpGet:
path: /_health
port: http
strategy:
type: RollingUpdate
topologySpreadConstraints: []
nodeSelector: {}
tolerations: []
affinity: {}
workloads:
admin:
image:
name: fluxer-admin
replicas: 1
env:
FLUXER_ENV: production
FLUXER_API_ENDPOINT: https://api.example.com
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
FLUXER_MEDIA_ENDPOINT: https://media.example.com
FLUXER_APP_ENDPOINT: https://web.example.com
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
memory: 384Mi
app-proxy:
image:
name: fluxer-app-proxy-self-hosted
replicas: 1
env:
RELEASE_CHANNEL: stable
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://web.example.com/api
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
memory: 384Mi
+343 -37
View File
@@ -1,6 +1,8 @@
# Every variable docker-compose.yml reads, uncommented when it has no default and
# commented with its default when it has one. Compose expands top to bottom, so a
# line using ${...} must sit below every name it reads.
# Every variable docker-compose.yml reads. A value an install must set is
# uncommented. A commented line shows the default, or an example where its comment
# says so, and nothing after = means the service decides. An empty value keeps the
# default too. Compose expands top to bottom, so a line using ${...} must sit below
# every name it reads.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
@@ -68,7 +70,9 @@ FLUXER_IMAGE_TAG=v1
POSTGRES_PASSWORD=CHANGE_ME
MEILI_MASTER_KEY=CHANGE_ME
# Set these to run Postgres or the object store outside the stack. Backing up a
# store you moved out is yours to arrange, and an upgrade skips it.
# store you moved out is yours to arrange. An upgrade dumps the bundled postgres
# service and skips the dump only when the stack defines none. The values below
# are examples.
#FLUXER_POSTGRES_HOST=db.example.com
#FLUXER_POSTGRES_PORT=5432
#FLUXER_POSTGRES_DATABASE=fluxer
@@ -78,45 +82,101 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
#FLUXER_S3_REGION=eu-central-1
#FLUXER_S3_FORCE_PATH_STYLE=false
# Bucket names. The bundled store creates these. An outside store needs them to
# exist already.
#FLUXER_S3_BUCKET_CDN=fluxer
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
# With the object store outside the stack, add this overlay to COMPOSE_FILE and
# the bundled seaweedfs no longer starts. Put it after any other overlay, such as
# docker-compose.yml:docker-compose.proxy.yml:external-object-store.compose.yml.
# Needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:external-object-store.compose.yml
# A full connection URL wins over the host, port and database above. The URL is an
# example. The CA is the PEM text of the certificate, with \n for line breaks.
#FLUXER_POSTGRES_URL=postgres://fluxer:[email protected]:5432/fluxer
#FLUXER_POSTGRES_SSL_CA=
# The Postgres table that holds the key-value store.
#FLUXER_POSTGRES_KV_TABLE=fluxer_kv
# media-proxy reads through these when the store serves reads from another
# address or bucket.
#FLUXER_S3_READ_ENDPOINT=
#FLUXER_S3_READ_BUCKET=
#FLUXER_S3_READ_BUCKET_STYLE=
# A temporary S3 session token, read by media-proxy only.
#FLUXER_S3_SESSION_TOKEN=
# The bundled store refuses unsigned reads. Set false only for a public-read bucket.
#FLUXER_S3_READ_SIGNED=true
# The other bundled services, pointed elsewhere. Removing a service from the
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
# The URLs below are examples.
#FLUXER_KV_URL=redis://cache.example.com:6379/0
#FLUXER_NATS_URL=nats://mq.example.com:4222
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
#FLUXER_SEARCH_URL=https://search.example.com
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
# How the stack talks to those services.
#FLUXER_KV_MODE=standalone
#FLUXER_SEARCH_ENGINE=meilisearch
#FLUXER_SEARCH_USERNAME=
#FLUXER_SEARCH_PASSWORD=
#FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED=true
# Voice off. The livekit service still runs until an override removes it.
#FLUXER_LIVEKIT_ENABLED=false
# Optional systems, each off unless configured.
#FLUXER_SMS_ENABLED=false
#FLUXER_STRIPE_ENABLED=false
#FLUXER_NCMEC_ENABLED=false
#FLUXER_CLAMAV_ENABLED=false
#FLUXER_STRIPE_SECRET_KEY=
#FLUXER_STRIPE_WEBHOOK_SECRET=
# Stripe prices as one JSON object. The admin dashboard can set them instead.
#FLUXER_STRIPE_PRICES={}
#FLUXER_STRIPE_LEGACY_PRICES={}
#FLUXER_API_DONATION_PROXY_KEY=
#FLUXER_VISIONARIES_GUILD_ID=
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
# Outside lookups, off unless turned on. The Tor exit list comes from
# onionoo.torproject.org and the breached password check asks
# NCMEC CyberTipline reporting, off by default. All four values are required
# once it is on. The values below are examples.
#FLUXER_NCMEC_ENABLED=true
#FLUXER_NCMEC_BASE_URL=https://report.cybertip.org/ispws
#FLUXER_NCMEC_USERNAME=
#FLUXER_NCMEC_PASSWORD=
#[email protected]
# Upload virus scanning, off by default. No ClamAV container ships, so point
# this at your own. The values below are examples.
#FLUXER_CLAMAV_ENABLED=true
#FLUXER_CLAMAV_HOST=clamav
#FLUXER_CLAMAV_PORT=3310
#FLUXER_CLAMAV_FAIL_OPEN=false
# Outside lookups, off unless turned on. The breached password check asks
# api.pwnedpasswords.com.
#FLUXER_TOR_EXIT_LIST_ENABLED=true
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=false
#FLUXER_BLOCKLIST_FEEDS_ENABLED=false
# A local path, or an s3:// URL read with the S3 credentials of this file.
#FLUXER_GEOIP_DB_PATH=
# 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
# The client address. The edge sets X-Forwarded-For on every hop, so keep the
# trust on and the default header. Turning the trust off makes the api refuse
# every request outside its exempt routes with a 403.
#FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
#FLUXER_TRUST_CLIENT_IP_HEADER=true
# How much the services write. trace, debug, info, warn, error or fatal.
#LOG_LEVEL=debug
# How much the services write. LOG_LEVEL covers the api and worker and takes trace,
# debug, info, warn, error or fatal. RUST_LOG covers the Rust services and takes
# an EnvFilter such as debug. The gateway takes an Erlang level such as notice,
# and LOGGER_LEVEL beats FLUXER_GATEWAY_LOGGER_LEVEL.
#LOG_LEVEL=info
#RUST_LOG=info
#FLUXER_GATEWAY_LOGGER_LEVEL=info
#LOGGER_LEVEL=
FLUXER_S3_ACCESS_KEY=fluxer
FLUXER_S3_SECRET_KEY=CHANGE_ME
@@ -141,13 +201,42 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# exist.
#[email protected]
# Passkeys follow FLUXER_DOMAIN. Set these only if browsers use another host.
# The passkey RP ID defaults to FLUXER_DOMAIN, whatever FLUXER_PUBLIC_ORIGIN says.
# Changing the RP ID invalidates every passkey registered against the old value.
#FLUXER_PASSKEY_RP_ID=chat.example.com
#FLUXER_PASSKEY_RP_NAME=Fluxer
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Notification jobs the push container holds at once, 1 to 1000000.
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
# Provider requests the push container sends at once, 1 to 65536.
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
# The push container's provider addresses and relay hosts.
#FLUXER_PUSH_SERVICE_APNS_BASE_URL=
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
# Direct mobile push through your own APNs and FCM credentials, off by default.
#FLUXER_PUSH_APNS_ENABLED=false
#FLUXER_PUSH_APNS_TEAM_ID=
#FLUXER_PUSH_APNS_KEY_ID=
#FLUXER_PUSH_APNS_PRIVATE_KEY=
#FLUXER_PUSH_APNS_PRIVATE_KEY_PATH=
#FLUXER_PUSH_APNS_APPS=
#FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT=production
#FLUXER_PUSH_FCM_ENABLED=false
#FLUXER_PUSH_FCM_PROJECT_ID=
#FLUXER_PUSH_FCM_CLIENT_EMAIL=
#FLUXER_PUSH_FCM_PRIVATE_KEY=
#FLUXER_PUSH_FCM_PRIVATE_KEY_PATH=
#FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH=
#FLUXER_PUSH_FCM_TOKEN_URI=https://oauth2.googleapis.com/token
#FLUXER_PUSH_FCM_APPS=
# Optional media policies, both off by default. See the operator docs.
#
# CORS limits which web origins may read media. A request with no Origin is
@@ -157,8 +246,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# 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.
# Each mode is off, report or enforce, and off is the default. Start at report.
# media-proxy reads these at start, so apply with docker compose up -d
# media-proxy. The values below are examples.
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
@@ -183,7 +273,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# Let the SSO provider resolve to a private address. Off by default, so a
# misconfigured provider URL cannot reach internal services. Turn it on only for
# a provider on your own network.
# a provider on your own network. The value below is an example.
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
# These reach both LiveKit and the api. Change them together.
@@ -200,32 +290,119 @@ LIVEKIT_API_SECRET=CHANGE_ME
# LiveKit finds its public address over STUN. A host that cannot reach one stops
# with "could not resolve external IP", so set the address by hand instead, or
# point STUN elsewhere.
# point STUN elsewhere. The values below are examples.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
# The voice region users see, and how much LiveKit logs.
#FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
#FLUXER_LIVEKIT_LOG_LEVEL=info
FLUXER_KLIPY_API_KEY=
#FLUXER_YOUTUBE_API_KEY=
# Hosts the api never unfurls, comma separated.
#FLUXER_API_UNFURL_IGNORED_HOSTS=
FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
FLUXER_EMAIL_FROM_NAME=Fluxer
#[email protected]
FLUXER_EMAIL_APP_BASE_URL=
FLUXER_EMAIL_SMTP_HOST=
FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=
FLUXER_EMAIL_SMTP_PASSWORD=
FLUXER_EMAIL_SMTP_SECURE=true
#FLUXER_EMAIL_WEBHOOK_SECRET=
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
#FLUXER_DISCOVERY_MIN_MEMBER_COUNT=1
# Instance identity and account policy.
#FLUXER_APP_PRODUCT_NAME=Fluxer
#FLUXER_APP_ICON_URL=
#FLUXER_APP_SYMBOL_URL=
#FLUXER_APP_LOGO_URL=
#FLUXER_APP_WORDMARK_URL=
#FLUXER_APP_FAVICON_URL=
#FLUXER_APP_THEME_COLOR=
#FLUXER_APP_STATUS_PAGE_URL=
#FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL=
#FLUXER_INSTANCE_SETUP_CONFIGURED=false
#FLUXER_AUTO_JOIN_INVITE_CODE=
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
# Sign in with Bluesky, off unless turned on.
#FLUXER_AUTH_BLUESKY_ENABLED=false
#FLUXER_AUTH_BLUESKY_CLIENT_NAME=Fluxer
#FLUXER_AUTH_BLUESKY_CLIENT_URI=
#FLUXER_AUTH_BLUESKY_LOGO_URI=
#FLUXER_AUTH_BLUESKY_TOS_URI=
#FLUXER_AUTH_BLUESKY_POLICY_URI=
#FLUXER_AUTH_BLUESKY_KEYS=
# Public addresses. Each follows the public origin unless set here.
#FLUXER_API_ENDPOINT=
#FLUXER_API_CLIENT_ENDPOINT=
#FLUXER_APP_ENDPOINT=
#FLUXER_GATEWAY_ENDPOINT=
#FLUXER_MEDIA_ENDPOINT=
#FLUXER_STATIC_CDN_ENDPOINT=
#FLUXER_ADMIN_ENDPOINT=
#FLUXER_MARKETING_ENDPOINT=
#FLUXER_INVITE_ENDPOINT=
#FLUXER_GIFT_ENDPOINT=
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
# Extra hosts for static assets, invites, gifts and the web app. Empty by default.
#FLUXER_STATIC_CDN_DOMAIN=
#FLUXER_INVITE_DOMAIN=
#FLUXER_GIFT_DOMAIN=
#FLUXER_APP_ORIGIN_ALIASES=
# The path the admin panel is served under. The edge and the admin service read
# it. The api follows it through the default FLUXER_ADMIN_ENDPOINT, and not when
# FLUXER_ADMIN_ENDPOINT is set. Write it with a leading slash and no trailing
# slash.
#FLUXER_ADMIN_BASE_PATH=/admin
# The compression the edge offers, as Caddy encode arguments.
#FLUXER_EDGE_ENCODE=zstd gzip
# Images of the bundled services, for a mirror or another tag. A new Postgres
# major needs a dump and restore, as the upgrade guide describes.
#FLUXER_CADDY_IMAGE=caddy:2.11-alpine
#FLUXER_POSTGRES_IMAGE=postgres:16-alpine
#FLUXER_VALKEY_IMAGE=valkey/valkey:9.1-alpine
#FLUXER_NATS_IMAGE=nats:2.14-alpine
#FLUXER_MEILISEARCH_IMAGE=getmeili/meilisearch:v1.53
#FLUXER_SEAWEEDFS_IMAGE=chrislusf/seaweedfs:4.47
#FLUXER_LIVEKIT_IMAGE=livekit/livekit-server:v1.12.0
# Restart policy for every long-running service.
#FLUXER_RESTART_POLICY=unless-stopped
# Health checks. Raise the retries or start periods on a slow host.
#FLUXER_HEALTHCHECK_INTERVAL=10s
#FLUXER_HEALTHCHECK_TIMEOUT=5s
#FLUXER_HEALTHCHECK_RETRIES=10
#FLUXER_APP_HEALTHCHECK_RETRIES=30
#FLUXER_APP_HEALTHCHECK_START_PERIOD=90s
#FLUXER_SVC_HEALTHCHECK_START_PERIOD=60s
#FLUXER_WORKER_HEALTHCHECK_RETRIES=3
#FLUXER_SEAWEEDFS_HEALTHCHECK_RETRIES=20
#FLUXER_SEAWEEDFS_HEALTHCHECK_START_PERIOD=60s
#FLUXER_SEAWEEDFS_INIT_ATTEMPTS=60
# Container memory. These are ceilings, not allocations, and the defaults suit a
# 16 GB host. The reservations bias the kernel away from reclaiming from services
@@ -235,7 +412,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
#FLUXER_NATS_MEMORY_LIMIT=256mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=1536mb
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
@@ -246,6 +423,7 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
#FLUXER_PUSH_MEMORY_LIMIT=256mb
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
@@ -260,20 +438,41 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
# Meilisearch indexing memory. Keep it well under the container limit above.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
# Meilisearch indexing memory and threads. Each indexing thread needs its own
# buffers on top of the indexing memory, so raise the threads only together with
# the container limit above.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=256mb
#FLUXER_MEILISEARCH_MAX_INDEXING_THREADS=2
#FLUXER_MEILISEARCH_ENV=production
#FLUXER_MEILISEARCH_NO_ANALYTICS=true
# SeaweedFS heap ceiling. Go cannot see the container limit, so without this an
# upload burst gets the container OOM-killed. Keep it near three quarters of
# FLUXER_SEAWEEDFS_MEMORY_LIMIT and raise both together.
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
#FLUXER_SEAWEEDFS_TELEMETRY=false
# Volumes SeaweedFS creates at once when a bucket needs space. Each reserves 1 GB
# of free disk from the start, and SeaweedFS's own default of 7 fills a small
# disk before every bucket has one, so uploads fail with no free volumes left.
#FLUXER_SEAWEEDFS_VOLUME_GROWTH=1
# Node sizes its heap from the container limit by default. Leave these unset
# unless you need to pin it. A heap ceiling above the container limit gets the
# container OOM-killed instead of reporting a heap error.
# container OOM-killed instead of reporting a heap error. The values below are
# examples.
#FLUXER_API_NODE_HEAP_MB=1792
#FLUXER_WORKER_NODE_HEAP_MB=1792
# Extra Node flags for api and worker, appended to NODE_OPTIONS. Empty by
# default. The value below is an example.
#FLUXER_API_NODE_OPTIONS=--heapsnapshot-near-heap-limit=1
#FLUXER_WORKER_NODE_OPTIONS=--heapsnapshot-near-heap-limit=1
# Extra CA certificates api and worker trust, as a PEM bundle path inside the
# container. The default is the image's system bundle.
#FLUXER_NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
# Bundled Postgres tuning. Keep it consistent with the memory limit above. This
# is the server setting, not the per-service pool sizes.
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
@@ -282,23 +481,82 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_POSTGRES_WORK_MEM=8MB
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
#FLUXER_POSTGRES_SHM_SIZE=1gb
#FLUXER_POSTGRES_RANDOM_PAGE_COST=1.1
#FLUXER_POSTGRES_EFFECTIVE_IO_CONCURRENCY=200
#FLUXER_POSTGRES_DEFAULT_STATISTICS_TARGET=200
#FLUXER_POSTGRES_JIT=off
#FLUXER_POSTGRES_MIN_WAL_SIZE=512MB
#FLUXER_POSTGRES_MAX_WAL_SIZE=2GB
#FLUXER_POSTGRES_CHECKPOINT_COMPLETION_TARGET=0.9
#FLUXER_POSTGRES_WAL_BUFFERS=16MB
#FLUXER_POSTGRES_WAL_COMPRESSION=zstd
#FLUXER_POSTGRES_BGWRITER_DELAY=50ms
#FLUXER_POSTGRES_BGWRITER_LRU_MAXPAGES=1000
#FLUXER_POSTGRES_AUTOVACUUM_VACUUM_SCALE_FACTOR=0.05
#FLUXER_POSTGRES_AUTOVACUUM_ANALYZE_SCALE_FACTOR=0.02
#FLUXER_POSTGRES_AUTOVACUUM_VACUUM_COST_LIMIT=2000
#FLUXER_POSTGRES_TRACK_IO_TIMING=on
#FLUXER_POSTGRES_SHARED_PRELOAD_LIBRARIES=pg_stat_statements
# Postgres pool size of each service that opens a pool.
#FLUXER_API_POSTGRES_MAX_CONNECTIONS=25
#FLUXER_WORKER_POSTGRES_MAX_CONNECTIONS=25
#FLUXER_USERS_SHARD_POSTGRES_MAX_CONNECTIONS=20
#FLUXER_MESSAGES_SHARD_POSTGRES_MAX_CONNECTIONS=20
# The bundled Valkey holds durable state as well as cache, so it runs with an
# append-only file and with noeviction, which fails an over-limit write instead
# of dropping queued work. Change the policy only if that state lives elsewhere.
#FLUXER_VALKEY_MAXMEMORY=192mb
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
#FLUXER_VALKEY_APPENDFSYNC=everysec
# The gateway derives its scheduler count from the CPU quota, clamped here. One
# scheduler lets a single blocking operation stall every websocket on the node.
#FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# A fixed scheduler count skips the clamp. Dirty CPU schedulers default to two
# thirds of it.
#FLUXER_ERLANG_SCHEDULERS=
#FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS=
# In-flight request ceiling for the users and messages routers and their shards.
# One value replaces the built-in default on all of them, so size it for the
# busiest. Too low a value rejects requests rather than slowing them, and the api
# turns that into a 503.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
# Gateway push and RPC tuning.
#FLUXER_GATEWAY_PUSH_ENABLED=true
#FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED=true
#FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS=100000
#FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES=128
#FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
#FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY=512
#FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS=512
#FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD=6
#FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS=15000
# In-flight request ceiling for every svc router and shard. Unset, each keeps its
# own default: 192 for messages, 320 for snowflakes and 64 for the rest. One value
# replaces all of them, so size it for the busiest. Too low a value rejects
# requests rather than slowing them, and the api turns that into a 503. The value
# below is an example.
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=320
# svc caches, and how the api calls the svc services over NATS.
#FLUXER_SVC_CACHE_MAX_ENTRIES=100000
#FLUXER_SVC_CACHE_TTL_MS=30000
#FLUXER_GIFS_SHARD_CACHE_MAX_BYTES=536870912
#FLUXER_GIF_SERVICE_NATS_CLIENT_NAME=fluxer-api-gifs
#FLUXER_GIF_SERVICE_TIMEOUT_MS=12000
#FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS=3000
#FLUXER_USERS_SERVICE_NATS_CLIENT_NAME=fluxer-api-users
#FLUXER_USERS_SERVICE_TIMEOUT_MS=6000
#FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES=10000
#FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME=fluxer-api-snowflakes
#FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE=128
#FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK=
#FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS=5000
#FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS=6000
# Worker concurrency per lane, as a JSON object keyed by lane.
#FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES=
# Named prepared statements need a session that outlives the transaction, so set
# this to false behind a transaction-pooling connection pooler. The bundled
@@ -310,3 +568,51 @@ FLUXER_DISCOVERY_ENABLED=true
# is clamped down to the second. Milliseconds, 1000 to 3600000.
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
# api request limits and IP bans. A refresh interval of 0 stops the periodic
# ban reload.
#FLUXER_API_MAX_INFLIGHT_REQUESTS=512
#FLUXER_API_IP_BAN_EXEMPT_IPS=
#FLUXER_IP_BAN_REFRESH_INTERVAL_MS=300000
# Uploads and data exports. Presigned exports link to FLUXER_S3_PUBLIC_ENDPOINT,
# so turn them on only once browsers can reach it.
#FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
#FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED=false
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES=524288000
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS=900
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES=
#FLUXER_API_STORAGE_CHANGE_FEED_ENABLED=false
#FLUXER_API_STORAGE_CHANGE_FEED_STREAM=STORAGE_CHANGES
#FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS=
#FLUXER_CACHE_PURGE_ADAPTER=none
#FLUXER_CACHE_PURGE_HTTP_ENDPOINT=
#FLUXER_CACHE_PURGE_HTTP_TOKEN=
#FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS=10000
# media-proxy limits and timeouts.
#FLUXER_MEDIA_PROXY_READ_ONLY=false
#FLUXER_MEDIA_PROXY_NSFW_THRESHOLD=0.85
#FLUXER_NSFW_SERVICE_ENDPOINT=
#FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS=
#FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY=
#FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS=30000
#FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES=20000
#FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS=15000
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES=268435456
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES=67108864
#FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS=120000
#FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS=30000
#FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS=30000
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS=900000
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES=33554432
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES=536870912
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR=
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES=1048576
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES=8589934592
# app-proxy discovery refresh, index upstream and manifest scope.
#DISCOVERY_REFRESH_INTERVAL_MS=60000
#FLUXER_APP_PROXY_INDEX_UPSTREAM_URL=
#FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS=
#FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS=
+3 -3
View File
@@ -6,7 +6,7 @@
}
{$FLUXER_EDGE_SITE_ADDRESS} {
encode zstd gzip
encode {$FLUXER_EDGE_ENCODE:zstd gzip}
handle /_health {
respond "OK" 200
@@ -33,12 +33,12 @@
reverse_proxy livekit:7880
}
handle /admin {
handle {$FLUXER_ADMIN_BASE_PATH:/admin} {
rewrite * /
reverse_proxy admin:8080
}
handle_path /admin/* {
handle_path {$FLUXER_ADMIN_BASE_PATH:/admin}/* {
reverse_proxy admin:8080
}
+329 -184
View File
@@ -3,40 +3,81 @@ name: fluxer
x-fluxer-postgres-env: &fluxer-postgres-env
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
FLUXER_POSTGRES_PORT: ${FLUXER_POSTGRES_PORT:-}
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-}
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-}
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "${FLUXER_POSTGRES_SSL:-false}"
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
FLUXER_POSTGRES_URL: ${FLUXER_POSTGRES_URL:-}
FLUXER_POSTGRES_SSL: ${FLUXER_POSTGRES_SSL:-}
FLUXER_POSTGRES_SSL_CA: ${FLUXER_POSTGRES_SSL_CA:-}
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-}
FLUXER_POSTGRES_KV_TABLE: ${FLUXER_POSTGRES_KV_TABLE:-}
x-fluxer-env: &fluxer-env
<<: *fluxer-postgres-env
FLUXER_ENV: production
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_LEVEL: ${LOG_LEVEL:-}
RUST_LOG: ${RUST_LOG:-}
FLUXER_SELF_HOSTED: "true"
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-}
FLUXER_API_MAX_INFLIGHT_REQUESTS: ${FLUXER_API_MAX_INFLIGHT_REQUESTS:-}
FLUXER_API_IP_BAN_EXEMPT_IPS: ${FLUXER_API_IP_BAN_EXEMPT_IPS:-}
FLUXER_IP_BAN_REFRESH_INTERVAL_MS: ${FLUXER_IP_BAN_REFRESH_INTERVAL_MS:-}
FLUXER_APP_ORIGIN_ALIASES: ${FLUXER_APP_ORIGIN_ALIASES:-}
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: ${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-}
FLUXER_BLOCKLIST_FEEDS_ENABLED: ${FLUXER_BLOCKLIST_FEEDS_ENABLED:-}
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
FLUXER_API_ENDPOINT: ${FLUXER_API_ENDPOINT:-}
FLUXER_API_CLIENT_ENDPOINT: ${FLUXER_API_CLIENT_ENDPOINT:-}
FLUXER_APP_ENDPOINT: ${FLUXER_APP_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
FLUXER_GATEWAY_ENDPOINT: ${FLUXER_GATEWAY_ENDPOINT:-}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT:-${FLUXER_MEDIA_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
FLUXER_ADMIN_ENDPOINT: ${FLUXER_ADMIN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}${FLUXER_ADMIN_BASE_PATH:-/admin}}
FLUXER_MARKETING_ENDPOINT: ${FLUXER_MARKETING_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
FLUXER_INVITE_ENDPOINT: ${FLUXER_INVITE_ENDPOINT:-}
FLUXER_GIFT_ENDPOINT: ${FLUXER_GIFT_ENDPOINT:-}
FLUXER_STATIC_CDN_DOMAIN: ${FLUXER_STATIC_CDN_DOMAIN:-}
FLUXER_INVITE_DOMAIN: ${FLUXER_INVITE_DOMAIN:-}
FLUXER_GIFT_DOMAIN: ${FLUXER_GIFT_DOMAIN:-}
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
FLUXER_KV_MODE: ${FLUXER_KV_MODE:-}
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
FLUXER_SVC_SHARD_COUNT: "1"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: ${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}
FLUXER_SVC_CACHE_MAX_ENTRIES: ${FLUXER_SVC_CACHE_MAX_ENTRIES:-}
FLUXER_SVC_CACHE_TTL_MS: ${FLUXER_SVC_CACHE_TTL_MS:-}
FLUXER_GIF_SERVICE_NATS_CLIENT_NAME: ${FLUXER_GIF_SERVICE_NATS_CLIENT_NAME:-}
FLUXER_GIF_SERVICE_TIMEOUT_MS: ${FLUXER_GIF_SERVICE_TIMEOUT_MS:-}
FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS: ${FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS:-}
FLUXER_USERS_SERVICE_NATS_CLIENT_NAME: ${FLUXER_USERS_SERVICE_NATS_CLIENT_NAME:-}
FLUXER_USERS_SERVICE_TIMEOUT_MS: ${FLUXER_USERS_SERVICE_TIMEOUT_MS:-}
FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES: ${FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES:-}
FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME: ${FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME:-}
FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE: ${FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE:-}
FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK: ${FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK:-}
FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS: ${FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS:-}
FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS: ${FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS:-}
FLUXER_SEARCH_ENGINE: meilisearch
FLUXER_SEARCH_ENGINE: ${FLUXER_SEARCH_ENGINE:-meilisearch}
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
FLUXER_SEARCH_USERNAME: ${FLUXER_SEARCH_USERNAME:-}
FLUXER_SEARCH_PASSWORD: ${FLUXER_SEARCH_PASSWORD:-}
FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED: ${FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED:-}
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
@@ -46,55 +87,99 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
AWS_DEFAULT_REGION: ${FLUXER_S3_REGION:-us-east-1}
AWS_EC2_METADATA_DISABLED: "true"
FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED: "${FLUXER_API_PRESIGNED_HARVEST_DOWNLOADS_ENABLED:-false}"
FLUXER_API_STORAGE_CHANGE_FEED_ENABLED: ${FLUXER_API_STORAGE_CHANGE_FEED_ENABLED:-}
FLUXER_API_STORAGE_CHANGE_FEED_STREAM: ${FLUXER_API_STORAGE_CHANGE_FEED_STREAM:-}
FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS: ${FLUXER_API_STORAGE_CHANGE_FEED_SKIP_BUCKETS:-}
FLUXER_CACHE_PURGE_ADAPTER: ${FLUXER_CACHE_PURGE_ADAPTER:-}
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: ${FLUXER_CACHE_PURGE_HTTP_ENDPOINT:-}
FLUXER_CACHE_PURGE_HTTP_TOKEN: ${FLUXER_CACHE_PURGE_HTTP_TOKEN:-}
FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS: ${FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS:-}
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
FLUXER_LIVEKIT_DEFAULT_REGION: '${FLUXER_LIVEKIT_DEFAULT_REGION:-{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}}'
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
FLUXER_YOUTUBE_API_KEY: ${FLUXER_YOUTUBE_API_KEY:-}
FLUXER_API_UNFURL_IGNORED_HOSTS: ${FLUXER_API_UNFURL_IGNORED_HOSTS:-}
FLUXER_EMAIL_ENABLED: ${FLUXER_EMAIL_ENABLED:-false}
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-none}
FLUXER_EMAIL_ENABLED: ${FLUXER_EMAIL_ENABLED:-}
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-}
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-Fluxer}
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-}
FLUXER_EMAIL_REPLY_TO_EMAIL: ${FLUXER_EMAIL_REPLY_TO_EMAIL:-}
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-}
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-587}
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-}
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-}
FLUXER_STRIPE_WEBHOOK_SECRET: ${FLUXER_STRIPE_WEBHOOK_SECRET:-}
FLUXER_STRIPE_PRICES: ${FLUXER_STRIPE_PRICES:-}
FLUXER_STRIPE_LEGACY_PRICES: ${FLUXER_STRIPE_LEGACY_PRICES:-}
FLUXER_API_DONATION_PROXY_KEY: ${FLUXER_API_DONATION_PROXY_KEY:-}
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
FLUXER_NCMEC_ENABLED: ${FLUXER_NCMEC_ENABLED:-}
FLUXER_NCMEC_BASE_URL: ${FLUXER_NCMEC_BASE_URL:-}
FLUXER_NCMEC_USERNAME: ${FLUXER_NCMEC_USERNAME:-}
FLUXER_NCMEC_PASSWORD: ${FLUXER_NCMEC_PASSWORD:-}
FLUXER_NCMEC_REPORTER_EMAIL: ${FLUXER_NCMEC_REPORTER_EMAIL:-}
FLUXER_CLAMAV_ENABLED: ${FLUXER_CLAMAV_ENABLED:-}
FLUXER_CLAMAV_HOST: ${FLUXER_CLAMAV_HOST:-}
FLUXER_CLAMAV_PORT: ${FLUXER_CLAMAV_PORT:-}
FLUXER_CLAMAV_FAIL_OPEN: ${FLUXER_CLAMAV_FAIL_OPEN:-}
FLUXER_APP_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
FLUXER_APP_STATUS_PAGE_URL: ${FLUXER_APP_STATUS_PAGE_URL:-}
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: ${FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL:-}
FLUXER_INSTANCE_SETUP_CONFIGURED: ${FLUXER_INSTANCE_SETUP_CONFIGURED:-}
FLUXER_AUTO_JOIN_INVITE_CODE: ${FLUXER_AUTO_JOIN_INVITE_CODE:-}
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-}
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
FLUXER_DELETION_GRACE_PERIOD_HOURS: ${FLUXER_DELETION_GRACE_PERIOD_HOURS:-}
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: ${FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES:-}
FLUXER_AUTH_BLUESKY_ENABLED: ${FLUXER_AUTH_BLUESKY_ENABLED:-}
FLUXER_AUTH_BLUESKY_CLIENT_NAME: ${FLUXER_AUTH_BLUESKY_CLIENT_NAME:-}
FLUXER_AUTH_BLUESKY_CLIENT_URI: ${FLUXER_AUTH_BLUESKY_CLIENT_URI:-}
FLUXER_AUTH_BLUESKY_LOGO_URI: ${FLUXER_AUTH_BLUESKY_LOGO_URI:-}
FLUXER_AUTH_BLUESKY_TOS_URI: ${FLUXER_AUTH_BLUESKY_TOS_URI:-}
FLUXER_AUTH_BLUESKY_POLICY_URI: ${FLUXER_AUTH_BLUESKY_POLICY_URI:-}
FLUXER_AUTH_BLUESKY_KEYS: ${FLUXER_AUTH_BLUESKY_KEYS:-}
FLUXER_PUSH_APNS_ENABLED: ${FLUXER_PUSH_APNS_ENABLED:-}
FLUXER_PUSH_APNS_TEAM_ID: ${FLUXER_PUSH_APNS_TEAM_ID:-}
FLUXER_PUSH_APNS_KEY_ID: ${FLUXER_PUSH_APNS_KEY_ID:-}
FLUXER_PUSH_APNS_PRIVATE_KEY: ${FLUXER_PUSH_APNS_PRIVATE_KEY:-}
FLUXER_PUSH_APNS_PRIVATE_KEY_PATH: ${FLUXER_PUSH_APNS_PRIVATE_KEY_PATH:-}
FLUXER_PUSH_APNS_APPS: ${FLUXER_PUSH_APNS_APPS:-}
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-false}
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-}
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
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}
@@ -104,34 +189,36 @@ x-fluxer-env: &fluxer-env
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
FLUXER_INTERNAL_GATEWAY_ENDPOINT: http://gateway:8080
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_KEEP_DIRECT_COUNTRIES:-}
x-fluxer-service: &fluxer-service
restart: unless-stopped
restart: ${FLUXER_RESTART_POLICY:-unless-stopped}
networks: [fluxer]
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
x-fluxer-app-healthcheck: &fluxer-app-healthcheck
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
start_interval: 1s
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
<<: *fluxer-app-healthcheck
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
services:
edge:
image: caddy:2.11-alpine
<<: *fluxer-service
image: ${FLUXER_CADDY_IMAGE:-caddy:2.11-alpine}
deploy:
resources:
limits:
memory: ${FLUXER_CADDY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
ports:
- "${FLUXER_HTTP_PORT:-80}:80"
- "${FLUXER_HTTPS_PORT:-443}:443"
@@ -139,15 +226,17 @@ services:
environment:
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
FLUXER_EDGE_ENCODE: ${FLUXER_EDGE_ENCODE:-zstd gzip}
FLUXER_ADMIN_BASE_PATH: ${FLUXER_ADMIN_BASE_PATH:-/admin}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- edge-data:/data
- edge-config:/config
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 10s
timeout: 5s
retries: 10
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
depends_on:
api: {condition: service_started}
gateway: {condition: service_healthy}
@@ -156,15 +245,14 @@ services:
admin: {condition: service_started}
postgres:
image: postgres:16-alpine
<<: *fluxer-service
image: ${FLUXER_POSTGRES_IMAGE:-postgres:16-alpine}
deploy:
resources:
limits:
memory: ${FLUXER_POSTGRES_MEMORY_LIMIT:-5gb}
reservations:
memory: ${FLUXER_POSTGRES_MEMORY_RESERVATION:-3gb}
restart: unless-stopped
networks: [fluxer]
command: >
postgres
-c max_connections=${FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS:-150}
@@ -173,115 +261,113 @@ services:
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
-c maintenance_work_mem=${FLUXER_POSTGRES_MAINTENANCE_WORK_MEM:-256MB}
-c autovacuum_work_mem=${FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM:-128MB}
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c default_statistics_target=200
-c jit=off
-c min_wal_size=512MB
-c max_wal_size=2GB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c wal_compression=zstd
-c bgwriter_delay=50ms
-c bgwriter_lru_maxpages=1000
-c autovacuum_vacuum_scale_factor=0.05
-c autovacuum_analyze_scale_factor=0.02
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
-c random_page_cost=${FLUXER_POSTGRES_RANDOM_PAGE_COST:-1.1}
-c effective_io_concurrency=${FLUXER_POSTGRES_EFFECTIVE_IO_CONCURRENCY:-200}
-c default_statistics_target=${FLUXER_POSTGRES_DEFAULT_STATISTICS_TARGET:-200}
-c jit=${FLUXER_POSTGRES_JIT:-off}
-c min_wal_size=${FLUXER_POSTGRES_MIN_WAL_SIZE:-512MB}
-c max_wal_size=${FLUXER_POSTGRES_MAX_WAL_SIZE:-2GB}
-c checkpoint_completion_target=${FLUXER_POSTGRES_CHECKPOINT_COMPLETION_TARGET:-0.9}
-c wal_buffers=${FLUXER_POSTGRES_WAL_BUFFERS:-16MB}
-c wal_compression=${FLUXER_POSTGRES_WAL_COMPRESSION:-zstd}
-c bgwriter_delay=${FLUXER_POSTGRES_BGWRITER_DELAY:-50ms}
-c bgwriter_lru_maxpages=${FLUXER_POSTGRES_BGWRITER_LRU_MAXPAGES:-1000}
-c autovacuum_vacuum_scale_factor=${FLUXER_POSTGRES_AUTOVACUUM_VACUUM_SCALE_FACTOR:-0.05}
-c autovacuum_analyze_scale_factor=${FLUXER_POSTGRES_AUTOVACUUM_ANALYZE_SCALE_FACTOR:-0.02}
-c autovacuum_vacuum_cost_limit=${FLUXER_POSTGRES_AUTOVACUUM_VACUUM_COST_LIMIT:-2000}
-c track_io_timing=${FLUXER_POSTGRES_TRACK_IO_TIMING:-on}
-c shared_preload_libraries=${FLUXER_POSTGRES_SHARED_PRELOAD_LIBRARIES:-pg_stat_statements}
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
POSTGRES_DB: ${FLUXER_POSTGRES_DATABASE:-fluxer}
POSTGRES_USER: ${FLUXER_POSTGRES_USERNAME:-fluxer}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fluxer -d fluxer"]
interval: 10s
timeout: 5s
retries: 10
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
valkey:
image: valkey/valkey:9.1-alpine
<<: *fluxer-service
image: ${FLUXER_VALKEY_IMAGE:-valkey/valkey:9.1-alpine}
deploy:
resources:
limits:
memory: ${FLUXER_VALKEY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data",
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "${FLUXER_VALKEY_APPENDFSYNC:-everysec}", "--dir", "/data",
"--maxmemory", "${FLUXER_VALKEY_MAXMEMORY:-192mb}",
"--maxmemory-policy", "${FLUXER_VALKEY_MAXMEMORY_POLICY:-noeviction}"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
nats:
image: nats:2.14-alpine
<<: *fluxer-service
image: ${FLUXER_NATS_IMAGE:-nats:2.14-alpine}
deploy:
resources:
limits:
memory: ${FLUXER_NATS_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
interval: 10s
timeout: 5s
retries: 10
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
meilisearch:
image: getmeili/meilisearch:v1.53
<<: *fluxer-service
image: ${FLUXER_MEILISEARCH_IMAGE:-getmeili/meilisearch:v1.53}
deploy:
resources:
limits:
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
restart: unless-stopped
networks: [fluxer]
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-1536mb}
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_ENV: ${FLUXER_MEILISEARCH_ENV:-production}
MEILI_NO_ANALYTICS: "${FLUXER_MEILISEARCH_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:-256mb}
MEILI_MAX_INDEXING_THREADS: ${FLUXER_MEILISEARCH_MAX_INDEXING_THREADS:-2}
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
- meilisearch-data:/meili_data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7700/health"]
interval: 10s
timeout: 5s
retries: 10
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
seaweedfs:
image: chrislusf/seaweedfs:4.47
<<: *fluxer-service
image: ${FLUXER_SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.47}
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
restart: unless-stopped
networks: [fluxer]
environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
command: ["server", "-s3", "-dir=/data", "-master.telemetry=false"]
WEED_MASTER_VOLUME_GROWTH_COPY_1: ${FLUXER_SEAWEEDFS_VOLUME_GROWTH:-1}
command: ["server", "-s3", "-dir=/data", "-master.telemetry=${FLUXER_SEAWEEDFS_TELEMETRY:-false}"]
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
interval: 10s
timeout: 5s
retries: 20
start_period: 60s
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_SEAWEEDFS_HEALTHCHECK_RETRIES:-20}
start_period: ${FLUXER_SEAWEEDFS_HEALTHCHECK_START_PERIOD:-60s}
seaweedfs-init:
image: chrislusf/seaweedfs:4.47
image: ${FLUXER_SEAWEEDFS_IMAGE:-chrislusf/seaweedfs:4.47}
deploy:
resources:
limits:
@@ -295,16 +381,16 @@ services:
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
FLUXER_SEAWEEDFS_INIT_ATTEMPTS: ${FLUXER_SEAWEEDFS_INIT_ATTEMPTS:-60}
entrypoint:
- /bin/sh
- -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";
for attempt in $$(seq 1 60); do
for attempt in $$(seq 1 $$FLUXER_SEAWEEDFS_INIT_ATTEMPTS); do
if ! nc -z seaweedfs 9333 2>/dev/null; then
sleep 2;
continue;
@@ -331,18 +417,17 @@ services:
exit 1;
livekit:
image: livekit/livekit-server:v1.12.0
<<: *fluxer-service
image: ${FLUXER_LIVEKIT_IMAGE:-livekit/livekit-server:v1.12.0}
deploy:
resources:
limits:
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
environment:
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}"
LIVEKIT_CONFIG: |
port: 7880
log_level: info
log_level: ${FLUXER_LIVEKIT_LOG_LEVEL:-info}
rtc:
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
@@ -360,9 +445,9 @@ services:
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:${FLUXER_LIVEKIT_UDP_PORT:-7882}/udp"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
interval: 10s
timeout: 5s
retries: 10
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
api:
<<: *fluxer-service
@@ -376,16 +461,13 @@ services:
environment:
<<: *fluxer-env
FLUXER_API_PORT: "8080"
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}${FLUXER_API_NODE_OPTIONS:+ $FLUXER_API_NODE_OPTIONS}
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_API_POSTGRES_MAX_CONNECTIONS:-25}"
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "${FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED:-true}"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
start_interval: 1s
<<: *fluxer-app-healthcheck
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
@@ -410,21 +492,18 @@ services:
memory: ${FLUXER_WORKER_MEMORY_LIMIT:-2560mb}
reservations:
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
working_dir: /usr/src/app/fluxer_api
command: ["sh", "-c", "if [ -f dist/WorkerEntrypoint.js ]; then exec node dist/WorkerEntrypoint.js; else exec ./node_modules/.bin/tsx src/WorkerEntrypoint.ts; fi"]
command: ["node", "dist/WorkerEntrypoint.js"]
environment:
<<: *fluxer-env
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_WORKER_NODE_OPTIONS:+ $FLUXER_WORKER_NODE_OPTIONS}
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_WORKER_POSTGRES_MAX_CONNECTIONS:-25}"
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)}"]
interval: 10s
timeout: 5s
retries: 3
start_period: 90s
start_interval: 1s
<<: *fluxer-app-healthcheck
retries: ${FLUXER_WORKER_HEALTHCHECK_RETRIES:-3}
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
@@ -446,18 +525,30 @@ services:
environment:
<<: *fluxer-env
FLUXER_GATEWAY_PORT: "8080"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_GATEWAY_LOGGER_LEVEL: info
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT:-${FLUXER_MEDIA_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media}}
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_GATEWAY_STATIC_CDN_ENDPOINT:-${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}}
FLUXER_GATEWAY_LOGGER_LEVEL: ${FLUXER_GATEWAY_LOGGER_LEVEL:-}
LOGGER_LEVEL: ${LOGGER_LEVEL:-}
FLUXER_GATEWAY_PUSH_ENABLED: ${FLUXER_GATEWAY_PUSH_ENABLED:-}
FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED: ${FLUXER_GATEWAY_PUSH_ENROLLED_CLEAR_NOTIFICATIONS_ENABLED:-}
FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS: ${FLUXER_GATEWAY_PUSH_OUTBOX_REQUEST_TIMEOUT_MS:-}
FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES: ${FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_ENTRIES:-}
FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES: ${FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES:-}
FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY: ${FLUXER_GATEWAY_HTTP_RPC_MAX_CONCURRENCY:-}
FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS: ${FLUXER_GATEWAY_NATS_RPC_MAX_HANDLERS:-}
FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD: ${FLUXER_GATEWAY_HTTP_FAILURE_THRESHOLD:-}
FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS: ${FLUXER_GATEWAY_HTTP_RECOVERY_TIMEOUT_MS:-}
FLUXER_ERLANG_COOKIE: ${FLUXER_ERLANG_COOKIE:?set FLUXER_ERLANG_COOKIE in .env}
FLUXER_ERLANG_SCHEDULERS_MIN: "${FLUXER_ERLANG_SCHEDULERS_MIN:-2}"
FLUXER_ERLANG_SCHEDULERS_MAX: "${FLUXER_ERLANG_SCHEDULERS_MAX:-16}"
FLUXER_ERLANG_SCHEDULERS: ${FLUXER_ERLANG_SCHEDULERS:-}
FLUXER_ERLANG_SCHEDULERS_MIN: ${FLUXER_ERLANG_SCHEDULERS_MIN:-}
FLUXER_ERLANG_SCHEDULERS_MAX: ${FLUXER_ERLANG_SCHEDULERS_MAX:-}
FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS: ${FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS:-}
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
depends_on:
nats: {condition: service_healthy}
valkey: {condition: service_healthy}
@@ -471,19 +562,73 @@ services:
memory: ${FLUXER_MEDIA_PROXY_MEMORY_LIMIT:-512mb}
environment:
<<: *fluxer-env
FLUXER_MEDIA_PROXY_HOST: 0.0.0.0
FLUXER_MEDIA_PROXY_PORT: "8080"
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_MEDIA_PROXY_CORS_MODE: ${FLUXER_MEDIA_PROXY_CORS_MODE:-off}
FLUXER_MEDIA_PROXY_CORS_MODE: ${FLUXER_MEDIA_PROXY_CORS_MODE:-}
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_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE: ${FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE:-}
FLUXER_MEDIA_PROXY_READ_ONLY: ${FLUXER_MEDIA_PROXY_READ_ONLY:-}
FLUXER_MEDIA_PROXY_NSFW_THRESHOLD: ${FLUXER_MEDIA_PROXY_NSFW_THRESHOLD:-}
FLUXER_NSFW_SERVICE_ENDPOINT: ${FLUXER_NSFW_SERVICE_ENDPOINT:-}
FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS: ${FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS:-}
FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY: ${FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY:-}
FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS: ${FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS:-}
FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES: ${FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES:-}
FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS: ${FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS:-}
FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES: ${FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES:-}
FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES: ${FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES:-}
FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS: ${FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS:-}
FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS: ${FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS:-}
FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS: ${FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES:-}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES:-}
FLUXER_S3_SESSION_TOKEN: ${FLUXER_S3_SESSION_TOKEN:-}
FLUXER_S3_READ_ENDPOINT: ${FLUXER_S3_READ_ENDPOINT:-}
FLUXER_S3_READ_BUCKET: ${FLUXER_S3_READ_BUCKET:-}
FLUXER_S3_READ_BUCKET_STYLE: ${FLUXER_S3_READ_BUCKET_STYLE:-}
FLUXER_S3_READ_SIGNED: "${FLUXER_S3_READ_SIGNED:-true}"
depends_on:
seaweedfs-init: {condition: service_completed_successfully}
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_QUEUE_CAPACITY: ${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: ${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}
FLUXER_PUSH_SERVICE_APNS_BASE_URL: ${FLUXER_PUSH_SERVICE_APNS_BASE_URL:-}
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
FLUXER_PUSH_FCM_PROJECT_ID: ${FLUXER_PUSH_FCM_PROJECT_ID:-}
FLUXER_PUSH_FCM_CLIENT_EMAIL: ${FLUXER_PUSH_FCM_CLIENT_EMAIL:-}
FLUXER_PUSH_FCM_PRIVATE_KEY: ${FLUXER_PUSH_FCM_PRIVATE_KEY:-}
FLUXER_PUSH_FCM_PRIVATE_KEY_PATH: ${FLUXER_PUSH_FCM_PRIVATE_KEY_PATH:-}
FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH: ${FLUXER_PUSH_FCM_SERVICE_ACCOUNT_JSON_PATH:-}
FLUXER_PUSH_FCM_TOKEN_URI: ${FLUXER_PUSH_FCM_TOKEN_URI:-}
FLUXER_PUSH_FCM_APPS: ${FLUXER_PUSH_FCM_APPS:-}
healthcheck:
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
<<: *fluxer-app-healthcheck
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
depends_on:
nats: {condition: service_healthy}
api: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
@@ -493,9 +638,9 @@ services:
memory: ${FLUXER_STATIC_PROXY_MEMORY_LIMIT:-256mb}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/avatars/0.png"]
interval: 10s
timeout: 5s
retries: 10
interval: ${FLUXER_HEALTHCHECK_INTERVAL:-10s}
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_HEALTHCHECK_RETRIES:-10}
app-proxy:
<<: *fluxer-service
@@ -505,15 +650,29 @@ services:
limits:
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
environment:
FLUXER_APP_PROXY_HOST: 0.0.0.0
RUST_LOG: ${RUST_LOG:-}
FLUXER_APP_PROXY_PORT: "8080"
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-}
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api}
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
@@ -571,7 +730,6 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -588,8 +746,7 @@ services:
FLUXER_SVC_NAME: users
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_USERS_SHARD_POSTGRES_MAX_CONNECTIONS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -606,7 +763,6 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -623,7 +779,7 @@ services:
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_GIFS_SHARD_CACHE_MAX_BYTES: ${FLUXER_GIFS_SHARD_CACHE_MAX_BYTES:-}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -639,7 +795,6 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -656,8 +811,7 @@ services:
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_MESSAGES_SHARD_POSTGRES_MAX_CONNECTIONS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -674,8 +828,6 @@ services:
<<: *fluxer-env
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -692,8 +844,9 @@ services:
FLUXER_SVC_NAME: unfurl
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_UNFURL_STATIC_CDN_ENDPOINT: ${FLUXER_UNFURL_STATIC_CDN_ENDPOINT:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
@@ -707,22 +860,14 @@ services:
memory: ${FLUXER_ADMIN_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_ADMIN_HOST: 0.0.0.0
FLUXER_ADMIN_PORT: "8080"
FLUXER_ADMIN_BASE_PATH: /admin
FLUXER_ADMIN_BASE_PATH: ${FLUXER_ADMIN_BASE_PATH:-/admin}
FLUXER_API_ENDPOINT: http://api:8080
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin/oauth2_callback
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
<<: *fluxer-app-healthcheck
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
depends_on:
api: {condition: service_healthy}
@@ -0,0 +1,14 @@
services:
seaweedfs:
profiles: [bundled-object-store]
seaweedfs-init:
profiles: [bundled-object-store]
api:
depends_on:
seaweedfs-init: !reset null
worker:
depends_on:
seaweedfs-init: !reset null
media-proxy:
depends_on:
seaweedfs-init: !reset null
+1 -1
View File
@@ -26,7 +26,7 @@ tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "s
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["compression-gzip", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
tracing-subscriber = "0.3.23"
url = "2.5"
urlencoding = "2.1.3"
progenitor-client = { version = "0.15.0", default-features = false }
+1 -4
View File
@@ -2,14 +2,13 @@
FROM rust:1-trixie AS builder
ARG BUILD_VERSION=""
ARG TARGETARCH
WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
&& npm install -g pnpm@12.4.2 \
&& npm install -g pnpm@11.27.0 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
@@ -45,8 +44,6 @@ RUN printf '%s\n' \
'strip = "symbols"' \
> Cargo.toml
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
RUN cargo build --release -p fluxer_admin \
&& cp target/release/fluxer_admin /usr/local/bin/fluxer-admin
+19 -1
View File
@@ -40,6 +40,7 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
adapt_progenitor_throttled_errors(&mut spec);
relax_guild_audit_log_schemas(&mut spec);
relax_progenitor_schema_strictness(&mut spec);
relax_integer_enums(&mut spec);
let mut settings = progenitor::GenerationSettings::new();
settings.with_interface(progenitor::InterfaceStyle::Positional);
@@ -174,6 +175,23 @@ fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
}
}
const OPEN_INTEGER_ENUMS: &[&str] = &["ChannelType", "MessageType", "WebhookType"];
fn relax_integer_enums(spec: &mut openapiv3::OpenAPI) {
let components = spec.components.as_mut().expect("missing API components");
for name in OPEN_INTEGER_ENUMS {
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(*name) else {
panic!("missing inline {name} schema");
};
let openapiv3::SchemaKind::Type(openapiv3::Type::Integer(integer)) =
&mut schema.schema_kind
else {
panic!("{name} must be an integer schema");
};
integer.enumeration.clear();
}
}
fn object_schema_mut<'a>(
components: &'a mut openapiv3::Components,
name: &str,
@@ -582,7 +600,7 @@ fn select_faces(package_dir: &Path) -> Vec<Face> {
}
assert!(
face["unicodeRange"].is_null(),
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
"{wanted} face {} has a unicode-range; Latin-core faces must not",
face["file"]
);
faces.push(Face {
File diff suppressed because it is too large Load Diff
-14
View File
@@ -17,9 +17,6 @@ pub const JOBS_CANCEL: &str = "jobs:cancel";
pub const BAN_EMAIL_ADD: &str = "ban:email:add";
pub const BAN_EMAIL_CHECK: &str = "ban:email:check";
pub const BAN_EMAIL_REMOVE: &str = "ban:email:remove";
pub const SUSPICIOUS_EMAIL_DOMAIN_ADD: &str = "suspicious_email_domain:add";
pub const SUSPICIOUS_EMAIL_DOMAIN_CHECK: &str = "suspicious_email_domain:check";
pub const SUSPICIOUS_EMAIL_DOMAIN_REMOVE: &str = "suspicious_email_domain:remove";
pub const BAN_PHRASE_ADD: &str = "ban:phrase:add";
pub const BAN_PHRASE_CHECK: &str = "ban:phrase:check";
pub const BAN_PHRASE_REMOVE: &str = "ban:phrase:remove";
@@ -45,7 +42,6 @@ pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
pub const BULK_UPDATE_SUSPICIOUS_ACTIVITY: &str = "bulk:update:suspicious_activity";
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
pub const CSAM_SUBMIT_NCMEC: &str = "csam:submit_ncmec";
pub const DISCOVERY_REMOVE: &str = "discovery:remove";
@@ -81,7 +77,6 @@ pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_DELETE: &str = "user:delete";
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
pub const USER_LIST_DM_CHANNELS: &str = "user:list:dm_channels";
pub const USER_LIST_GUILDS: &str = "user:list:guilds";
pub const USER_LIST_RELATIONSHIPS: &str = "user:list:relationships";
@@ -98,9 +93,7 @@ pub const USER_UPDATE_DOB: &str = "user:update:dob";
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
pub const USER_UPDATE_MFA: &str = "user:update:mfa";
pub const USER_UPDATE_PHONE: &str = "user:update:phone";
pub const USER_UPDATE_PROFILE: &str = "user:update:profile";
pub const USER_UPDATE_SUSPICIOUS_ACTIVITY: &str = "user:update:suspicious_activity";
pub const USER_UPDATE_TRAITS: &str = "user:update:traits";
pub const USER_UPDATE_USERNAME: &str = "user:update:username";
pub const VOICE_REGION_CREATE: &str = "voice:region:create";
@@ -129,9 +122,6 @@ pub const ALL_ACLS: &[&str] = &[
BAN_EMAIL_ADD,
BAN_EMAIL_CHECK,
BAN_EMAIL_REMOVE,
SUSPICIOUS_EMAIL_DOMAIN_ADD,
SUSPICIOUS_EMAIL_DOMAIN_CHECK,
SUSPICIOUS_EMAIL_DOMAIN_REMOVE,
BAN_PHRASE_ADD,
BAN_PHRASE_CHECK,
BAN_PHRASE_REMOVE,
@@ -157,7 +147,6 @@ pub const ALL_ACLS: &[&str] = &[
BULK_DELETE_USERS,
BULK_DELETE_USER_MESSAGES,
BULK_UPDATE_GUILD_FEATURES,
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
BULK_UPDATE_USER_FLAGS,
CSAM_SUBMIT_NCMEC,
DISCOVERY_REMOVE,
@@ -193,7 +182,6 @@ pub const ALL_ACLS: &[&str] = &[
SYSTEM_DM_SEND,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_DELETE,
USER_DISABLE_SUSPICIOUS,
USER_LIST_DM_CHANNELS,
USER_LIST_GUILDS,
USER_LIST_RELATIONSHIPS,
@@ -210,9 +198,7 @@ pub const ALL_ACLS: &[&str] = &[
USER_UPDATE_EMAIL,
USER_UPDATE_FLAGS,
USER_UPDATE_MFA,
USER_UPDATE_PHONE,
USER_UPDATE_PROFILE,
USER_UPDATE_SUSPICIOUS_ACTIVITY,
USER_UPDATE_TRAITS,
USER_UPDATE_USERNAME,
VOICE_REGION_CREATE,
+8 -52
View File
@@ -19,19 +19,18 @@ pub mod user_flag_bits {
pub const SPAMMER: u64 = 1 << 6;
pub const HIGH_GLOBAL_RATE_LIMIT: u64 = 1 << 33;
pub const DELETED: u64 = 1 << 34;
pub const DISABLED_SUSPICIOUS_ACTIVITY: u64 = 1 << 35;
pub const SELF_DELETED: u64 = 1 << 36;
pub const DISABLED: u64 = 1 << 38;
pub const HAS_SESSION_STARTED: u64 = 1 << 39;
pub const RATE_LIMIT_BYPASS: u64 = 1 << 47;
pub const REPORT_BANNED: u64 = 1 << 48;
pub const VERIFIED_NOT_UNDERAGE: u64 = 1 << 49;
pub const ACCOUNT_LIMITED: u64 = 1 << 50;
pub const HAS_DISMISSED_PREMIUM_ONBOARDING: u64 = 1 << 51;
pub const APP_STORE_REVIEWER: u64 = 1 << 53;
pub const STAFF_HIDDEN: u64 = 1 << 57;
pub const AGE_VERIFIED_ADULT: u64 = 1 << 60;
pub const FORCE_INBOUND_PHONE_VERIFICATION: u64 = 1 << 61;
pub const NOT_SUSPICIOUS: u64 = 1 << 62;
pub const LIMIT_EXEMPT: u64 = 1 << 62;
}
pub const USER_FLAGS: &[U64Flag] = &[
@@ -67,10 +66,6 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "DELETED",
value: user_flag_bits::DELETED,
},
U64Flag {
name: "DISABLED_SUSPICIOUS_ACTIVITY",
value: user_flag_bits::DISABLED_SUSPICIOUS_ACTIVITY,
},
U64Flag {
name: "SELF_DELETED",
value: user_flag_bits::SELF_DELETED,
@@ -95,6 +90,10 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "VERIFIED_NOT_UNDERAGE",
value: user_flag_bits::VERIFIED_NOT_UNDERAGE,
},
U64Flag {
name: "ACCOUNT_LIMITED",
value: user_flag_bits::ACCOUNT_LIMITED,
},
U64Flag {
name: "HAS_DISMISSED_PREMIUM_ONBOARDING",
value: user_flag_bits::HAS_DISMISSED_PREMIUM_ONBOARDING,
@@ -112,12 +111,8 @@ pub const USER_FLAGS: &[U64Flag] = &[
value: user_flag_bits::AGE_VERIFIED_ADULT,
},
U64Flag {
name: "FORCE_INBOUND_PHONE_VERIFICATION",
value: user_flag_bits::FORCE_INBOUND_PHONE_VERIFICATION,
},
U64Flag {
name: "NOT_SUSPICIOUS",
value: user_flag_bits::NOT_SUSPICIOUS,
name: "LIMIT_EXEMPT",
value: user_flag_bits::LIMIT_EXEMPT,
},
];
@@ -159,42 +154,3 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
value: 1 << 8,
},
];
pub const SUSPICIOUS_ACTIVITY_FLAGS: &[I32Flag] = &[
I32Flag {
name: "REQUIRE_VERIFIED_EMAIL",
value: 1 << 0,
},
I32Flag {
name: "REQUIRE_REVERIFIED_EMAIL",
value: 1 << 1,
},
I32Flag {
name: "REQUIRE_VERIFIED_PHONE",
value: 1 << 2,
},
I32Flag {
name: "REQUIRE_REVERIFIED_PHONE",
value: 1 << 3,
},
I32Flag {
name: "REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE",
value: 1 << 4,
},
I32Flag {
name: "REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE",
value: 1 << 5,
},
I32Flag {
name: "REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE",
value: 1 << 6,
},
I32Flag {
name: "REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE",
value: 1 << 7,
},
I32Flag {
name: "REQUIRE_INBOUND_PHONE_VERIFICATION",
value: 1 << 8,
},
];
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::tooltip::{Hint, HintLink};
pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
match key {
"feature_guild_create" => Some(Hint {
name: Some("Community Creation Access"),
body: "Admins with the wildcard ACL can always create communities.",
link: Some(HintLink::new(
"/instance-config#community-creation",
"Community creation policy",
)),
}),
_ => None,
}
}
+3 -6
View File
@@ -12,7 +12,7 @@ impl AdminApiClient {
acls: &[String],
) -> ApiResult<CreateAdminApiKeyResponse> {
let body = generated_types::CreateAdminApiKeyRequest {
acls: parse_acls(acls)?,
acls: parse_acls(acls),
expires_in_days: None,
name: generated_types::CreateAdminApiKeyRequestName::try_from(name)
.map_err(|e| ApiError::Parse(e.to_string()))?,
@@ -44,11 +44,8 @@ impl AdminApiClient {
}
}
pub(super) fn parse_acls(acls: &[String]) -> ApiResult<Vec<generated_types::AdminAclType>> {
pub(super) fn parse_acls(acls: &[String]) -> Vec<generated_types::AdminAclType> {
acls.iter()
.map(|acl| {
generated_types::AdminAclType::try_from(acl.as_str())
.map_err(|e| ApiError::Parse(e.to_string()))
})
.filter_map(|acl| generated_types::AdminAclType::try_from(acl.as_str()).ok())
.collect()
}
+31 -80
View File
@@ -9,12 +9,11 @@ impl AdminApiClient {
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"email",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_1: Some(generated_types::BanEmailRequest {
email: generated_types::EmailType::from(email.to_owned()),
}),
..Default::default()
},
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanEmailRequest {
email: generated_types::EmailBlocklistEntryType::from(email.to_owned()),
},
),
audit_log_reason,
)
.await
@@ -32,10 +31,9 @@ impl AdminApiClient {
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
..Default::default()
},
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanIpRequest { ip: ip.to_owned() },
),
audit_log_reason,
)
.await
@@ -50,45 +48,14 @@ impl AdminApiClient {
self.check_blocklist_entry("ip", ip, None).await
}
pub async fn add_suspicious_email_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
SUSPICIOUS_EMAIL_DOMAIN_LIST,
generated_types::AdminBlocklistEntryCreateRequest {
subtype_2: Some(suspicious_email_domain_request(domain)?),
..Default::default()
},
audit_log_reason,
)
.await
}
pub async fn remove_suspicious_email_domain(
&self,
domain: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason)
.await
}
pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult<BanCheckResult> {
self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
.await
}
pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"phrase",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_3: Some(generated_types::BanPhraseRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanPhraseRequest {
phrase: phrase.to_owned(),
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -110,16 +77,15 @@ impl AdminApiClient {
pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
self.create_blocklist_entry(
"url",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_4: Some(generated_types::BanUrlRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanUrlRequest {
category: None,
notes: None,
severity: None,
source_url: None,
url: url.to_owned(),
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -142,17 +108,16 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
"url-domain",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_5: Some(generated_types::BanUrlDomainRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanUrlDomainRequest {
category: None,
domain: domain.to_owned(),
match_subdomains,
notes: None,
severity: None,
source_url: None,
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -178,17 +143,16 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
"file-sha",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_6: Some(generated_types::BanFileShaRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanFileShaRequest {
category: None,
content_type: None,
notes: None,
severity: None,
sha256_hex: sha256_hex.to_owned(),
source_url: None,
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -231,17 +195,16 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
"avatar-hash",
generated_types::AdminBlocklistEntryCreateRequest {
subtype_7: Some(generated_types::BanAvatarHashRequest {
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanAvatarHashRequest {
category: None,
hashes: vec![hash_short.to_owned()],
notes: None,
reason: None,
severity: None,
source_url: None,
}),
..Default::default()
},
},
),
audit_log_reason,
)
.await
@@ -279,10 +242,9 @@ impl AdminApiClient {
) -> ApiResult<()> {
self.create_blocklist_entry(
PROFILE_SUBSTRING_LIST,
generated_types::AdminBlocklistEntryCreateRequest {
subtype_8: Some(profile_substring_request(scope, substring)?),
..Default::default()
},
generated_types::AdminBlocklistEntryCreateRequest::from(profile_substring_request(
scope, substring,
)?),
audit_log_reason,
)
.await
@@ -359,8 +321,6 @@ impl AdminApiClient {
}
}
const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
@@ -380,15 +340,6 @@ fn blocklist_delete_scope(
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn suspicious_email_domain_request(
domain: &str,
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
Ok(generated_types::SuspiciousEmailDomainRequest {
domain: generated_types::SuspiciousEmailDomainRequestDomain::try_from(domain)
.map_err(|e| ApiError::Parse(e.to_string()))?,
})
}
fn profile_substring_request(
scope: &str,
substring: &str,
+2 -16
View File
@@ -22,22 +22,6 @@ impl AdminApiClient {
.await
}
pub async fn bulk_update_suspicious_activity_flags(
&self,
user_ids: &[String],
add_flags: &[String],
remove_flags: &[String],
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
add_flags: add_flags.to_vec(),
remove_flags: remove_flags.to_vec(),
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
.await
}
pub async fn bulk_update_guild_features(
&self,
guild_ids: &[String],
@@ -86,6 +70,7 @@ impl AdminApiClient {
reason_code: u32,
days_until_deletion: u32,
public_reason: Option<&str>,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
@@ -95,6 +80,7 @@ impl AdminApiClient {
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
+17 -4
View File
@@ -90,10 +90,7 @@ impl AdminApiClient {
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
let mut headers = self.generated.inner().clone();
if let Some(reason) = audit_log_reason {
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);
headers.insert("x-audit-log-reason", audit_log_reason_header(reason)?);
}
Ok(headers)
}
@@ -422,11 +419,27 @@ impl std::fmt::Display for ApiError {
}
}
fn audit_log_reason_header(reason: &str) -> ApiResult<HeaderValue> {
let mut value = HeaderValue::from_bytes(reason.as_bytes())
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
value.set_sensitive(true);
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
#[test]
fn audit_log_reason_header_keeps_utf8_bytes() {
let reason = "§ 3 Regel – wiederholt 日本";
let value = audit_log_reason_header(reason).expect("valid reason header");
assert_eq!(value.as_bytes(), reason.as_bytes());
assert!(value.is_sensitive());
assert!(audit_log_reason_header("line one\nline two").is_err());
}
fn response(status: u16, body: &'static str) -> reqwest::Response {
axum::http::Response::builder()
.status(status)
-3
View File
@@ -85,7 +85,6 @@ mod tests {
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"has_verified_phone": false,
"date_of_birth": "2000-01-15",
"locale": "en-US",
"premium_type": 2,
@@ -93,8 +92,6 @@ mod tests {
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
+5
View File
@@ -4,6 +4,7 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -11,6 +12,10 @@ impl AdminApiClient {
self.get("/admin/instance/config", None).await
}
pub async fn get_instance_premium_discovery(&self) -> ApiResult<InstancePremiumDiscovery> {
self.get("/.well-known/fluxer", None).await
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
+2
View File
@@ -56,12 +56,14 @@ impl AdminApiClient {
&self,
report_id: &str,
public_comment: Option<&str>,
notify_reporter: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let mut body = serde_json::json!({"status": "resolved"});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
self.patch_with_reason(
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
Some(&body),
+7 -2
View File
@@ -8,13 +8,18 @@ use super::types::SendSystemDmResponse;
impl AdminApiClient {
pub async fn send_system_dm(
&self,
user_ids: &[String],
user_ids: Option<&[String]>,
content: &str,
) -> ApiResult<SendSystemDmResponse> {
let body = generated_types::SendSystemDmRequest {
content: generated_types::SendSystemDmRequestContent::try_from(content)
.map_err(|e| ApiError::Parse(e.to_string()))?,
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
user_ids: user_ids
.unwrap_or_default()
.iter()
.map(|id| snowflake(id))
.collect(),
all_users: user_ids.is_none().then_some(true),
};
let response = self
.generated()
+6 -6
View File
@@ -108,20 +108,20 @@ pub struct AdminUser {
pub premium_grace_ends_at: Option<String>,
pub premium_lifetime_sequence: Option<i32>,
#[serde(default)]
pub suspicious_activity_flags: i32,
#[serde(default)]
pub phone_verification_deferred: bool,
#[serde(default)]
pub has_totp: bool,
#[serde(default)]
pub authenticator_types: Vec<i32>,
#[serde(default)]
pub has_verified_phone: bool,
pub temp_banned_until: Option<String>,
pub pending_deletion_at: Option<String>,
pub pending_bulk_message_deletion_at: Option<String>,
pub deletion_reason_code: Option<i32>,
pub deletion_public_reason: Option<String>,
#[serde(default)]
pub deletion_audit_log_reason: Option<String>,
#[serde(default)]
pub deletion_scheduled_by: Option<String>,
#[serde(default)]
pub deletion_scheduled_at: Option<String>,
pub last_active_at: Option<String>,
pub last_active_ip: Option<String>,
pub last_active_ip_reverse: Option<String>,
@@ -0,0 +1,332 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::{InstanceConfigResponse, PremiumMode};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const BILLING_MAX_CURRENCIES: usize = 64;
pub const BILLING_MAX_COUNTRY_CURRENCIES: usize = 300;
pub const BILLING_MAX_LEGACY_SLOTS: usize = 256;
pub const BILLING_MAX_LEGACY_PRICES_PER_SLOT: usize = 32;
pub const BILLING_PRICE_SLOTS: [&str; 4] = ["monthly", "yearly", "gift_1_month", "gift_1_year"];
pub const PREMIUM_PRODUCT_NAME_MAX_CHARS: usize = 40;
pub const TRI_STATE_DEFAULT: &str = "default";
pub const TRI_STATE_ON: &str = "on";
pub const TRI_STATE_OFF: &str = "off";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BillingCatalogMode {
#[default]
Env,
Operator,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct BillingPriceSet {
pub monthly: Option<String>,
pub yearly: Option<String>,
pub gift_1_month: Option<String>,
pub gift_1_year: Option<String>,
}
impl BillingPriceSet {
pub fn has_recurring_pair(&self) -> bool {
self.monthly.is_some() && self.yearly.is_some()
}
pub fn is_empty(&self) -> bool {
self.monthly.is_none()
&& self.yearly.is_none()
&& self.gift_1_month.is_none()
&& self.gift_1_year.is_none()
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceBillingResponse {
pub enabled: Option<bool>,
#[serde(default)]
pub effective_enabled: bool,
#[serde(default)]
pub stripe_secret_key_set: bool,
#[serde(default)]
pub stripe_webhook_secret_set: bool,
#[serde(default)]
pub stripe_secret_key_stored: bool,
#[serde(default)]
pub stripe_webhook_secret_stored: bool,
pub default_currency: Option<String>,
pub prices: Option<BTreeMap<String, BillingPriceSet>>,
pub country_currencies: Option<BTreeMap<String, String>>,
pub legacy_prices: Option<BTreeMap<String, Vec<String>>>,
#[serde(default)]
pub billing_active: bool,
#[serde(default)]
pub stripe_serviceable: bool,
#[serde(default)]
pub catalog_mode: BillingCatalogMode,
#[serde(default)]
pub webhook_url: String,
pub automatic_tax: Option<bool>,
pub tax_id_collection: Option<bool>,
pub terms_consent_required: Option<bool>,
#[serde(default)]
pub effective_automatic_tax: bool,
#[serde(default)]
pub effective_tax_id_collection: bool,
#[serde(default)]
pub effective_terms_consent_required: bool,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceBillingUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_secret_key: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stripe_webhook_secret: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_currency: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prices: Option<Option<BTreeMap<String, BillingPriceSet>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub country_currencies: Option<Option<BTreeMap<String, String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legacy_prices: Option<Option<BTreeMap<String, Vec<String>>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automatic_tax: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tax_id_collection: Option<Option<bool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub terms_consent_required: Option<Option<bool>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscovery {
#[serde(default)]
pub app_public: InstancePremiumDiscoveryAppPublic,
#[serde(default)]
pub features: InstancePremiumDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryAppPublic {
#[serde(default)]
pub branding: InstancePremiumDiscoveryBranding,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryBranding {
pub premium_product_name: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstancePremiumDiscoveryFeatures {
#[serde(default)]
pub premium_enabled: bool,
}
impl InstancePremiumDiscovery {
pub fn premium_product_name(&self) -> Option<&str> {
self.app_public
.branding
.premium_product_name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PremiumBranding {
pub name: Option<String>,
pub premium_enabled: bool,
}
impl PremiumBranding {
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
Self {
name: discovery.premium_product_name().map(str::to_owned),
premium_enabled: discovery.features.premium_enabled,
}
}
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
Self::from_config_parts(
config.self_hosted,
&config.app_public.branding.premium_product_name,
config.policy.premium_mode,
)
}
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
let name = name.trim();
Self {
name: (!name.is_empty()).then(|| name.to_owned()),
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
#[test]
fn billing_response_round_trips_through_the_generated_contract() {
let value = json!({
"enabled": true,
"effective_enabled": true,
"stripe_secret_key_set": true,
"stripe_webhook_secret_set": false,
"stripe_secret_key_stored": true,
"stripe_webhook_secret_stored": false,
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP"},
"legacy_prices": {"monthly_GBP": ["price_1Old"]},
"billing_active": false,
"stripe_serviceable": false,
"catalog_mode": "operator",
"webhook_url": "https://api.example.com/stripe/webhook",
"automatic_tax": null,
"tax_id_collection": false,
"terms_consent_required": true,
"effective_automatic_tax": false,
"effective_tax_id_collection": false,
"effective_terms_consent_required": true
});
let generated: generated_types::InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("generated billing response");
let ours: InstanceBillingResponse =
serde_json::from_value(value.clone()).expect("hand-written billing response");
assert_eq!(ours.catalog_mode, BillingCatalogMode::Operator);
assert!(ours.stripe_secret_key_stored);
assert_eq!(ours.automatic_tax, None);
assert_eq!(ours.tax_id_collection, Some(false));
assert!(ours.effective_terms_consent_required);
assert!(ours.prices.as_ref().expect("prices")["GBP"].has_recurring_pair());
assert_eq!(serde_json::to_value(&ours).expect("serializable"), value);
assert_eq!(
serde_json::to_value(generated).expect("serializable generated"),
value
);
}
#[test]
fn default_billing_response_matches_the_generated_contract() {
let value = serde_json::to_value(InstanceBillingResponse::default()).expect("serializable");
serde_json::from_value::<generated_types::InstanceBillingResponse>(value.clone())
.expect("generated billing response");
assert_eq!(value["catalog_mode"], json!("env"));
assert_eq!(value["prices"], json!(null));
}
#[test]
fn billing_update_preserves_explicit_nulls_and_omits_untouched_fields() {
let mut prices = BTreeMap::new();
prices.insert(
"SEK".to_owned(),
BillingPriceSet {
monthly: Some("price_1Monthly".to_owned()),
yearly: Some("price_1Yearly".to_owned()),
..Default::default()
},
);
let update = InstanceBillingUpdateRequest {
enabled: Some(None),
stripe_secret_key: Some(None),
default_currency: Some(None),
prices: Some(Some(prices)),
country_currencies: Some(None),
legacy_prices: Some(Some(BTreeMap::new())),
automatic_tax: Some(None),
tax_id_collection: Some(Some(true)),
terms_consent_required: Some(Some(false)),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::InstanceBillingUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": {
"SEK": {
"monthly": "price_1Monthly",
"yearly": "price_1Yearly",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": null,
"legacy_prices": {},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
})
);
assert_eq!(
serde_json::to_value(InstanceBillingUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn premium_discovery_reads_the_name_and_feature_flag() {
let discovery: InstancePremiumDiscovery = serde_json::from_value(json!({
"app_public": {"branding": {"product_name": "Example", "premium_product_name": " Gold "}},
"features": {"premium_enabled": true, "stripe_enabled": false}
}))
.expect("discovery");
assert_eq!(discovery.premium_product_name(), Some("Gold"));
assert!(discovery.features.premium_enabled);
let empty: InstancePremiumDiscovery =
serde_json::from_value(json!({})).expect("empty discovery");
assert_eq!(empty.premium_product_name(), None);
assert!(!empty.features.premium_enabled);
assert_eq!(
PremiumBranding::from_discovery(&discovery),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true
}
);
}
#[test]
fn premium_branding_from_instance_config_matches_discovery_rules() {
assert_eq!(
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false
}
);
assert!(
PremiumBranding::from_config_parts(true, "Gold", PremiumMode::Mirror).premium_enabled
);
assert_eq!(
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
PremiumBranding {
name: None,
premium_enabled: true
}
);
}
}
+206 -155
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
use super::{InstanceBillingResponse, InstanceBillingUpdateRequest};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstanceConfigResponse {
@@ -21,9 +21,17 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub media: InstanceMediaResponse,
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
pub push_relay: PushRelayConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub plutonium_page: PlutoniumPageConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -37,34 +45,18 @@ pub struct InstancePolicyResponse {
pub direct_messages_locked: bool,
#[serde(default)]
pub premium_mode: PremiumMode,
#[serde(default = "default_guild_create_access")]
pub guild_create_access: bool,
#[serde(default)]
pub services: InstanceServicesOverrides,
#[serde(default)]
pub services_resolved: InstanceServicesResolved,
#[serde(default)]
pub services_available: InstanceServicesAvailable,
#[serde(default)]
pub deferred_phone_gate: DeferredPhoneGateResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeferredPhoneGateResponse {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub window_hours: f64,
#[serde(default)]
pub member_threshold: i64,
}
impl Default for DeferredPhoneGateResponse {
fn default() -> Self {
Self {
enabled: true,
window_hours: 6.0,
member_threshold: 50,
}
}
fn default_guild_create_access() -> bool {
true
}
impl Default for InstancePolicyResponse {
@@ -75,10 +67,10 @@ impl Default for InstancePolicyResponse {
direct_messages_disabled: false,
direct_messages_locked: false,
premium_mode: PremiumMode::Everyone,
guild_create_access: default_guild_create_access(),
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
deferred_phone_gate: DeferredPhoneGateResponse::default(),
}
}
}
@@ -117,8 +109,6 @@ pub struct InstanceIntegrationsResponse {
#[serde(default)]
pub youtube: InstanceYoutubeIntegrationResponse,
#[serde(default)]
pub captcha: InstanceCaptchaIntegrationResponse,
#[serde(default)]
pub email: InstanceEmailIntegrationResponse,
#[serde(default)]
pub bluesky: InstanceBlueskyIntegrationResponse,
@@ -139,21 +129,6 @@ pub struct InstanceYoutubeIntegrationResponse {
pub effective_available: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceCaptchaIntegrationResponse {
pub provider: Option<String>,
#[serde(default)]
pub effective_provider: String,
pub hcaptcha_site_key: Option<String>,
#[serde(default)]
pub hcaptcha_secret_key_set: bool,
pub turnstile_site_key: Option<String>,
#[serde(default)]
pub turnstile_secret_key_set: bool,
#[serde(default)]
pub effective_enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct InstanceEmailIntegrationResponse {
pub enabled: Option<bool>,
@@ -328,6 +303,9 @@ pub struct AppBrandingConfigResponse {
pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
#[serde(default = "default_premium_product_name")]
pub premium_product_name: String,
pub premium_info_url: Option<String>,
}
impl Default for AppBrandingConfigResponse {
@@ -342,6 +320,8 @@ impl Default for AppBrandingConfigResponse {
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
premium_product_name: default_premium_product_name(),
premium_info_url: None,
}
}
}
@@ -350,6 +330,10 @@ fn default_product_name() -> String {
"Fluxer".to_owned()
}
fn default_premium_product_name() -> String {
"Premium".to_owned()
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AppSetupConfigResponse {
#[serde(default)]
@@ -446,99 +430,152 @@ impl VoiceE2eeScope {
}
}
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
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, 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, Deserialize, Eq, PartialEq, Serialize)]
pub struct VoiceNoiseSuppressionGuildOverride {
pub guild_id: String,
pub backend: NoiseSuppressionBackend,
#[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 VoiceNoiseSuppressionConfigResponse {
pub struct DomainMigrationConfigResponse {
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 stereo_enabled: bool,
pub suppression_strength: u32,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for VoiceNoiseSuppressionConfigResponse {
impl Default for DomainMigrationConfigResponse {
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(),
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(),
guild_overrides: Vec::new(),
stereo_enabled: false,
suppression_strength: 80,
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
pub struct DomainMigrationConfigUpdateRequest {
#[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>>,
pub anonymous_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo_enabled: Option<bool>,
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PlutoniumPageConfigResponse {
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 PlutoniumPageConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PLUTONIUM_PAGE_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 PlutoniumPageConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
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 CaptchaConfigResponse {
pub enabled: bool,
pub cost: u32,
pub max_counter: u32,
}
impl Default for CaptchaConfigResponse {
fn default() -> Self {
Self {
enabled: true,
cost: 5_000,
max_counter: 1_000,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct CaptchaConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_counter: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -655,9 +692,17 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<InstanceMediaUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
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 plutonium_page: Option<PlutoniumPageConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub billing: Option<InstanceBillingUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -669,21 +714,11 @@ pub struct InstancePolicyUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub direct_messages_disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_create_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DeferredPhoneGateUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_hours: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub member_threshold: Option<i64>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -703,8 +738,6 @@ pub struct InstanceIntegrationsUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub youtube: Option<InstanceYoutubeIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<InstanceCaptchaIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<InstanceEmailIntegrationUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bluesky: Option<InstanceBlueskyIntegrationUpdateRequest>,
@@ -722,20 +755,6 @@ pub struct InstanceYoutubeIntegrationUpdateRequest {
pub api_key: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceCaptchaIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hcaptcha_site_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hcaptcha_secret_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turnstile_site_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turnstile_secret_key: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceEmailIntegrationUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -866,6 +885,10 @@ pub struct AppBrandingConfigUpdateRequest {
pub status_page_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_incident_history_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_product_name: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_info_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -971,40 +994,47 @@ mod tests {
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() {
fn default_instance_config_sections_match_the_published_contract() {
let schema: serde_json::Value =
serde_json::from_str(include_str!("../../../openapi-admin.json"))
.expect("admin schema");
let 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 plutonium_page = serde_json::from_value::<PlutoniumPageConfigResponse>(json!({}))
.expect("default plutonium page config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let 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 plutonium_page =
serde_json::to_value(plutonium_page).expect("serializable plutonium page config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_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_plutonium_page: generated_types::PlutoniumPageConfigResponse =
serde_json::from_value(plutonium_page.clone())
.expect("generated plutonium page config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_plutonium_page)
.expect("serializable generated plutonium page config"),
plutonium_page
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
);
assert_eq!(
serde_json::to_value(generated_delivery)
@@ -1012,7 +1042,9 @@ mod tests {
delivery
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("PlutoniumPageConfigResponse", plutonium_page),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1025,25 +1057,44 @@ mod tests {
}
#[test]
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceNoiseSuppressionConfigUpdateRequest {
enabled_backends: Some(Vec::new()),
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()),
guild_overrides: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
let update = PlutoniumPageConfigUpdateRequest {
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::PlutoniumPageConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod instance_billing;
mod instance_config;
mod jobs;
mod limit_config;
@@ -28,6 +29,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
pub use limit_config::*;
+1 -1
View File
@@ -4,5 +4,5 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SendSystemDmResponse {
pub recipient_count: i64,
pub recipient_count: Option<i64>,
}
+54 -39
View File
@@ -231,22 +231,9 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn update_suspicious_flags(&self, user_id: &str, flags: i32) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserSuspiciousActivityFlagsRequest {
flags: generated_types::SuspiciousActivityFlags::from(flags),
};
let response = self
.generated()
.update_admin_user_suspicious_activity_flags(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn set_user_acls(&self, user_id: &str, acls: &[String]) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserAclsRequest {
acls: super::admin_api_keys::parse_acls(acls)?,
acls: super::admin_api_keys::parse_acls(acls),
};
let response = self
.generated()
@@ -296,21 +283,6 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn update_has_verified_phone(
&self,
user_id: &str,
has_verified_phone: bool,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserPhoneVerificationRequest { has_verified_phone };
let response = self
.generated()
.update_admin_user_phone_verification(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn clear_user_fields(
&self,
user_id: &str,
@@ -393,12 +365,14 @@ impl AdminApiClient {
user_id: &str,
duration_hours: u32,
reason: Option<&str>,
notify_user: bool,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
notify_user,
reason: reason.map(std::borrow::ToOwned::to_owned),
};
let resp: UserMutationResponse = self
@@ -411,10 +385,20 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn unban_user(&self, user_id: &str) -> ApiResult<AdminUser> {
pub async fn unban_user(
&self,
user_id: &str,
public_reason: Option<&str>,
notify_user: bool,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserUnbanRequest {
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
};
let response = self
.generated()
.unban_admin_user(&snowflake(user_id))
.generated_with_reason(private_reason)?
.unban_admin_user(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -427,6 +411,7 @@ impl AdminApiClient {
reason_code: i32,
public_reason: Option<&str>,
days_until_deletion: u32,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDeletionScheduleRequest {
@@ -436,9 +421,11 @@ impl AdminApiClient {
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?,
replace_pending_deletion_at: None,
};
let response = self
.generated_with_reason(audit_log_reason)?
@@ -449,16 +436,44 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
let response = self
.generated()
.cancel_admin_user_deletion(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
pub async fn cancel_deletion(
&self,
user_id: &str,
expected_pending_deletion_at: &str,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = serde_json::json!({
"expected_pending_deletion_at": expected_pending_deletion_at,
"notify_user": notify_user,
});
let resp: UserMutationResponse = self
.delete_with_reason(
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
Some(&body),
audit_log_reason,
)
.await?;
Ok(resp.user)
}
pub async fn annotate_ban(
&self,
user_id: &str,
ban_audit_log_id: &str,
note: &str,
) -> ApiResult<()> {
let body = serde_json::json!({
"ban_audit_log_id": ban_audit_log_id,
"note": note,
});
self.post_void(
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
Some(&body),
)
.await
}
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDobUpdateRequest {
date_of_birth: dob.to_owned(),
+14 -68
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::normalize_public_endpoint_from_env;
use std::env;
use fluxer_common::config::{
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
read_first_env, trim_trailing_slash,
};
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
@@ -17,12 +19,10 @@ pub struct AdminConfig {
pub static_cdn_endpoint: String,
pub admin_endpoint: String,
pub web_app_endpoint: String,
pub kv_url: String,
pub oauth_client_id: String,
pub oauth_client_secret: String,
pub oauth_redirect_uri: String,
pub build_version: String,
pub release_channel: String,
pub self_hosted: bool,
pub proxy: ProxyConfig,
}
@@ -47,8 +47,8 @@ impl AdminConfig {
"FLUXER_ADMIN_ENDPOINT",
"https://admin.fluxer.app",
)));
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred(
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
&format!("{admin_endpoint}/oauth2_callback"),
));
let secret_key_base = read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "");
@@ -82,38 +82,22 @@ impl AdminConfig {
"FLUXER_APP_ENDPOINT",
"https://app.fluxer.app",
))),
kv_url: read_env("FLUXER_KV_URL", ""),
oauth_client_id: read_env(
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
DEFAULT_ADMIN_OAUTH_CLIENT_ID,
),
oauth_client_secret: read_env("FLUXER_ADMIN_OAUTH_CLIENT_SECRET", ""),
oauth_redirect_uri,
build_version: read_env_preferred(
build_version: read_first_env(
&["BUILD_VERSION", "FLUXER_BUILD_VERSION"],
env!("CARGO_PKG_VERSION"),
),
release_channel: read_env_preferred(
&["RELEASE_CHANNEL", "FLUXER_RELEASE_CHANNEL"],
"stable",
),
self_hosted: read_bool_env(&["FLUXER_SELF_HOSTED"], false),
self_hosted: read_bool_env("FLUXER_SELF_HOSTED", false),
proxy: ProxyConfig {
trust_client_ip_header: read_bool_env(
&["FLUXER_TRUST_CLIENT_IP_HEADER", "TRUST_CLIENT_IP_HEADER"],
false,
),
client_ip_header_name: read_env_preferred(
&[
"FLUXER_CLIENT_IP_HEADER_NAME",
"FLUXER_CLIENT_IP_HEADER",
"CLIENT_IP_HEADER_NAME",
"CLIENT_IP_HEADER",
],
"x-forwarded-for",
)
.trim()
.to_ascii_lowercase(),
trust_client_ip_header: read_bool_env("FLUXER_TRUST_CLIENT_IP_HEADER", false),
client_ip_header_name: read_env("FLUXER_CLIENT_IP_HEADER_NAME", "x-forwarded-for")
.trim()
.to_ascii_lowercase(),
},
})
}
@@ -146,55 +130,21 @@ impl RuntimeEnv {
}
}
pub fn normalize_base_path(value: &str) -> String {
let trimmed = value.trim().trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}")
}
}
pub fn trim_trailing_slash(value: &str) -> String {
value.trim_end_matches('/').to_owned()
}
pub(crate) fn read_env(name: &str, fallback: &str) -> String {
env::var(name).unwrap_or_else(|_| fallback.to_owned())
}
pub(crate) fn read_env_preferred(names: &[&str], fallback: &str) -> String {
names
.iter()
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
.unwrap_or_else(|| fallback.to_owned())
}
pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
let Some(value) = names.iter().find_map(|name| env::var(name).ok()) else {
return fallback;
};
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 11] = [
const MANAGED_ENV: [&str; 10] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
"FLUXER_APP_ENDPOINT",
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
@@ -291,12 +241,10 @@ mod tests {
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
kv_url: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: String::new(),
release_channel: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
@@ -321,12 +269,10 @@ mod tests {
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
kv_url: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: String::new(),
release_channel: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
+1
View File
@@ -2,6 +2,7 @@
pub mod acl;
pub mod admin_flags;
pub mod admin_hints;
pub mod api;
pub mod config;
pub mod fonts;
+1 -3
View File
@@ -8,9 +8,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.with(fluxer_common::config::env_filter("info"))
.with(tracing_subscriber::fmt::layer())
.init();
-2
View File
@@ -215,12 +215,10 @@ mod tests {
static_cdn_endpoint: String::new(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: String::new(),
kv_url: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
release_channel: String::new(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
-6
View File
@@ -23,10 +23,6 @@ pub fn router() -> Router<AppState> {
Router::new()
.route("/ip-bans", get(ip_bans).post(ip_bans_post))
.route("/email-bans", get(email_bans).post(email_bans_post))
.route(
"/suspicious-email-domains",
get(suspicious_email_domains).post(suspicious_email_domains_post),
)
.route("/phrase-bans", get(phrase_bans).post(phrase_bans_post))
.route("/url-bans", get(url_bans).post(url_bans_post))
.route(
@@ -72,7 +68,6 @@ macro_rules! ban_get {
ban_get!(ip_bans, "ip-bans");
ban_get!(email_bans, "email-bans");
ban_get!(suspicious_email_domains, "suspicious-email-domains");
ban_get!(phrase_bans, "phrase-bans");
ban_get!(url_bans, "url-bans");
ban_get!(file_sha_bans, "file-sha-bans");
@@ -141,7 +136,6 @@ macro_rules! ban_post {
ban_post!(ip_bans_post, "ip-bans");
ban_post!(email_bans_post, "email-bans");
ban_post!(suspicious_email_domains_post, "suspicious-email-domains");
ban_post!(phrase_bans_post, "phrase-bans");
ban_post!(url_bans_post, "url-bans");
ban_post!(file_sha_bans_post, "file-sha-bans");
-11
View File
@@ -116,11 +116,6 @@ async fn execute_single_ban(
let result = match ban_type {
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
"email-bans" => client.ban_email(value, audit_log_reason).await,
"suspicious-email-domains" => {
client
.add_suspicious_email_domain(value, audit_log_reason)
.await
}
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
"url-bans" => client.ban_url(value, audit_log_reason).await,
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
@@ -143,11 +138,6 @@ async fn execute_single_unban(
let result = match ban_type {
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
"email-bans" => client.unban_email(value, audit_log_reason).await,
"suspicious-email-domains" => {
client
.remove_suspicious_email_domain(value, audit_log_reason)
.await
}
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
"url-bans" => client.unban_url(value, audit_log_reason).await,
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
@@ -169,7 +159,6 @@ async fn execute_check(
let result = match ban_type {
"ip-bans" => client.check_ip_ban(value).await,
"email-bans" => client.check_email_ban(value).await,
"suspicious-email-domains" => client.check_suspicious_email_domain(value).await,
"phrase-bans" => client.check_phrase_ban(value).await,
"url-bans" => client.check_url_ban(value).await,
"file-sha-bans" => client.check_file_sha_ban(value).await,
+597
View File
@@ -0,0 +1,597 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::{
client::ApiError,
types::{
AppBrandingConfigUpdateRequest, AppPublicConfigUpdateRequest,
BILLING_MAX_COUNTRY_CURRENCIES, BILLING_MAX_CURRENCIES,
BILLING_MAX_LEGACY_PRICES_PER_SLOT, BILLING_MAX_LEGACY_SLOTS, BILLING_PRICE_SLOTS,
BillingPriceSet, InstanceBillingUpdateRequest, InstanceConfigUpdateRequest,
PREMIUM_PRODUCT_NAME_MAX_CHARS, TRI_STATE_DEFAULT, TRI_STATE_OFF, TRI_STATE_ON,
},
},
middleware::flash::FlashData,
utils::forms::MultiValueForm,
};
use std::collections::BTreeMap;
const PRICE_ID_MAX_CHARS: usize = 255;
const INFO_URL_MAX_CHARS: usize = 2048;
pub(super) fn build_billing_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let premium_product_name = if form.contains_key("billing_premium_product_name") {
Some(parse_premium_product_name(
form.clean("billing_premium_product_name"),
)?)
} else {
None
};
let premium_info_url = if form.contains_key("billing_premium_info_url") {
Some(parse_info_url(form.clean("billing_premium_info_url"))?)
} else {
None
};
let branding = (premium_product_name.is_some() || premium_info_url.is_some()).then(|| {
AppBrandingConfigUpdateRequest {
premium_product_name,
premium_info_url,
..Default::default()
}
});
let prices = if form.contains_key("billing_price_currency") {
Some(parse_price_rows(form)?)
} else {
None
};
let default_currency = if form.contains_key("billing_default_currency") {
Some(
form.clean("billing_default_currency")
.map(|value| parse_currency(&value))
.transpose()?,
)
} else {
None
};
let country_currencies = if form.contains_key("billing_country_currencies") {
Some(parse_country_currencies(
form.first("billing_country_currencies").unwrap_or(""),
)?)
} else {
None
};
let legacy_prices = if form.contains_key("billing_legacy_prices") {
Some(parse_legacy_prices(
form.first("billing_legacy_prices").unwrap_or(""),
)?)
} else {
None
};
if let Some(Some(prices)) = &prices {
if let Some(Some(currency)) = &default_currency
&& !prices.contains_key(currency)
{
return Err(format!(
"Default currency {currency} has no row in the price table"
));
}
if let Some(Some(countries)) = &country_currencies
&& let Some((country, currency)) = countries
.iter()
.find(|(_, currency)| !prices.contains_key(*currency))
{
return Err(format!(
"{country} maps to {currency}, which has no row in the price table"
));
}
}
Ok(InstanceConfigUpdateRequest {
app_public: branding.map(|branding| AppPublicConfigUpdateRequest {
branding: Some(branding),
..Default::default()
}),
billing: Some(InstanceBillingUpdateRequest {
enabled: parse_tri_state(form, "billing_enabled")?,
stripe_secret_key: secret_update(
form,
"billing_stripe_secret_key",
"billing_clear_stripe_secret_key",
),
stripe_webhook_secret: secret_update(
form,
"billing_stripe_webhook_secret",
"billing_clear_stripe_webhook_secret",
),
default_currency,
prices,
country_currencies,
legacy_prices,
automatic_tax: parse_tri_state(form, "billing_automatic_tax")?,
tax_id_collection: parse_tri_state(form, "billing_tax_id_collection")?,
terms_consent_required: parse_tri_state(form, "billing_terms_consent_required")?,
}),
..Default::default()
})
}
fn parse_tri_state(form: &MultiValueForm, key: &str) -> Result<Option<Option<bool>>, String> {
if !form.contains_key(key) {
return Ok(None);
}
match form.first(key).map(str::trim).unwrap_or("") {
TRI_STATE_DEFAULT => Ok(Some(None)),
TRI_STATE_ON => Ok(Some(Some(true))),
TRI_STATE_OFF => Ok(Some(Some(false))),
other => Err(format!("Invalid choice \"{other}\" for {key}")),
}
}
pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
match result {
Ok(_) => FlashData::success("Premium and billing settings updated"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: update billing config");
match validation_message(&error) {
Some(message) => FlashData::error(format!(
"Failed to update premium and billing settings: {message}"
)),
None => FlashData::error("Failed to update premium and billing settings"),
}
}
}
}
fn validation_message(error: &ApiError) -> Option<String> {
let ApiError::Http {
status: 400,
message,
} = error
else {
return None;
};
let body: serde_json::Value = serde_json::from_str(message).ok()?;
let first = body["errors"].as_array().and_then(|errors| errors.first());
let detail = first.and_then(|error| {
let message = error["message"].as_str()?;
Some(
match error["path"].as_str().filter(|path| !path.is_empty()) {
Some(path) => format!("{path}: {message}"),
None => message.to_owned(),
},
)
});
detail.or_else(|| body["message"].as_str().map(str::to_owned))
}
fn secret_update(form: &MultiValueForm, key: &str, clear_key: &str) -> Option<Option<String>> {
match form.clean(key) {
Some(secret) => Some(Some(secret)),
None if form.bool_value(clear_key) => Some(None),
None => None,
}
}
fn parse_premium_product_name(value: Option<String>) -> Result<Option<String>, String> {
match value {
Some(name) if name.encode_utf16().count() > PREMIUM_PRODUCT_NAME_MAX_CHARS => Err(format!(
"Premium name must be at most {PREMIUM_PRODUCT_NAME_MAX_CHARS} characters"
)),
other => Ok(other),
}
}
fn parse_info_url(value: Option<String>) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let valid = value.chars().count() <= INFO_URL_MAX_CHARS
&& url::Url::parse(&value).is_ok_and(|url| {
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some_and(|h| !h.is_empty())
});
if valid {
Ok(Some(value))
} else {
Err("Premium info URL must be an absolute http or https URL".to_owned())
}
}
fn parse_currency(value: &str) -> Result<String, String> {
let currency = value.trim().to_ascii_uppercase();
if currency.len() == 3 && currency.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(currency)
} else {
Err(format!(
"Invalid currency \"{}\": use a 3-letter ISO 4217 code such as GBP",
value.trim()
))
}
}
fn parse_country(value: &str) -> Result<String, String> {
let country = value.trim().to_ascii_uppercase();
if country.len() == 2 && country.bytes().all(|byte| byte.is_ascii_uppercase()) {
Ok(country)
} else {
Err(format!(
"Invalid country \"{}\": use a 2-letter ISO 3166 code such as SE",
value.trim()
))
}
}
fn parse_price_id(value: &str) -> Result<String, String> {
let id = value.trim();
let valid = id.len() <= PRICE_ID_MAX_CHARS
&& id.strip_prefix("price_").is_some_and(|rest| {
!rest.is_empty() && rest.bytes().all(|b| b.is_ascii_alphanumeric())
});
if valid {
Ok(id.to_owned())
} else {
Err(format!(
"Invalid Stripe price ID \"{id}\": it must look like price_1AbC"
))
}
}
fn parse_optional_price_id(value: Option<&String>) -> Result<Option<String>, String> {
match value
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
Some(id) => parse_price_id(id).map(Some),
None => Ok(None),
}
}
fn parse_price_rows(
form: &MultiValueForm,
) -> Result<Option<BTreeMap<String, BillingPriceSet>>, String> {
let currencies = form.values("billing_price_currency");
let column = |key: &str, index: usize| form.values(key).get(index);
let mut prices = BTreeMap::new();
for (index, currency) in currencies.iter().enumerate() {
if currency.trim().is_empty() {
continue;
}
let currency = parse_currency(currency)?;
let set = BillingPriceSet {
monthly: parse_optional_price_id(column("billing_price_monthly", index))?,
yearly: parse_optional_price_id(column("billing_price_yearly", index))?,
gift_1_month: parse_optional_price_id(column("billing_price_gift_1_month", index))?,
gift_1_year: parse_optional_price_id(column("billing_price_gift_1_year", index))?,
};
if set.is_empty() {
return Err(format!("{currency} needs at least one price ID"));
}
if prices.insert(currency.clone(), set).is_some() {
return Err(format!(
"{currency} appears more than once in the price table"
));
}
}
if prices.len() > BILLING_MAX_CURRENCIES {
return Err(format!(
"The price table holds at most {BILLING_MAX_CURRENCIES} currencies"
));
}
Ok((!prices.is_empty()).then_some(prices))
}
fn key_value_lines(value: &str) -> impl Iterator<Item = Result<(&str, &str), String>> {
value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.split_once('=')
.map(|(key, value)| (key.trim(), value.trim()))
.ok_or_else(|| format!("Line \"{line}\" must use the form KEY=VALUE"))
})
}
fn parse_country_currencies(value: &str) -> Result<Option<BTreeMap<String, String>>, String> {
let mut countries = BTreeMap::new();
for line in key_value_lines(value) {
let (country, currency) = line?;
let country = parse_country(country)?;
let currency = parse_currency(currency)?;
if countries.insert(country.clone(), currency).is_some() {
return Err(format!("{country} is mapped more than once"));
}
}
if countries.len() > BILLING_MAX_COUNTRY_CURRENCIES {
return Err(format!(
"At most {BILLING_MAX_COUNTRY_CURRENCIES} country mappings are allowed"
));
}
Ok((!countries.is_empty()).then_some(countries))
}
fn parse_legacy_slot(value: &str) -> Result<String, String> {
let invalid = || {
format!(
"Invalid legacy price slot \"{value}\": use monthly, yearly, gift_1_month or gift_1_year followed by _ and a currency, such as monthly_GBP"
)
};
let (slot, currency) = value.rsplit_once('_').ok_or_else(invalid)?;
let slot = slot.to_ascii_lowercase();
if !BILLING_PRICE_SLOTS.contains(&slot.as_str()) {
return Err(invalid());
}
let currency = parse_currency(currency).map_err(|_| invalid())?;
Ok(format!("{slot}_{currency}"))
}
fn parse_legacy_prices(value: &str) -> Result<Option<BTreeMap<String, Vec<String>>>, String> {
let mut legacy: BTreeMap<String, Vec<String>> = BTreeMap::new();
for line in key_value_lines(value) {
let (slot, ids) = line?;
let slot = parse_legacy_slot(slot)?;
let entry = legacy.entry(slot.clone()).or_default();
for id in ids.split(',').map(str::trim).filter(|id| !id.is_empty()) {
let id = parse_price_id(id)?;
if !entry.contains(&id) {
entry.push(id);
}
}
if entry.is_empty() {
return Err(format!("{slot} needs at least one price ID"));
}
if entry.len() > BILLING_MAX_LEGACY_PRICES_PER_SLOT {
return Err(format!(
"{slot} holds at most {BILLING_MAX_LEGACY_PRICES_PER_SLOT} legacy price IDs"
));
}
}
if legacy.len() > BILLING_MAX_LEGACY_SLOTS {
return Err(format!(
"At most {BILLING_MAX_LEGACY_SLOTS} legacy price slots are allowed"
));
}
Ok((!legacy.is_empty()).then_some(legacy))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::generated::types as generated_types;
use serde_json::json;
fn full_form(extra: &str) -> MultiValueForm {
let base = "billing_premium_product_name=%20Gold%20\
&billing_premium_info_url=https%3A%2F%2Fexample.com%2Fgold\
&billing_enabled=on\
&billing_automatic_tax=default&billing_tax_id_collection=on&billing_terms_consent_required=off\
&billing_stripe_secret_key=\
&billing_stripe_webhook_secret=whsec_new\
&billing_default_currency=gbp\
&billing_price_currency=gbp&billing_price_monthly=price_1GbpM&billing_price_yearly=price_1GbpY\
&billing_price_gift_1_month=&billing_price_gift_1_year=price_1GbpG\
&billing_price_currency=SEK&billing_price_monthly=price_1SekM&billing_price_yearly=price_1SekY\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_price_currency=&billing_price_monthly=&billing_price_yearly=\
&billing_price_gift_1_month=&billing_price_gift_1_year=\
&billing_country_currencies=se%3Dsek%0D%0AGB%20%3D%20GBP%0D%0A\
&billing_legacy_prices=monthly_GBP%3Dprice_1OldA%0Amonthly_gbp%3Dprice_1OldB%2Cprice_1OldA%0Ayearly_SEK%3Dprice_1OldC";
MultiValueForm::parse(format!("{base}{extra}").as_bytes())
}
#[test]
fn full_billing_form_builds_the_expected_patch() {
let update = build_billing_update(&full_form("")).expect("valid form");
let value = serde_json::to_value(&update).expect("serializable");
serde_json::from_value::<generated_types::InstanceConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({
"app_public": {
"branding": {
"premium_product_name": "Gold",
"premium_info_url": "https://example.com/gold"
}
},
"billing": {
"enabled": true,
"stripe_webhook_secret": "whsec_new",
"default_currency": "GBP",
"prices": {
"GBP": {
"monthly": "price_1GbpM",
"yearly": "price_1GbpY",
"gift_1_month": null,
"gift_1_year": "price_1GbpG"
},
"SEK": {
"monthly": "price_1SekM",
"yearly": "price_1SekY",
"gift_1_month": null,
"gift_1_year": null
}
},
"country_currencies": {"GB": "GBP", "SE": "SEK"},
"legacy_prices": {
"monthly_GBP": ["price_1OldA", "price_1OldB"],
"yearly_SEK": ["price_1OldC"]
},
"automatic_tax": null,
"tax_id_collection": true,
"terms_consent_required": false
}
})
);
}
#[test]
fn blank_fields_clear_and_the_default_choice_sends_null() {
let form = MultiValueForm::parse(
b"billing_premium_product_name=&billing_premium_info_url=&billing_enabled=default\
&billing_stripe_secret_key=&billing_clear_stripe_secret_key=true\
&billing_stripe_webhook_secret=\
&billing_default_currency=\
&billing_price_currency=&billing_price_monthly=price_1Ignored\
&billing_country_currencies=&billing_legacy_prices=",
);
let value = serde_json::to_value(build_billing_update(&form).expect("valid form")).unwrap();
assert_eq!(
value,
json!({
"app_public": {
"branding": {"premium_product_name": null, "premium_info_url": null}
},
"billing": {
"enabled": null,
"stripe_secret_key": null,
"default_currency": null,
"prices": null,
"country_currencies": null,
"legacy_prices": null
}
})
);
}
#[test]
fn a_new_secret_wins_over_the_clear_checkbox() {
let form = MultiValueForm::parse(
b"billing_stripe_secret_key=%20sk_live_x%20&billing_clear_stripe_secret_key=true",
);
let billing = build_billing_update(&form)
.expect("valid form")
.billing
.expect("billing");
assert_eq!(
billing.stripe_secret_key,
Some(Some("sk_live_x".to_owned()))
);
assert_eq!(billing.stripe_webhook_secret, None);
}
#[test]
fn absent_form_keys_leave_their_fields_untouched() {
let update = build_billing_update(&MultiValueForm::parse(b"billing_enabled=off"))
.expect("valid form");
assert!(update.app_public.is_none());
assert_eq!(
serde_json::to_value(update.billing).unwrap(),
json!({"enabled": false})
);
let untouched = build_billing_update(&MultiValueForm::parse(b"")).expect("valid form");
assert_eq!(serde_json::to_value(untouched.billing).unwrap(), json!({}));
}
#[test]
fn invalid_input_is_rejected_with_a_message() {
let cases: &[(&str, &str)] = &[
(
"billing_premium_info_url=ftp%3A%2F%2Fexample.com",
"http or https",
),
("billing_premium_info_url=example.com", "http or https"),
("billing_default_currency=GB", "Invalid currency"),
("billing_enabled=true", "Invalid choice"),
("billing_automatic_tax=maybe", "Invalid choice"),
(
"billing_price_currency=GBPX&billing_price_monthly=price_1A",
"Invalid currency",
),
(
"billing_price_currency=GBP&billing_price_monthly=prod_1A",
"Invalid Stripe price ID",
),
(
"billing_price_currency=GBP&billing_price_monthly=price_1-A",
"Invalid Stripe price ID",
),
("billing_price_currency=GBP", "needs at least one price ID"),
(
"billing_price_currency=GBP&billing_price_monthly=price_1A&billing_price_currency=gbp&billing_price_monthly=price_1B",
"more than once",
),
("billing_country_currencies=SWE%3DSEK", "Invalid country"),
("billing_country_currencies=SE", "KEY=VALUE"),
(
"billing_country_currencies=SE%3DSEK%0ASE%3DEUR",
"mapped more than once",
),
(
"billing_legacy_prices=weekly_GBP%3Dprice_1A",
"Invalid legacy price slot",
),
(
"billing_legacy_prices=monthly_GBP%3D",
"needs at least one price ID",
),
(
"billing_default_currency=EUR&billing_price_currency=GBP&billing_price_monthly=price_1A",
"Default currency EUR has no row",
),
(
"billing_country_currencies=SE%3DSEK&billing_price_currency=GBP&billing_price_monthly=price_1A",
"SE maps to SEK",
),
];
let long_name = format!("billing_premium_product_name={}", "A".repeat(41));
let emoji_name = format!(
"billing_premium_product_name=Gold{}",
"%F0%9F%92%8E".repeat(20)
);
let long_case = [
(long_name.as_str(), "at most 40"),
(emoji_name.as_str(), "at most 40"),
];
for (body, expected) in long_case.iter().chain(cases.iter()) {
let error =
build_billing_update(&MultiValueForm::parse(body.as_bytes())).expect_err(body);
assert!(error.contains(expected), "{body}: {error}");
}
}
#[test]
fn premium_name_limit_counts_utf16_units() {
let name = format!("{}{}", "A".repeat(39), "\u{1F48E}");
assert_eq!(name.chars().count(), 40);
assert!(parse_premium_product_name(Some(name)).is_err());
let fits = format!("{}{}", "A".repeat(38), "\u{E9}\u{E9}");
assert_eq!(
parse_premium_product_name(Some(fits.clone())),
Ok(Some(fits))
);
}
#[test]
fn country_currencies_are_not_cross_checked_without_a_price_table() {
let form = MultiValueForm::parse(b"billing_country_currencies=SE%3DSEK");
let billing = build_billing_update(&form).unwrap().billing.unwrap();
assert_eq!(
billing.country_currencies,
Some(Some(BTreeMap::from([("SE".to_owned(), "SEK".to_owned())])))
);
}
#[test]
fn validation_errors_surface_the_first_api_message() {
let error = ApiError::Http {
status: 400,
message: json!({
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [{"path": "billing.enabled", "code": "X", "message": "Switch the premium model to mirror first"}]
})
.to_string(),
};
assert_eq!(
validation_message(&error).as_deref(),
Some("billing.enabled: Switch the premium model to mirror first")
);
let server_error = ApiError::Http {
status: 500,
message: "{}".to_owned(),
};
assert_eq!(validation_message(&server_error), None);
}
}
+11 -9
View File
@@ -8,12 +8,15 @@ use crate::{
flash::{self, FlashData},
},
state::AppState,
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
templates::{
self,
pages::gift_codes::{GiftCodesPremium, MAX_GIFT_CODES},
},
};
use axum::{
Form, Router,
extract::{FromRequest, Query, Request, State},
response::{Html, IntoResponse, Redirect, Response},
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
@@ -46,10 +49,11 @@ async fn gift_codes_page(
Query(query): Query<GiftCodesQuery>,
) -> Response {
let config = state.config();
if config.self_hosted {
return Redirect::to(&format!("{}/dashboard", config.base_path)).into_response();
}
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let premium = GiftCodesPremium::from_branding(
config.self_hosted,
state.premium_branding(&client).await.as_ref(),
);
let generated_codes: Option<Vec<String>> = query
.codes
@@ -60,6 +64,7 @@ async fn gift_codes_page(
config,
&auth.0,
&csrf.0.0,
&premium,
generated_codes.as_deref(),
);
Html(markup.into_string()).into_response()
@@ -72,9 +77,6 @@ async fn gift_codes_post(
) -> Response {
let config = state.config();
let base = &config.base_path;
if config.self_hosted {
return Redirect::to(&format!("{base}/dashboard")).into_response();
}
let form: GiftCodesForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
+4 -14
View File
@@ -171,7 +171,8 @@ pub(crate) async fn system_dms_post(
let flash = if let Some(content) = content.as_deref()
&& !user_ids.is_empty()
{
match client.send_system_dm(&user_ids, content).await {
let recipients = (user_ids != ["*"]).then_some(user_ids.as_slice());
match client.send_system_dm(recipients, content).await {
Ok(_) => FlashData::success("System DM sent"),
Err(error) => {
tracing::warn!(%error, "admin API request failed: send system DM");
@@ -218,19 +219,6 @@ pub(crate) async fn bulk_actions_post(
.bulk_update_user_flags(&user_ids, &add, &remove, audit_log_reason.as_deref())
.await
}
"bulk-update-suspicious-activity-flags" => {
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
let add = form.list_values_any(&["add_flags[]", "add_flags"]);
let remove = form.list_values_any(&["remove_flags[]", "remove_flags"]);
client
.bulk_update_suspicious_activity_flags(
&user_ids,
&add,
&remove,
audit_log_reason.as_deref(),
)
.await
}
"bulk-update-guild-features" => {
let guild_ids = form.list_values_any(&["guild_ids[]", "guild_ids"]);
let mut add = form.list_values_any(&["add_features[]", "add_features"]);
@@ -261,12 +249,14 @@ pub(crate) async fn bulk_actions_post(
);
};
let public_reason = form.clean("public_reason");
let notify_user = form.opt_out_value("notify_user");
client
.bulk_schedule_user_deletion(
&user_ids,
reason_code.unwrap_or(2),
days.unwrap_or(14),
public_reason.as_deref(),
notify_user,
audit_log_reason.as_deref(),
)
.await
+1
View File
@@ -5,6 +5,7 @@ pub mod applications;
pub mod auth;
pub mod bans;
mod bans_actions;
mod billing_actions;
pub mod codes;
pub mod discovery;
mod guild_tabs;
+8 -2
View File
@@ -52,6 +52,10 @@ struct ResolveForm {
_csrf: Option<String>,
#[serde(default)]
resolution: Option<String>,
#[serde(default)]
notify_reporter: Option<String>,
#[serde(default)]
notify_reporter_present: Option<String>,
}
pub fn router() -> Router<AppState> {
@@ -80,7 +84,7 @@ async fn reports_list(
return reports_error_page(
config,
&auth.0,
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
"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);
@@ -227,8 +231,10 @@ async fn report_resolve(
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
let notify_reporter =
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
let result = client
.resolve_report(&report_id, public_comment.as_deref(), None)
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
.await;
match result {
Ok(_) => {
+5
View File
@@ -221,6 +221,11 @@ async fn instance_config_page(
.get_instance_config()
.await
.log_error("load instance config");
if let Some(instance_config) = &instance_config {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
let limit_config = client
.get_limit_config()
.await
+354 -358
View File
@@ -6,21 +6,20 @@ use crate::{
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -193,7 +192,9 @@ pub async fn instance_config_post(
}
"update_policy" => {
let update = build_policy_update(&form);
instance_config_result(client.update_instance_config(&update).await)
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
instance_config_result(result)
}
"update_integrations" => {
let update = build_integrations_update(&form);
@@ -203,7 +204,27 @@ pub async fn instance_config_post(
let update = build_media_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_voice_noise_suppression" => match build_voice_noise_suppression_update(&form) {
"update_billing" => match super::billing_actions::build_billing_update(&form) {
Ok(update) => {
let result = client.update_instance_config(&update).await;
remember_premium_branding(&state, &result);
super::billing_actions::billing_result(result)
}
Err(message) => FlashData::error(message),
},
"update_push_relay" => {
let update = build_push_relay_update(&form);
instance_config_result(client.update_instance_config(&update).await)
}
"update_domain_migration" => match build_domain_migration_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_plutonium_page" => match build_plutonium_page_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_captcha" => match build_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -334,6 +355,17 @@ pub async fn instance_config_post(
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
}
fn remember_premium_branding(
state: &AppState,
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
) {
if let Ok(instance_config) = result {
state.remember_premium_branding(crate::api::types::PremiumBranding::from_instance_config(
instance_config,
));
}
}
fn render_registration_url_list_response(
config: &AdminConfig,
csrf_token: &str,
@@ -448,7 +480,6 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
}
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
@@ -489,6 +520,12 @@ fn parse_experiment_rollout_salt(
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
if !salt
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(Some(salt.to_owned()))
}
@@ -514,9 +551,9 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
if ids.iter().any(|existing| existing == candidate) {
continue;
}
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
return Err(format!(
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
));
}
ids.push(candidate.to_owned());
@@ -524,106 +561,110 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
Ok(ids)
}
fn parse_voice_noise_suppression_guild_overrides(
value: &str,
) -> Result<Vec<VoiceNoiseSuppressionGuildOverride>, String> {
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
for (index, line) in value.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let line_number = index + 1;
let (guild_id, backend) = line.split_once('=').ok_or_else(|| {
format!("Guild overrides line {line_number} must use guild_id=backend")
})?;
let guild_id = guild_id.trim();
if !is_experiment_snowflake(guild_id) {
return Err(format!(
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
));
}
let backend = backend.trim().parse().map_err(|_| {
format!("Guild overrides line {line_number} must name a supported backend")
})?;
if let Some(existing) = overrides
.iter()
.find(|existing| existing.guild_id == guild_id)
{
if existing.backend != backend {
return Err(format!(
"Guild overrides line {line_number} conflicts with an earlier rule for guild {guild_id}"
));
}
continue;
}
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
return Err(format!(
"Guild overrides must contain at most {VOICE_NS_MAX_GUILD_OVERRIDES} unique guilds"
));
}
overrides.push(VoiceNoiseSuppressionGuildOverride {
guild_id: guild_id.to_owned(),
backend,
});
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
relay_consent_accepted: Some(form.bool_value("push_relay_consent_accepted")),
}),
..Default::default()
}
Ok(overrides)
}
fn build_voice_noise_suppression_update(
fn build_domain_migration_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
let selected: Vec<NoiseSuppressionBackend> = form
.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"])
.into_iter()
.map(|value| {
value.parse().map_err(|_| {
"Enabled backends must name supported noise suppression backends".to_owned()
})
})
.collect::<Result<_, _>>()?;
let enabled_backends = NoiseSuppressionBackend::ALL
.into_iter()
.filter(|backend| selected.contains(backend))
.collect();
Ok(InstanceConfigUpdateRequest {
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
enabled: Some(form.bool_value("voice_ns_enabled")),
default_backend: form
.first("voice_ns_default_backend")
.map(|value| {
value.parse().map_err(|_| {
"Default backend must name a supported noise suppression backend".to_owned()
})
})
.transpose()?,
enabled_backends: Some(enabled_backends),
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
domain_migration: Some(DomainMigrationConfigUpdateRequest {
enabled: Some(form.bool_value("domain_migration_enabled")),
rollout_basis_points: parse_form_number(
form,
"voice_ns_rollout_basis_points",
"domain_migration_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("domain_migration_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
form.first("voice_ns_guild_overrides").unwrap_or_default(),
)?),
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
suppression_strength: parse_form_number(
anonymous_rollout_basis_points: parse_form_number(
form,
"voice_ns_suppression_strength",
"Suppression strength",
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout basis points",
0,
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
}),
..Default::default()
})
}
fn build_plutonium_page_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
enabled: Some(form.bool_value("plutonium_page_enabled")),
rollout_basis_points: parse_form_number(
form,
"plutonium_page_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
captcha: Some(CaptchaConfigUpdateRequest {
enabled: Some(form.bool_value("captcha_enabled")),
cost: parse_form_number(
form,
"captcha_cost",
"Cost",
*CAPTCHA_COST_RANGE.start(),
*CAPTCHA_COST_RANGE.end(),
)?,
max_counter: parse_form_number(
form,
"captcha_max_counter",
"Maximum counter",
*CAPTCHA_MAX_COUNTER_RANGE.start(),
*CAPTCHA_MAX_COUNTER_RANGE.end(),
)?,
}),
..Default::default()
@@ -686,6 +727,7 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
theme_color: optional("app_theme_color"),
status_page_url: optional("app_status_page_url"),
status_page_incident_history_url: optional("app_status_page_incident_history_url"),
..Default::default()
}),
setup: Some(AppSetupConfigUpdateRequest {
configured: Some(form.bool_value("app_setup_configured")),
@@ -731,50 +773,28 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let direct_messages_disabled = form
.first("policy_direct_messages_disabled")
.map(|value| value == "true");
let guild_create_access = form
.first("policy_guild_create_access")
.map(|value| value == "true");
let premium_mode = match form.first("policy_premium_mode") {
Some("mirror") => Some(PremiumMode::Mirror),
Some("everyone") => Some(PremiumMode::Everyone),
_ => None,
};
let services = build_services_update(form);
let deferred_phone_gate = build_deferred_phone_gate_update(form);
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: None,
single_community_name: None,
direct_messages_disabled,
guild_create_access,
premium_mode,
services,
deferred_phone_gate,
}),
..Default::default()
}
}
fn build_deferred_phone_gate_update(
form: &MultiValueForm,
) -> Option<DeferredPhoneGateUpdateRequest> {
let enabled = form
.first("policy_deferred_phone_gate_enabled")
.map(|value| value == "true");
let window_hours = form
.first("policy_deferred_phone_gate_window_hours")
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| *value > 0.0);
let member_threshold = form
.first("policy_deferred_phone_gate_member_threshold")
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() {
return None;
}
Some(DeferredPhoneGateUpdateRequest {
enabled,
window_hours,
member_threshold,
})
}
fn build_services_update(form: &MultiValueForm) -> Option<InstanceServicesUpdateRequest> {
let parse_tristate = |key: &str| match form.first(key) {
Some("inherit") => Some(None),
@@ -818,13 +838,6 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
youtube: Some(InstanceYoutubeIntegrationUpdateRequest {
api_key: clean("integration_youtube_api_key"),
}),
captcha: Some(InstanceCaptchaIntegrationUpdateRequest {
provider: clean("integration_captcha_provider"),
hcaptcha_site_key: clean("integration_hcaptcha_site_key"),
hcaptcha_secret_key: clean("integration_hcaptcha_secret_key"),
turnstile_site_key: clean("integration_turnstile_site_key"),
turnstile_secret_key: clean("integration_turnstile_secret_key"),
}),
email: Some(InstanceEmailIntegrationUpdateRequest {
enabled: Some(form.bool_value("integration_email_enabled")),
provider: Some("smtp".to_owned()),
@@ -905,11 +918,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(enabled),
single_community_name: None,
direct_messages_disabled: None,
premium_mode: None,
services: None,
deferred_phone_gate: None,
..Default::default()
}),
..Default::default()
}
@@ -1236,77 +1245,6 @@ mod tests {
);
}
#[test]
fn build_voice_noise_suppression_update_collects_backends_and_validates_numbers() {
let form = MultiValueForm::parse(
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=none&voice_ns_rollout_basis_points=10000&voice_ns_suppression_strength=100&voice_ns_rollout_salt=%20voice-ns-v2%20",
);
let request = build_voice_noise_suppression_update(&form).expect("valid form");
let update = request
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.allow_user_override, Some(true));
assert_eq!(update.stereo_enabled, Some(false));
assert_eq!(
update.default_backend,
Some(NoiseSuppressionBackend::Rnnoise)
);
assert_eq!(
update.enabled_backends,
Some(vec![
NoiseSuppressionBackend::None,
NoiseSuppressionBackend::DeepFilter
])
);
assert_eq!(update.rollout_basis_points, Some(10_000));
assert_eq!(update.suppression_strength, Some(100));
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
}
#[test]
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_voice_noise_suppression_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"voice_noise_suppression": {
"enabled": false,
"allow_user_override": false,
"stereo_enabled": false,
"enabled_backends": [],
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
}})
);
}
#[test]
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
let form = MultiValueForm::parse(
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(
update.included_user_ids,
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn parse_experiment_user_ids_splits_newlines_and_commas() {
assert_eq!(
@@ -1351,13 +1289,13 @@ mod tests {
#[test]
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
@@ -1367,187 +1305,245 @@ mod tests {
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_malformed_lines() {
for (line, message) in [
("456", "Guild overrides line 3 must use guild_id=backend"),
(
"=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"not-a-guild=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"999999999999999999999=gate",
"Guild overrides line 3 must use a guild ID with 1 to 20 decimal digits",
),
(
"456=unknown_backend",
"Guild overrides line 3 must name a supported backend",
),
(
"456=",
"Guild overrides line 3 must name a supported backend",
),
(
"123=gate",
"Guild overrides line 3 conflicts with an earlier rule for guild 123",
),
] {
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("\n123=rnnoise\n{line}"))
.expect_err("invalid guild rule"),
message,
"{line}"
);
}
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_numbers() {
for (key, message, above_max) in [
(
"voice_ns_rollout_basis_points",
"Rollout basis points must be a whole number between 0 and 10000",
"10001",
),
(
"voice_ns_suppression_strength",
"Suppression strength must be a whole number between 0 and 100",
"101",
),
] {
for value in [
"",
"%20%20",
"abc",
"-1",
"1.5",
"9999999999999999999999999",
above_max,
] {
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid number"),
message,
"{key}={value}"
);
}
}
}
#[test]
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
let update = build_voice_noise_suppression_update(&form)
fn build_domain_migration_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
);
let update = build_domain_migration_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
.domain_migration
.expect("domain migration update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(250));
}
#[test]
fn build_voice_noise_suppression_update_rejects_invalid_rollout_salts() {
for salt in [
String::new(),
" ".to_owned(),
"é".repeat(65),
"🎲".repeat(33),
] {
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid salt"),
"Rollout salt must be between 1 and 64 characters"
);
}
}
#[test]
fn build_voice_noise_suppression_update_preserves_valid_rollout_salts() {
for salt in ["x".to_owned(), "é".repeat(64), "🎲".repeat(32)] {
let form =
MultiValueForm::parse(format!("voice_ns_rollout_salt=%20{salt}%20").as_bytes());
let update = build_voice_noise_suppression_update(&form)
.expect("valid form")
.voice_noise_suppression
.expect("voice noise suppression update");
assert_eq!(update.rollout_salt, Some(salt));
}
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_normalizes_identical_rules() {
let overrides = parse_voice_noise_suppression_guild_overrides(
" 1600000000000000001 = rnnoise \n\n1600000000000000001=rnnoise\n1600000000000000002=speex\n",
).expect("valid guild rules");
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
assert_eq!(
overrides,
vec![
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000001".to_owned(),
backend: NoiseSuppressionBackend::Rnnoise,
},
VoiceNoiseSuppressionGuildOverride {
guild_id: "1600000000000000002".to_owned(),
backend: NoiseSuppressionBackend::Speex,
},
]
update.included_user_ids,
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
assert_eq!(update.standalone_forwarding, Some(true));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
}
#[test]
fn parse_voice_noise_suppression_guild_overrides_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_GUILD_OVERRIDES)
.map(|index| format!("{index}=gate"))
.collect::<Vec<_>>()
.join("\n");
let overrides =
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n199=gate"))
.expect("valid guild rules at cap");
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
fn build_domain_migration_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_domain_migration_update(&form).expect("valid form");
assert_eq!(
overrides.last().map(|entry| entry.guild_id.as_str()),
Some("199")
);
assert_eq!(
parse_voice_noise_suppression_guild_overrides(&format!("{value}\n200=gate"))
.expect_err("too many guild rules"),
"Guild overrides must contain at most 200 unique guilds"
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"domain_migration": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
);
}
#[test]
fn build_voice_noise_suppression_update_reports_invalid_targeting_fields() {
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"voice_ns_default_backend=unknown",
"Default backend must name a supported noise suppression backend",
"domain_migration_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_ns_default_backend=",
"Default backend must name a supported noise suppression backend",
"domain_migration_anonymous_rollout_basis_points=10001",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_ns_enabled_backends%5B%5D=rnnoise&voice_ns_enabled_backends%5B%5D=unknown",
"Enabled backends must name supported noise suppression backends",
"domain_migration_anonymous_rollout_basis_points=abc",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_ns_included_user_ids=123%2Cinvalid",
"domain_migration_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
"Rollout salt must be between 1 and 64 characters",
),
(
"domain_migration_rollout_salt=caf%C3%A9",
"Rollout salt must use printable ASCII",
),
(
"domain_migration_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_excluded_user_ids=123%2Cinvalid",
"domain_migration_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_ns_guild_overrides=123%3Dgate%0A123%3Drnnoise",
"Guild overrides line 2 conflicts with an earlier rule for guild 123",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_voice_noise_suppression_update(&form).expect_err("invalid targeting"),
build_domain_migration_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
fn build_push_relay_update_reads_the_consent_checkbox() {
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
assert_eq!(
serde_json::to_value(&unchecked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": false}})
);
let checked = build_push_relay_update(&MultiValueForm::parse(
b"_csrf=token&push_relay_consent_accepted=true",
));
assert_eq!(
serde_json::to_value(&checked).expect("serialize update"),
serde_json::json!({"push_relay": {"relay_consent_accepted": true}})
);
}
#[test]
fn build_captcha_update_reads_the_switch_and_difficulty_fields() {
let form = MultiValueForm::parse(
b"captcha_enabled=true&captcha_cost=%202000%20&captcha_max_counter=400",
);
let update = build_captcha_update(&form)
.expect("valid form")
.captcha
.expect("captcha update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.cost, Some(2000));
assert_eq!(update.max_counter, Some(400));
}
#[test]
fn build_captcha_update_turns_the_check_off_when_the_box_is_unchecked() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_captcha_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"captcha": {"enabled": false}})
);
}
#[test]
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
for (form, message) in [
(
"captcha_cost=999",
"Cost must be a whole number between 1000 and 20000",
),
(
"captcha_cost=20001",
"Cost must be a whole number between 1000 and 20000",
),
(
"captcha_max_counter=99",
"Maximum counter must be a whole number between 100 and 20000",
),
(
"captcha_max_counter=20001",
"Maximum counter must be a whole number between 100 and 20000",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_captcha_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
let form = MultiValueForm::parse(
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
);
assert_eq!(
build_domain_migration_update(&form).expect_err("invalid guild id"),
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
);
}
#[test]
fn build_plutonium_page_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
);
let update = build_plutonium_page_update(&form)
.expect("valid form")
.plutonium_page
.expect("plutonium page update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_plutonium_page_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"plutonium_page": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"plutonium_page_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_plutonium_page_update(&form).expect_err("invalid field"),
message
);
}
+71 -32
View File
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
admin_flags, api::client::AdminApiClient, middleware::flash::FlashData,
admin_flags,
api::client::{AdminApiClient, ApiError},
middleware::flash::FlashData,
utils::forms::MultiValueForm,
};
use std::collections::HashSet;
@@ -132,19 +134,6 @@ pub async fn dispatch(
"Failed to update premium flags",
)
}
"update_suspicious_flags" => {
let Ok(submitted) =
form.parse_list_values::<i32>(&["suspicious_flags[]", "suspicious_flags"])
else {
return DispatchOutcome::error("Invalid suspicious activity flag value");
};
let flags = submitted.into_iter().fold(0, |acc, flag| acc | flag);
DispatchOutcome::from_result(
client.update_suspicious_flags(user_id, flags).await,
"Suspicious activity flags updated successfully",
"Failed to update suspicious activity flags",
)
}
"update_acls" => {
let acls = form.list_values_any(&["acls[]", "acls"]);
DispatchOutcome::from_result(
@@ -176,14 +165,6 @@ pub async fn dispatch(
"Email verified successfully",
"Failed to verify email",
),
"update_has_verified_phone" => {
let val = form.bool_value("has_verified_phone");
DispatchOutcome::from_result(
client.update_has_verified_phone(user_id, val).await,
"Phone verification status updated successfully",
"Failed to update phone verification status",
)
}
"terminate_sessions" => DispatchOutcome::from_result(
client.terminate_user_sessions(user_id).await,
"User sessions terminated successfully",
@@ -242,12 +223,14 @@ pub async fn dispatch(
};
let reason = get("reason");
let private = get("private_reason");
let notify_user = form.opt_out_value("notify_user");
DispatchOutcome::from_result(
client
.temp_ban_user(
user_id,
duration.unwrap_or(24),
reason.as_deref(),
notify_user,
private.as_deref(),
)
.await,
@@ -255,11 +238,23 @@ pub async fn dispatch(
"Failed to temporarily ban user",
)
}
"unban" => DispatchOutcome::from_result(
client.unban_user(user_id).await,
"User unbanned successfully",
"Failed to unban user",
),
"unban" => {
let public_reason = get("public_reason");
let private_reason = get("private_reason");
let notify_user = form.opt_out_value("notify_user");
DispatchOutcome::from_result(
client
.unban_user(
user_id,
public_reason.as_deref(),
notify_user,
private_reason.as_deref(),
)
.await,
"User unbanned successfully",
"Failed to unban user",
)
}
"ban_ip" => {
let Some(ip) = get("ip") else {
return DispatchOutcome::error("IP address is required");
@@ -289,6 +284,7 @@ pub async fn dispatch(
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
return DispatchOutcome::error("Invalid deletion delay");
};
let notify_user = form.opt_out_value("notify_user");
DispatchOutcome::from_result(
client
.schedule_deletion(
@@ -296,6 +292,7 @@ pub async fn dispatch(
reason_code.unwrap_or(0),
public_reason.as_deref(),
days.unwrap_or(60),
notify_user,
private_reason.as_deref(),
)
.await,
@@ -303,11 +300,53 @@ pub async fn dispatch(
"Failed to schedule user deletion",
)
}
"cancel_deletion" => DispatchOutcome::from_result(
client.cancel_deletion(user_id).await,
"User deletion cancelled successfully",
"Failed to cancel user deletion",
),
"cancel_deletion" => {
let Some(expected) = get("expected_pending_deletion_at") else {
return DispatchOutcome::error(
"The pending deletion is missing from the form. Reload and review.",
);
};
if !form.bool_value("confirm") {
return DispatchOutcome::error(
"Confirm whose deletion you are cancelling before submitting",
);
}
let Some(private_reason) = get("private_reason") else {
return DispatchOutcome::error("A private reason is required to cancel a deletion");
};
let notify_user = form.bool_value("notify_user");
match client
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
.await
{
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
"The pending deletion changed since this page loaded. Reload and review.",
),
Err(error) => {
tracing::warn!(%error, user_id, "admin API request failed: cancel user deletion");
DispatchOutcome::error("Failed to cancel user deletion")
}
}
}
"annotate_ban" => {
let Some(ban_audit_log_id) = get("ban_audit_log_id") else {
return DispatchOutcome::error("The ban audit log entry is missing from the form");
};
let Some(note) = get("note") else {
return DispatchOutcome::error("Note is required");
};
match client.annotate_ban(user_id, &ban_audit_log_id, &note).await {
Ok(()) => DispatchOutcome::success("Note added to the ban"),
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
"The ban changed since this page loaded. Reload and review.",
),
Err(error) => {
tracing::warn!(%error, user_id, "admin API request failed: annotate ban");
DispatchOutcome::error("Failed to add the note to the ban")
}
}
}
"change_dob" => {
let Some(dob) = get("date_of_birth") else {
return DispatchOutcome::error("Date of birth is required");
+41 -9
View File
@@ -73,15 +73,11 @@ pub async fn render(
.map(|r| r.sessions)
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
.unwrap_or_default();
let webauthn_credentials = if u.authenticator_types.contains(&2) {
client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default()
} else {
Vec::new()
};
let webauthn_credentials = client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default();
Some(tabs::account::account_tab(
config,
&u,
@@ -115,11 +111,47 @@ pub async fn render(
query.delete_all_messages_channel_count.unwrap_or(0),
query.delete_all_messages_message_count.unwrap_or(0),
));
let deletion_scheduler = match u.deletion_scheduled_by.as_deref() {
Some(scheduler_id) if u.pending_deletion_at.is_some() && scheduler_id != u.id => {
client
.get_user_by_id(scheduler_id)
.await
.log_error("load deletion scheduler")
}
_ => None,
};
let ban_logs = if u.temp_banned_until.is_some()
&& acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW)
{
client
.search_audit_logs(&SearchAuditLogsParams {
query: None,
admin_user_id: None,
target_id: Some(user_id.to_owned()),
target_type: Some("user".to_owned()),
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()),
limit: 100,
offset: 0,
})
.await
.log_error("load ban audit logs")
.map(|response| response.logs)
.unwrap_or_default()
} else {
Vec::new()
};
let context = tabs::moderation::ModerationContext {
deletion_scheduler: deletion_scheduler.as_ref(),
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
};
Some(tabs::moderation::moderation_tab(
config,
&u,
csrf_token,
admin_acls,
&context,
query.message_shred_job_id.as_deref(),
message_shred_status.as_ref(),
delete_all_messages_dry_run,
+118 -33
View File
@@ -4,7 +4,7 @@ use crate::{
acl,
api::{
client::{AdminApiClient, ApiResult, ApiResultExt},
types::AdminUser,
types::{AdminUser, PremiumBranding},
},
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
@@ -22,6 +22,7 @@ use axum::{
use serde::Deserialize;
const USER_ID_LOOKUP_BATCH: usize = 100;
const DEFAULT_PREMIUM_NAME: &str = "Premium";
#[derive(Deserialize)]
struct UserListQuery {
@@ -87,32 +88,47 @@ async fn users_list(
.unwrap_or(&[]);
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let results = if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
let searching = params.has_id_lookup() || params.has_search();
let results = async {
if params.has_id_lookup() {
lookup_users_in_batches(&client, &params.requested_ids)
.await
.log_error("lookup users by ids")
.map(|users| (users, false))
} else if params.has_search() {
let offset = u64::from(params.page) * u64::from(params.limit);
client
.search_users(
params.search_query(),
params.email_query(),
params.ip_query(),
params.limit,
offset,
)
.await
.log_error("search users")
.map(|r| {
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
(r.users, has_more)
})
} else {
None
}
};
let badge = async {
if searching {
self_hosted_premium_badge_name(&state, &client).await
} else {
None
}
};
let (results, badge_name) = tokio::join!(results, badge);
let result_users = results.as_ref().map(|r| r.0.as_slice());
let has_more = results.as_ref().is_some_and(|r| r.1);
let premium_badge_name = match result_users {
Some(users) if !users.is_empty() => badge_name,
_ => None,
};
let markup = templates::pages::users_list::users_list_page(
config,
&auth.0,
@@ -120,11 +136,34 @@ async fn users_list(
result_users,
has_more,
can_view_email,
premium_badge_name.as_deref(),
is_results_fragment,
);
Html(markup.into_string()).into_response()
}
async fn self_hosted_premium_badge_name(
state: &AppState,
client: &AdminApiClient,
) -> Option<String> {
if !state.config().self_hosted {
return None;
}
premium_badge_name(state.premium_branding(client).await.as_ref())
}
fn premium_badge_name(branding: Option<&PremiumBranding>) -> Option<String> {
match branding {
Some(branding) => branding.premium_enabled.then(|| {
branding
.name
.clone()
.unwrap_or_else(|| DEFAULT_PREMIUM_NAME.to_owned())
}),
None => Some(DEFAULT_PREMIUM_NAME.to_owned()),
}
}
async fn lookup_users_in_batches(
client: &AdminApiClient,
user_ids: &[String],
@@ -148,10 +187,15 @@ async fn user_detail(
let is_detail_fragment = htmx::targets(&headers, "main-content");
let active_tab = query.tab.as_deref().unwrap_or("overview");
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user detail");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user detail")
},
self_hosted_premium_badge_name(&state, &client)
);
let tq = to_tab_query(&query);
let admin_acls = auth
.0
@@ -167,6 +211,7 @@ async fn user_detail(
} else {
None
};
let premium_badge_name = user.as_ref().and(badge_name);
let markup = templates::pages::user_detail::user_detail_with_tab(
config,
&auth.0,
@@ -174,6 +219,7 @@ async fn user_detail(
&user_id,
active_tab,
tab_body,
premium_badge_name.as_deref(),
is_detail_fragment,
);
Html(markup.into_string()).into_response()
@@ -275,18 +321,29 @@ async fn user_peek(
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let user = client
.get_user_by_id(&user_id)
.await
.log_error("load user peek");
let (user, badge_name) = tokio::join!(
async {
client
.get_user_by_id(&user_id)
.await
.log_error("load user peek")
},
self_hosted_premium_badge_name(&state, &client)
);
let admin_acls = auth
.0
.admin_user
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let premium_badge_name = user.as_ref().and(badge_name);
let markup = match user {
Some(ref u) => templates::pages::user_peek::user_peek_fragment(config, u, admin_acls),
Some(ref u) => templates::pages::user_peek::user_peek_fragment(
config,
u,
admin_acls,
premium_badge_name.as_deref(),
),
None => maud::html! {
div class="p-4 text-red-600 text-sm" { "User not found." }
},
@@ -319,3 +376,31 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
url.push_str(&urlencoding::encode(value));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
let gold = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: true,
};
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
let unnamed = PremiumBranding {
name: None,
premium_enabled: true,
};
assert_eq!(
premium_badge_name(Some(&unnamed)).as_deref(),
Some("Premium")
);
let everyone = PremiumBranding {
name: Some("Gold".to_owned()),
premium_enabled: false,
};
assert_eq!(premium_badge_name(Some(&everyone)), None);
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
}
}
+49 -2
View File
@@ -1,7 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AdminConfig;
use std::sync::Arc;
use crate::{
api::{
client::{AdminApiClient, ApiResultExt},
types::PremiumBranding,
},
config::AdminConfig,
};
use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct AppState {
@@ -11,6 +22,7 @@ pub struct AppState {
struct AppStateInner {
pub config: AdminConfig,
pub http_client: reqwest::Client,
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
}
impl AppState {
@@ -23,6 +35,7 @@ impl AppState {
inner: Arc::new(AppStateInner {
config,
http_client,
premium_branding: Mutex::new(None),
}),
}
}
@@ -34,6 +47,40 @@ impl AppState {
pub fn http_client(&self) -> &reqwest::Client {
&self.inner.http_client
}
pub fn cached_premium_branding(&self) -> Option<PremiumBranding> {
let cache = self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache
.as_ref()
.filter(|(fetched_at, _)| fetched_at.elapsed() < PREMIUM_BRANDING_TTL)
.map(|(_, branding)| branding.clone())
}
pub fn remember_premium_branding(&self, branding: PremiumBranding) {
*self
.inner
.premium_branding
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some((Instant::now(), branding));
}
pub async fn premium_branding(&self, client: &AdminApiClient) -> Option<PremiumBranding> {
if let Some(branding) = self.cached_premium_branding() {
return Some(branding);
}
let branding = PremiumBranding::from_discovery(
&client
.get_instance_premium_discovery()
.await
.log_error("load premium branding")?,
);
self.remember_premium_branding(branding.clone());
Some(branding)
}
}
impl axum::extract::FromRef<AppState> for AdminConfig {
+25
View File
@@ -238,3 +238,28 @@ input:disabled + .checkbox-custom {
border: 2px solid transparent;
background-clip: content-box;
}
:target {
padding: 0.5rem;
border-radius: 0.25rem;
scroll-margin-top: 6rem;
animation: target-pulse 700ms ease-in-out 3;
}
@keyframes target-pulse {
0%,
100% {
background-color: transparent;
}
50% {
background-color: hsl(242 70% 55% / 0.18);
}
}
@media (prefers-reduced-motion: reduce) {
:target {
background-color: hsl(242 70% 55% / 0.12);
animation: none;
}
}
@@ -227,6 +227,13 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
}
}
pub fn opt_out_checkbox(name: &str, label: &str) -> Markup {
html! {
input type="hidden" name={(name) "_present"} value="1";
(checkbox(name, "true", label, true, true))
}
}
pub fn secondary_button_link(label: &str, href: &str) -> Markup {
html! {
a href=(href) role="button"
@@ -21,6 +21,7 @@ pub mod resource_link;
pub mod section_card;
pub mod stack;
pub mod table;
pub mod tooltip;
pub mod typography;
pub mod user_display;
pub mod user_profile_badges;
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::icons::paperclip_icon;
use maud::{Markup, html};
use std::sync::atomic::{AtomicUsize, Ordering};
static HINT_TOGGLE_ID: AtomicUsize = AtomicUsize::new(0);
pub struct HintLink<'a> {
href: &'a str,
label: &'a str,
}
impl<'a> HintLink<'a> {
pub fn new(href: &'a str, label: &'a str) -> Self {
debug_assert!(
href.starts_with('/'),
"hint link href must be admin-absolute: {href:?}"
);
debug_assert!(
href.contains('#'),
"hint link href should point at an anchor: {href:?}"
);
debug_assert!(!label.trim().is_empty(), "hint link needs a label");
Self { href, label }
}
}
pub struct Hint<'a> {
pub name: Option<&'a str>,
pub body: &'a str,
pub link: Option<HintLink<'a>>,
}
pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
let aria_label = match hint.name {
Some(name) => format!("About {name}"),
None => "More information".to_owned(),
};
let toggle_id = format!(
"hint-toggle-{}",
HINT_TOGGLE_ID.fetch_add(1, Ordering::Relaxed)
);
html! {
span class="group relative inline-flex items-center" {
input type="checkbox" id=(toggle_id) class="peer sr-only";
label for=(toggle_id) tabindex="0" aria-label=(aria_label)
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full \
font-semibold text-brand-primary leading-none active:scale-97 \
hover:text-brand-primary-dark" {
"?"
}
label for=(toggle_id) aria-hidden="true"
class="invisible fixed inset-0 z-20 cursor-default peer-checked:visible" {}
div class="invisible absolute bottom-full left-2 z-30 w-64 pb-3 pl-2 opacity-0 \
transition-[opacity,visibility] duration-200 ease-out motion-reduce:transition-none \
group-hover:visible group-hover:opacity-100 \
group-focus-within:visible group-focus-within:opacity-100 \
peer-checked:visible peer-checked:opacity-100" {
div class="rounded-lg border border-neutral-200 bg-white p-3 text-neutral-600 \
text-xs shadow-lg" {
@if let Some(name) = hint.name {
p class="font-semibold text-neutral-900" { (name) }
}
p class=[hint.name.is_some().then_some("mt-1")] { (hint.body) }
@if let Some(link) = &hint.link {
a href={(base) (link.href)} hx-boost="false"
class="mt-2 inline-flex items-center gap-1 text-blue-600 hover:underline" {
(paperclip_icon(""))(link.label)
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::HintLink;
#[test]
#[should_panic(expected = "anchor")]
fn rejects_a_link_that_points_at_no_anchor() {
let _ = HintLink::new("/instance-config", "Instance policy");
}
#[test]
#[should_panic(expected = "label")]
fn rejects_a_link_with_no_label() {
let _ = HintLink::new("/instance-config#community-creation", " ");
}
}
@@ -13,12 +13,39 @@ struct BadgeDef {
tooltip: String,
}
fn premium_tooltip(
premium_type: i32,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
) -> Option<String> {
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
None => "Fluxer Plutonium".into(),
}
})
}
pub fn user_profile_badges(
static_cdn_endpoint: &str,
flags: u64,
premium_type: Option<i32>,
premium_since: Option<&str>,
is_self_hosted: bool,
self_hosted_premium_name: Option<&str>,
size_sm: bool,
) -> Markup {
let cdn = static_cdn_endpoint.trim_end_matches('/');
@@ -42,23 +69,11 @@ pub fn user_profile_badges(
tooltip: "Fluxer Bug Hunter".into(),
});
}
if !is_self_hosted
&& let Some(pt) = premium_type
if let Some(pt) = premium_type
&& pt != premium_types::NONE
&& let Some(tooltip) =
premium_tooltip(pt, premium_since, is_self_hosted, self_hosted_premium_name)
{
let tooltip = if pt == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => {
format!("Fluxer Plutonium subscriber since {since}")
}
None => "Fluxer Plutonium".into(),
}
};
badges.push(BadgeDef {
icon_url: format!("{cdn}/badges/plutonium.svg"),
tooltip,
@@ -84,3 +99,38 @@ pub fn user_profile_badges(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(self_hosted: bool, name: Option<&str>, premium_type: i32) -> String {
user_profile_badges(
"https://static.example.com",
0,
Some(premium_type),
Some("2026-01-01"),
self_hosted,
name,
false,
)
.into_string()
}
#[test]
fn hosted_premium_badges_keep_their_fluxer_labels() {
assert!(
render(false, Some("Gold"), 1).contains("Fluxer Plutonium subscriber since 2026-01-01")
);
assert!(render(false, None, 2).contains("Fluxer Visionary since 2026-01-01"));
}
#[test]
fn self_hosted_premium_badges_use_the_configured_name() {
let markup = render(true, Some("Gold"), 1);
assert!(markup.contains("Gold subscriber since 2026-01-01"));
assert!(!markup.contains("Plutonium"));
assert!(render(true, Some("Gold"), 2).contains("Gold subscriber since"));
assert!(!render(true, None, 1).contains("img"));
}
}

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