Compare commits

...
Author SHA1 Message Date
HampusandGitHub 6730a242db feat(web): prepare the fluxer.com domain migration (#2952) 2026-09-25 13:43:34 +02:00
HampusandGitHub e62ae77643 refactor(config): trim the default passkey origin list (#2951) 2026-09-25 13:42:02 +02:00
HampusandGitHub f4f39e6a89 feat(app): show where forward destinations come from (#2950) 2026-09-25 13:12:17 +02:00
HampusandGitHub 00bf74cef5 fix(app): handle swapped overwrites when comparing channels (#2949) 2026-09-24 23:38:04 +02:00
HampusandGitHub c1c45d835f fix(app): only parse markdown in rich embeds (#2948) 2026-09-24 22:50:34 +02:00
HampusandGitHub bbfe809bef fix(app): crop animated images on web with libwebp (#2947) 2026-09-24 22:45:53 +02:00
HampusandGitHub e0843ac4f5 fix(app): keep guild folder expansion state local (#2944) 2026-09-24 17:52:33 +02:00
HampusandGitHub 43741cdad8 fix(gateway): always trim the connect snapshot for guild connects (#2943) 2026-09-24 17:09:48 +02:00
HampusandGitHub b8e3807262 Revert "fix(push): deliver direct messages without holding them" (#2942) 2026-09-24 17:09:44 +02:00
HampusandGitHub 3304f01a84 chore(i18n): recompile uk error catalog (#2941) 2026-09-24 17:09:36 +02:00
fluxer-weblate[bot]andGitHub 2ba463235b chore(i18n): update translations from Weblate (#2909) 2026-09-24 16:25:26 +02:00
fluxer-weblate[bot]andGitHub 15136fed59 chore(i18n): update translations from Weblate (#2923) 2026-09-24 16:25:05 +02:00
HampusandGitHub 6013581dd9 fix(push): deliver direct messages without holding them (#2938) 2026-09-24 16:21:42 +02:00
HampusandGitHub 7a91f128e9 fix(app-proxy): drop link preview metadata on self-hosted (#2936) 2026-09-24 16:07:00 +02:00
HampusandGitHub 963ffc5550 feat(push): scope read clears to the enrolled cohort (#2935) 2026-09-24 15:15:45 +02:00
HampusandGitHub a90991612c fix(gateway): truncate reads on an expired outbox entry (#2934) 2026-09-24 15:04:24 +02:00
HampusandGitHub 50ad23b760 fix(api): run the notification extension on every iOS alert (#2933) 2026-09-24 15:04:01 +02:00
HampusandGitHub 425dab983b fix(push): restore iOS avatars and stop misrouting relay endpoints (#2932) 2026-09-24 15:03:32 +02:00
HampusandGitHub a0825e77c4 feat(voice): ship the screen share delivery rework to everyone (#2931) 2026-09-24 14:57:40 +02:00
HampusandGitHub 88038a1d5b fix(voice): stop direct input capturing microphones in stereo (#2929) 2026-09-24 14:51:05 +02:00
HampusandGitHub c2c0fdb445 fix(app): make corner volume control the focused stream (#2928) 2026-09-24 14:04:05 +02:00
HampusandGitHub dcd5f09d6a feat(api): add env toggles for automatic phone flagging (#2927) 2026-09-24 03:36:35 +02:00
HampusandGitHub 590b1f36fd docs(downloads): document the canary apt and dnf repositories (#2926) 2026-09-24 03:29:52 +02:00
HampusandGitHub 168ac727f1 fix(desktop): set the deb package synopsis (#2925) 2026-09-24 03:29:33 +02:00
HampusandGitHub deb86dd92e fix(admin): format users list search hint (#2924) 2026-09-24 02:12:37 +02:00
omsterandGitHub 7ccec4d3b8 feat(admin): hint text for * search in user page (#2922) 2026-09-24 01:56:17 +02:00
omsterandGitHub 2f38bcdf26 fix(admin): ordering fixes for admin user search and meilisearch (#2920) 2026-09-24 01:45:56 +02:00
HampusandGitHub f2785941aa fix(app): point self-hosted users at their instance admins (#2921) 2026-09-24 01:42:33 +02:00
HampusandGitHub ea9f83a443 fix(push): keep read-state clears alive as long as the alert (#2919) 2026-09-24 01:26:18 +02:00
HampusandGitHub bd6ca7290e fix(api): allow deleting messages without send permission (#2918) 2026-09-24 01:14:01 +02:00
HampusandGitHub b85e975fb5 feat(push): deliver our own relay endpoints in process (#2917) 2026-09-24 01:07:09 +02:00
HampusandGitHub b6e504f68c fix(push): keep device tokens out of logs (#2916) 2026-09-24 00:33:50 +02:00
HampusandGitHub 5fde6eb484 feat(push): ring Android calls and harden the relay (#2915) 2026-09-24 00:07:15 +02:00
HampusandGitHub b16989d567 feat(push): ring incoming calls on Apple PushKit devices (#2911) 2026-09-23 20:21:08 +02:00
HampusandGitHub c9754ac11a fix(api): exempt internal rpc from the client ip check (#2910) 2026-09-23 18:03:36 +02:00
fluxer-weblate[bot]andGitHub f34e4a5115 chore(i18n): update translations from Weblate (#2903) 2026-09-23 17:28:25 +02:00
fluxer-weblate[bot]andGitHub 44b3615298 chore(i18n): update translations from Weblate (#2904) 2026-09-23 17:27:59 +02:00
HampusandGitHub 211e98307d perf(push): cache endpoint guard dns verdicts (#2907) 2026-09-23 17:27:19 +02:00
HampusandGitHub 18c303abf6 feat(push): relay notifications as encrypted web push (#2906) 2026-09-23 14:04:55 +02:00
JiraliteandGitHub 7021a58090 fix: allow copying message snapshots (#2905) 2026-09-23 14:01:10 +02:00
WagnerandGitHub 320725a587 fix(desktop): capture full pipewire quantum on linux (#2481) 2026-09-22 21:37:20 +02:00
fluxer-weblate[bot]andGitHub 8450edc072 chore(i18n): update translations from Weblate (#2895) 2026-09-22 21:28:24 +02:00
omsterandGitHub a1e2bf2c8d feat(dev/linux): select the wayland backend when reachable in the native desktop app (#2899)
Signed-off-by: omstr <[email protected]>
2026-09-22 21:27:59 +02:00
HampusandGitHub 82b2f4ec5e fix(app): put jxl and other image attachments in the mosaic (#2902) 2026-09-22 21:18:39 +02:00
HampusandGitHub c92e5d03a7 fix(api): accept any image or video attachment as embed media (#2901) 2026-09-22 21:18:35 +02:00
HampusandGitHub 91340c5c84 fix(markdown): compile the parser wasm asynchronously (#2900) 2026-09-22 19:58:38 +02:00
HampusandGitHub 045dd5d027 test(api): make the harvest token tamper test deterministic (#2894) 2026-09-22 02:20:18 +02:00
HampusandGitHub a21b9c4659 docs(readme): clean up the download prose (#2893) 2026-09-22 02:08:42 +02:00
HampusandGitHub 4b1b869802 docs(readme): point Linux installs at Flathub (#2892) 2026-09-22 02:04:06 +02:00
HampusandGitHub 1ab7e7dfcc fix(api): unfurl links to a self-hosted instance's own domain (#2891) 2026-09-22 02:00:51 +02:00
HampusandGitHub 31c53d2dff fix(app): stop pending stickers from reloading the channel (#2890) 2026-09-22 02:00:26 +02:00
HampusandGitHub 412a1ae79d perf(api): stop ledgering session payment reconciliation (#2889) 2026-09-22 01:37:21 +02:00
HampusandGitHub 0b2306ec3d fix(api): honour default TTLs and expire stale job ledger rows (#2887) 2026-09-21 23:16:39 +02:00
HampusandGitHub 242ed3a934 fix(desktop): drop orphaned Squirrel uninstall entry (#2885) 2026-09-21 20:03:33 +02:00
HampusandGitHub 70e1ce682a feat(emoji): add Unicode 17 emoji and fix mixed skin tones (#2883) 2026-09-21 16:26:06 +02:00
HampusandGitHub 7601bf98ee fix(channel): sync a cleared group DM name without a reload (#2882) 2026-09-21 15:34:18 +02:00
c7ec2a0f58 chore(tooling): Ignore .vscode/ in .gitignore (#2868)
Co-authored-by: Hampus <[email protected]>
2026-09-21 13:29:33 +02:00
XeonandGitHub 6a5e0056a8 fix(flatpak): Add a release tag and make small corrections (#2872) 2026-09-21 13:28:36 +02:00
HampusandGitHub 78d105b46e fix(desktop): stop looping on an update that never installs (#2879) 2026-09-21 03:36:11 +02:00
HampusandGitHub c68d62b8a0 fix(voice): darken screen share source titles in light theme (#2878) 2026-09-21 01:20:54 +02:00
HampusandGitHub df58020f4c fix(api): keep premium paid for after a subscription cancels (#2875) 2026-09-20 23:50:49 +02:00
HampusandGitHub f052ce05aa fix(workspace): point the Erlang extension at the repo root (#2871) 2026-09-20 19:49:06 +02:00
HampusandGitHub eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
HampusandGitHub 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
HampusandGitHub 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
HampusandGitHub a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
HampusandGitHub 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
HampusandGitHub c157ab5752 feat(voice): rework screen share delivery behind an experiment (#2859) 2026-09-20 06:10:20 +02:00
HampusandGitHub 574a93257c docs(downloads): the pacman repository is signed (#2858) 2026-09-20 05:54:28 +02:00
HampusandGitHub ba7d8781cf feat(auth): make passkey two-factor authentication opt-in (#2857) 2026-09-20 05:06:50 +02:00
HampusandGitHub 3256af8d92 refactor(app-proxy): remove the stable time freeze (#2856) 2026-09-20 04:02:47 +02:00
HampusandGitHub 86043212f2 docs(downloads): one pacman repository holds both channels (#2855) 2026-09-20 02:50:08 +02:00
HampusandGitHub 5d85e88532 fix(search): suggest yourself in DM from: and mentions: filters (#2854) 2026-09-20 01:24:43 +02:00
HampusandGitHub e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
HampusandGitHub 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
HampusandGitHub a3454e8245 fix(installer): name the services that are not ready (#2851) 2026-09-19 23:34:08 +02:00
HampusandGitHub bf7567b768 fix(user): push guild member updates on profile field changes (#2850) 2026-09-19 23:30:25 +02:00
HampusandGitHub ac3450ab32 feat(ci): publish appimage zsync control files (#2849) 2026-09-19 22:22:54 +02:00
HampusandGitHub 5d034becb8 fix(installer): stop waiting for an absent bucket initialiser (#2848) 2026-09-19 22:14:13 +02:00
HampusandGitHub f9397d0db9 feat(ci): publish linux repositories from the desktop release (#2847) 2026-09-19 22:01:06 +02:00
HampusandGitHub 9005139dc8 fix(voice): stop stereo microphones publishing as mono (#2846) 2026-09-19 21:54:38 +02:00
HampusandGitHub d93604afa2 fix(voice): let screen shares use the hardware H.264 encoder (#2845) 2026-09-19 21:54:30 +02:00
HampusandGitHub c4f0b2ece0 feat(desktop): self-update appimages in place (#2843) 2026-09-19 19:06:53 +02:00
HampusandGitHub 98a42f612b fix(desktop): supersede the legacy linux packages on upgrade (#2842) 2026-09-19 18:50:37 +02:00
HampusandGitHub cc75e1318d fix(ci): raise the macos minimum to 13.0 (#2841) 2026-09-19 18:35:05 +02:00
HampusandGitHub 9027cbdf3e fix(voice): send screen shares at the quality the user picked (#2840) 2026-09-19 16:46:22 +02:00
HampusandGitHub 2119e10ed5 chore(static): update marketing screenshots and readme cover (#2839) 2026-09-19 16:44:32 +02:00
HampusandGitHub 87f3eb3c81 feat(desktop): add flatpak and arch packaging inputs (#2838) 2026-09-19 15:31:41 +02:00
HampusandGitHub f9bb8bd585 test(voice): remove the slow screen share delivery proof (#2836) 2026-09-19 02:33:32 +02:00
HampusandGitHub bc47a724af fix(voice): stop screen shares failing to reach their viewers (#2835) 2026-09-19 02:17:25 +02:00
HampusandGitHub f32356801d feat(api): make tor and breached password lookups opt-in (#2834) 2026-09-19 01:22:17 +02:00
HampusandGitHub efd677f32b feat(api): exempt configured ASNs from abusive IP auto-bans (#2833) 2026-09-18 23:01:58 +02:00
HampusandGitHub 3cec27ba57 fix(static): vendor the deepfilternet 1.3.0 assets (#2832) 2026-09-18 18:47:42 +02:00
HampusandGitHub 1f810ba04d fix(api): drop the upload segment signal and dead exports (#2831) 2026-09-18 17:35:58 +02:00
HampusandGitHub 522cf08e61 feat(media-proxy): sign attachment URLs and gate origins (#2830) 2026-09-18 15:57:32 +02:00
HampusandGitHub 025c01ab13 fix(api): chunk guild permission batch RPC over 100 guilds (#2829) 2026-09-18 12:54:03 +02:00
HampusandGitHub dc41b53d60 fix(desktop): drop redundant casts flagged by clippy 1.98 (#2826) 2026-09-17 21:28:48 +02:00
HampusandGitHub 3b552e00ef chore(deps): upgrade all dependencies, toolchains and images (#2825) 2026-09-17 21:08:56 +02:00
HampusandGitHub 56e04e7b53 test(backend): remove duplicate and useless tests (#2820) 2026-09-17 15:32:25 +02:00
HampusandGitHub deac653a9e test(app): remove useless frontend tests (#2819) 2026-09-17 15:05:36 +02:00
HampusandGitHub ed9528834d fix(gateway): stop dead sessions leaving voice states behind (#2818) 2026-09-17 14:55:18 +02:00
HampusandGitHub 4cecbf1f43 fix(auth): disable TOTP with one code instead of two (#2816) 2026-09-17 04:21:50 +02:00
HampusandGitHub b019f4a91f fix(gateway): act on voice states in the voice server (#2815) 2026-09-17 03:59:36 +02:00
HampusandGitHub 34b6ecfbd2 chore(admin): remove the heap snapshot endpoint (#2814) 2026-09-16 18:56:01 +02:00
HampusandGitHub 4ef9c4c65b fix(api): restore commas in geoip location labels (#2812) 2026-09-16 18:27:50 +02:00
HampusandGitHub 3276039e41 feat(admin): audit admin reads and filter the log by access (#2811) 2026-09-16 17:23:03 +02:00
HampusandGitHub 03d1354562 chore(voice): remove voice reconciliation leftovers (#2810) 2026-09-16 17:09:57 +02:00
HampusandGitHub 964845d7a7 chore(voice): remove the recon service (#2808) 2026-09-16 16:53:30 +02:00
HampusandGitHub 3bc5dd8e0f fix(gateway): always clear expired custom statuses (#2807) 2026-09-16 16:52:22 +02:00
HampusandGitHub 17292fd6a5 fix(app): stop plain unicode symbols rendering as color emoji (#2806) 2026-09-16 16:33:13 +02:00
TarekandGitHub f753659899 feat(instance): make the status page URL configurable (#1159) 2026-09-16 15:20:37 +02:00
HampusandGitHub 7412ec3395 refactor(api): purge cache by canonical media prefix (#2802) 2026-09-16 02:32:36 +02:00
1931 changed files with 125089 additions and 181811 deletions
+9 -11
View File
@@ -1,14 +1,14 @@
FROM chrislusf/seaweedfs:4.31 AS seaweedfs
FROM chrislusf/seaweedfs:4.47 AS seaweedfs
FROM erlang:28.5.0.1
FROM erlang:28.5.0.6
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=1000
ARG NODE_MAJOR=24
ARG ELP_VERSION=2026-02-27
ARG PNPM_VERSION=10.29.3
ARG WASM_BINDGEN_VERSION=0.2.123
ARG NODE_MAJOR=26
ARG ELP_VERSION=2026-08-10
ARG PNPM_VERSION=11.27.0
ARG WASM_BINDGEN_VERSION=0.2.128
ENV DEBIAN_FRONTEND=noninteractive
@@ -131,7 +131,8 @@ RUN apt-get update \
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
&& npm install -g "pnpm@${PNPM_VERSION}" \
&& pnpm --version
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
@@ -167,7 +168,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
arm64) ELP_ARCH="aarch64" ;; \
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
esac \
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.5.tar.gz" -o /tmp/elp.tgz \
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
&& chmod +x /usr/local/bin/elp \
&& rm /tmp/elp.tgz
@@ -194,7 +195,4 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
&& pnpm --version
WORKDIR /workspaces/fluxer
+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",
+6 -5
View File
@@ -9,7 +9,7 @@ services:
init: true
environment:
DOCKER_HOST: unix:///var/run/docker.sock
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
pnpm_config_store_dir: /home/vscode/.local/share/pnpm/store
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
@@ -292,13 +292,13 @@ services:
start_period: 5s
valkey:
image: valkey/valkey:8.1.7-alpine
image: valkey/valkey:9.1.2-alpine
command: ["valkey-server", "--save", "", "--appendonly", "no"]
ports:
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
nats:
image: nats:2.14.2-alpine
image: nats:2.14.7-alpine
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- nats-data:/data
@@ -321,9 +321,10 @@ services:
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
meilisearch:
image: getmeili/meilisearch:v1.12
image: getmeili/meilisearch:v1.53
environment:
MEILI_NO_ANALYTICS: "true"
MEILI_UPGRADE_DB: "true"
MEILI_MASTER_KEY: fluxer-dev-meilisearch
volumes:
- meilisearch-data:/meili_data
@@ -337,7 +338,7 @@ services:
start_period: 5s
mailpit:
image: axllent/mailpit:v1.30
image: axllent/mailpit:v1.31
environment:
MP_DATABASE: /data/mailpit.db
MP_MAX_MESSAGES: 5000
+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 -2
View File
@@ -28,9 +28,9 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:recon:
f:push:
- changed-files:
- any-glob-to-any-file: fluxer_recon/**/*
- any-glob-to-any-file: fluxer_push/**/*
f:snowflakes:
- changed-files:
- any-glob-to-any-file: fluxer_snowflakes/**/*
+12 -12
View File
@@ -58,13 +58,13 @@ jobs:
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
@@ -101,19 +101,19 @@ jobs:
- platform: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
@@ -141,15 +141,15 @@ jobs:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -43,13 +43,13 @@ jobs:
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: set variables
id: vars
run: >-
@@ -71,20 +71,19 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
@@ -131,19 +130,19 @@ jobs:
- platform: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: .
file: fluxer_app_proxy/Dockerfile
@@ -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 }}
@@ -173,15 +171,15 @@ jobs:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+20 -20
View File
@@ -43,13 +43,13 @@ jobs:
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: set variables
id: vars
run: >-
@@ -67,18 +67,18 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
@@ -131,13 +131,13 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
@@ -145,7 +145,7 @@ jobs:
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
@@ -178,19 +178,19 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
with:
context: .
file: fluxer_app_proxy/Dockerfile
@@ -219,15 +219,15 @@ jobs:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+68 -109
View File
@@ -11,11 +11,6 @@ on:
- stable
- canary
default: stable
test_build:
description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release).
required: false
default: false
type: boolean
build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false
@@ -32,13 +27,12 @@ permissions:
actions: read
concurrency:
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }}
group: desktop-${{ inputs.channel }}
cancel-in-progress: true
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
jobs:
meta:
@@ -53,19 +47,17 @@ jobs:
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
test_build: ${{ steps.meta.outputs.test_build }}
s3_prefix: ${{ steps.meta.outputs.s3_prefix }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Create token
id: create-token
@@ -85,7 +77,6 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
--test-build "${{ inputs.test_build }}"
matrix:
name: Resolve build matrix
@@ -98,12 +89,12 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Build platform matrix
id: set-matrix
@@ -113,7 +104,7 @@ jobs:
--skip-targets "${{ inputs.skip_targets }}"
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }})
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
@@ -137,41 +128,34 @@ jobs:
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Set up Python (Windows)
if: runner.os == 'Windows'
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.13"
python-version: "3.14"
- name: Ensure python3 command (Windows)
if: runner.os == 'Windows'
@@ -196,14 +180,14 @@ jobs:
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
node-version: 26
- name: Set up pnpm via corepack
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm_corepack
--step setup_pnpm
- name: Resolve pnpm store path (Windows)
if: runner.os == 'Windows'
@@ -247,9 +231,9 @@ jobs:
- name: Set up Rust toolchain (Unix)
if: matrix.platform != 'windows'
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
- name: Install MSVC ARM64 build tools
@@ -260,7 +244,7 @@ jobs:
- name: Set up MSVC env (Windows)
if: matrix.platform == 'windows'
uses: TheMrMilchmann/setup-msvc-dev@79dac248aac9d0059f86eae9d8b5bfab4e95e97c
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9
with:
arch: ${{ matrix.arch == 'arm64' && 'amd64_arm64' || 'amd64' }}
@@ -302,9 +286,9 @@ jobs:
- name: Set up .NET SDK (Windows)
if: matrix.platform == 'windows'
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
with:
dotnet-version: "8.0.x"
dotnet-version: "10.0.x"
- name: Install Velopack CLI
if: matrix.platform == 'windows'
@@ -363,7 +347,7 @@ jobs:
- name: Azure login for Artifact Signing
if: matrix.platform == 'windows'
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
@@ -477,6 +461,12 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_artifacts_unix
- name: Build AppImage update feed (Linux)
if: matrix.platform == 'linux'
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_appimage_update_feed
- name: Normalize updater YAML (macOS)
if: matrix.platform == 'macos'
run: >-
@@ -495,13 +485,23 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step generate_checksums_windows
- name: Upload artifacts to S3 handoff
- name: Stage build artifacts
id: handoff
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step upload_handoff
--step stage_handoff
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ${{ steps.handoff.outputs.artifact_name }}
path: upload_staging
if-no-files-found: error
retention-days: 1
compression-level: 0
upload:
name: Upload to S3
name: Assemble desktop release assets
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
@@ -520,34 +520,26 @@ jobs:
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Download S3 handoff artifacts
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_handoff
- name: Download build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: artifacts
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
- name: Build S3 payload layout (+ manifest.json)
- name: Build payload layout (+ manifest.json)
env:
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
@@ -556,42 +548,27 @@ jobs:
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_summary
- name: Cleanup S3 handoff
if: ${{ success() }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step cleanup_handoff
publish_release:
name: Publish GitHub desktop release
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
if: ${{ !cancelled() && needs.upload.result == 'success' }}
needs:
- meta
- upload
@@ -603,28 +580,22 @@ jobs:
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-release-assets
path: release_assets
- name: Create token
id: create-token
@@ -656,15 +627,3 @@ jobs:
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
- name: Publish payload metadata to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_payload_metadata
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build recon
name: build push
on:
workflow_dispatch:
@@ -31,6 +31,6 @@ jobs:
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-recon
dockerfile: fluxer_recon/Dockerfile
image: fluxer-push
dockerfile: fluxer_push/Dockerfile
build-version: ${{ inputs['build-version'] }}
+6 -6
View File
@@ -19,22 +19,22 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout fluxer
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
+6 -6
View File
@@ -35,24 +35,24 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
token: ${{ steps.create-token.outputs.token }}
fetch-depth: 0
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: "24"
node-version: "26"
cache: "pnpm"
- name: Install dependencies
+6 -6
View File
@@ -40,7 +40,7 @@ jobs:
permission-pull-requests: write
- name: Checkout Weblate branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
token: ${{ steps.create-token.outputs.token }}
ref: ${{ env.WEBLATE_BRANCH }}
@@ -48,17 +48,17 @@ jobs:
persist-credentials: false
- name: Set up Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: "24"
node-version: "26"
cache: "pnpm"
- name: Install dependencies
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
permission-pull-requests: write
- name: Label pull request
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13
with:
repo-token: ${{ steps.create-token.outputs.token }}
configuration-path: .github/labeller.yaml
+5 -5
View File
@@ -56,15 +56,15 @@ jobs:
contents: write
packages: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
toolchain: "1.98.1"
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ghcr.io
username: ${{ github.actor }}
+78 -56
View File
@@ -28,12 +28,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
@@ -42,7 +42,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -55,16 +55,16 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -83,12 +83,12 @@ jobs:
PNPM_TEST_WORKSPACE_CONCURRENCY: '2'
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
@@ -97,7 +97,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -105,12 +105,12 @@ jobs:
run: cargo build --locked --package fluxer-ci
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -123,12 +123,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -142,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.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -156,21 +164,21 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
components: clippy, rustfmt
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Cache cargo
@@ -185,11 +193,14 @@ jobs:
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.19.6 --locked
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check libfluxwebp dependencies
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
@@ -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
@@ -273,12 +287,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
- name: Cache cargo (gateway NIFs)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
@@ -294,7 +308,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -305,7 +319,7 @@ jobs:
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
with:
otp-version: '28'
rebar3-version: '3.24.0'
rebar3-version: '3.27.0'
- name: Restore rebar3 dependencies
id: rebar3-cache
@@ -317,10 +331,10 @@ jobs:
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >-
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
restore-keys: |
rebar3-${{ runner.os }}-otp28-rebar3.24.0-
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
- name: Check formatting
run: |
@@ -348,7 +362,7 @@ jobs:
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
key: >-
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
'fluxer_gateway/rebar.config') }}
knip:
@@ -358,12 +372,12 @@ jobs:
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.93.0"
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Restore ci helper
@@ -372,7 +386,7 @@ jobs:
with:
path: target/debug/fluxer-ci
key: >-
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
- name: Build ci helper
@@ -380,12 +394,12 @@ jobs:
run: cargo build --locked --package fluxer-ci
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -398,12 +412,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -417,12 +435,16 @@ jobs:
with:
path: |
fluxer_app/pkgs/libfluxcore
fluxer_app/pkgs/libfluxwebp
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
key: >-
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
'fluxer_app/rust/libfluxwebp/simd/**',
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
'packages/markdown_parser/rust/src/**') }}
@@ -431,15 +453,15 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -456,15 +478,15 @@ jobs:
timeout-minutes: 25
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -490,15 +512,15 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
- name: Install Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: '24'
node-version: '26'
cache: 'pnpm'
- name: Install dependencies
@@ -515,12 +537,12 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.13"
python-version: "3.14"
- name: Install font tooling
run: python3 -m pip install -r tools/fonts/requirements.txt
+2
View File
@@ -10,6 +10,7 @@
/.direnv/
/.fluxer/
/.pnpm-store/
/.vscode/
**/*.css.d.ts
**/*.tsbuildinfo
@@ -25,6 +26,7 @@
/fluxer_app/.devserver-cache.json
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
Generated
+850 -859
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -7,15 +7,14 @@ 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",
"fluxer_unfurl",
"packages/markdown_parser/rust",
"fluxer_recon",
]
exclude = [
"packages/markdown_parser/rust/fuzz",
+157 -3
View File
@@ -6,18 +6,172 @@
</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] | [APK][android-apk] | [TestFlight][ios-testflight] |
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
| [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-apk]: https://github.com/fluxerapp/flutter_client/releases
[obtainium]: https://obtainium.imranr.dev/
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
[docs]: https://docs.fluxer.app
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
+3 -2
View File
@@ -48,7 +48,7 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"preset": "recommended",
"complexity": {
"noForEach": "off",
"noImportantStyles": "off",
@@ -83,6 +83,7 @@
}
},
"useConst": "error",
"noDescendingSpecificity": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"noRestrictedImports": {
@@ -98,7 +99,7 @@
}
},
"a11y": {
"recommended": true,
"preset": "recommended",
"useAriaPropsForRole": "error",
"useValidAriaRole": "error",
"useValidAriaValues": "error",
+1 -6
View File
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
FLUXER_S3_FORCE_PATH_STYLE=true
FLUXER_S3_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
@@ -65,11 +64,6 @@ 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_RECON_MODE=observing
FLUXER_RECON_EXPECTED_ROOMS=64
FLUXER_RECON_WARMUP_SECONDS=15
FLUXER_RECON_MAX_HOT_ROOMS=8
FLUXER_API_PORT=8080
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
FLUXER_API_WORKER_MODE=all_lanes
@@ -135,6 +129,7 @@ PUBLIC_RELEASE_CHANNEL=canary
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
AWS_EC2_METADATA_DISABLED=true
AWS_ACCESS_KEY_ID=fluxer
AWS_SECRET_ACCESS_KEY=fluxer-secret
+18 -10
View File
@@ -79,34 +79,42 @@ deny = [
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
]
skip = [
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older crypto API" },
{ crate = "[email protected].7", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected].6", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
{ crate = "[email protected].6", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected].4", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected].8", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected].5", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected].6", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected].7", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "s[email protected]0", reason = "transitive dependency requires the older socket API" },
{ crate = "s[email protected].0", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI binding API" },
]
skip-tree = []
+117 -186
View File
@@ -1,108 +1,64 @@
# Every variable docker-compose.yml reads is named here, uncommented when it has
# no default and commented with its default when it has one. A name absent from
# this file reaches a service only through a Compose override. Compose expands
# top to bottom, so a line using ${...} must sit below every name it reads.
# 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.
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
# The lines above are the address browsers use, and every advertised endpoint
# carries FLUXER_PUBLIC_PORT. They do not move what the host publishes.
# FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below do that, and a non-default port
# needs the matching one set as well. Complete recipes sit beside them.
# The address browsers use. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below decide
# which host ports Fluxer binds.
# How browsers reach this instance.
#
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
# Point DNS at this host.
#
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
# proxy serves, not this local port.
# By default Fluxer binds 80 and 443 and gets its own certificate. Point DNS here.
# Behind your own reverse proxy, uncomment this instead: Fluxer then serves plain
# HTTP on 127.0.0.1:8080. Keep the scheme and port above describing the public
# address, not this one.
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
# is on another machine, and firewall the port to that machine.
# Where that plain-HTTP port binds. Use 0.0.0.0:8080 only when the proxy is on
# another machine, and firewall it to that machine.
#FLUXER_EDGE_BIND=127.0.0.1:8080
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
# this to the real client address, so IP bans, rate limits and abuse detection
# see the caller rather than the proxy. The default covers proxies on private or
# loopback addresses, which is every same-host setup. Set it to your proxy's
# address if it reaches Fluxer from a public IP.
# Which hops may set X-Forwarded-For. The default covers private and loopback
# addresses. Set your proxy's address if it reaches Fluxer from a public IP.
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
# The origin browsers see, without a trailing slash. Leave it unset and each
# service builds one from the three values at the top of this file. Set it and it
# wins: every service reads the host, the scheme and the port out of it and
# ignores those three names. Use it when browsers reach the instance on a host
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
# and an optional port and nothing after them, or the services refuse to start.
# It does not move the edge listener or the published ports either, so set the
# publish below to the port written here.
# The origin browsers see, no trailing slash. Set it when browsers reach the
# instance on a host FLUXER_DOMAIN does not name, and it wins over the three
# values above. Scheme, host and optional port only. It does not move the
# published ports.
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
# Overrides the address the edge listens on inside its container. Compose builds
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
# site by host and ignores the port in the Host header, so a request arriving on
# a non-default published port still lands on this site. Put a port in this value
# only if you also publish that same container port below, or nothing will be
# listening where the publish points. Honoured in the default mode only:
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
# literal :80, and Compose lets the last file win, so a value here is discarded
# under either overlay with no warning. Set it for an unusual default-mode
# layout, such as serving several hostnames. Write the scheme into it: a bare
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
# The address the edge listens on inside its container. Both proxy overlays set
# this themselves, so a value here is ignored under either. Include the scheme.
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
# The old name for the value above, read only when FLUXER_EDGE_SITE_ADDRESS is
# unset, so an existing .env keeps the listener it already had.
# The old name for the line above, read only when it is unset.
#FLUXER_CADDY_SITE_ADDRESS=
# Host side of the edge's publishes, and the only names that decide which host
# ports Fluxer binds. The container side is fixed. Container 80 carries the
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
# scheme, and the site itself under an http one. Container 443 carries the TLS
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
# HTTP/3 needs both on the same port. Both take an optional bind address in front
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
# them different host ports: the same host port on both is two publishes of one
# port and the edge refuses to start.
# Host ports. Container 80 handles the redirect and the certificate challenge,
# container 443 the TLS site. FLUXER_HTTPS_PORT moves TCP and UDP together, since
# HTTP/3 needs both. Both accept a bind address. Give them different host ports.
#FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80
#FLUXER_HTTPS_PORT=127.0.0.1:443
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
# certificate is issued if a router in front forwards public 80 to this host and
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
# cannot.
# HTTPS on 8443. Host 80 stays published for the certificate challenge, which
# only ever arrives on public 80 or 443. Serve your own certificate if nothing
# forwards those.
#FLUXER_PUBLIC_PORT=8443
#FLUXER_HTTPS_PORT=8443
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
# binds host 80. Under an http scheme nothing listens on container 443, so the
# last line parks that publish on loopback for a host that wants 443 for
# something else. Drop it and 443 is published and idle, which is what earlier
# releases did.
# Plain HTTP on 19080. Nothing binds host 80, and the last line parks the idle
# 443 publish on loopback.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_HTTP_PORT=19080
#FLUXER_HTTPS_PORT=127.0.0.1:443
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
# still moves that one publish. Set the line below and plain docker compose
# commands pick the file up, or add it to your own -f flags if you pass any. The
# file uses the !override tag, which needs Compose 2.24.4 or newer.
# A tunnel needs no HTTPS publish. tunnel.compose.yml ships beside this file and
# leaves one loopback HTTP publish. Needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
FLUXER_REGISTRY_OWNER=fluxerapp
@@ -111,11 +67,8 @@ FLUXER_IMAGE_TAG=v1
POSTGRES_PASSWORD=CHANGE_ME
MEILI_MASTER_KEY=CHANGE_ME
# The stack ships its own Postgres and its own object store, and points at both
# by service name. Set these to run either one outside the stack. Leave them
# unset and the bundled services are used. Taking a service out of the stack
# means an upgrade skips the backup step that reaches into it, and backing that
# store up belongs to whoever runs it.
# 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.
#FLUXER_POSTGRES_HOST=db.example.com
#FLUXER_POSTGRES_PORT=5432
#FLUXER_POSTGRES_DATABASE=fluxer
@@ -125,19 +78,15 @@ 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 object store creates whichever names these hold, so
# the two stay in step. An object store outside the stack needs the buckets to
# 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
# The rest of the bundled services, pointed somewhere else the same way. Leave a
# line unset and the service in the stack is used. Taking a service out of the
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
# replaces docker-compose.yml.
# The other bundled services, pointed elsewhere. Removing a service from the
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
#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
@@ -145,24 +94,27 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_SEARCH_URL=https://search.example.com
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
# Voice off. The livekit service still runs until an override file takes it out.
# Voice off. The livekit service still runs until an override removes it.
#FLUXER_LIVEKIT_ENABLED=false
# Optional systems, each off unless the instance is configured for it.
# Optional systems, each off unless configured.
#FLUXER_SMS_ENABLED=false
#FLUXER_STRIPE_ENABLED=false
#FLUXER_NCMEC_ENABLED=false
#FLUXER_CLAMAV_ENABLED=false
# The client address. Set the header name a proxy in front actually writes, and
# turn the trust off when nothing sits in front, because a trusted header an
# attacker can set is a spoofed client address.
# Outside lookups, off unless turned on. The Tor exit list comes from
# onionoo.torproject.org and the breached password check asks
# api.pwnedpasswords.com.
#FLUXER_TOR_EXIT_LIST_ENABLED=true
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
# The client address. Name the header your proxy actually writes, and turn the
# trust off when nothing sits in front.
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
#FLUXER_TRUST_CLIENT_IP_HEADER=true
# How much the services write. trace, debug, info, warn, error or fatal. Every
# service names the object storage endpoint and its addressing at info on start,
# so a bucket that answers 404 is visible without raising this.
# How much the services write. trace, debug, info, warn, error or fatal.
#LOG_LEVEL=debug
FLUXER_S3_ACCESS_KEY=fluxer
@@ -177,32 +129,49 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
# The token every service sends to NATS. The bundled NATS runs without
# authentication, so this stays empty unless a Compose override points the stack
# at an external NATS that requires a token. Compose forwards the name to every
# container that connects.
# The token every service sends to NATS. The bundled NATS needs none, so this
# stays empty unless an override points at an external one.
#FLUXER_NATS_AUTH_TOKEN=
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
# only if that mailbox does not exist.
# Defaults to admin@ followed by FLUXER_DOMAIN. Set it if that mailbox does not
# exist.
#[email protected]
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
# invalidates every passkey already registered against the old value.
# Passkeys follow FLUXER_DOMAIN. Set these only if browsers use another host.
# Changing the RP ID invalidates every passkey registered against the old value.
#FLUXER_PASSKEY_RP_ID=chat.example.com
#FLUXER_PASSKEY_RP_NAME=Fluxer
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
# only when a browser must reach an origin the defaults do not cover, such as a
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
# sources with spaces or commas. Every one of them is empty by default, and the
# three carrying a value below are illustrations, not defaults.
# 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
# Optional media policies, both off by default. See the operator docs.
#
# CORS limits which web origins may read media. A request with no Origin is
# always served. Add https://web.fluxer.app if people use the hosted client.
#
# Signatures make an attachment read need a signed URL, so a copied link stops
# working. Needs a secret from openssl rand -base64 32, first entry signs and
# every entry verifies.
#
# Each mode is off, report or enforce. Start at report. media-proxy reads these
# at start, so apply with docker compose up -d media-proxy.
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
#FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE=enforce
# Extra Content-Security-Policy sources, appended to the built-in ones. Set one
# only when a browser must reach an origin the defaults do not cover. Separate
# several with spaces or commas. The three values below are illustrations.
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
@@ -214,39 +183,29 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_CSP_EXTRA_WORKER_SRC=
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
# directive off the header.
# One report-uri for CSP violation reports. Empty leaves the directive off.
#FLUXER_CSP_REPORT_URI=
# Allow the SSO identity provider to resolve to a private or internal address.
# Off by default: the API refuses to call non-public addresses so a misconfigured
# provider URL cannot be used to reach internal services. Turn it on only when the
# provider genuinely lives on your own network, such as split-horizon DNS or a LAN
# identity provider, and only when you trust everyone who can configure SSO.
# 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.
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
# These reach both LiveKit and the api. Change them together.
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# The URL browsers use for voice signalling. Compose builds it from
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
# leading http to ws itself. Set it only when LiveKit is served from another
# host.
# The URL browsers use for voice signalling. Built from the public origin plus
# /livekit. Set it only when LiveKit is served from another host.
#FLUXER_LIVEKIT_URL=
# Media ports. LiveKit advertises these in ICE candidates, so the host must
# forward the same numbers.
# Media ports. LiveKit advertises these, so forward the same numbers.
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# LiveKit finds the address browsers dial by asking a STUN server. A host that
# cannot reach one over UDP stops with "could not resolve external IP", and the
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the STUN entries at another
# server to keep the lookup and leave Google out of it.
# 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.
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
@@ -273,11 +232,9 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
FLUXER_DISCOVERY_ENABLED=true
# Container memory. The limits sum to 18.25 GiB, which is a sum of ceilings and
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
# The reservations are cgroup memory.low, which biases the kernel away from
# reclaiming from services whose death takes the instance down. They reserve
# nothing. Lower the limits on a smaller host.
# Container memory. These are ceilings, not allocations, and the defaults suit a
# 16 GB host. The reservations bias the kernel away from reclaiming from services
# whose death takes the instance down. Lower the limits on a smaller host.
#FLUXER_CADDY_MEMORY_LIMIT=256mb
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
@@ -294,6 +251,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
@@ -308,81 +266,54 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
# which is the container ceiling the indexer shares with the search process.
# Meilisearch indexing memory. Keep it well under the container limit above.
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
# SeaweedFS heap ceiling. Go collects against this value instead of against the
# container limit, which it cannot see, so without it an upload burst grows the
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
# together: the peak is the parts of one upload in flight at once, which is 25 MB
# times 20 for a 500 MB attachment.
# 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
# Node sizes its own heap from the container memory limit by default, at roughly
# 55 percent of it, which always leaves room for the buffers and stacks that live
# outside the heap. Leave these unset unless you have a reason to pin the value.
# Any value set here must stay well below the container limit above: a heap ceiling
# above the container limit makes the kernel OOM-kill the container (exit 137, no
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
# 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.
#FLUXER_API_NODE_HEAP_MB=1792
#FLUXER_WORKER_NODE_HEAP_MB=1792
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
# budget roughly shared_buffers + (server max_connections x 12 MB) +
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
# pool sizes used by the api, worker and shards.
# Bundled Postgres tuning. Keep it consistent with the memory limit above. This
# is the server setting, not the per-service pool sizes.
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
#FLUXER_POSTGRES_WORK_MEM=8MB
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
#FLUXER_POSTGRES_SHM_SIZE=1gb
# The bundled Valkey holds durable state as well as cache. The bulk message
# deletion queue and the account deletion queue are sorted sets with no expiry,
# and nothing else stores the first of the two. It therefore runs with an
# append-only file on a named volume and with noeviction, so an over-limit write
# fails loudly instead of silently deleting queued work. Distributed locks all
# carry a TTL and are not what the durability is for. Only change the policy if
# you have moved that durable state elsewhere.
# 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
# The gateway derives its BEAM scheduler count from the container CPU quota,
# clamped to this range. The floor matters: a single scheduler lets one blocking
# operation stall every websocket on the node. The ceiling stops a large host
# from starting far more schedulers than the container can actually use.
# 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
# In-flight request ceiling for the services Compose forwards it to: the users
# and messages routers and their shards. Leave it unset and each service uses its
# built-in default. Set it and the one value replaces that default on all of
# them, so size it for the busiest. The built-in defaults are 192 for
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
# Compose does not forward this to. A router holds a slot for the whole round
# trip to its shard, so this is a ceiling on requests in flight at once and not a
# rate: too low a value does not slow requests down, it rejects them. The api
# turns that rejection into a 503 and logs "shard rejected the request because
# it is at its concurrency limit".
# 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
# The api and the Rust services name their fixed Postgres statement shapes so the
# server can reuse their plans. Named prepared statements require a session that
# outlives the transaction, so set this to false if you put a transaction-pooling
# connection pooler such as PgBouncer in front of Postgres. One setting governs
# every service. The bundled compose talks to Postgres directly, where naming is
# a win and the default is correct.
# Named prepared statements need a session that outlives the transaction, so set
# this to false behind a transaction-pooling connection pooler. The bundled
# compose talks to Postgres directly, where the default is correct.
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
# The api bounds how long a client may take to send a request. The header timeout
# covers the request line and headers only, while the request timeout covers the
# whole exchange, so a slow uploader is bounded by the second value and not by
# the first. Raise both if you front large uploads or serve clients on high
# latency links. The header timeout is clamped down to the request timeout, so
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
# How long a client may take to send a request. The header timeout covers the
# request line and headers, the request timeout the whole exchange, and the first
# is clamped down to the second. Milliseconds, 1000 to 3600000.
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
+39 -11
View File
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
FLUXER_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_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
@@ -44,7 +46,6 @@ x-fluxer-env: &fluxer-env
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
FLUXER_S3_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}
@@ -97,6 +98,7 @@ x-fluxer-env: &fluxer-env
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ${FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64:-}
FLUXER_ADMIN_SECRET_KEY_BASE: ${FLUXER_ADMIN_SECRET_KEY_BASE:?set FLUXER_ADMIN_SECRET_KEY_BASE in .env}
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
@@ -122,7 +124,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
services:
edge:
image: caddy:2.10-alpine
image: caddy:2.11-alpine
deploy:
resources:
limits:
@@ -186,7 +188,7 @@ services:
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
@@ -200,7 +202,7 @@ services:
retries: 10
valkey:
image: valkey/valkey:8.1-alpine
image: valkey/valkey:9.1-alpine
deploy:
resources:
limits:
@@ -236,7 +238,7 @@ services:
retries: 10
meilisearch:
image: getmeili/meilisearch:v1.12
image: getmeili/meilisearch:v1.53
deploy:
resources:
limits:
@@ -246,6 +248,7 @@ services:
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_UPGRADE_DB: "true"
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
@@ -257,7 +260,7 @@ services:
retries: 10
seaweedfs:
image: chrislusf/seaweedfs:4.34
image: chrislusf/seaweedfs:4.47
deploy:
resources:
limits:
@@ -266,7 +269,7 @@ services:
networks: [fluxer]
environment:
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
command: ["server", "-s3", "-dir=/data"]
command: ["server", "-s3", "-dir=/data", "-master.telemetry=false"]
volumes:
- seaweedfs-data:/data
healthcheck:
@@ -277,7 +280,7 @@ services:
start_period: 60s
seaweedfs-init:
image: chrislusf/seaweedfs:4.34
image: chrislusf/seaweedfs:4.47
deploy:
resources:
limits:
@@ -291,14 +294,13 @@ 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}
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
if ! nc -z seaweedfs 9333 2>/dev/null; then
@@ -413,7 +415,6 @@ services:
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
FLUXER_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)}"]
@@ -473,11 +474,38 @@ services:
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_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"
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_HOST: 0.0.0.0
FLUXER_PUSH_SERVICE_PORT: "8126"
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: "${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}"
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: "${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}"
healthcheck:
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
nats: {condition: service_healthy}
api: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
+13 -13
View File
@@ -9,32 +9,32 @@ build = "build.rs"
[dependencies]
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1"
base64 = "0.23.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.1"
cookie = "0.18.2"
fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10"
regress = "0.11"
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
regress = "0.12"
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
time = { version = "0.3.47", features = ["formatting", "parsing"] }
tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal"] }
time = { version = "0.3.55", features = ["formatting", "parsing"] }
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["compression-gzip", "trace"] }
tower-http = { version = "0.7.1", features = ["compression-gzip", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5"
urlencoding = "2.1.3"
progenitor-client = { version = "0.14.0", default-features = false }
progenitor-client = { version = "0.15.0", default-features = false }
[build-dependencies]
openapiv3 = "2.2.0"
prettyplease = "0.2"
progenitor = { version = "0.14.0", default-features = false }
prettyplease = "0.3"
progenitor = { version = "0.15.0", default-features = false }
serde_json = "1"
sha2 = "0.11.0"
syn = "2"
syn = "3"
+3 -3
View File
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
FROM rust:1-bookworm AS builder
FROM rust:1-trixie AS builder
ARG BUILD_VERSION=""
ARG TARGETARCH
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
&& npm install -g pnpm@10.29.3 \
&& npm install -g pnpm@11.27.0 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
@@ -57,7 +57,7 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
&& echo "Latin-core fonts bundled successfully"
FROM debian:bookworm-slim AS runtime
FROM debian:trixie-slim AS runtime
ARG BUILD_VERSION=""
ARG SOURCE_SHA=""
+12 -2
View File
@@ -54,9 +54,9 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
.generate_tokens(&spec)
.expect("failed to generate admin API client");
let content = prettyplease::unparse(
let content = relax_required_nullable_fields(&prettyplease::unparse(
&syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens"),
);
));
let output_path = out_dir.join("admin_api_generated.rs");
fs::write(&output_path, content).expect("failed to write generated API code");
@@ -190,6 +190,16 @@ fn object_schema_mut<'a>(
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
fn relax_required_nullable_fields(generated: &str) -> String {
const PRESENCE_CHECK: &str =
"#[serde(deserialize_with = \"::std::option::Option::deserialize\")]";
generated
.lines()
.filter(|line| line.trim() != PRESENCE_CHECK)
.flat_map(|line| [line, "\n"])
.collect()
}
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
let registry = spec.components.clone().unwrap_or_default();
+223 -98
View File
@@ -933,6 +933,22 @@
},
"description": "Filter by target entity ID (user, channel, role, invite code, etc.)"
},
{
"name": "access",
"in": "query",
"required": false,
"schema": {
"description": "Only return entries recorded by reads or only entries recorded by writes",
"x-enumNames": ["read", "write"],
"x-enumDescriptions": [
"An entry recorded by an operation that only reads data",
"An entry recorded by an operation that changes data or triggers work"
],
"enum": ["read", "write"],
"type": "string"
},
"description": "Only return entries recorded by reads or only entries recorded by writes"
},
{
"name": "sort_by",
"in": "query",
@@ -5427,59 +5443,6 @@
}
}
},
"/admin/system/heap-snapshots": {
"post": {
"operationId": "create_admin_system_heap_snapshot",
"summary": "Create a V8 heap snapshot",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/octet-stream": {"schema": {"$ref": "#/components/schemas/HeapSnapshotResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.",
"security": [{"adminApiKey": []}]
}
},
"/admin/users": {
"get": {
"operationId": "list_admin_users",
@@ -9874,7 +9837,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -9904,7 +9867,6 @@
"required": ["users", "total"],
"additionalProperties": false
},
"HeapSnapshotResponse": {"type": "string", "format": "binary", "description": "V8 heap snapshot file"},
"SendSystemDmRequest": {
"type": "object",
"properties": {
@@ -10562,6 +10524,8 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"push_service_delivery": {"$ref": "#/components/schemas/PushServiceDeliveryConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
"type": "object",
@@ -10579,13 +10543,13 @@
"created_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"expires_at": {
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"max_uses": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 9007199254740991},
"use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
@@ -10593,14 +10557,14 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"approval_required": {"type": "boolean"},
"last_used_at": {
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"last_used_by_user_id": {
"nullable": true,
@@ -10636,7 +10600,7 @@
"requested_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"registration_url_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 128},
"client_ip": {"nullable": true, "type": "string"}
@@ -10671,7 +10635,9 @@
"logo_url": {"nullable": true, "type": "string"},
"wordmark_url": {"nullable": true, "type": "string"},
"favicon_url": {"nullable": true, "type": "string"},
"theme_color": {"nullable": true, "type": "string"}
"theme_color": {"nullable": true, "type": "string"},
"status_page_url": {"nullable": true, "type": "string"},
"status_page_incident_history_url": {"nullable": true, "type": "string"}
},
"required": [
"product_name",
@@ -10680,7 +10646,9 @@
"logo_url",
"wordmark_url",
"favicon_url",
"theme_color"
"theme_color",
"status_page_url",
"status_page_incident_history_url"
],
"additionalProperties": false
},
@@ -10889,12 +10857,14 @@
"min_lifetime_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
"max_lifetime_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
@@ -10902,12 +10872,14 @@
"renew_threshold_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
"renew_window_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
@@ -10918,15 +10890,31 @@
"min_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true},
"max_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true},
"max_eligible_size_mb": {"type": "number", "minimum": 0, "exclusiveMinimum": true},
"min_lifetime_days": {"type": "integer", "exclusiveMinimum": true, "maximum": 9007199254740991},
"max_lifetime_days": {"type": "integer", "exclusiveMinimum": true, "maximum": 9007199254740991},
"curve": {"type": "number", "minimum": 0, "maximum": 1},
"renew_threshold_days": {
"min_lifetime_days": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
"renew_window_days": {"type": "integer", "exclusiveMinimum": true, "maximum": 9007199254740991}
"max_lifetime_days": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
"curve": {"type": "number", "minimum": 0, "maximum": 1},
"renew_threshold_days": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
"renew_window_days": {
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
}
},
"required": [
"enabled",
@@ -10965,6 +10953,8 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"push_service_delivery",
"domain_migration",
"experiment_delivery",
"registration",
"self_hosted",
@@ -10983,7 +10973,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"max_uses": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 1000000},
"approval_required": {"default": false, "type": "boolean"}
@@ -11001,13 +10991,13 @@
"created_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"expires_at": {
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"max_uses": {"nullable": true, "type": "integer", "minimum": 1, "maximum": 9007199254740991},
"use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991},
@@ -11015,14 +11005,14 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"approval_required": {"type": "boolean"},
"last_used_at": {
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"last_used_by_user_id": {
"nullable": true,
@@ -11099,6 +11089,14 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"push_service_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/PushServiceDeliveryConfigUpdateRequest"}]
},
"domain_migration": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
@@ -11144,7 +11142,9 @@
"logo_url": {"nullable": true, "type": "string", "maxLength": 2048},
"wordmark_url": {"nullable": true, "type": "string", "maxLength": 2048},
"favicon_url": {"nullable": true, "type": "string", "maxLength": 2048},
"theme_color": {"nullable": true, "type": "string", "maxLength": 64}
"theme_color": {"nullable": true, "type": "string", "maxLength": 64},
"status_page_url": {"nullable": true, "type": "string", "maxLength": 2048},
"status_page_incident_history_url": {"nullable": true, "type": "string", "maxLength": 2048}
}
},
"setup": {"nullable": true, "type": "object", "properties": {"configured": {"type": "boolean"}}},
@@ -11254,12 +11254,14 @@
"min_lifetime_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
"max_lifetime_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
@@ -11267,12 +11269,14 @@
"renew_threshold_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
},
"renew_window_days": {
"nullable": true,
"type": "integer",
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 9007199254740991
}
@@ -11304,7 +11308,7 @@
"properties": {
"enabled": {"type": "boolean"},
"window_hours": {"type": "number", "minimum": 0, "exclusiveMinimum": true, "maximum": 8760},
"member_threshold": {"type": "integer", "exclusiveMinimum": true, "maximum": 1000000}
"member_threshold": {"type": "integer", "minimum": 0, "exclusiveMinimum": true, "maximum": 1000000}
}
}
}
@@ -12621,6 +12625,16 @@
}
},
"action": {"type": "string", "minLength": 1, "maxLength": 256},
"access": {
"description": "Whether the recorded operation read data or changed it",
"x-enumNames": ["read", "write"],
"x-enumDescriptions": [
"An entry recorded by an operation that only reads data",
"An entry recorded by an operation that changes data or triggers work"
],
"enum": ["read", "write"],
"type": "string"
},
"audit_log_reason": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 4000},
"metadata": {"type": "object", "additionalProperties": {"type": "string", "maxLength": 4000}},
"created_at": {"type": "string"}
@@ -12638,6 +12652,7 @@
"related_guilds",
"related_channels",
"action",
"access",
"audit_log_reason",
"metadata",
"created_at"
@@ -12726,7 +12741,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12744,7 +12759,7 @@
"type": "string"
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12774,7 +12789,7 @@
"maximum": 365
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12795,7 +12810,7 @@
"type": "string"
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12808,7 +12823,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -12899,7 +12914,6 @@
"report:view",
"report:view:reporter_pii",
"system_dm:send",
"system:heap_snapshot",
"user:cancel:bulk_message_deletion",
"user:delete",
"user:disable:suspicious",
@@ -13011,14 +13025,14 @@
"description": "ISO 8601 timestamp when the bot token was created",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"client_secret_created_at": {
"nullable": true,
"description": "ISO 8601 timestamp when the client secret was created",
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"version": {
"description": "The optimistic locking version of the application record",
@@ -13446,7 +13460,7 @@
"timestamp": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "The ISO 8601 timestamp of when the message was created"
},
"edited_timestamp": {
@@ -13454,7 +13468,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"pinned": {"type": "boolean", "description": "Whether the message is pinned"},
"mention_everyone": {"type": "boolean", "description": "Whether the message mentions @everyone"},
@@ -13554,7 +13568,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": ["participants"],
@@ -13591,7 +13605,7 @@
"timestamp": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "The ISO 8601 timestamp of when the message was created"
},
"edited_timestamp": {
@@ -13599,7 +13613,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"pinned": {"type": "boolean", "description": "Whether the message is pinned"},
"mention_everyone": {"type": "boolean", "description": "Whether the message mentions @everyone"},
@@ -13699,7 +13713,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
}
},
"required": ["participants"],
@@ -13863,7 +13877,7 @@
"timestamp": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "The ISO 8601 timestamp of when the original message was created"
},
"edited_timestamp": {
@@ -13871,7 +13885,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"mentions": {
"description": "The user IDs mentioned in the snapshot",
@@ -14040,7 +14054,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"description": {"description": "The description of the embed", "nullable": true, "type": "string"},
"author": {
@@ -14252,7 +14266,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"description": {"description": "The description of the embed", "nullable": true, "type": "string"},
"author": {
@@ -15124,7 +15138,7 @@
"joined_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "ISO8601 timestamp of when the user joined the guild"
},
"mute": {"type": "boolean", "description": "Whether the member is muted in voice channels"},
@@ -15134,7 +15148,7 @@
"nullable": true,
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"profile_flags": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/GuildMemberProfileFlags"}]},
"mention_flags": {
@@ -15176,6 +15190,44 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"type": "boolean"}
}
},
"PushServiceDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
}
},
"VoiceNoiseSuppressionConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15211,7 +15263,6 @@
"required": ["guild_id", "backend"]
}
},
"stereo_enabled": {"type": "boolean"},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
@@ -15242,6 +15293,82 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "domain-migration-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"anonymous_rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"standalone_forwarding": {"default": false, "type": "boolean"}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids",
"anonymous_rollout_basis_points",
"standalone_forwarding"
],
"additionalProperties": false
},
"PushServiceDeliveryConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {
"default": "push-service-delivery-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_salt",
"included_user_ids",
"excluded_user_ids"
],
"additionalProperties": false
},
"VoiceNoiseSuppressionConfigResponse": {
"type": "object",
"properties": {
@@ -15286,7 +15413,6 @@
"additionalProperties": false
}
},
"stereo_enabled": {"default": false, "type": "boolean"},
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
@@ -15300,7 +15426,6 @@
"included_user_ids",
"excluded_user_ids",
"guild_overrides",
"stereo_enabled",
"suppression_strength"
],
"additionalProperties": false
@@ -15438,7 +15563,7 @@
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
"deletion_public_reason": {"nullable": true, "type": "string"},
"acls": {"maxItems": 112, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 111, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
-2
View File
@@ -79,7 +79,6 @@ pub const REPORT_RESOLVE: &str = "report:resolve";
pub const REPORT_VIEW: &str = "report:view";
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
pub const SYSTEM_HEAP_SNAPSHOT: &str = "system:heap_snapshot";
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_DELETE: &str = "user:delete";
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
@@ -192,7 +191,6 @@ pub const ALL_ACLS: &[&str] = &[
REPORT_VIEW,
REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND,
SYSTEM_HEAP_SNAPSHOT,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_DELETE,
USER_DISABLE_SUSPICIOUS,
+41
View File
@@ -10,6 +10,7 @@ pub struct SearchAuditLogsParams {
pub admin_user_id: Option<String>,
pub target_id: Option<String>,
pub target_type: Option<String>,
pub access: Option<String>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
pub limit: u32,
@@ -21,6 +22,12 @@ impl AdminApiClient {
&self,
params: &SearchAuditLogsParams,
) -> ApiResult<AuditLogsListResponse> {
let access = params
.access
.as_deref()
.map(audit_access)
.transpose()?
.map(|value| value.to_string());
let sort_by = params
.sort_by
.as_deref()
@@ -46,6 +53,7 @@ impl AdminApiClient {
params.target_type.as_deref().unwrap_or_default(),
),
("target_id", params.target_id.as_deref().unwrap_or_default()),
("access", access.as_deref().unwrap_or_default()),
("sort_by", sort_by.as_deref().unwrap_or_default()),
("sort_order", sort_order.as_deref().unwrap_or_default()),
("limit", limit.as_str()),
@@ -55,6 +63,11 @@ impl AdminApiClient {
}
}
fn audit_access(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsAccess> {
generated_types::ListAdminAuditLogsAccess::try_from(value)
.map_err(|e| ApiError::Parse(e.to_string()))
}
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
let value = match value {
"created_at" => "createdAt",
@@ -83,6 +96,13 @@ mod tests {
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc");
}
#[test]
fn accepts_only_known_access_filters() {
assert_eq!(audit_access("read").unwrap().to_string(), "read");
assert_eq!(audit_access("write").unwrap().to_string(), "write");
assert!(audit_access("all").is_err());
}
#[test]
fn rejects_lossy_audit_totals() {
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
@@ -99,6 +119,7 @@ mod tests {
"admin_user_id": "234567890123456789",
"admin_user": null,
"action": "USER_UPDATE",
"access": "write",
"target_id": "345678901234567890",
"target_type": "user",
"target_user": null,
@@ -118,6 +139,26 @@ mod tests {
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
assert_eq!(response.logs[0].access.as_deref(), Some("write"));
assert_eq!(serde_json::to_value(response).unwrap(), json);
}
#[test]
fn deserializes_audit_entries_from_an_api_without_access() {
let json = serde_json::json!({
"logs": [{
"log_id": "123456789012345678",
"admin_user_id": "234567890123456789",
"action": "USER_UPDATE",
"target_id": "345678901234567890",
"target_type": "user",
"audit_log_reason": null,
"metadata": {},
"created_at": "2026-09-11T12:00:00.000Z"
}],
"total": 1
});
let response: AuditLogsListResponse = serde_json::from_value(json).unwrap();
assert_eq!(response.logs[0].access, None);
}
}
+7 -5
View File
@@ -14,8 +14,8 @@ impl AdminApiClient {
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
add_flags: user_flags(add_flags)?,
remove_flags: user_flags(remove_flags)?,
user_ids: snowflakes(user_ids),
};
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
@@ -112,11 +112,13 @@ fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
values.iter().map(|value| snowflake(value)).collect()
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
.cloned()
.map(generated_types::UserFlags::from)
.map(|value| {
generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect()
}
+2
View File
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
#[serde(default)]
pub admin_user: Option<AuditLogUserSummary>,
pub action: String,
#[serde(default)]
pub access: Option<String>,
pub target_id: String,
pub target_type: String,
#[serde(default)]
+139 -5
View File
@@ -23,6 +23,10 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub push_service_delivery: PushServiceDeliveryConfigResponse,
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
}
@@ -326,6 +330,8 @@ pub struct AppBrandingConfigResponse {
pub wordmark_url: Option<String>,
pub favicon_url: Option<String>,
pub theme_color: Option<String>,
pub status_page_url: Option<String>,
pub status_page_incident_history_url: Option<String>,
}
impl Default for AppBrandingConfigResponse {
@@ -338,6 +344,8 @@ impl Default for AppBrandingConfigResponse {
wordmark_url: None,
favicon_url: None,
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
}
}
}
@@ -442,7 +450,9 @@ impl VoiceE2eeScope {
}
}
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const PUSH_SERVICE_DELIVERY_DEFAULT_SALT: &str = "push-service-delivery-v1";
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -488,7 +498,6 @@ pub struct VoiceNoiseSuppressionConfigResponse {
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
pub stereo_enabled: bool,
pub suppression_strength: u32,
}
@@ -505,7 +514,6 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
guild_overrides: Vec::new(),
stereo_enabled: false,
suppression_strength: 80,
}
}
@@ -532,11 +540,93 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stereo_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_strength: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PushServiceDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
}
impl Default for PushServiceDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PUSH_SERVICE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PushServiceDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct DomainMigrationConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub excluded_user_ids: Vec<String>,
pub anonymous_rollout_basis_points: u32,
pub standalone_forwarding: bool,
}
impl Default for DomainMigrationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
anonymous_rollout_basis_points: 0,
standalone_forwarding: false,
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct ExperimentDeliveryConfigResponse {
@@ -653,6 +743,10 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push_service_delivery: Option<PushServiceDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
}
@@ -858,6 +952,10 @@ pub struct AppBrandingConfigUpdateRequest {
pub favicon_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub theme_color: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_page_incident_history_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -986,18 +1084,30 @@ mod tests {
.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 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 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_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_domain_migration)
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
@@ -1005,6 +1115,7 @@ mod tests {
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("DomainMigrationConfigResponse", domain_migration),
("ExperimentDeliveryConfigResponse", delivery),
] {
for (field, value) in value.as_object().expect("config object") {
@@ -1040,4 +1151,27 @@ mod tests {
json!({})
);
}
#[test]
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
let update = DomainMigrationConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+7 -5
View File
@@ -95,8 +95,8 @@ impl AdminApiClient {
remove_flags: &[String],
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserFlagsUpdateRequest {
add_flags: user_flags(add_flags),
remove_flags: user_flags(remove_flags),
add_flags: user_flags(add_flags)?,
remove_flags: user_flags(remove_flags)?,
};
let response = self
.generated()
@@ -567,11 +567,13 @@ fn bool_param(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
values
.iter()
.cloned()
.map(generated_types::UserFlags::from)
.map(|value| {
generated_types::UserFlags::try_from(value.as_str())
.map_err(|error| ApiError::Parse(error.to_string()))
})
.collect()
}
+2 -1
View File
@@ -121,6 +121,7 @@ pub async fn render(
admin_user_id: None,
target_id: Some(guild_id.to_owned()),
target_type: Some("guild".to_owned()),
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()),
limit: 50,
@@ -134,7 +135,7 @@ pub async fn render(
@if let Some(resp) = resp {
@if resp.logs.is_empty() {
p class="text-sm text-neutral-500" {
"No admin audit log entries for this guild."
"No admin write actions have been recorded for this guild."
}
} @else {
(table_container(table(maud::html! {
+1 -1
View File
@@ -80,7 +80,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);
+3
View File
@@ -28,6 +28,7 @@ struct AuditLogsQuery {
admin_user_id: Option<String>,
target_id: Option<String>,
target_type: Option<String>,
access: Option<String>,
sort_by: Option<String>,
sort_order: Option<String>,
limit: Option<u32>,
@@ -119,6 +120,7 @@ async fn audit_logs_page(
admin_user_id: query.admin_user_id.as_deref().unwrap_or(""),
target_id: query.target_id.as_deref().unwrap_or(""),
target_type: query.target_type.as_deref().unwrap_or(""),
access: query.access.as_deref().unwrap_or(""),
sort_by: query.sort_by.as_deref().unwrap_or("createdAt"),
sort_order: query.sort_order.as_deref().unwrap_or("desc"),
limit,
@@ -131,6 +133,7 @@ async fn audit_logs_page(
admin_user_id: nonempty(params.admin_user_id),
target_id: nonempty(params.target_id),
target_type: nonempty(params.target_type),
access: nonempty(params.access),
sort_by: Some(params.sort_by.to_owned()),
sort_order: Some(params.sort_order.to_owned()),
limit,
+219 -43
View File
@@ -7,7 +7,8 @@ use crate::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
DeferredPhoneGateUpdateRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
@@ -17,9 +18,9 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode,
PushServiceDeliveryConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
},
},
@@ -207,6 +208,14 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_push_service_delivery" => match build_push_service_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"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_experiment_delivery" => match build_experiment_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -447,10 +456,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
}
}
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
@@ -476,35 +485,51 @@ where
Ok(Some(value))
}
fn parse_voice_noise_suppression_rollout_salt(
fn parse_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let Some(raw) = form.first("voice_ns_rollout_salt") else {
let Some(raw) = form.first(key) else {
return Ok(None);
};
let salt = raw.trim();
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
return Err(format!(
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
));
}
Ok(Some(salt.to_owned()))
}
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
fn parse_ascii_experiment_rollout_salt(
form: &MultiValueForm,
key: &str,
) -> Result<Option<String>, String> {
let salt = parse_experiment_rollout_salt(form, key)?;
if let Some(value) = salt.as_deref()
&& !value
.bytes()
.all(|byte| byte.is_ascii_graphic() || byte == b' ')
{
return Err("Rollout salt must use printable ASCII".to_owned());
}
Ok(salt)
}
fn is_experiment_snowflake(value: &str) -> bool {
!value.is_empty()
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
&& value.bytes().all(|byte| byte.is_ascii_digit())
}
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
let mut ids: Vec<String> = Vec::new();
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
let candidate = candidate.trim();
if candidate.is_empty() {
continue;
}
if !is_voice_noise_suppression_snowflake(candidate) {
if !is_experiment_snowflake(candidate) {
return Err(format!(
"{label} entry {} must contain 1 to 20 decimal digits",
index + 1
@@ -513,9 +538,9 @@ fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Ve
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());
@@ -536,7 +561,7 @@ fn parse_voice_noise_suppression_guild_overrides(
format!("Guild overrides line {line_number} must use guild_id=backend")
})?;
let guild_id = guild_id.trim();
if !is_voice_noise_suppression_snowflake(guild_id) {
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"
));
@@ -602,21 +627,20 @@ fn build_voice_noise_suppression_update(
"voice_ns_rollout_basis_points",
"Rollout basis points",
0,
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_included_user_ids").unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_ns_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(
form,
"voice_ns_suppression_strength",
@@ -629,6 +653,78 @@ fn build_voice_noise_suppression_update(
})
}
fn build_push_service_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
push_service_delivery: Some(PushServiceDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("push_service_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"push_service_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"push_service_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("push_service_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_domain_migration_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
domain_migration: Some(DomainMigrationConfigUpdateRequest {
enabled: Some(form.bool_value("domain_migration_enabled")),
rollout_basis_points: parse_form_number(
form,
"domain_migration_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_ascii_experiment_rollout_salt(
form,
"domain_migration_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
anonymous_rollout_basis_points: parse_form_number(
form,
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
standalone_forwarding: Some(form.bool_value("domain_migration_standalone_forwarding")),
}),
..Default::default()
})
}
fn build_experiment_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
@@ -683,6 +779,8 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
wordmark_url: optional("app_wordmark_url"),
favicon_url: optional("app_favicon_url"),
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"),
}),
setup: Some(AppSetupConfigUpdateRequest {
configured: Some(form.bool_value("app_setup_configured")),
@@ -1244,7 +1342,6 @@ mod tests {
.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)
@@ -1270,7 +1367,6 @@ mod tests {
serde_json::json!({"voice_noise_suppression": {
"enabled": false,
"allow_user_override": false,
"stereo_enabled": false,
"enabled_backends": [],
"included_user_ids": [],
"excluded_user_ids": [],
@@ -1305,9 +1401,9 @@ mod tests {
}
#[test]
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
fn parse_experiment_user_ids_splits_newlines_and_commas() {
assert_eq!(
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
.expect("valid IDs"),
vec![
"1".to_owned(),
@@ -1320,16 +1416,15 @@ mod tests {
}
#[test]
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
fn parse_experiment_user_ids_dedupes_preserving_order() {
assert_eq!(
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
.expect("valid IDs"),
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
);
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
for value in [
"abc",
"12a",
@@ -1339,11 +1434,8 @@ mod tests {
"<script>",
] {
assert_eq!(
parse_voice_noise_suppression_user_ids(
&format!("123,{value}"),
"Included user IDs"
)
.expect_err("invalid ID"),
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
.expect_err("invalid ID"),
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
"{value}"
);
@@ -1351,18 +1443,17 @@ mod tests {
}
#[test]
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids =
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
.expect_err("too many IDs"),
"Included user IDs must contain at most 1000 unique IDs"
);
@@ -1555,6 +1646,91 @@ mod tests {
}
}
#[test]
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",
);
let update = build_domain_migration_update(&form)
.expect("valid form")
.domain_migration
.expect("domain migration update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(250));
assert_eq!(update.rollout_salt, Some("domain-migration-v2".to_owned()));
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()
])
);
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
assert_eq!(update.standalone_forwarding, Some(true));
}
#[test]
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!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"domain_migration": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
"standalone_forwarding": false,
}})
);
}
#[test]
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"domain_migration_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=10001",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_anonymous_rollout_basis_points=abc",
"Anonymous rollout basis points must be a whole number between 0 and 10000",
),
(
"domain_migration_rollout_salt=%20%20",
"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",
),
(
"domain_migration_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_domain_migration_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
+6 -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,
@@ -245,6 +241,7 @@ pub async fn render(
admin_user_id: None,
target_id: Some(user_id.to_owned()),
target_type: None,
access: Some("write".to_owned()),
sort_by: Some("created_at".to_owned()),
sort_order: Some("desc".to_owned()),
limit,
@@ -72,7 +72,7 @@ pub fn audit_logs_for_target(
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
let page_number = u64::from(current_page) + 1;
let all_logs_href = format!(
"{base_path}/audit-logs?target_id={}",
"{base_path}/audit-logs?target_id={}&access=write",
urlencoding::encode(target_id)
);
@@ -94,7 +94,7 @@ pub fn audit_logs_for_target(
}
}
@if entries.is_empty() {
(empty_state("No admin actions have been recorded against this entity."))
(empty_state("No admin write actions have been recorded against this entity."))
} @else {
(table_container(html! {
(table(html! {
@@ -22,6 +22,7 @@ pub struct AuditLogsParams<'a> {
pub admin_user_id: &'a str,
pub target_id: &'a str,
pub target_type: &'a str,
pub access: &'a str,
pub sort_by: &'a str,
pub sort_order: &'a str,
pub limit: u32,
@@ -42,6 +43,11 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
("file_sha", "File SHA"),
("email", "Email"),
];
let access_options: &[(&str, &str)] = &[
("", "All entries"),
("write", "Writes only"),
("read", "Reads only"),
];
let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")];
let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")];
let limit_options: &[(&str, &str)] =
@@ -60,6 +66,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
"Filter by admin user ID..."))
(select_input("target_type", "Target type",
target_type_options, params.target_type))
(select_input("access", "Access", access_options, params.access))
(select_input("sort_by", "Sort by", sort_options, params.sort_by))
(select_input("sort_order", "Order", order_options, params.sort_order))
(select_input("limit", "Page size", limit_options, &limit_str))
@@ -81,6 +88,7 @@ fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) ->
("admin_user_id", params.admin_user_id),
("target_id", params.target_id),
("target_type", params.target_type),
("access", params.access),
("sort_by", params.sort_by),
("sort_order", params.sort_order),
]
@@ -169,6 +177,7 @@ mod tests {
admin_user_id: "",
target_id: "",
target_type: "bulk_job",
access: "",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
@@ -176,5 +185,28 @@ mod tests {
};
let markup = filters_section("/admin", &params).into_string();
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
}
#[test]
fn access_filter_survives_form_and_pagination() {
let params = AuditLogsParams {
query: "",
admin_user_id: "",
target_id: "1500000000000000001",
target_type: "",
access: "read",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
current_page: 0,
};
let markup = filters_section("/admin", &params).into_string();
assert!(markup.contains(r#"<select id="access" name="access""#));
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
assert_eq!(
build_pagination_url("/admin", 1, &params),
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
);
}
}
@@ -2,12 +2,13 @@
use crate::{
api::types::{
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
VoiceNoiseSuppressionConfigResponse,
AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, NoiseSuppressionBackend, PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
PendingRegistrationResponse, PushServiceDeliveryConfigResponse, RegistrationUrlResponse,
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -148,6 +149,8 @@ pub fn instance_config_page(
html! {
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
(push_service_delivery_section(base, csrf_token, &instance_config.push_service_delivery))
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -848,6 +851,18 @@ fn app_public_config_section(
app_public.branding.favicon_url.as_deref().unwrap_or(""),
"https://example.com/favicon.ico",
))
(text_input(
"app_status_page_url",
"Status page URL",
app_public.branding.status_page_url.as_deref().unwrap_or(""),
"https://fluxerstatus.com",
))
(text_input(
"app_status_page_incident_history_url",
"Status page history URL",
app_public.branding.status_page_incident_history_url.as_deref().unwrap_or(""),
"https://fluxerstatus.com/history",
))
}
div class="space-y-2" {
(checkbox(
@@ -1093,12 +1108,12 @@ fn voice_noise_suppression_section(
))
(entry_count_hint(
voice_noise_suppression.included_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save; blank entries and duplicate \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
@@ -1113,11 +1128,11 @@ fn voice_noise_suppression_section(
))
(entry_count_hint(
voice_noise_suppression.excluded_user_ids.len(),
VOICE_NS_MAX_TARGETED_USERS,
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage, so this is the per-user kill switch."
percentage. This is the per-user kill switch."
}
}
@@ -1145,17 +1160,6 @@ fn voice_noise_suppression_section(
}
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
(checkbox(
"voice_ns_stereo_enabled",
"true",
"Process stereo input instead of downmixing to mono",
voice_noise_suppression.stereo_enabled,
true,
))
p class="text-xs text-neutral-500" {
"Costs more CPU on the client. Leave off unless you are testing stereo \
capture."
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(number_field(
"voice_ns_suppression_strength",
@@ -1175,6 +1179,248 @@ fn voice_noise_suppression_section(
)
}
fn push_service_delivery_section(
base: &str,
csrf_token: &str,
push_service_delivery: &PushServiceDeliveryConfigResponse,
) -> Markup {
let status = if push_service_delivery.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = push_service_delivery.included_user_ids.join("\n");
let excluded_user_ids = push_service_delivery.excluded_user_ids.join("\n");
section_card_with_description(
"Push Service Delivery",
"Routes push notification delivery for the selected accounts through the push service. \
Accounts the rollout does not select keep the current path.",
html! {
form method="post" action={(base) "/instance-config?action=update_push_service_delivery"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (push_service_delivery.config_version)
}
}
(checkbox(
"push_service_delivery_enabled",
"true",
"Hand push notifications to the push service",
push_service_delivery.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every notification keeps the \
current delivery path, so the rollout and targeting fields below have no \
effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"push_service_delivery_rollout_basis_points",
"Rollout (basis points)",
&push_service_delivery.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
div class="flex flex-col gap-2" {
(text_input(
"push_service_delivery_rollout_salt",
"Rollout Salt",
&push_service_delivery.rollout_salt,
PUSH_SERVICE_DELIVERY_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"push_service_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
push_service_delivery.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. This is the per-user kill switch."
}
}
(form_actions(html! {
(submit_button("Save Push Service Delivery Configuration"))
}))
}
}
},
)
}
fn domain_migration_section(
base: &str,
csrf_token: &str,
domain_migration: &DomainMigrationConfigResponse,
) -> Markup {
let status = if domain_migration.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = domain_migration.included_user_ids.join("\n");
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
section_card_with_description(
"Domain Migration",
"Moves web clients of the official instance from the legacy web app origin to the new \
one. Selected accounts copy their local data across and continue on the new origin. \
Clients of other instances read this configuration and ignore it.",
html! {
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (domain_migration.config_version)
}
}
(checkbox(
"domain_migration_enabled",
"true",
"Move selected web clients to the new origin",
domain_migration.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked no client \
starts a migration and clients that already migrated stop forwarding the \
legacy origin, so the rollout and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
(checkbox(
"domain_migration_standalone_forwarding",
"true",
"Forward installed desktop web apps to the new origin",
domain_migration.standalone_forwarding,
true,
))
p class="text-xs text-neutral-500" {
"Leave this off until the manifest scope extension and the association file \
are live and verified. While it is off, installed Chromium desktop apps copy \
their data across but stay on the legacy origin and offer to install the new \
app. Installed mobile and Safari apps never forward either way."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"domain_migration_rollout_basis_points",
"Rollout (basis points)",
&domain_migration.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
(number_field(
"domain_migration_anonymous_rollout_basis_points",
"Anonymous rollout (basis points)",
&domain_migration.anonymous_rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
))
div class="flex flex-col gap-2" {
(text_input(
"domain_migration_rollout_salt",
"Rollout Salt",
&domain_migration.rollout_salt,
DOMAIN_MIGRATION_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
which users and devices fall inside the percentages above. Leave it \
alone to keep the current cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
domain_migration.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"domain_migration_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
domain_migration.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage. It stops new migrations only. A user who already moved \
stays on the new origin."
}
}
(form_actions(html! {
(submit_button("Save Domain Migration Configuration"))
}))
}
}
},
)
}
fn experiment_delivery_section(
base: &str,
csrf_token: &str,
@@ -1183,7 +1429,7 @@ fn experiment_delivery_section(
section_card_with_description(
"Experiment Delivery",
"How often every client revalidates its experiment assignments. This is instance-wide \
and covers every experiment, not just the one above. Raising the interval sheds \
and covers every experiment, not just the ones above. Raising the interval sheds \
request volume and makes a change take longer to reach a client. Raising the jitter \
spreads a fleet that has synchronised on one tick back out across the interval.",
html! {
@@ -1818,10 +2064,32 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
let domain_migration = DomainMigrationConfigResponse {
anonymous_rollout_basis_points: 250,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..DomainMigrationConfigResponse::default()
};
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
assert!(markup.contains("action=update_domain_migration"));
assert!(markup.contains("domain_migration_enabled"));
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect(),
..VoiceNoiseSuppressionConfigResponse::default()
@@ -114,7 +114,10 @@ pub fn users_list_page(
let content = html! {
div class="space-y-6" {
(page_header("Users", None))
div class="rounded-lg bg-white transition-all border border-neutral-200 p-4" {
div class="rounded-lg bg-white transition-all border border-neutral-200 p-3" {
p class="mb-1 text-xs text-neutral-500" {
"For example, type " span class="font-mono" { "*" } " in to search for all users."
}
(search_form(base, params))
}
(results_markup)
+29 -1
View File
@@ -230,6 +230,7 @@ fn deserialize_audit_logs_response() {
"target_type": "user",
"target_id": "1130958221824557056",
"action": "list_user_sessions",
"access": "read",
"audit_log_reason": null,
"metadata": {"session_count": "3"},
"created_at": "2026-05-26T13:21:47.138Z"
@@ -243,6 +244,7 @@ fn deserialize_audit_logs_response() {
assert_eq!(resp.logs.len(), 1);
assert_eq!(resp.logs[0].log_id, "1508822460457747580");
assert_eq!(resp.logs[0].action, "list_user_sessions");
assert_eq!(resp.logs[0].access.as_deref(), Some("read"));
assert_eq!(resp.logs[0].target_type, "user");
assert!(resp.logs[0].audit_log_reason.is_none());
assert_eq!(resp.logs[0].metadata.get("session_count").unwrap(), "3");
@@ -401,13 +403,31 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"push_service_delivery": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 5000,
"rollout_salt": "push-service-delivery-v1",
"included_user_ids": ["1500000000000000002"],
"excluded_user_ids": []
},
"domain_migration": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 2500,
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
"future_migration_knob": 9,
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
@@ -537,6 +557,13 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
assert!(resp.domain_migration.enabled);
assert_eq!(resp.domain_migration.config_version, 2);
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert_eq!(resp.experiment_delivery.poll_interval_seconds, 300);
assert!(resp.policy.single_community_guild_id.is_none());
assert_eq!(resp.policy.services.gif_enabled, Some(true));
@@ -547,6 +574,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
.replace("\"future_rollout_knob\": 3,", "")
.replace("\"future_presentation_knob\": \"verbose\",", "")
.replace("\"future_knob\": 7,", "")
.replace("\"future_migration_knob\": 9,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
+92 -1
View File
@@ -168,6 +168,39 @@ async fn detail_tab_routes_return_layout_or_fragments_by_route_shape() {
}
}
#[tokio::test]
async fn target_audit_log_tabs_request_write_entries_only() {
let app = setup().await;
for path in [
"/users/1500000000000000001/tabs/audit_logs",
"/guilds/1600000000000000001/tabs/audit_logs",
] {
let fragment = get(&app, path, &[]).await;
assert!(fragment.contains("Temp ban"), "{path}\n{fragment}");
assert!(!fragment.contains("Get user"), "{path}\n{fragment}");
}
}
#[tokio::test]
async fn audit_log_page_forwards_the_access_filter() {
let app = setup().await;
let all = get(&app, "/audit-logs", &[]).await;
assert!(all.contains("Temp ban"), "{all}");
assert!(all.contains("Get user"), "{all}");
assert!(
all.contains(r#"<option value="" selected>All entries</option>"#),
"{all}"
);
let reads = get(&app, "/audit-logs?access=read", &[]).await;
assert!(reads.contains("Get user"), "{reads}");
assert!(!reads.contains("Temp ban"), "{reads}");
assert!(
reads.contains(r#"<option value="read" selected>Reads only</option>"#),
"{reads}"
);
}
#[tokio::test]
async fn report_routes_keep_layout_and_fragment_contract() {
let app = setup().await;
@@ -432,6 +465,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_voice_noise_suppression",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -783,6 +817,9 @@ async fn spawn_mock_api() -> String {
async fn mock_api(method: Method, uri: Uri) -> Response {
let path = uri.path().to_owned();
if method == Method::PATCH && path == "/admin/instance/config" {
return json_response(instance_config());
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
(Method::GET, "/admin/api-keys") => json_response(json!([])),
@@ -844,6 +881,25 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
json_response(instance_config_without_pending_registrations())
}
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
(Method::GET, "/admin/audit-logs") => {
let access = uri.query().and_then(|query| {
url::form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == "access")
.map(|(_, value)| value.into_owned())
});
let logs = [
audit_log_entry("1900000000000000101", "get_user", "read"),
audit_log_entry("1900000000000000102", "temp_ban", "write"),
]
.into_iter()
.filter(|entry| {
access
.as_deref()
.is_none_or(|access| entry.access.to_string() == access)
})
.collect::<Vec<_>>();
json_response(json!({ "total": logs.len(), "logs": logs }))
}
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
@@ -975,6 +1031,32 @@ fn guild_fixtures_match_generated_response_contracts() {
assert_eq!(detail.member_count, 12);
}
fn audit_log_entry(
log_id: &str,
action: &str,
access: &str,
) -> generated_types::AdminAuditLogResponseSchema {
serde_json::from_value(json!({
"log_id": log_id,
"admin_user_id": "1500000000000000000",
"admin_user": null,
"target_type": "user",
"target_id": "1500000000000000001",
"target_user": null,
"target_guild": null,
"target_channel": null,
"related_users": {},
"related_guilds": {},
"related_channels": {},
"action": action,
"access": access,
"audit_log_reason": null,
"metadata": {},
"created_at": "2026-09-16T12:00:00.000Z"
}))
.expect("audit log fixture must match the generated response contract")
}
fn searched_application() -> Value {
json!({
"id": "1700000000000000001",
@@ -1116,9 +1198,18 @@ fn instance_config() -> Value {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"domain_migration": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "domain-migration-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
+8 -12
View File
@@ -1,11 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
ARG BUILD_VERSION
FROM node:24-bookworm-slim AS base
FROM node:26-trixie-slim AS base
WORKDIR /usr/src/app
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
RUN npm install -g pnpm@11.27.0
FROM base AS deploy
@@ -23,9 +23,9 @@ COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter fluxer_api run build
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allow-unused-patches=true /out
FROM node:24-bookworm-slim
FROM node:26-trixie-slim
ARG BUILD_VERSION
ARG SOURCE_SHA
@@ -45,32 +45,28 @@ LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/src/app/fluxer_api
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/sources.list.d/backports.list && \
apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
ffmpeg \
libimage-exiftool-perl \
libwebp7 \
libwebpmux3 \
libheif1 \
libvips42 && \
apt-get install -y --no-install-recommends -t bookworm-backports \
libheif-plugin-libde265 \
libheif-plugin-dav1d && \
libheif-plugin-dav1d \
libvips42t64 && \
rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
RUN npm install -g pnpm@11.27.0
COPY --from=deploy /out ./
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
COPY --from=deploy /usr/src/app/tsconfigs /usr/src/app/tsconfigs
RUN rm -rf pkgs && \
mkdir -p /usr/src/app/.cache/corepack && \
chown -R 65532:65532 /usr/src/app
ENV HOME=/usr/src/app
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
ENV NODE_ENV=production
ENV NODE_OPTIONS="--enable-source-maps"
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
+8 -7
View File
@@ -5,19 +5,19 @@
"scripts": {
"build": "node scripts/build.mjs",
"test": "vitest run",
"typecheck": "tsgo --noEmit",
"typecheck": "tsc --noEmit",
"dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts",
"start": "tsx src/AppEntrypoint.ts",
"start:worker": "tsx src/WorkerEntrypoint.ts"
},
"dependencies": {
"@atproto/api": "catalog:",
"@atproto/jwk-jose": "catalog:",
"@atproto/oauth-client-node": "catalog:",
"@aws-sdk/client-s3": "catalog:",
"@aws-sdk/lib-storage": "catalog:",
"@aws-sdk/s3-request-presigner": "catalog:",
"@bluesky-social/jwk-jose": "catalog:",
"@bluesky-social/oauth-client-node": "catalog:",
"@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protobuf": "^2.15.0",
"@elastic/elasticsearch": "catalog:",
"@fluxer/config": "workspace:*",
"@fluxer/constants": "workspace:*",
@@ -34,6 +34,8 @@
"@hono/node-server": "catalog:",
"@messageformat/core": "catalog:",
"@messageformat/parser": "catalog:",
"@nats-io/jetstream": "catalog:",
"@nats-io/transport-node": "catalog:",
"@pkgs/cache": "workspace:*",
"@pkgs/captcha": "workspace:*",
"@pkgs/cassandra": "workspace:*",
@@ -71,7 +73,6 @@
"lodash": "catalog:",
"maxmind": "catalog:",
"mime": "catalog:",
"nats": "catalog:",
"nodemailer": "catalog:",
"pg": "catalog:",
"pino": "catalog:",
@@ -88,10 +89,10 @@
"devDependencies": {
"@types/archiver": "catalog:",
"@types/lodash": "catalog:",
"@typescript/native-preview": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
},
"packageManager": "pnpm@10.29.3"
"packageManager": "pnpm@11.27.0"
}
+2 -2
View File
@@ -9,14 +9,14 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pkgs/kv_client": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
+2 -2
View File
@@ -7,7 +7,7 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/logger": "workspace:*",
@@ -15,6 +15,6 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
+2 -2
View File
@@ -7,13 +7,13 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"cassandra-driver": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
@@ -7,7 +7,7 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@elastic/elasticsearch": "catalog:",
@@ -15,6 +15,6 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
@@ -6,7 +6,7 @@ import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src
import type {ElasticsearchDistributedLock} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import {ElasticsearchIndexAdapter} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
import type {ElasticsearchFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {compactFilters, esTermFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {compactFilters, esTermFilter, esTermsFilter} from '@pkgs/elasticsearch_search/src/ElasticsearchFilterUtils';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<ElasticsearchFilter | undefined> {
@@ -15,6 +15,10 @@ function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<Elasticsear
if (filters.targetType) clauses.push(esTermFilter('targetType', filters.targetType));
if (filters.targetId) clauses.push(esTermFilter('targetId', filters.targetId));
if (filters.action) clauses.push(esTermFilter('action', filters.action));
if (filters.actions && filters.actions.length > 0) clauses.push(esTermsFilter('action.keyword', filters.actions));
if (filters.excludeActions && filters.excludeActions.length > 0) {
clauses.push({bool: {must_not: [esTermsFilter('action.keyword', filters.excludeActions)]}});
}
return compactFilters(clauses);
}
+2 -3
View File
@@ -9,7 +9,7 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/i18n": "workspace:*",
@@ -19,8 +19,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@types/nodemailer": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
@@ -2,7 +2,7 @@
import {createLogger} from '@fluxer/logger/src/Logger';
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import nodemailer from 'nodemailer';
import nodemailer, {type Transporter} from 'nodemailer';
const logger = createLogger('@pkgs/email/src/SmtpEmailProvider');
@@ -18,7 +18,7 @@ interface SmtpEmailConfig {
}
export class SmtpEmailProvider implements IEmailProvider {
private readonly transporter: nodemailer.Transporter;
private readonly transporter: Transporter;
constructor(config: SmtpEmailConfig) {
this.transporter = nodemailer.createTransport({
+2 -2
View File
@@ -7,7 +7,7 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
@@ -21,6 +21,6 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
+1 -2
View File
@@ -285,6 +285,5 @@ export function formatGeoipLocation(result: GeoipResult, locale?: string | null)
const localizedCountry = locale && result.countryCode ? countryDisplayName(result.countryCode, locale) : null;
const countryLabel = localizedCountry ?? result.countryName ?? result.countryCode;
if (countryLabel) parts.push(countryLabel);
if (parts.length === 0) return null;
return new Intl.ListFormat(locale ?? 'en', {style: 'narrow', type: 'unit'}).format(parts);
return parts.length > 0 ? parts.join(', ') : null;
}
+1 -1
View File
@@ -67,7 +67,7 @@ interface GeoipRuntimePathOptions {
}
export function parseGeoipSourceConfig(rawValue: string | undefined): GeoipSourceConfig {
if (!rawValue || !rawValue.startsWith('s3://')) {
if (!rawValue?.startsWith('s3://')) {
return createGeoipFilesystemSourceConfig(rawValue);
}
return parseGeoipS3SourceConfig(rawValue);
+13 -6
View File
@@ -11,6 +11,8 @@ interface PostgresIpInfoOptions {
}
const VALUE_SEPARATOR = '\u001f';
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
return options.client ?? options.getClient?.() ?? null;
@@ -34,12 +36,9 @@ async function upsertKvRow(
partitionKey: string,
key: string,
row: Record<string, unknown>,
ttlSeconds?: number,
ttlSeconds: number,
): Promise<void> {
const expiresAt =
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0
? new Date(Date.now() + ttlSeconds * 1000)
: null;
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await client.query(
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
@@ -77,7 +76,14 @@ export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInf
try {
const client = getClient(options);
if (!client) return;
await upsertKvRow(client, 'ipinfo_cache', rowKey([key]), rowKey([key]), {cache_key: key, payload}, ttlSeconds);
await upsertKvRow(
client,
'ipinfo_cache',
rowKey([key]),
rowKey([key]),
{cache_key: key, payload},
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_set');
}
@@ -124,6 +130,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_request_audit_record');
+2 -3
View File
@@ -9,7 +9,7 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
@@ -17,8 +17,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"undici-types": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
@@ -16,6 +16,7 @@ import {
} from '@pkgs/http_client/src/HttpClientRequestInternals';
import type {HttpClientMetrics, HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
import type {
FetchDispatcher,
HttpClient,
HttpClientFactoryOptions,
HttpMethod,
@@ -26,7 +27,6 @@ import type {
StreamResponse,
} from '@pkgs/http_client/src/HttpClientTypes';
import {HttpError} from '@pkgs/http_client/src/HttpError';
import type {Dispatcher} from 'undici-types';
const DEFAULT_SERVICE_NAME = 'unknown';
@@ -79,7 +79,7 @@ function createFetchInit(
headers: Headers,
body: string | undefined,
signal: AbortSignal,
dispatcher: Dispatcher | undefined,
dispatcher: FetchDispatcher | undefined,
): RequestInit {
return {
method,
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
import type {Dispatcher} from 'undici-types';
export type ResponseStream = ReadableStream<Uint8Array> | null;
export type FetchDispatcher = NonNullable<RequestInit['dispatcher']>;
export type HttpMethod = 'GET' | 'POST' | 'HEAD' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
export type RequestUrlValidationPhase = 'initial' | 'redirect';
@@ -14,7 +14,7 @@ export interface RequestUrlValidationContext {
}
export interface RequestUrlPolicy {
readonly dispatcher?: Dispatcher;
readonly dispatcher?: FetchDispatcher;
validate(url: URL, context: RequestUrlValidationContext): Promise<void>;
}
@@ -5,10 +5,13 @@ import dns from 'node:dns';
import type {LookupFunction} from 'node:net';
import {BlockList, isIP} from 'node:net';
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
import type {
FetchDispatcher,
RequestUrlPolicy,
RequestUrlValidationContext,
} from '@pkgs/http_client/src/HttpClientTypes';
import {HttpError} from '@pkgs/http_client/src/HttpError';
import {Agent} from 'undici';
import type {Dispatcher} from 'undici-types';
import {Agent, Dispatcher1Wrapper} from 'undici';
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
const DNS_CACHE_MAX_ENTRIES = 10000;
@@ -223,7 +226,7 @@ async function defaultLookupHost(hostname: string): Promise<Array<string>> {
return addresses.map((addressEntry) => addressEntry.address);
}
function createBlocklistDispatcher(allowPrivateAddresses: boolean): Dispatcher {
function createBlocklistDispatcher(allowPrivateAddresses: boolean): FetchDispatcher {
const lookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
if (error) {
@@ -246,15 +249,18 @@ function createBlocklistDispatcher(allowPrivateAddresses: boolean): Dispatcher {
callback(null, primary.address, primary.family);
});
};
return new Agent({
connect: {
lookup,
},
}) as unknown as Dispatcher;
return new Dispatcher1Wrapper(
new Agent({
allowH2: false,
connect: {
lookup,
},
}),
) as unknown as FetchDispatcher;
}
interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy {
readonly dispatcher: Dispatcher;
readonly dispatcher: FetchDispatcher;
}
export function createPublicInternetRequestUrlPolicy(
+2 -2
View File
@@ -9,7 +9,7 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ export interface IKVSubscription {
}
export interface KVPurgeBatchResult {
urls: Array<string>;
entries: Array<string>;
tokensConsumed: number;
}
+13 -11
View File
@@ -217,7 +217,7 @@ local refillIntervalMs = tonumber(ARGV[5])
local queueSize = redis.call('SCARD', queueKey)
if queueSize == 0 then
return '{"urls":[],"tokens":0}'
return '{"entries":[],"tokens":0}'
end
local tokens = maxTokens
@@ -237,14 +237,14 @@ end
local toPop = math.min(maxItems, math.floor(tokens), queueSize)
if toPop <= 0 then
redis.call('SET', bucketKey, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
return '{"urls":[],"tokens":0}'
return '{"entries":[],"tokens":0}'
end
local urls = redis.call('SPOP', queueKey, toPop)
tokens = tokens - #urls
local entries = redis.call('SPOP', queueKey, toPop)
tokens = tokens - #entries
redis.call('SET', bucketKey, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
return cjson.encode({urls = urls, tokens = #urls})
return cjson.encode({entries = entries, tokens = #entries})
`;
const CLAIM_BULK_DELETION_SCRIPT = `
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
@@ -295,6 +295,7 @@ export class KVClient implements IKVProvider {
connectTimeout: this.timeoutMs,
commandTimeout: this.timeoutMs,
maxRetriesPerRequest: 1,
protocol: 2,
retryStrategy: createRetryStrategy(),
});
}
@@ -311,6 +312,7 @@ export class KVClient implements IKVProvider {
connectTimeout: clusterConfig.timeoutMs,
commandTimeout: clusterConfig.timeoutMs,
maxRetriesPerRequest: 1,
protocol: 2,
},
scaleReads: 'master',
...(hasNatMap ? {natMap} : {}),
@@ -902,17 +904,17 @@ function parseRateLimitResult(value: unknown): KVRateLimitResult {
function parsePurgeBatchResult(value: unknown, maxItems: number): KVPurgeBatchResult {
const command = 'dequeuePurgeBatch';
if (!isJsonObject(value)) throw createInvalidResponseError(command, 'a purge batch object');
const {urls, tokens} = value;
const {entries, tokens} = value;
if (
!Array.isArray(urls) ||
!urls.every((url): url is string => typeof url === 'string') ||
!Array.isArray(entries) ||
!entries.every((entry): entry is string => typeof entry === 'string') ||
!isNonNegativeSafeInteger(tokens) ||
tokens !== urls.length ||
urls.length > maxItems
tokens !== entries.length ||
entries.length > maxItems
) {
throw createInvalidResponseError(command, 'a bounded string array and matching token count');
}
return {urls, tokensConsumed: tokens};
return {entries, tokensConsumed: tokens};
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
@@ -72,6 +72,7 @@ export class KVSubscription implements IKVSubscription {
connectTimeout: this.timeoutMs,
commandTimeout: this.timeoutMs,
maxRetriesPerRequest: 1,
protocol: 2,
retryStrategy: createRetryStrategy(),
};
const connection = this.mode === 'cluster' ? resolveKVClusterConnection(this.url, this.clusterNodes) : null;
@@ -153,7 +153,7 @@ describe('KVClient script execution', () => {
},
{
name: 'dequeuePurgeBatch',
reply: JSON.stringify({urls: ['https://fluxer.test/a.png'], tokens: 1}),
reply: JSON.stringify({entries: ['/attachments/1/2/a'], tokens: 1}),
keyCount: 2,
run: async (client) => client.dequeuePurgeBatch('queue:key', 'bucket:key', 10, 10, 1, 1000),
},
+2 -2
View File
@@ -9,14 +9,14 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
@@ -24,6 +24,10 @@
"import": "./src/MediaProxySigner.ts",
"types": "./src/MediaProxySigner.ts"
},
"./src/AttachmentUrlSignature": {
"import": "./src/AttachmentUrlSignature.ts",
"types": "./src/AttachmentUrlSignature.ts"
},
"./*": "./*"
},
"main": "./src/MediaProxyUtils.ts",
@@ -31,13 +35,13 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@types/node": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
@@ -0,0 +1,211 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import crypto from 'node:crypto';
export const ATTACHMENT_URL_TTL_SECS = 86_400;
export const ATTACHMENT_URL_BUCKET_SECS = 43_200;
export const ORDINARY_USAGE = '';
export const DATA_PACKAGE_USAGE = 'dp';
export type AttachmentUrlUsage = typeof ORDINARY_USAGE | typeof DATA_PACKAGE_USAGE;
export type SignatureParameterName = 'ex' | 'is' | 'hm' | 'uc';
const SIGNATURE_DOMAIN = 'fluxer-attachment-url-v1';
const ATTACHMENT_PATH_PREFIX = '/attachments/';
const SIGNATURE_PARAMETER_NAMES: ReadonlyArray<SignatureParameterName> = ['ex', 'is', 'hm', 'uc'];
const DATA_PACKAGE_EXPIRES = '0';
const WINDOW_HEX_LENGTH = 8;
const MAX_WINDOW_SECS = 0xff_ff_ff_ff;
const LEADING_SLASHES_REGEX = /^\/+/u;
const TRAILING_SLASHES_REGEX = /\/+$/u;
const textEncoder = new TextEncoder();
const strictTextDecoder = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true});
export interface AttachmentUrlWindow {
issued: number;
expires: number;
}
export interface SignAttachmentUrlOptions {
mediaEndpoint: string;
secret: Uint8Array;
nowSecs: number;
anchorSecs: number;
}
function hexNibble(byte: number): number {
if (byte >= 0x30 && byte <= 0x39) return byte - 0x30;
if (byte >= 0x41 && byte <= 0x46) return byte - 0x41 + 10;
if (byte >= 0x61 && byte <= 0x66) return byte - 0x61 + 10;
return -1;
}
function percentDecodeBytes(value: string, plusAsSpace: boolean): Uint8Array {
const bytes = textEncoder.encode(value);
const decoded = new Uint8Array(bytes.length);
let length = 0;
let index = 0;
while (index < bytes.length) {
const byte = bytes[index] as number;
if (byte === 0x25 && index + 2 < bytes.length) {
const high = hexNibble(bytes[index + 1] as number);
const low = hexNibble(bytes[index + 2] as number);
if (high >= 0 && low >= 0) {
decoded[length] = (high << 4) | low;
length += 1;
index += 3;
continue;
}
}
decoded[length] = plusAsSpace && byte === 0x2b ? 0x20 : byte;
length += 1;
index += 1;
}
return decoded.subarray(0, length);
}
export function percentDecodeStorageKey(path: string): string | null {
try {
return strictTextDecoder.decode(percentDecodeBytes(path.replace(LEADING_SLASHES_REGEX, ''), false));
} catch {
return null;
}
}
export function signatureParameterName(name: string): SignatureParameterName | null {
const decoded = percentDecodeBytes(name, true);
if (decoded.length !== 2) return null;
const candidate = String.fromCharCode(decoded[0] as number, decoded[1] as number);
return SIGNATURE_PARAMETER_NAMES.find((entry) => entry === candidate) ?? null;
}
export function isSignatureParameterName(name: string): boolean {
return signatureParameterName(name) !== null;
}
function isSafeStorageKey(key: string): boolean {
if (key.length === 0 || key.startsWith('/')) return false;
return key
.split('/')
.every((component) => component.length > 0 && component !== '.' && component !== '..' && !component.includes('\0'));
}
function firstIndexOf(value: string, characters: ReadonlyArray<string>): number {
let found = -1;
for (const character of characters) {
const index = value.indexOf(character);
if (index >= 0 && (found < 0 || index < found)) {
found = index;
}
}
return found;
}
function rawPathFromUrl(url: string): string | null {
const schemeIndex = url.indexOf('://');
if (schemeIndex < 0) return null;
const afterAuthority = url.slice(schemeIndex + 3);
const boundary = firstIndexOf(afterAuthority, ['/', '?', '#']);
if (boundary < 0 || afterAuthority[boundary] !== '/') return '';
const path = afterAuthority.slice(boundary);
const queryIndex = firstIndexOf(path, ['?', '#']);
return queryIndex < 0 ? path : path.slice(0, queryIndex);
}
function parseWebUrl(value: string): URL | null {
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed : null;
} catch {
return null;
}
}
export function attachmentStorageKeyFromUrl(url: string, mediaEndpoint: string): string | null {
const target = parseWebUrl(url);
const endpoint = parseWebUrl(mediaEndpoint);
if (!target || !endpoint || target.origin !== endpoint.origin) return null;
const path = rawPathFromUrl(url);
if (path === null) return null;
const endpointPath = (rawPathFromUrl(mediaEndpoint) ?? '').replace(TRAILING_SLASHES_REGEX, '');
if (!path.startsWith(`${endpointPath}${ATTACHMENT_PATH_PREFIX}`)) return null;
const storageKey = percentDecodeStorageKey(path.slice(endpointPath.length));
if (storageKey === null || !isSafeStorageKey(storageKey)) return null;
return storageKey;
}
interface SplitUrl {
base: string;
query: string;
fragment: string;
}
function splitUrl(url: string): SplitUrl {
const fragmentIndex = url.indexOf('#');
const head = fragmentIndex < 0 ? url : url.slice(0, fragmentIndex);
const fragment = fragmentIndex < 0 ? '' : url.slice(fragmentIndex);
const queryIndex = head.indexOf('?');
if (queryIndex < 0) return {base: head, query: '', fragment};
return {base: head.slice(0, queryIndex), query: head.slice(queryIndex + 1), fragment};
}
function preservedFields(query: string): Array<string> {
if (query.length === 0) return [];
return query.split('&').filter((field) => {
if (field.length === 0 || field === '=') return false;
const separator = field.indexOf('=');
return !isSignatureParameterName(separator < 0 ? field : field.slice(0, separator));
});
}
export function stripAttachmentSignature(url: string): string {
const {base, query, fragment} = splitUrl(url);
const preserved = preservedFields(query);
if (preserved.length === 0) return `${base}${fragment}`;
return `${base}?${preserved.join('&')}${fragment}`;
}
export function issueWindow(anchorSecs: number, nowSecs: number): AttachmentUrlWindow {
const elapsed = Math.max(0, nowSecs - anchorSecs);
const issued = anchorSecs + Math.floor(elapsed / ATTACHMENT_URL_BUCKET_SECS) * ATTACHMENT_URL_BUCKET_SECS;
return {issued, expires: issued + ATTACHMENT_URL_TTL_SECS};
}
export function canonicalInput(storageKey: string, exHex: string, isHex: string, usage: AttachmentUrlUsage): string {
return `${SIGNATURE_DOMAIN}\n${exHex}\n${isHex}\n${usage}\n${storageKey}`;
}
function windowHex(value: number): string {
return value.toString(16).padStart(WINDOW_HEX_LENGTH, '0');
}
function signUsage(url: string, options: SignAttachmentUrlOptions, usage: AttachmentUrlUsage): string {
const storageKey = attachmentStorageKeyFromUrl(url, options.mediaEndpoint);
if (storageKey === null) return url;
const {issued, expires} = issueWindow(options.anchorSecs, options.nowSecs);
if (!Number.isSafeInteger(issued) || issued < 0 || expires > MAX_WINDOW_SECS) return url;
const isDataPackage = usage === DATA_PACKAGE_USAGE;
const exHex = windowHex(isDataPackage ? 0 : expires);
const isHex = windowHex(issued);
const signature = crypto
.createHmac('sha256', options.secret)
.update(canonicalInput(storageKey, exHex, isHex, usage))
.digest('hex');
const signatureFields = isDataPackage
? `ex=${DATA_PACKAGE_EXPIRES}&is=${isHex}&hm=${signature}&uc=${DATA_PACKAGE_USAGE}`
: `ex=${exHex}&is=${isHex}&hm=${signature}`;
const {base, query, fragment} = splitUrl(url);
const preserved = preservedFields(query);
const fields = preserved.length === 0 ? signatureFields : `${signatureFields}&${preserved.join('&')}`;
return `${base}?${fields}${fragment}`;
}
export function signAttachmentUrl(url: string, options: SignAttachmentUrlOptions): string {
return signUsage(url, options, ORDINARY_USAGE);
}
export function signDataPackageAttachmentUrl(url: string, options: SignAttachmentUrlOptions): string {
return signUsage(url, options, DATA_PACKAGE_USAGE);
}
+2 -2
View File
@@ -10,13 +10,13 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"mime": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
+4 -3
View File
@@ -7,13 +7,14 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"nats": "catalog:"
"@nats-io/jetstream": "catalog:",
"@nats-io/transport-node": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {NatsConnection} from 'nats';
import type {NatsConnection} from '@nats-io/transport-node';
export interface INatsConnectionManager {
connect(): Promise<void>;
@@ -1,14 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type JetStreamClient, type JetStreamManager, jetstream, jetstreamManager} from '@nats-io/jetstream';
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
import type {JetStreamClient, JetStreamManager} from 'nats';
export class JetStreamConnectionManager extends NatsConnectionManager {
getJetStreamClient(): JetStreamClient {
return this.getConnection().jetstream();
return jetstream(this.getConnection());
}
async getJetStreamManager(): Promise<JetStreamManager> {
return this.getConnection().jetstreamManager();
return jetstreamManager(this.getConnection());
}
}
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {connect, DrainingConnectionError, type NatsConnection} from '@nats-io/transport-node';
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
import type {NatsConnectionOptions} from '@pkgs/nats/src/NatsConnectionOptions';
import {connect, ErrorCode, type NatsConnection, NatsError} from 'nats';
const DEFAULT_MAX_RECONNECT_ATTEMPTS = -1;
const DEFAULT_RECONNECT_TIME_WAIT_MS = 500;
@@ -46,7 +46,7 @@ export class NatsConnectionManager implements INatsConnectionManager {
});
await this.connectPromise;
if (generation !== this.drainGeneration) {
throw NatsError.errorForCode(ErrorCode.ConnectionDraining);
throw new DrainingConnectionError();
}
}
@@ -139,7 +139,7 @@ export class NatsConnectionManager implements INatsConnectionManager {
private assertNotDraining(): void {
if (this.drainPromise !== null) {
throw NatsError.errorForCode(ErrorCode.ConnectionDraining);
throw new DrainingConnectionError();
}
}
+2 -2
View File
@@ -7,7 +7,7 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"pg": "catalog:"
@@ -15,6 +15,6 @@
"devDependencies": {
"@types/node": "catalog:",
"@types/pg": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
+5 -3
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import type {Pool, PoolClient, QueryResult, QueryResultRow} from 'pg';
import type {Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow} from 'pg';
import pg from 'pg';
const MAX_DIAGNOSTIC_FIELD_LENGTH = 128;
@@ -131,7 +131,7 @@ class PostgresClient implements IPostgresClient {
}
private async openPool(): Promise<void> {
const pool = new pg.Pool({
const poolConfig: PoolConfig & {scramMaxIterations: number} = {
connectionString: this.config.url || undefined,
host: this.config.url ? undefined : (this.config.host ?? '127.0.0.1'),
port: this.config.url ? undefined : (this.config.port ?? 5432),
@@ -140,7 +140,9 @@ class PostgresClient implements IPostgresClient {
password: this.config.url ? undefined : (this.config.password ?? 'fluxer'),
ssl: this.config.ssl ? {rejectUnauthorized: true, ca: normalizePem(this.config.sslCa)} : undefined,
max: this.config.maxConnections ?? 20,
});
scramMaxIterations: 0,
};
const pool = new pg.Pool(poolConfig);
this.observePoolConnections(pool);
try {
const client = await pool.connect();
+2 -5
View File
@@ -7,16 +7,13 @@
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@pkgs/kv_client": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vitest": "catalog:"
"typescript": "catalog:ts7"
}
}
@@ -1,14 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
interface KVRequiredErrorOptions {
serviceName: string;
configPath: string;
}
export function throwKVRequiredError(options: KVRequiredErrorOptions): never {
const {serviceName, configPath} = options;
throw new Error(
`${serviceName} requires KV-backed rate limiting. ${configPath} is not set. ` +
`internal.kv must be configured for distributed rate limiting.`,
);
}
@@ -1,63 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {throwKVRequiredError} from '@pkgs/rate_limit/src/KVRequiredError';
import {describe, expect, it} from 'vitest';
describe('throwKVRequiredError', () => {
it('should throw an error with the service name', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'fluxer_api',
configPath: 'internal.kv.url',
}),
).toThrow('fluxer_api requires KV-backed rate limiting');
});
it('should include the config path in the error message', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'TestService',
configPath: 'config.kv.connection_string',
}),
).toThrow('config.kv.connection_string is not set');
});
it('should construct complete error message with all parts', () => {
let errorMessage = '';
try {
throwKVRequiredError({
serviceName: 'fluxer_admin',
configPath: 'admin.kv.endpoint',
});
} catch (error) {
if (error instanceof Error) {
errorMessage = error.message;
}
}
expect(errorMessage).toContain('fluxer_admin requires KV-backed rate limiting');
expect(errorMessage).toContain('admin.kv.endpoint is not set');
expect(errorMessage).toContain('internal.kv must be configured for distributed rate limiting');
});
it('should always throw (never return)', () => {
const fn = () =>
throwKVRequiredError({
serviceName: 'test',
configPath: 'test.path',
});
expect(fn).toThrow(Error);
});
it('should handle empty service name', () => {
expect(() =>
throwKVRequiredError({
serviceName: '',
configPath: 'internal.kv',
}),
).toThrow('requires KV-backed rate limiting');
});
it('should handle empty config path', () => {
expect(() =>
throwKVRequiredError({
serviceName: 'TestService',
configPath: '',
}),
).toThrow('is not set');
});
});
@@ -1,18 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineConfig} from 'vitest/config';
export default defineConfig({
resolve: {tsconfigPaths: true},
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+2 -2
View File
@@ -9,7 +9,7 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:ts7",
"vitest": "catalog:"
}
}
@@ -1,40 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {createMockLogger} from '@fluxer/logger/src/mock';
import {createSmsProvider} from '@pkgs/sms/src/providers/SmsProviderFactory';
import {describe, expect, it} from 'vitest';
describe('createSmsProvider', () => {
it('creates a test provider that accepts the configured code', async () => {
const provider = createSmsProvider({
mode: 'test',
logger: createMockLogger(),
verificationCode: '654321',
});
await expect(provider.startVerification('+15551234567')).resolves.toBeUndefined();
await expect(provider.checkVerification('+15551234567', '654321')).resolves.toBe(true);
await expect(provider.checkVerification('+15551234567', '123456')).resolves.toBe(false);
});
it('creates an unavailable provider that throws on verification checks', async () => {
const provider = createSmsProvider({
mode: 'unavailable',
logger: createMockLogger(),
});
await expect(provider.startVerification('+15551234567')).resolves.toBeUndefined();
await expect(provider.checkVerification('+15551234567', '123456')).rejects.toThrow(SmsVerificationUnavailableError);
});
it('creates a Twilio provider in twilio mode', async () => {
const provider = createSmsProvider({
mode: 'twilio',
config: {
accountSid: 'AC123',
authToken: 'twilio-secret',
verifyServiceSid: 'VA123',
},
logger: createMockLogger(),
fetchFn: async () => new Response(JSON.stringify({status: 'pending'}), {status: 200}),
});
await expect(provider.startVerification('+15551234567')).resolves.toBeUndefined();
});
});
@@ -1,46 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createMockLogger} from '@fluxer/logger/src/mock';
import {TestSmsProvider} from '@pkgs/sms/src/providers/TestSmsProvider';
import {describe, expect, it} from 'vitest';
describe('TestSmsProvider', () => {
describe('startVerification', () => {
it('completes without error', async () => {
const logger = createMockLogger();
const provider = new TestSmsProvider({logger});
await expect(provider.startVerification('+15551234567')).resolves.toBeUndefined();
});
it('supports different phone number formats', async () => {
const logger = createMockLogger();
const provider = new TestSmsProvider({logger});
await expect(provider.startVerification('+14155552671')).resolves.toBeUndefined();
await expect(provider.startVerification('+447911123456')).resolves.toBeUndefined();
await expect(provider.startVerification('+81312345678')).resolves.toBeUndefined();
});
});
describe('checkVerification', () => {
it('returns true for the default valid code', async () => {
const logger = createMockLogger();
const provider = new TestSmsProvider({logger});
await provider.startVerification('+15551234567');
const result = await provider.checkVerification('+15551234567', '123456');
expect(result).toBe(true);
});
it('returns false for invalid codes', async () => {
const logger = createMockLogger();
const provider = new TestSmsProvider({logger});
await provider.startVerification('+15551234567');
expect(await provider.checkVerification('+15551234567', '000000')).toBe(false);
expect(await provider.checkVerification('+15551234567', '654321')).toBe(false);
expect(await provider.checkVerification('+15551234567', 'abcdef')).toBe(false);
expect(await provider.checkVerification('+15551234567', '')).toBe(false);
});
it('supports custom verification code overrides', async () => {
const logger = createMockLogger();
const provider = new TestSmsProvider({logger, verificationCode: '654321'});
expect(await provider.checkVerification('+15551111111', '123456')).toBe(false);
expect(await provider.checkVerification('+15551111111', '654321')).toBe(true);
});
});
});
@@ -38,7 +38,7 @@ describe('TwilioSmsProvider', () => {
const fetchStub: typeof fetch = async (_input, init) => {
capturedRequest = {
url: String(_input),
authHeader: (init?.headers as Record<string, string>).Authorization,
authHeader: (init?.headers as Record<string, string>)?.Authorization,
body: init?.body as string,
};
return new Response(JSON.stringify({success: true}), {status: 200});
@@ -65,7 +65,7 @@ describe('TwilioSmsProvider', () => {
const fetchStub: typeof fetch = async (_input, init) => {
capturedRequest = {
url: String(_input),
authHeader: (init?.headers as Record<string, string>).Authorization,
authHeader: (init?.headers as Record<string, string>)?.Authorization,
body: init?.body as string,
};
return new Response(JSON.stringify({channel: 'auto'}), {status: 200});
@@ -232,7 +232,7 @@ describe('TwilioSmsProvider', () => {
capturedRequest = {
url: String(input),
method: init?.method,
authHeader: (init?.headers as Record<string, string>).Authorization,
authHeader: (init?.headers as Record<string, string>)?.Authorization,
};
return new Response(
JSON.stringify({
-1
View File
@@ -1,7 +1,6 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"types": ["node"],
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
+2 -2
View File
@@ -7,7 +7,7 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/logger": "workspace:*",
@@ -15,6 +15,6 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
+2 -2
View File
@@ -51,7 +51,7 @@
"./*": "./*"
},
"scripts": {
"typecheck": "tsgo --noEmit"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*",
@@ -60,6 +60,6 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"typescript": "catalog:ts7"
}
}
+2 -1
View File
@@ -45,10 +45,11 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
configureMiddleware(routes, {
logger,
nodeEnv: config.nodeEnv,
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
corsOrigins: [...config.endpoints.webAppOrigins, config.endpoints.marketing],
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
torExitBlockingEnabled: config.torExitList.enabled,
});
routes.onError(AbuseAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
+54
View File
@@ -166,3 +166,57 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
});
});
function withOptionalOutboundLookups(
master: MasterConfig,
selfHosted: boolean,
overrides: {torExitList?: boolean; breachedPasswordCheck?: boolean} = {},
): MasterConfig {
return {
...master,
integrations: {
...master.integrations,
tor_exit_list: {enabled: overrides.torExitList},
breached_password_check: {enabled: overrides.breachedPasswordCheck},
},
instance: {
...master.instance,
self_hosted: selfHosted,
},
};
}
describe('buildAPIConfigFromMaster optional outbound lookups', () => {
let master: MasterConfig;
beforeAll(async () => {
master = await loadConfig();
});
it('keeps both lookups on when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false));
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('leaves both lookups off on a self-hosted instance', () => {
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true));
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
it('lets a self-hosted operator switch each lookup on', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, true, {torExitList: true, breachedPasswordCheck: true}),
);
expect(config.torExitList.enabled).toBe(true);
expect(config.breachedPasswordCheck.enabled).toBe(true);
});
it('lets an operator switch each lookup off when the instance is not self-hosted', () => {
const config = buildAPIConfigFromMaster(
withOptionalOutboundLookups(master, false, {torExitList: false, breachedPasswordCheck: false}),
);
expect(config.torExitList.enabled).toBe(false);
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
+16 -22
View File
@@ -3,7 +3,6 @@
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
@@ -92,18 +91,6 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
const normalized = new Set<string>();
for (const value of values) {
const countryCode = value.trim().toUpperCase();
if (!/^[A-Z]{2}$/u.test(countryCode)) {
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
}
normalized.add(countryCode);
}
return normalized;
}
function mapPushProviderApps(
apps:
| Array<{
@@ -157,7 +144,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
const s3Buckets = s3Config.buckets ?? {
cdn: '',
uploads: '',
downloads: '',
reports: '',
harvests: '',
};
@@ -174,10 +160,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042,
@@ -263,6 +245,9 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
tokenTtlSecs: uploadRelayConfig.token_ttl_secs,
keepDirectCountries: uploadRelayConfig.keep_direct_countries,
},
attachmentUrls: {
secretsBase64: master.services.media_proxy.attachment_urls.secrets_base64,
},
},
geoip: geoipSourceConfig,
proxy: {
@@ -273,6 +258,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
apiPublic: master.endpoints.api,
apiClient: master.endpoints.api_client,
webApp: master.endpoints.app,
webAppOrigins: [...new Set([new URL(master.endpoints.app).origin, ...master.services.api.app_origin_aliases])],
gateway: master.endpoints.gateway,
media: master.endpoints.media,
marketing: master.endpoints.marketing,
@@ -287,8 +273,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
donationProxyKey,
},
hosts: {
invite: extractHostname(master.endpoints.invite),
gift: extractHostname(master.endpoints.gift),
marketing: extractHostname(master.endpoints.marketing),
unfurlIgnored: master.services.api.unfurl_ignored_hosts,
},
@@ -301,7 +285,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
},
s3Downloads: resolveDownloadsProvider(master),
s3: {
endpoint: s3Config.endpoint,
presignedUrlBase: s3Config.presigned_url_base,
@@ -345,6 +328,12 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
blocklistFeeds: {
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
},
torExitList: {
enabled: master.integrations.tor_exit_list.enabled ?? !master.instance.self_hosted,
},
breachedPasswordCheck: {
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
},
captcha: {
enabled: master.integrations.captcha.enabled,
provider: master.integrations.captcha.provider,
@@ -479,6 +468,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
wordmarkUrl: master.instance.branding.wordmark_url,
faviconUrl: master.instance.branding.favicon_url,
themeColor: master.instance.branding.theme_color,
statusPageUrl: master.instance.branding.status_page_url,
statusPageIncidentHistoryUrl: master.instance.branding.status_page_incident_history_url,
},
setup: {
configured: master.instance.setup.configured,
@@ -486,6 +477,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
abusePolicy: {
inboundPhoneCountryCodes: master.instance.abuse_policy.inbound_phone_country_codes,
phoneFlagging: {
enabled: master.instance.abuse_policy.phone_flagging.enabled,
exemptCountryCodes: master.instance.abuse_policy.phone_flagging.exempt_country_codes,
},
phoneVerification: {
inboundRequiredPrefixes: master.instance.abuse_policy.phone_verification.inbound_required_prefixes,
},
@@ -512,7 +507,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
validateResponses: resolveValidateResponses(master),
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
+38
View File
@@ -341,6 +341,7 @@ import {
type UsersPendingDeletionRow,
} from '@app/api/database/types/UserTypes';
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
import {seconds} from 'itty-time';
export const Users = defineTable<UserRow, 'user_id'>({
name: 'users',
@@ -499,16 +500,19 @@ export const GuildAuditLogs = defineTable<GuildAuditLogRow, 'guild_id' | 'log_id
name: 'guild_audit_logs_v2',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByUser = defineTable<GuildAuditLogRow, 'guild_id' | 'user_id' | 'log_id'>({
name: 'guild_audit_logs_v2_by_user',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'user_id', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByAction = defineTable<GuildAuditLogRow, 'guild_id' | 'action_type' | 'log_id'>({
name: 'guild_audit_logs_v2_by_action',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'action_type', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildAuditLogsByUserAction = defineTable<
GuildAuditLogRow,
@@ -517,6 +521,7 @@ export const GuildAuditLogsByUserAction = defineTable<
name: 'guild_audit_logs_v2_by_user_action',
columns: GUILD_AUDIT_LOG_COLUMNS,
primaryKey: ['guild_id', 'user_id', 'action_type', 'log_id'],
defaultTtlSeconds: seconds('45 days'),
});
export const GuildMembershipMetadata = defineTable<GuildMembershipMetadataRow, 'guild_id' | 'user_id'>({
name: 'guild_membership_metadata',
@@ -655,6 +660,7 @@ export const RecentMentions = defineTable<RecentMentionRow, 'user_id' | 'message
name: 'recent_mentions',
columns: RECENT_MENTION_COLUMNS,
primaryKey: ['user_id', 'message_id'],
defaultTtlSeconds: seconds('7 days'),
});
interface RecentMentionsByGuildRow {
@@ -678,6 +684,7 @@ export const RecentMentionsByGuild = defineTable<RecentMentionsByGuildRow, 'user
name: 'recent_mentions_by_guild',
columns: RECENT_MENTIONS_BY_GUILD_COLUMNS,
primaryKey: ['user_id', 'guild_id', 'message_id'],
defaultTtlSeconds: seconds('7 days'),
});
export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_id'>({
name: 'saved_messages',
@@ -688,6 +695,7 @@ export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 's
name: 'push_subscriptions',
columns: PUSH_SUBSCRIPTION_COLUMNS,
primaryKey: ['user_id', 'subscription_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const Payments = defineTable<PaymentRow, 'checkout_session_id'>({
name: 'payments',
@@ -854,11 +862,13 @@ export const EmailVerificationTokens = defineTable<EmailVerificationTokenRow, 't
name: 'email_verification_tokens',
columns: EMAIL_VERIFICATION_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('24 hours'),
});
export const PasswordResetTokens = defineTable<PasswordResetTokenRow, 'token_' | 'user_id'>({
name: 'password_reset_tokens',
columns: PASSWORD_RESET_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('24 hours'),
});
export const PasswordResetTokensByUserId = defineTable<
{
@@ -870,16 +880,19 @@ export const PasswordResetTokensByUserId = defineTable<
name: 'password_reset_tokens_by_user_id',
columns: ['user_id', 'token_'],
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('24 hours'),
});
export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'user_id'>({
name: 'email_revert_tokens',
columns: EMAIL_REVERT_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('48 hours'),
});
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
name: 'phone_tokens',
columns: PHONE_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('30 days'),
});
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
name: 'auth_sessions',
@@ -901,11 +914,13 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
name: 'auth_session_tombstones',
columns: AUTH_SESSION_TOMBSTONE_COLUMNS,
primaryKey: ['user_id', 'session_id_hash'],
defaultTtlSeconds: seconds('30 days'),
});
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
name: 'user_country_history',
columns: USER_COUNTRY_HISTORY_COLUMNS,
primaryKey: ['user_id', 'country'],
defaultTtlSeconds: seconds('365 days'),
});
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
name: 'mfa_backup_codes',
@@ -932,6 +947,7 @@ export const IpAuthorizationTokens = defineTable<IpAuthorizationTokenRow, 'token
name: 'ip_authorization_tokens',
columns: IP_AUTHORIZATION_TOKEN_COLUMNS,
primaryKey: ['token_', 'user_id'],
defaultTtlSeconds: seconds('30 minutes'),
});
export const AuthorizedIps = defineTable<AuthorizedIpRow, 'user_id' | 'ip'>({
name: 'authorized_ips_v2',
@@ -1057,26 +1073,31 @@ export const OAuth2AuthorizationCodes = defineTable<OAuth2AuthorizationCodeRow,
name: 'oauth2_authorization_codes',
columns: OAUTH2_AUTHORIZATION_CODE_COLUMNS,
primaryKey: ['code'],
defaultTtlSeconds: seconds('10 minutes'),
});
export const OAuth2AccessTokens = defineTable<OAuth2AccessTokenRow, 'token_'>({
name: 'oauth2_access_tokens',
columns: OAUTH2_ACCESS_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('7 days'),
});
export const OAuth2AccessTokensByUser = defineTable<OAuth2AccessTokenByUserRow, 'user_id' | 'token_'>({
name: 'oauth2_access_tokens_by_user',
columns: OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('7 days'),
});
export const OAuth2RefreshTokens = defineTable<OAuth2RefreshTokenRow, 'token_'>({
name: 'oauth2_refresh_tokens',
columns: OAUTH2_REFRESH_TOKEN_COLUMNS,
primaryKey: ['token_'],
defaultTtlSeconds: seconds('30 days'),
});
export const OAuth2RefreshTokensByUser = defineTable<OAuth2RefreshTokenByUserRow, 'user_id' | 'token_'>({
name: 'oauth2_refresh_tokens_by_user',
columns: OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS,
primaryKey: ['user_id', 'token_'],
defaultTtlSeconds: seconds('30 days'),
});
interface WebhooksByChannelRow {
@@ -1117,12 +1138,14 @@ export const JobsById = defineTable<JobByIdRow, 'job_id'>({
name: 'jobs_by_id',
columns: JOB_BY_ID_COLUMNS,
primaryKey: ['job_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const JobsByDayBucket = defineTable<JobByDayBucketRow, 'bucket_day' | 'created_at' | 'job_id'>({
name: 'jobs_by_day_bucket',
columns: JOB_BY_DAY_BUCKET_COLUMNS,
primaryKey: ['bucket_day', 'created_at', 'job_id'],
partitionKey: ['bucket_day'],
defaultTtlSeconds: seconds('90 days'),
});
export const JobsActive = defineTable<JobActiveRow, 'job_id'>({
name: 'jobs_active',
@@ -1133,11 +1156,13 @@ export const AttachmentUploadTracesByKey = defineTable<AttachmentUploadTraceByKe
name: 'attachment_upload_traces_by_key',
columns: ATTACHMENT_UPLOAD_TRACE_BY_KEY_COLUMNS,
primaryKey: ['upload_key'],
defaultTtlSeconds: seconds('30 days'),
});
export const AttachmentUploadTracesByAttachment = defineTable<AttachmentUploadTraceByAttachmentRow, 'attachment_id'>({
name: 'attachment_upload_traces_by_attachment',
columns: ATTACHMENT_UPLOAD_TRACE_BY_ATTACHMENT_COLUMNS,
primaryKey: ['attachment_id'],
defaultTtlSeconds: seconds('30 days'),
});
export const NcmecAttachmentSubmissions = defineTable<NcmecAttachmentSubmissionRow, 'attachment_id'>({
name: 'ncmec_attachment_submissions',
@@ -1154,6 +1179,7 @@ export const RegistrationEventsByIp = defineTable<RegistrationEventByIpRow, 'ip'
columns: REGISTRATION_EVENT_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsBySubnet = defineTable<
RegistrationEventBySubnetRow,
@@ -1164,6 +1190,7 @@ export const RegistrationEventsBySubnet = defineTable<
columns: REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByEmailDomain = defineTable<
RegistrationEventByEmailDomainRow,
@@ -1174,6 +1201,7 @@ export const RegistrationEventsByEmailDomain = defineTable<
columns: REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('30 days'),
});
export const RegistrationEventsByPlusAddressBase = defineTable<
RegistrationEventByPlusAddressBaseRow,
@@ -1184,6 +1212,7 @@ export const RegistrationEventsByPlusAddressBase = defineTable<
columns: REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
primaryKey: ['plus_address_base', 'created_at', 'user_id'],
partitionKey: ['plus_address_base'],
defaultTtlSeconds: seconds('30 days'),
});
export const LatestRiskContextByUser = defineTable<LatestRiskContextByUserRow, 'user_id'>({
name: 'latest_risk_context_by_user',
@@ -1194,6 +1223,7 @@ export const SuspiciousIps = defineTable<SuspiciousIpRow, 'ip'>({
name: 'suspicious_ips',
columns: SUSPICIOUS_IP_COLUMNS,
primaryKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_at' | 'user_id' | 'outcome_code', 'ip'>(
{
@@ -1201,6 +1231,7 @@ export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_
columns: RISK_OUTCOME_BY_IP_COLUMNS,
primaryKey: ['ip', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['ip'],
defaultTtlSeconds: seconds('180 days'),
},
);
export const RiskOutcomesBySubnet = defineTable<
@@ -1212,6 +1243,7 @@ export const RiskOutcomesBySubnet = defineTable<
columns: RISK_OUTCOME_BY_SUBNET_COLUMNS,
primaryKey: ['subnet', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['subnet'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByEmailDomain = defineTable<
RiskOutcomeByEmailDomainRow,
@@ -1222,6 +1254,7 @@ export const RiskOutcomesByEmailDomain = defineTable<
columns: RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
primaryKey: ['email_domain', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['email_domain'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskOutcomesByAsn = defineTable<
RiskOutcomeByAsnRow,
@@ -1232,6 +1265,7 @@ export const RiskOutcomesByAsn = defineTable<
columns: RISK_OUTCOME_BY_ASN_COLUMNS,
primaryKey: ['asn', 'created_at', 'user_id', 'outcome_code'],
partitionKey: ['asn'],
defaultTtlSeconds: seconds('180 days'),
});
export const RiskAssessments = defineTable<RiskAssessmentRow, 'assessment_id'>({
name: 'risk_assessments',
@@ -1248,6 +1282,7 @@ export const InboundSmsChallenges = defineTable<InboundSmsChallengeRow, 'challen
name: 'inbound_sms_challenges',
columns: INBOUND_SMS_CHALLENGE_COLUMNS,
primaryKey: ['challenge_code'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const InboundSmsChallengesByUser = defineTable<
InboundSmsChallengeByUserRow,
@@ -1258,16 +1293,19 @@ export const InboundSmsChallengesByUser = defineTable<
columns: INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
primaryKey: ['user_id', 'created_at'],
partitionKey: ['user_id'],
defaultTtlSeconds: seconds('15 minutes'),
});
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
name: 'phone_lookup_cache',
columns: PHONE_LOOKUP_CACHE_COLUMNS,
primaryKey: ['phone'],
defaultTtlSeconds: seconds('7 days'),
});
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
name: 'phone_verification_attempts',
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
primaryKey: ['attempt_id'],
defaultTtlSeconds: seconds('90 days'),
});
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
name: 'billing_customers',
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
import type {AdminAuditAccess} from '@fluxer/schema/src/domains/admin/AdminSchemas';
export const AdminAuditReadActions = {
CHECK_BLOCKLIST_ENTRY: 'check_blocklist_entry',
GET_ADMIN_API_KEY: 'get_admin_api_key',
GET_APPLICATION: 'get_application',
GET_ARCHIVE: 'get_archive',
GET_ARCHIVE_DOWNLOAD_URL: 'get_archive_download_url',
GET_AUDIT_LOG: 'get_audit_log',
GET_GATEWAY_STATS: 'get_gateway_stats',
GET_GUILD: 'get_guild',
GET_INSTANCE_CONFIG: 'get_instance_config',
GET_LIMIT_CONFIG: 'get_limit_config',
GET_MESSAGE: 'get_message',
GET_MESSAGE_SHRED_STATUS: 'get_message_shred_status',
GET_REPORT: 'get_report',
GET_USER: 'get_user',
GET_VOICE_REGION: 'get_voice_region',
GET_VOICE_SERVER: 'get_voice_server',
GET_VOICE_STATE_COUNTS: 'get_voice_state_counts',
LIST_ADMIN_ACLS: 'list_admin_acls',
LIST_ADMIN_API_KEYS: 'list_admin_api_keys',
LIST_ARCHIVES: 'list_archives',
LIST_AUDIT_LOGS: 'list_audit_logs',
LIST_BLOCKLIST_ENTRIES: 'list_blocklist_entries',
LIST_BLOCKLISTS: 'list_blocklists',
LIST_CHANNEL_MESSAGES: 'list_channel_messages',
LIST_DISCOVERY_APPLICATIONS: 'list_discovery_applications',
LIST_DISCOVERY_CATEGORIES: 'list_discovery_categories',
LIST_DISCOVERY_CATEGORY_LISTINGS: 'list_discovery_category_listings',
LIST_DISCOVERY_LISTINGS: 'list_discovery_listings',
LIST_GUILD_APPLICATIONS: 'list_guild_applications',
LIST_GUILD_AUDIT_LOGS: 'list_guild_audit_logs',
LIST_GUILD_EMOJIS: 'list_guild_emojis',
LIST_GUILD_MEMBERS: 'list_guild_members',
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
LIST_GUILD_STICKERS: 'list_guild_stickers',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
LIST_USER_DM_CHANNELS: 'list_user_dm_channels',
LIST_USER_GUILDS: 'list_user_guilds',
LIST_USER_RELATIONSHIPS: 'list_user_relationships',
LIST_USER_SESSIONS: 'list_user_sessions',
LIST_VOICE_REGIONS: 'list_voice_regions',
LIST_VOICE_SERVERS: 'list_voice_servers',
LIST_WEBAUTHN_CREDENTIALS: 'list_webauthn_credentials',
SEARCH_AUDIT_LOGS: 'search_audit_logs',
SEARCH_GUILDS: 'search_guilds',
SEARCH_MESSAGES: 'search_messages',
SEARCH_REPORTS: 'search_reports',
SEARCH_USERS: 'search_users',
} as const;
export type AdminAuditReadAction = ValueOf<typeof AdminAuditReadActions>;
export const ADMIN_AUDIT_READ_ACTIONS: ReadonlyArray<AdminAuditReadAction> = Object.values(AdminAuditReadActions);
const READ_ACTION_SET: ReadonlySet<string> = new Set(ADMIN_AUDIT_READ_ACTIONS);
export function getAdminAuditAccess(action: string): AdminAuditAccess {
return READ_ACTION_SET.has(action) ? 'read' : 'write';
}
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditReadAction} from '@app/api/admin/AdminAuditActions';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import type {Context} from 'hono';
type AdminAuditMetadataValue = string | number | bigint | boolean | null | undefined;
type AdminAuditMetadataInput = Readonly<Record<string, AdminAuditMetadataValue>>;
interface AdminAuditEntryInput<TAction extends string> {
targetType: string;
targetId: bigint;
action: TAction;
metadata?: AdminAuditMetadataInput;
}
const SNOWFLAKE_PATTERN = /^(0|[1-9][0-9]{0,19})$/;
export function snowflakeOrUndefined(value: string | undefined): string | undefined {
return value !== undefined && SNOWFLAKE_PATTERN.test(value) ? value : undefined;
}
function toAdminAuditMetadata(input: AdminAuditMetadataInput = {}): Map<string, string> {
const metadata = new Map<string, string>();
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null) continue;
metadata.set(key, String(value));
}
return metadata;
}
async function recordAdminAuditEntry(ctx: Context<HonoEnv>, entry: AdminAuditEntryInput<string>): Promise<void> {
await ctx.get('adminService').auditService.createAuditLog({
adminUserId: ctx.get('adminUserId'),
targetType: entry.targetType,
targetId: entry.targetId,
action: entry.action,
auditLogReason: ctx.get('auditLogReason'),
metadata: toAdminAuditMetadata(entry.metadata),
});
}
export async function recordAdminRead(
ctx: Context<HonoEnv>,
entry: AdminAuditEntryInput<AdminAuditReadAction>,
): Promise<void> {
await recordAdminAuditEntry(ctx, entry);
}
export async function recordAdminWrite(ctx: Context<HonoEnv>, entry: AdminAuditEntryInput<string>): Promise<void> {
await recordAdminAuditEntry(ctx, entry);
}
+19 -1
View File
@@ -2,7 +2,15 @@
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
import {createUserID} from '@app/api/BrandedTypes';
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Config} from '@app/api/Config';
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
import {
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import type {
AdminAuditLogRow,
BannedAvatarHashRow,
@@ -282,6 +290,7 @@ export class AdminRepository implements IAdminRepository {
}
async isEmailDomainDisposable(domain: string): Promise<boolean> {
if (!Config.blocklistFeeds.enabled) return false;
const domainLower = domain.toLowerCase();
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
const result = await fetchOne<{
@@ -395,6 +404,15 @@ export class AdminRepository implements IAdminRepository {
await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()}));
}
async unbanFeedFileSha(sha256Hex: string): Promise<boolean> {
return executeConditional(
BannedFileShas.conditionalDeleteByPk(
{sha256_hex: sha256Hex.toLowerCase()},
{added_by: null, category: ContentBlocklistCategory.MALWARE_BAZAAR},
),
);
}
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> {
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
}
@@ -109,6 +109,8 @@ export abstract class IAdminRepository {
abstract unbanFileSha(sha256Hex: string): Promise<void>;
abstract unbanFeedFileSha(sha256Hex: string): Promise<boolean>;
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import type {AdminApiKeyView} from '@app/api/admin/services/AdminApiKeyService';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -62,6 +64,15 @@ export function AdminApiKeyAdminController(app: HonoApp) {
expires_at: result.apiKey.expiresAt?.toISOString() ?? null,
acls: Array.from(result.apiKey.acls),
};
await recordAdminWrite(ctx, {
targetType: 'admin_api_key',
targetId: BigInt(result.apiKey.keyId),
action: 'create_admin_api_key',
metadata: {
acls: response.acls.join(','),
expires_in_days: request.expires_in_days,
},
});
return ctx.json(response);
},
);
@@ -84,6 +95,12 @@ export function AdminApiKeyAdminController(app: HonoApp) {
const user = ctx.get('user');
const keys = await adminApiKeyService.listKeys(user.id);
const response: Array<ListAdminApiKeyResponseType> = keys.map(toApiKeyResponse);
await recordAdminRead(ctx, {
targetType: 'admin_api_key',
targetId: 0n,
action: AdminAuditReadActions.LIST_ADMIN_API_KEYS,
metadata: {result_count: response.length},
});
return ctx.json(response);
},
);
@@ -107,6 +124,11 @@ export function AdminApiKeyAdminController(app: HonoApp) {
const user = ctx.get('user');
const keyId = ctx.req.valid('param').key_id;
const key = await adminApiKeyService.getKey(keyId, user.id);
await recordAdminRead(ctx, {
targetType: 'admin_api_key',
targetId: keyId,
action: AdminAuditReadActions.GET_ADMIN_API_KEY,
});
return ctx.json(toApiKeyResponse(key));
},
);
@@ -131,8 +153,19 @@ export function AdminApiKeyAdminController(app: HonoApp) {
const user = ctx.get('user');
const adminUserAcls = ctx.get('adminUserAcls');
const keyId = ctx.req.valid('param').key_id;
const key = await adminApiKeyService.updateKey(keyId, user.id, ctx.req.valid('json'), adminUserAcls);
return ctx.json(toApiKeyResponse(key));
const request = ctx.req.valid('json');
const key = await adminApiKeyService.updateKey(keyId, user.id, request, adminUserAcls);
const response = toApiKeyResponse(key);
await recordAdminWrite(ctx, {
targetType: 'admin_api_key',
targetId: keyId,
action: 'update_admin_api_key',
metadata: {
fields: (['name', 'acls'] as const).filter((field) => request[field] !== undefined).join(','),
acls: request.acls !== undefined ? response.acls.join(',') : undefined,
},
});
return ctx.json(response);
},
);
app.delete(
@@ -155,6 +188,11 @@ export function AdminApiKeyAdminController(app: HonoApp) {
const user = ctx.get('user');
const keyId = ctx.req.valid('param').key_id;
await adminApiKeyService.revokeKey(keyId, user.id);
await recordAdminWrite(ctx, {
targetType: 'admin_api_key',
targetId: keyId,
action: 'revoke_admin_api_key',
});
return ctx.json({success: true}, 200);
},
);
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead} from '@app/api/admin/AdminAuditRecorder';
import {createApplicationID, createGuildID, createUserID} from '@app/api/BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -50,11 +52,25 @@ export function ApplicationAdminController(app: HonoApp) {
}
if (guildId != null) {
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LOOKUP);
return ctx.json(await adminService.applicationService.listGuildApplications(createGuildID(guildId)));
const response = await adminService.applicationService.listGuildApplications(createGuildID(guildId));
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: guildId,
action: AdminAuditReadActions.LIST_GUILD_APPLICATIONS,
metadata: {application_count: response.applications.length},
});
return ctx.json(response);
}
if (ownerId != null) {
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LIST_BY_OWNER);
return ctx.json(await adminService.applicationService.listUserApplications(createUserID(ownerId)));
const response = await adminService.applicationService.listUserApplications(createUserID(ownerId));
await recordAdminRead(ctx, {
targetType: 'user',
targetId: ownerId,
action: AdminAuditReadActions.LIST_USER_APPLICATIONS,
metadata: {application_count: response.applications.length},
});
return ctx.json(response);
}
throw InputValidationError.create('owner_id', 'One of owner_id and guild_id is required');
},
@@ -76,7 +92,14 @@ export function ApplicationAdminController(app: HonoApp) {
async (ctx) => {
const adminService = ctx.get('adminService');
const userId = createUserID(ctx.req.valid('param').user_id);
return ctx.json(await adminService.applicationService.listUserApplications(userId));
const response = await adminService.applicationService.listUserApplications(userId);
await recordAdminRead(ctx, {
targetType: 'user',
targetId: userId,
action: AdminAuditReadActions.LIST_USER_APPLICATIONS,
metadata: {application_count: response.applications.length},
});
return ctx.json(response);
},
);
app.get(
@@ -97,7 +120,18 @@ export function ApplicationAdminController(app: HonoApp) {
async (ctx) => {
const adminService = ctx.get('adminService');
const applicationId = createApplicationID(ctx.req.valid('param').application_id);
return ctx.json(await adminService.applicationService.lookupApplication(applicationId));
const response = await adminService.applicationService.lookupApplication(applicationId);
await recordAdminRead(ctx, {
targetType: 'application',
targetId: applicationId,
action: AdminAuditReadActions.GET_APPLICATION,
metadata: {
found: response.application !== null,
owner_user_id: response.application?.owner_user_id,
bot_user_id: response.application?.bot_user_id,
},
});
return ctx.json(response);
},
);
app.patch(
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -66,11 +68,15 @@ export function ArchiveAdminController(app: HonoApp) {
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminUserId = ctx.get('adminUserId');
const result = await adminArchiveService.triggerUserArchive(
createUserID(ctx.req.valid('param').user_id),
adminUserId,
ctx.req.valid('json').include_attachments,
);
const userId = createUserID(ctx.req.valid('param').user_id);
const includeAttachments = ctx.req.valid('json').include_attachments;
const result = await adminArchiveService.triggerUserArchive(userId, adminUserId, includeAttachments);
await recordAdminWrite(ctx, {
targetType: 'user',
targetId: userId,
action: 'trigger_user_archive',
metadata: {archive_id: result.archive_id, include_attachments: includeAttachments},
});
return ctx.json(result, 200);
},
);
@@ -93,11 +99,15 @@ export function ArchiveAdminController(app: HonoApp) {
async (ctx) => {
const adminArchiveService = ctx.get('adminArchiveService');
const adminUserId = ctx.get('adminUserId');
const result = await adminArchiveService.triggerGuildArchive(
createGuildID(ctx.req.valid('param').guild_id),
adminUserId,
ctx.req.valid('json').include_attachments,
);
const guildId = createGuildID(ctx.req.valid('param').guild_id);
const includeAttachments = ctx.req.valid('json').include_attachments;
const result = await adminArchiveService.triggerGuildArchive(guildId, adminUserId, includeAttachments);
await recordAdminWrite(ctx, {
targetType: 'guild',
targetId: guildId,
action: 'trigger_guild_archive',
metadata: {archive_id: result.archive_id, include_attachments: includeAttachments},
});
return ctx.json(result, 200);
},
);
@@ -120,13 +130,28 @@ export function ArchiveAdminController(app: HonoApp) {
const adminArchiveService = ctx.get('adminArchiveService');
const adminAcls = ctx.get('adminUserAcls');
const query = ctx.req.valid('query');
const subjectType = resolveListSubjectType(adminAcls, query.subject_type);
const result = await adminArchiveService.listArchives({
subjectType: resolveListSubjectType(adminAcls, query.subject_type),
subjectType,
subjectId: query.subject_id ?? undefined,
requestedBy: query.requested_by ?? undefined,
limit: query.limit,
includeExpired: query.include_expired,
});
await recordAdminRead(ctx, {
targetType: 'archive',
targetId: 0n,
action: AdminAuditReadActions.LIST_ARCHIVES,
metadata: {
subject_type: subjectType,
subject_user_id: subjectType === 'user' ? query.subject_id : undefined,
subject_guild_id: subjectType === 'guild' ? query.subject_id : undefined,
requested_by_user_id: query.requested_by,
limit: query.limit,
include_expired: query.include_expired,
result_count: result.length,
},
});
return ctx.json({archives: result}, 200);
},
);
@@ -151,6 +176,12 @@ export function ArchiveAdminController(app: HonoApp) {
const params = ctx.req.valid('param');
requireArchiveSubjectAccess(adminAcls, params.subject_type);
const archive = await adminArchiveService.getArchive(params.subject_type, params.subject_id, params.archive_id);
await recordAdminRead(ctx, {
targetType: params.subject_type,
targetId: params.subject_id,
action: AdminAuditReadActions.GET_ARCHIVE,
metadata: {archive_id: params.archive_id, found: archive !== null},
});
return ctx.json({archive}, 200);
},
);
@@ -179,6 +210,12 @@ export function ArchiveAdminController(app: HonoApp) {
params.subject_id,
params.archive_id,
);
await recordAdminRead(ctx, {
targetType: params.subject_type,
targetId: params.subject_id,
action: AdminAuditReadActions.GET_ARCHIVE_DOWNLOAD_URL,
metadata: {archive_id: params.archive_id},
});
return ctx.json(result, 200);
},
);

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