Compare commits

...
Author SHA1 Message Date
HampusandGitHub eedfd9275f fix(api): only require permissions a channel overwrite grants (#2867) 2026-09-20 17:56:22 +02:00
HampusandGitHub 416af4bec4 fix(docs): correct the flatpak and dnf signing instructions (#2865) 2026-09-20 16:42:33 +02:00
HampusandGitHub 108d282ddd chore(deps): pin pnpm 11 so the lockfile parses for packagers (#2864) 2026-09-20 15:30:50 +02:00
HampusandGitHub a6103244b0 docs(readme): fix the license wording and shrink the preview (#2862) 2026-09-20 15:11:06 +02:00
HampusandGitHub 38935c83c5 docs(readme): document every download and install method (#2861) 2026-09-20 15:05:54 +02:00
HampusandGitHub c157ab5752 feat(voice): rework screen share delivery behind an experiment (#2859) 2026-09-20 06:10:20 +02:00
HampusandGitHub 574a93257c docs(downloads): the pacman repository is signed (#2858) 2026-09-20 05:54:28 +02:00
HampusandGitHub ba7d8781cf feat(auth): make passkey two-factor authentication opt-in (#2857) 2026-09-20 05:06:50 +02:00
HampusandGitHub 3256af8d92 refactor(app-proxy): remove the stable time freeze (#2856) 2026-09-20 04:02:47 +02:00
HampusandGitHub 86043212f2 docs(downloads): one pacman repository holds both channels (#2855) 2026-09-20 02:50:08 +02:00
HampusandGitHub 5d85e88532 fix(search): suggest yourself in DM from: and mentions: filters (#2854) 2026-09-20 01:24:43 +02:00
HampusandGitHub e2abfd476a feat(api): redirect desktop downloads to pkgs (#2853) 2026-09-20 01:20:30 +02:00
HampusandGitHub 487febac8e fix(voice): make stereo microphones work in studio and custom (#2852) 2026-09-20 00:19:53 +02:00
HampusandGitHub a3454e8245 fix(installer): name the services that are not ready (#2851) 2026-09-19 23:34:08 +02:00
HampusandGitHub bf7567b768 fix(user): push guild member updates on profile field changes (#2850) 2026-09-19 23:30:25 +02:00
HampusandGitHub ac3450ab32 feat(ci): publish appimage zsync control files (#2849) 2026-09-19 22:22:54 +02:00
HampusandGitHub 5d034becb8 fix(installer): stop waiting for an absent bucket initialiser (#2848) 2026-09-19 22:14:13 +02:00
HampusandGitHub f9397d0db9 feat(ci): publish linux repositories from the desktop release (#2847) 2026-09-19 22:01:06 +02:00
HampusandGitHub 9005139dc8 fix(voice): stop stereo microphones publishing as mono (#2846) 2026-09-19 21:54:38 +02:00
HampusandGitHub d93604afa2 fix(voice): let screen shares use the hardware H.264 encoder (#2845) 2026-09-19 21:54:30 +02:00
HampusandGitHub c4f0b2ece0 feat(desktop): self-update appimages in place (#2843) 2026-09-19 19:06:53 +02:00
HampusandGitHub 98a42f612b fix(desktop): supersede the legacy linux packages on upgrade (#2842) 2026-09-19 18:50:37 +02:00
HampusandGitHub cc75e1318d fix(ci): raise the macos minimum to 13.0 (#2841) 2026-09-19 18:35:05 +02:00
HampusandGitHub 9027cbdf3e fix(voice): send screen shares at the quality the user picked (#2840) 2026-09-19 16:46:22 +02:00
HampusandGitHub 2119e10ed5 chore(static): update marketing screenshots and readme cover (#2839) 2026-09-19 16:44:32 +02:00
HampusandGitHub 87f3eb3c81 feat(desktop): add flatpak and arch packaging inputs (#2838) 2026-09-19 15:31:41 +02:00
HampusandGitHub f9bb8bd585 test(voice): remove the slow screen share delivery proof (#2836) 2026-09-19 02:33:32 +02:00
HampusandGitHub bc47a724af fix(voice): stop screen shares failing to reach their viewers (#2835) 2026-09-19 02:17:25 +02:00
HampusandGitHub f32356801d feat(api): make tor and breached password lookups opt-in (#2834) 2026-09-19 01:22:17 +02:00
HampusandGitHub efd677f32b feat(api): exempt configured ASNs from abusive IP auto-bans (#2833) 2026-09-18 23:01:58 +02:00
HampusandGitHub 3cec27ba57 fix(static): vendor the deepfilternet 1.3.0 assets (#2832) 2026-09-18 18:47:42 +02:00
HampusandGitHub 1f810ba04d fix(api): drop the upload segment signal and dead exports (#2831) 2026-09-18 17:35:58 +02:00
HampusandGitHub 522cf08e61 feat(media-proxy): sign attachment URLs and gate origins (#2830) 2026-09-18 15:57:32 +02:00
HampusandGitHub 025c01ab13 fix(api): chunk guild permission batch RPC over 100 guilds (#2829) 2026-09-18 12:54:03 +02:00
HampusandGitHub dc41b53d60 fix(desktop): drop redundant casts flagged by clippy 1.98 (#2826) 2026-09-17 21:28:48 +02:00
HampusandGitHub 3b552e00ef chore(deps): upgrade all dependencies, toolchains and images (#2825) 2026-09-17 21:08:56 +02:00
HampusandGitHub 56e04e7b53 test(backend): remove duplicate and useless tests (#2820) 2026-09-17 15:32:25 +02:00
HampusandGitHub deac653a9e test(app): remove useless frontend tests (#2819) 2026-09-17 15:05:36 +02:00
HampusandGitHub ed9528834d fix(gateway): stop dead sessions leaving voice states behind (#2818) 2026-09-17 14:55:18 +02:00
HampusandGitHub 4cecbf1f43 fix(auth): disable TOTP with one code instead of two (#2816) 2026-09-17 04:21:50 +02:00
HampusandGitHub b019f4a91f fix(gateway): act on voice states in the voice server (#2815) 2026-09-17 03:59:36 +02:00
HampusandGitHub 34b6ecfbd2 chore(admin): remove the heap snapshot endpoint (#2814) 2026-09-16 18:56:01 +02:00
HampusandGitHub 4ef9c4c65b fix(api): restore commas in geoip location labels (#2812) 2026-09-16 18:27:50 +02:00
HampusandGitHub 3276039e41 feat(admin): audit admin reads and filter the log by access (#2811) 2026-09-16 17:23:03 +02:00
HampusandGitHub 03d1354562 chore(voice): remove voice reconciliation leftovers (#2810) 2026-09-16 17:09:57 +02:00
HampusandGitHub 964845d7a7 chore(voice): remove the recon service (#2808) 2026-09-16 16:53:30 +02:00
HampusandGitHub 3bc5dd8e0f fix(gateway): always clear expired custom statuses (#2807) 2026-09-16 16:52:22 +02:00
HampusandGitHub 17292fd6a5 fix(app): stop plain unicode symbols rendering as color emoji (#2806) 2026-09-16 16:33:13 +02:00
TarekandGitHub f753659899 feat(instance): make the status page URL configurable (#1159) 2026-09-16 15:20:37 +02:00
HampusandGitHub 7412ec3395 refactor(api): purge cache by canonical media prefix (#2802) 2026-09-16 02:32:36 +02:00
HampusandGitHub 570c8776c4 fix(api): require manage messages to remove others' reactions (#2799) 2026-09-15 18:16:55 +02:00
HampusandGitHub 910db6734b feat(experiments): ship seven treatments to everyone (#2798) 2026-09-15 18:03:35 +02:00
HampusandGitHub 9e614026d7 fix(api): stop exporting the change feed stats type (#2797) 2026-09-15 17:27:09 +02:00
HampusandGitHub b38e7c6433 feat(api): publish object storage changes to a JetStream feed (#2796) 2026-09-15 17:20:26 +02:00
HampusandGitHub 83c8e91955 fix(app): fit the user area popout shadow to its card (#2795) 2026-09-15 17:11:43 +02:00
HampusandGitHub 0532dd0440 fix(app): stop guild banner jumps and restore hover animation (#2792) 2026-09-15 09:31:52 +02:00
HampusandGitHub a08615e306 fix(gateway): match member search on username and global name (#2791) 2026-09-15 08:48:33 +02:00
HampusandGitHub f4c5fee17e feat(app): rank forward destinations and preview the message (#2790) 2026-09-15 07:23:26 +02:00
HampusandGitHub c5aaf65a10 fix(app): list friends with closed DMs in the forward modal (#2787) 2026-09-15 00:39:18 +02:00
HampusandGitHub 951e39da3d feat(api): add expression source guild routes (#2786) 2026-09-15 00:31:47 +02:00
HampusandGitHub b693d84d2b fix(openapi): restore named discriminated union branches (#2785) 2026-09-14 23:42:26 +02:00
HampusandGitHub 9bbf6c513b fix(installer): say what the email prompt is for (#2784) 2026-09-14 23:07:03 +02:00
HampusandGitHub 50cec92738 fix(api): batch member user lookups on guild load (#2783) 2026-09-14 23:06:36 +02:00
1671 changed files with 88881 additions and 225519 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
+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
-3
View File
@@ -28,9 +28,6 @@ f:media_proxy:
f:messages:
- changed-files:
- any-glob-to-any-file: fluxer_messages/**/*
f:recon:
- changed-files:
- any-glob-to-any-file: fluxer_recon/**/*
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
-36
View File
@@ -1,36 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: build recon
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: write
jobs:
approve:
name: approve build release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Build release approved."
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-recon
dockerfile: fluxer_recon/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 }}
+56 -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
@@ -125,7 +125,7 @@ jobs:
fluxer_app/pkgs/libfluxcore
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/**',
@@ -144,7 +144,7 @@ jobs:
fluxer_app/pkgs/libfluxcore
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/**',
@@ -156,21 +156,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,7 +185,7 @@ 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
@@ -273,12 +273,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 +294,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 +305,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 +317,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 +348,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 +358,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 +372,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 +380,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
@@ -400,7 +400,7 @@ jobs:
fluxer_app/pkgs/libfluxcore
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/**',
@@ -419,7 +419,7 @@ jobs:
fluxer_app/pkgs/libfluxcore
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/**',
@@ -431,15 +431,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 +456,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 +490,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 +515,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
Generated
+828 -874
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -9,13 +9,11 @@ members = [
"fluxer_messages",
"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",
+128 -3
View File
@@ -6,10 +6,12 @@
</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>
@@ -19,5 +21,128 @@
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] | [Flatpak][flatpak-ref] | [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 is universal and runs on both Apple silicon and Intel. Windows and Linux need the build that matches your processor.
On Linux, prefer a package repository over a file. Fluxer then updates with the rest of your system.
## Linux package repositories
All four repositories serve stable and canary. The package is `fluxer` for stable and `fluxer-canary` for canary.
### Flatpak
Opening [this reference file][flatpak-ref] hands the install to your desktop software manager. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` pasted into 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
```
### 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
```
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because the base repositories do not ship `libXScrnSaver`. Fedora does not need this.
### Arch Linux
The repository is signed, so pacman needs the key in its own keyring 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 the step that makes pacman trust the key. 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, which is why the heredoc above is quoted.
Full setup notes, including the canary channel, live in the [Linux repositories documentation][docs-linux].
## Other ways to run it
- [Open Fluxer in a browser](https://web.fluxer.app) with no install at all.
- [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 and 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
[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 = []
+109 -185
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,43 @@ 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.
# 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 +177,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 +226,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
@@ -308,32 +259,22 @@ 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
@@ -341,47 +282,30 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
# The bundled Valkey holds durable state as well as cache. The bulk message
# deletion queue 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, and the api
# turns that rejection into a 503.
# 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
+14 -10
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:
@@ -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,6 +474,9 @@ 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}
+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();
+104 -436
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,16 +10524,8 @@
},
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
"guild_activity_log_presentation": {
"$ref": "#/components/schemas/GuildActivityLogPresentationConfigResponse"
},
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingConfigResponse"},
"message_keyboard_focus": {"$ref": "#/components/schemas/MessageKeyboardFocusConfigResponse"},
"blocked_message_groups": {"$ref": "#/components/schemas/BlockedMessageGroupsConfigResponse"},
"expression_info_card": {"$ref": "#/components/schemas/ExpressionInfoCardConfigResponse"},
"guild_header_collapse": {"$ref": "#/components/schemas/GuildHeaderCollapseConfigResponse"},
"typing_indicator_rework": {"$ref": "#/components/schemas/TypingIndicatorReworkConfigResponse"},
"registration": {
"type": "object",
"properties": {
@@ -10588,13 +10542,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},
@@ -10602,14 +10556,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,
@@ -10645,7 +10599,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"}
@@ -10680,7 +10634,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",
@@ -10689,7 +10645,9 @@
"logo_url",
"wordmark_url",
"favicon_url",
"theme_color"
"theme_color",
"status_page_url",
"status_page_incident_history_url"
],
"additionalProperties": false
},
@@ -10898,12 +10856,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
},
@@ -10911,12 +10871,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
},
@@ -10927,15 +10889,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",
@@ -10974,14 +10952,8 @@
"sso",
"gateway_rollout",
"voice_noise_suppression",
"guild_activity_log_presentation",
"screen_share_delivery",
"experiment_delivery",
"message_hover_tracking",
"message_keyboard_focus",
"blocked_message_groups",
"expression_info_card",
"guild_header_collapse",
"typing_indicator_rework",
"registration",
"self_hosted",
"app_public",
@@ -10999,7 +10971,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"}
@@ -11017,13 +10989,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},
@@ -11031,14 +11003,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,
@@ -11115,38 +11087,14 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
},
"guild_activity_log_presentation": {
"screen_share_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/GuildActivityLogPresentationConfigUpdateRequest"}]
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
},
"experiment_delivery": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
},
"message_hover_tracking": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/MessageHoverTrackingConfigUpdateRequest"}]
},
"message_keyboard_focus": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/MessageKeyboardFocusConfigUpdateRequest"}]
},
"blocked_message_groups": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/BlockedMessageGroupsConfigUpdateRequest"}]
},
"expression_info_card": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/ExpressionInfoCardConfigUpdateRequest"}]
},
"guild_header_collapse": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/GuildHeaderCollapseConfigUpdateRequest"}]
},
"typing_indicator_rework": {
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/TypingIndicatorReworkConfigUpdateRequest"}]
},
"registration": {
"nullable": true,
"type": "object",
@@ -11188,7 +11136,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"}}},
@@ -11298,12 +11248,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
},
@@ -11311,12 +11263,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
}
@@ -11348,7 +11302,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}
}
}
}
@@ -12665,6 +12619,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"}
@@ -12682,6 +12646,7 @@
"related_guilds",
"related_channels",
"action",
"access",
"audit_log_reason",
"metadata",
"created_at"
@@ -12770,7 +12735,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12788,7 +12753,7 @@
"type": "string"
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12818,7 +12783,7 @@
"maximum": 365
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12839,7 +12804,7 @@
"type": "string"
},
"acls": {
"maxItems": 112,
"maxItems": 111,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12852,7 +12817,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"
@@ -12943,7 +12908,6 @@
"report:view",
"report:view:reporter_pii",
"system_dm:send",
"system:heap_snapshot",
"user:cancel:bulk_message_deletion",
"user:delete",
"user:disable:suspicious",
@@ -13055,14 +13019,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",
@@ -13490,7 +13454,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": {
@@ -13498,7 +13462,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"},
@@ -13598,7 +13562,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"],
@@ -13635,7 +13599,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": {
@@ -13643,7 +13607,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"},
@@ -13743,7 +13707,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"],
@@ -13907,7 +13871,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": {
@@ -13915,7 +13879,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",
@@ -14084,7 +14048,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": {
@@ -14296,7 +14260,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": {
@@ -15168,7 +15132,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"},
@@ -15178,7 +15142,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": {
@@ -15213,114 +15177,6 @@
"enum": ["open", "approval", "closed"],
"type": "string"
},
"TypingIndicatorReworkConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
}
},
"GuildHeaderCollapseConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
}
},
"ExpressionInfoCardConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
}
},
"BlockedMessageGroupsConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
}
},
"MessageKeyboardFocusConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
}
},
"MessageHoverTrackingConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
"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}$"}
}
}
},
"ExperimentDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
@@ -15328,7 +15184,7 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
}
},
"GuildActivityLogPresentationConfigUpdateRequest": {
"ScreenShareDeliveryConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
@@ -15381,7 +15237,6 @@
"required": ["guild_id", "backend"]
}
},
"stereo_enabled": {"type": "boolean"},
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
@@ -15403,186 +15258,6 @@
"type": "string",
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
},
"TypingIndicatorReworkConfigResponse": {
"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": "typing-indicator-rework-v1", "type": "string", "minLength": 1, "maxLength": 64},
"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
},
"GuildHeaderCollapseConfigResponse": {
"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": "guild-header-collapse-v1", "type": "string", "minLength": 1, "maxLength": 64},
"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
},
"ExpressionInfoCardConfigResponse": {
"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": "expression-info-card-v1", "type": "string", "minLength": 1, "maxLength": 64},
"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
},
"BlockedMessageGroupsConfigResponse": {
"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": "blocked-message-groups-v1", "type": "string", "minLength": 1, "maxLength": 64},
"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
},
"MessageKeyboardFocusConfigResponse": {
"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": "message-keyboard-focus-v1", "type": "string", "minLength": 1, "maxLength": 64},
"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
},
"MessageHoverTrackingConfigResponse": {
"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": "message-hover-tracking-v1", "type": "string", "minLength": 1, "maxLength": 64},
"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
},
"ExperimentDeliveryConfigResponse": {
"type": "object",
"properties": {
@@ -15592,18 +15267,13 @@
"required": ["poll_interval_seconds", "poll_jitter_percent"],
"additionalProperties": false
},
"GuildActivityLogPresentationConfigResponse": {
"ScreenShareDeliveryConfigResponse": {
"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": "guild-activity-log-presentation-v1",
"type": "string",
"minLength": 1,
"maxLength": 64
},
"rollout_salt": {"default": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64},
"included_user_ids": {
"default": [],
"maxItems": 1000,
@@ -15671,7 +15341,6 @@
"additionalProperties": false
}
},
"stereo_enabled": {"default": false, "type": "boolean"},
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
},
"required": [
@@ -15685,7 +15354,6 @@
"included_user_ids",
"excluded_user_ids",
"guild_overrides",
"stereo_enabled",
"suppression_strength"
],
"additionalProperties": false
@@ -15823,7 +15491,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)]
+35 -563
View File
@@ -23,21 +23,9 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
#[serde(default)]
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
#[serde(default)]
pub message_hover_tracking: MessageHoverTrackingConfigResponse,
#[serde(default)]
pub message_keyboard_focus: MessageKeyboardFocusConfigResponse,
#[serde(default)]
pub blocked_message_groups: BlockedMessageGroupsConfigResponse,
#[serde(default)]
pub guild_activity_log_presentation: GuildActivityLogPresentationConfigResponse,
#[serde(default)]
pub expression_info_card: ExpressionInfoCardConfigResponse,
#[serde(default)]
pub guild_header_collapse: GuildHeaderCollapseConfigResponse,
#[serde(default)]
pub typing_indicator_rework: TypingIndicatorReworkConfigResponse,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -340,6 +328,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 {
@@ -352,6 +342,8 @@ impl Default for AppBrandingConfigResponse {
wordmark_url: None,
favicon_url: None,
theme_color: None,
status_page_url: None,
status_page_incident_history_url: None,
}
}
}
@@ -456,7 +448,8 @@ impl VoiceE2eeScope {
}
}
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const SCREEN_SHARE_DELIVERY_DEFAULT_SALT: &str = "screen-share-delivery-v1";
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
impl NoiseSuppressionBackend {
@@ -502,7 +495,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,
}
@@ -519,7 +511,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,
}
}
@@ -546,14 +537,12 @@ 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 MessageHoverTrackingConfigResponse {
pub struct ScreenShareDeliveryConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
@@ -562,13 +551,13 @@ pub struct MessageHoverTrackingConfigResponse {
pub excluded_user_ids: Vec<String>,
}
impl Default for MessageHoverTrackingConfigResponse {
impl Default for ScreenShareDeliveryConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "message-hover-tracking-v1".to_owned(),
rollout_salt: SCREEN_SHARE_DELIVERY_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
@@ -576,235 +565,7 @@ impl Default for MessageHoverTrackingConfigResponse {
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct MessageHoverTrackingConfigUpdateRequest {
#[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 MessageKeyboardFocusConfigResponse {
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 MessageKeyboardFocusConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "message-keyboard-focus-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct MessageKeyboardFocusConfigUpdateRequest {
#[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 BlockedMessageGroupsConfigResponse {
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 BlockedMessageGroupsConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "blocked-message-groups-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct BlockedMessageGroupsConfigUpdateRequest {
#[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 GuildActivityLogPresentationConfigResponse {
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 GuildActivityLogPresentationConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "guild-activity-log-presentation-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct GuildActivityLogPresentationConfigUpdateRequest {
#[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 ExpressionInfoCardConfigResponse {
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 ExpressionInfoCardConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "expression-info-card-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct ExpressionInfoCardConfigUpdateRequest {
#[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 GuildHeaderCollapseConfigResponse {
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 GuildHeaderCollapseConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "guild-header-collapse-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct GuildHeaderCollapseConfigUpdateRequest {
#[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 TypingIndicatorReworkConfigResponse {
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 TypingIndicatorReworkConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: "typing-indicator-rework-v1".to_owned(),
included_user_ids: Vec::new(),
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct TypingIndicatorReworkConfigUpdateRequest {
pub struct ScreenShareDeliveryConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -933,21 +694,9 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_share_delivery: Option<ScreenShareDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_hover_tracking: Option<MessageHoverTrackingConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_keyboard_focus: Option<MessageKeyboardFocusConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blocked_message_groups: Option<BlockedMessageGroupsConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_activity_log_presentation: Option<GuildActivityLogPresentationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expression_info_card: Option<ExpressionInfoCardConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_header_collapse: Option<GuildHeaderCollapseConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub typing_indicator_rework: Option<TypingIndicatorReworkConfigUpdateRequest>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1152,6 +901,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)]
@@ -1280,118 +1033,39 @@ mod tests {
.expect("admin schema");
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
.expect("default noise config");
let screen_share = serde_json::from_value::<ScreenShareDeliveryConfigResponse>(json!({}))
.expect("default screen share config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let hover = serde_json::from_value::<MessageHoverTrackingConfigResponse>(json!({}))
.expect("default message hover tracking config");
let keyboard = serde_json::from_value::<MessageKeyboardFocusConfigResponse>(json!({}))
.expect("default message keyboard focus config");
let blocked = serde_json::from_value::<BlockedMessageGroupsConfigResponse>(json!({}))
.expect("default blocked message groups config");
let activity_log =
serde_json::from_value::<GuildActivityLogPresentationConfigResponse>(json!({}))
.expect("default guild activity log presentation config");
let expression = serde_json::from_value::<ExpressionInfoCardConfigResponse>(json!({}))
.expect("default expression info card config");
let collapse = serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
.expect("default guild header collapse config");
let typing = serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
.expect("default typing indicator rework config");
let noise = serde_json::to_value(noise).expect("serializable noise config");
let screen_share =
serde_json::to_value(screen_share).expect("serializable screen share config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let hover =
serde_json::to_value(hover).expect("serializable message hover tracking config");
let keyboard =
serde_json::to_value(keyboard).expect("serializable message keyboard focus config");
let blocked =
serde_json::to_value(blocked).expect("serializable blocked message groups config");
let activity_log = serde_json::to_value(activity_log)
.expect("serializable guild activity log presentation config");
let expression =
serde_json::to_value(expression).expect("serializable expression info card config");
let collapse =
serde_json::to_value(collapse).expect("serializable guild header collapse config");
let typing =
serde_json::to_value(typing).expect("serializable typing indicator rework config");
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
serde_json::from_value(noise.clone()).expect("generated noise config contract");
let generated_screen_share: generated_types::ScreenShareDeliveryConfigResponse =
serde_json::from_value(screen_share.clone())
.expect("generated screen share config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
let generated_hover: generated_types::MessageHoverTrackingConfigResponse =
serde_json::from_value(hover.clone())
.expect("generated message hover tracking config contract");
let generated_keyboard: generated_types::MessageKeyboardFocusConfigResponse =
serde_json::from_value(keyboard.clone())
.expect("generated message keyboard focus config contract");
let generated_blocked: generated_types::BlockedMessageGroupsConfigResponse =
serde_json::from_value(blocked.clone())
.expect("generated blocked message groups config contract");
let generated_activity_log: generated_types::GuildActivityLogPresentationConfigResponse =
serde_json::from_value(activity_log.clone())
.expect("generated guild activity log presentation config contract");
let generated_expression: generated_types::ExpressionInfoCardConfigResponse =
serde_json::from_value(expression.clone())
.expect("generated expression info card config contract");
let generated_collapse: generated_types::GuildHeaderCollapseConfigResponse =
serde_json::from_value(collapse.clone())
.expect("generated guild header collapse config contract");
let generated_typing: generated_types::TypingIndicatorReworkConfigResponse =
serde_json::from_value(typing.clone())
.expect("generated typing indicator rework config contract");
assert_eq!(
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
noise
);
assert_eq!(
serde_json::to_value(generated_screen_share)
.expect("serializable generated screen share config"),
screen_share
);
assert_eq!(
serde_json::to_value(generated_delivery)
.expect("serializable generated delivery config"),
delivery
);
assert_eq!(
serde_json::to_value(generated_hover)
.expect("serializable generated message hover tracking config"),
hover
);
assert_eq!(
serde_json::to_value(generated_keyboard)
.expect("serializable generated message keyboard focus config"),
keyboard
);
assert_eq!(
serde_json::to_value(generated_blocked)
.expect("serializable generated blocked message groups config"),
blocked
);
assert_eq!(
serde_json::to_value(generated_activity_log)
.expect("serializable generated guild activity log presentation config"),
activity_log
);
assert_eq!(
serde_json::to_value(generated_expression)
.expect("serializable generated expression info card config"),
expression
);
assert_eq!(
serde_json::to_value(generated_collapse)
.expect("serializable generated guild header collapse config"),
collapse
);
assert_eq!(
serde_json::to_value(generated_typing)
.expect("serializable generated typing indicator rework config"),
typing
);
for (name, value) in [
("VoiceNoiseSuppressionConfigResponse", noise),
("ScreenShareDeliveryConfigResponse", screen_share),
("ExperimentDeliveryConfigResponse", delivery),
("MessageHoverTrackingConfigResponse", hover),
("MessageKeyboardFocusConfigResponse", keyboard),
("BlockedMessageGroupsConfigResponse", blocked),
("GuildActivityLogPresentationConfigResponse", activity_log),
("ExpressionInfoCardConfigResponse", expression),
("GuildHeaderCollapseConfigResponse", collapse),
("TypingIndicatorReworkConfigResponse", typing),
] {
for (field, value) in value.as_object().expect("config object") {
assert_eq!(
@@ -1403,44 +1077,14 @@ mod tests {
}
#[test]
fn generated_client_accepts_unknown_response_fields() {
const GENERATED_CLIENT: &str =
include_str!(concat!(env!("OUT_DIR"), "/admin_api_generated.rs"));
assert!(
!GENERATED_CLIENT.contains("deny_unknown_fields"),
"fluxer_admin/build.rs must clear additionalProperties so a new API field cannot \
blank an admin page"
);
let mut section = serde_json::to_value(ExpressionInfoCardConfigResponse::default())
.expect("serializable expression info card config");
section
.as_object_mut()
.expect("expression info card object")
.insert("future_knob".to_owned(), json!(7));
serde_json::from_value::<generated_types::ExpressionInfoCardConfigResponse>(section)
.expect("generated instance config section tolerates unknown fields");
}
#[test]
fn generated_audit_log_change_accepts_scalar_and_object_values() {
for value in [json!("old"), json!(7), json!(true), json!(null)] {
let change = serde_json::from_value::<generated_types::AuditLogChangeSchema>(
json!({"key": "name", "old_value": value, "new_value": {"added": [], "removed": []}}),
)
.expect("generated audit log change tolerates scalar values");
assert_eq!(change.key, "name");
}
}
#[test]
fn expression_info_card_update_preserves_empty_lists_and_omitted_fields() {
let update = ExpressionInfoCardConfigUpdateRequest {
fn screen_share_delivery_update_preserves_empty_lists_and_omitted_fields() {
let update = ScreenShareDeliveryConfigUpdateRequest {
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::ExpressionInfoCardConfigUpdateRequest>(
serde_json::from_value::<generated_types::ScreenShareDeliveryConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
@@ -1449,179 +1093,7 @@ mod tests {
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(ExpressionInfoCardConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn guild_header_collapse_update_preserves_empty_lists_and_omitted_fields() {
let update = GuildHeaderCollapseConfigUpdateRequest {
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::GuildHeaderCollapseConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(GuildHeaderCollapseConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn guild_header_collapse_response_defaults_to_the_guild_header_collapse_v1_salt() {
let config = GuildHeaderCollapseConfigResponse::default();
assert!(!config.enabled);
assert_eq!(config.config_version, 0);
assert_eq!(config.rollout_basis_points, 0);
assert_eq!(config.rollout_salt, "guild-header-collapse-v1");
assert!(config.included_user_ids.is_empty());
assert!(config.excluded_user_ids.is_empty());
assert_eq!(
serde_json::from_value::<GuildHeaderCollapseConfigResponse>(json!({}))
.expect("default guild header collapse config")
.rollout_salt,
"guild-header-collapse-v1"
);
}
#[test]
fn typing_indicator_rework_update_preserves_empty_lists_and_omitted_fields() {
let update = TypingIndicatorReworkConfigUpdateRequest {
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::TypingIndicatorReworkConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(TypingIndicatorReworkConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn typing_indicator_rework_response_defaults_to_the_typing_indicator_rework_v1_salt() {
let config = TypingIndicatorReworkConfigResponse::default();
assert!(!config.enabled);
assert_eq!(config.config_version, 0);
assert_eq!(config.rollout_basis_points, 0);
assert_eq!(config.rollout_salt, "typing-indicator-rework-v1");
assert!(config.included_user_ids.is_empty());
assert!(config.excluded_user_ids.is_empty());
assert_eq!(
serde_json::from_value::<TypingIndicatorReworkConfigResponse>(json!({}))
.expect("default typing indicator rework config")
.rollout_salt,
"typing-indicator-rework-v1"
);
}
#[test]
fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() {
let update = MessageHoverTrackingConfigUpdateRequest {
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::MessageHoverTrackingConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(MessageHoverTrackingConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn message_keyboard_focus_update_preserves_empty_lists_and_omitted_fields() {
let update = MessageKeyboardFocusConfigUpdateRequest {
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::MessageKeyboardFocusConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(MessageKeyboardFocusConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn blocked_message_groups_update_preserves_empty_lists_and_omitted_fields() {
let update = BlockedMessageGroupsConfigUpdateRequest {
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::BlockedMessageGroupsConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(BlockedMessageGroupsConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
#[test]
fn guild_activity_log_presentation_update_preserves_empty_lists_and_omitted_fields() {
let update = GuildActivityLogPresentationConfigUpdateRequest {
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::GuildActivityLogPresentationConfigUpdateRequest>(
value.clone(),
)
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(GuildActivityLogPresentationConfigUpdateRequest::default())
serde_json::to_value(ScreenShareDeliveryConfigUpdateRequest::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! {
+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,
+67 -350
View File
@@ -6,12 +6,10 @@ use crate::{
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, BlockedMessageGroupsConfigUpdateRequest,
CreateRegistrationUrlRequest, DeferredPhoneGateUpdateRequest,
ExperimentDeliveryConfigUpdateRequest, ExpressionInfoCardConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
GuildActivityLogPresentationConfigUpdateRequest,
GuildHeaderCollapseConfigUpdateRequest, InstanceAttachmentDecayUpdateRequest,
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
@@ -19,12 +17,10 @@ use crate::{
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters,
MessageHoverTrackingConfigUpdateRequest, MessageKeyboardFocusConfigUpdateRequest,
NoiseSuppressionBackend, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
TypingIndicatorReworkConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES,
VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
VoiceNoiseSuppressionGuildOverride,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
PremiumMode, RegistrationMode, ScreenShareDeliveryConfigUpdateRequest,
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
},
},
config::AdminConfig,
@@ -211,33 +207,7 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_message_hover_tracking" => match build_message_hover_tracking_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_message_keyboard_focus" => match build_message_keyboard_focus_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_blocked_message_groups" => match build_blocked_message_groups_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_guild_activity_log_presentation" => {
match build_guild_activity_log_presentation_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
}
}
"update_expression_info_card" => match build_expression_info_card_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_guild_header_collapse" => match build_guild_header_collapse_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_typing_indicator_rework" => match build_typing_indicator_rework_update(&form) {
"update_screen_share_delivery" => match build_screen_share_delivery_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
@@ -548,9 +518,9 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
if ids.iter().any(|existing| existing == candidate) {
continue;
}
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
return Err(format!(
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
));
}
ids.push(candidate.to_owned());
@@ -651,7 +621,6 @@ fn build_voice_noise_suppression_update(
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",
@@ -664,213 +633,30 @@ fn build_voice_noise_suppression_update(
})
}
fn build_message_hover_tracking_update(
fn build_screen_share_delivery_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
message_hover_tracking: Some(MessageHoverTrackingConfigUpdateRequest {
enabled: Some(form.bool_value("message_hover_enabled")),
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
rollout_basis_points: parse_form_number(
form,
"message_hover_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "message_hover_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("message_hover_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("message_hover_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_message_keyboard_focus_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
message_keyboard_focus: Some(MessageKeyboardFocusConfigUpdateRequest {
enabled: Some(form.bool_value("message_keyboard_focus_enabled")),
rollout_basis_points: parse_form_number(
form,
"message_keyboard_focus_rollout_basis_points",
"screen_share_delivery_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"message_keyboard_focus_rollout_salt",
"screen_share_delivery_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("message_keyboard_focus_included_user_ids")
form.first("screen_share_delivery_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("message_keyboard_focus_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_blocked_message_groups_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
blocked_message_groups: Some(BlockedMessageGroupsConfigUpdateRequest {
enabled: Some(form.bool_value("blocked_groups_enabled")),
rollout_basis_points: parse_form_number(
form,
"blocked_groups_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "blocked_groups_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("blocked_groups_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("blocked_groups_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_guild_activity_log_presentation_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
guild_activity_log_presentation: Some(GuildActivityLogPresentationConfigUpdateRequest {
enabled: Some(form.bool_value("guild_activity_log_presentation_enabled")),
rollout_basis_points: parse_form_number(
form,
"guild_activity_log_presentation_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"guild_activity_log_presentation_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("guild_activity_log_presentation_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("guild_activity_log_presentation_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_expression_info_card_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
expression_info_card: Some(ExpressionInfoCardConfigUpdateRequest {
enabled: Some(form.bool_value("expression_card_enabled")),
rollout_basis_points: parse_form_number(
form,
"expression_card_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "expression_card_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("expression_card_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("expression_card_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_guild_header_collapse_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
guild_header_collapse: Some(GuildHeaderCollapseConfigUpdateRequest {
enabled: Some(form.bool_value("guild_header_collapse_enabled")),
rollout_basis_points: parse_form_number(
form,
"guild_header_collapse_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"guild_header_collapse_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("guild_header_collapse_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("guild_header_collapse_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_typing_indicator_rework_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
typing_indicator_rework: Some(TypingIndicatorReworkConfigUpdateRequest {
enabled: Some(form.bool_value("typing_indicator_rework_enabled")),
rollout_basis_points: parse_form_number(
form,
"typing_indicator_rework_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(
form,
"typing_indicator_rework_rollout_salt",
)?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("typing_indicator_rework_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("typing_indicator_rework_excluded_user_ids")
form.first("screen_share_delivery_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
@@ -933,6 +719,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")),
@@ -1494,7 +1282,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)
@@ -1520,7 +1307,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": [],
@@ -1598,13 +1384,13 @@ mod tests {
#[test]
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
.map(|index| index.to_string())
.collect::<Vec<_>>()
.join("\n");
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
.expect("valid IDs at cap");
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
assert_eq!(ids.last(), Some(&"999".to_owned()));
assert_eq!(
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
@@ -1801,40 +1587,43 @@ mod tests {
}
#[test]
fn build_guild_header_collapse_update_reads_the_whole_form() {
fn build_screen_share_delivery_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=%20guild-header-collapse-v2%20&guild_header_collapse_included_user_ids=1500000000000000001%0A1500000000000000001&guild_header_collapse_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
b"screen_share_delivery_enabled=true&screen_share_delivery_rollout_basis_points=%20250%20&screen_share_delivery_rollout_salt=%20screen-share-delivery-v2%20&screen_share_delivery_included_user_ids=1500000000000000001%0A1500000000000000002&screen_share_delivery_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
);
let update = build_guild_header_collapse_update(&form)
let update = build_screen_share_delivery_update(&form)
.expect("valid form")
.guild_header_collapse
.expect("guild header collapse update");
.screen_share_delivery
.expect("screen share delivery update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(2_500));
assert_eq!(update.rollout_basis_points, Some(250));
assert_eq!(
update.rollout_salt,
Some("guild-header-collapse-v2".to_owned())
Some("screen-share-delivery-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
Some(vec![
"1500000000000000001".to_owned(),
"1500000000000000002".to_owned()
])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned()
"1500000000000000003".to_owned(),
"1500000000000000004".to_owned()
])
);
}
#[test]
fn build_guild_header_collapse_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
fn build_screen_share_delivery_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_guild_header_collapse_update(&form).expect("valid form");
let request = build_screen_share_delivery_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"guild_header_collapse": {
serde_json::json!({"screen_share_delivery": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
@@ -1843,107 +1632,35 @@ mod tests {
}
#[test]
fn build_guild_header_collapse_update_rejects_a_rollout_above_the_maximum() {
let form = MultiValueForm::parse(b"guild_header_collapse_rollout_basis_points=10001");
assert_eq!(
build_guild_header_collapse_update(&form).expect_err("invalid rollout"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[test]
fn build_guild_header_collapse_update_reads_only_its_own_prefix() {
let form = MultiValueForm::parse(
b"guild_header_collapse_enabled=true&guild_header_collapse_rollout_basis_points=2500&guild_header_collapse_rollout_salt=guild-header-collapse-v2&guild_header_collapse_included_user_ids=1500000000000000001&expression_card_rollout_basis_points=750&expression_card_rollout_salt=expression-info-card-v2&expression_card_excluded_user_ids=1500000000000000009",
);
let update = build_guild_header_collapse_update(&form)
.expect("valid form")
.guild_header_collapse
.expect("guild header collapse update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(2_500));
assert_eq!(
update.rollout_salt,
Some("guild-header-collapse-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(update.excluded_user_ids, Some(Vec::new()));
}
#[test]
fn build_typing_indicator_rework_update_reads_the_whole_form() {
let form = MultiValueForm::parse(
b"typing_indicator_rework_enabled=true&typing_indicator_rework_rollout_basis_points=2500&typing_indicator_rework_rollout_salt=%20typing-indicator-rework-v2%20&typing_indicator_rework_included_user_ids=1500000000000000001%0A1500000000000000001&typing_indicator_rework_excluded_user_ids=1500000000000000002%2C%201500000000000000003",
);
let update = build_typing_indicator_rework_update(&form)
.expect("valid form")
.typing_indicator_rework
.expect("typing indicator rework update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(2_500));
assert_eq!(
update.rollout_salt,
Some("typing-indicator-rework-v2".to_owned())
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned()
])
);
}
#[test]
fn build_typing_indicator_rework_update_leaves_the_rollout_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_typing_indicator_rework_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"typing_indicator_rework": {
"enabled": false,
"included_user_ids": [],
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_typing_indicator_rework_update_rejects_a_rollout_above_the_maximum() {
let form = MultiValueForm::parse(b"typing_indicator_rework_rollout_basis_points=10001");
assert_eq!(
build_typing_indicator_rework_update(&form).expect_err("invalid rollout"),
"Rollout basis points must be a whole number between 0 and 10000"
);
}
#[test]
fn build_typing_indicator_rework_update_reads_only_its_own_prefix() {
let form = MultiValueForm::parse(
b"expression_card_enabled=true&expression_card_rollout_basis_points=2500&expression_card_rollout_salt=expression-info-card-v2&expression_card_included_user_ids=1500000000000000001&typing_indicator_rework_rollout_basis_points=750&typing_indicator_rework_rollout_salt=typing-indicator-rework-v2&typing_indicator_rework_excluded_user_ids=1500000000000000009",
);
let update = build_typing_indicator_rework_update(&form)
.expect("valid form")
.typing_indicator_rework
.expect("typing indicator rework update");
assert_eq!(update.enabled, Some(false));
assert_eq!(update.rollout_basis_points, Some(750));
assert_eq!(
update.rollout_salt,
Some("typing-indicator-rework-v2".to_owned())
);
assert_eq!(update.included_user_ids, Some(Vec::new()));
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000009".to_owned()])
);
fn build_screen_share_delivery_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"screen_share_delivery_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"screen_share_delivery_rollout_basis_points=abc",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"screen_share_delivery_rollout_salt=%20%20",
"Rollout salt must be between 1 and 64 characters",
),
(
"screen_share_delivery_included_user_ids=123%2Cinvalid",
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"screen_share_delivery_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_screen_share_delivery_update(&form).expect_err("invalid rollout field"),
message
);
}
}
#[test]
+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,15 +2,12 @@
use crate::{
api::types::{
AppPublicConfigResponse, BlockedMessageGroupsConfigResponse,
ExperimentDeliveryConfigResponse, ExpressionInfoCardConfigResponse,
GatewayRolloutConfigResponse, GuildActivityLogPresentationConfigResponse,
GuildHeaderCollapseConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, MessageHoverTrackingConfigResponse,
MessageKeyboardFocusConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
RegistrationUrlResponse, SsoConfigResponse, TypingIndicatorReworkConfigResponse,
VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
LimitConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
RegistrationUrlResponse, SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
ScreenShareDeliveryConfigResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
VoiceNoiseSuppressionConfigResponse,
},
config::AdminConfig,
@@ -152,13 +149,7 @@ 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))
(message_hover_tracking_section(base, csrf_token, &instance_config.message_hover_tracking))
(message_keyboard_focus_section(base, csrf_token, &instance_config.message_keyboard_focus))
(blocked_message_groups_section(base, csrf_token, &instance_config.blocked_message_groups))
(guild_activity_log_presentation_section(base, csrf_token, &instance_config.guild_activity_log_presentation))
(expression_info_card_section(base, csrf_token, &instance_config.expression_info_card))
(guild_header_collapse_section(base, csrf_token, &instance_config.guild_header_collapse))
(typing_indicator_rework_section(base, csrf_token, &instance_config.typing_indicator_rework))
(screen_share_delivery_section(base, csrf_token, &instance_config.screen_share_delivery))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -859,6 +850,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(
@@ -1104,7 +1107,7 @@ 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 \
@@ -1124,7 +1127,7 @@ 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 \
@@ -1156,17 +1159,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",
@@ -1186,63 +1178,62 @@ fn voice_noise_suppression_section(
)
}
fn message_hover_tracking_section(
fn screen_share_delivery_section(
base: &str,
csrf_token: &str,
message_hover_tracking: &MessageHoverTrackingConfigResponse,
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
) -> Markup {
let status = if message_hover_tracking.enabled {
let status = if screen_share_delivery.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = message_hover_tracking.included_user_ids.join("\n");
let excluded_user_ids = message_hover_tracking.excluded_user_ids.join("\n");
let included_user_ids = screen_share_delivery.included_user_ids.join("\n");
let excluded_user_ids = screen_share_delivery.excluded_user_ids.join("\n");
section_card_with_description(
"Message Hover Tracking",
"Picks which message hover implementation targeted clients run in the message list. A \
targeted client resolves the hovered message from one shared pointer oracle and drives \
the message action bar from that state. While the master switch below is off every \
client keeps the per-row implementation it ships with, whatever the rest of these \
fields say.",
"Screen Share Delivery",
"Pick how many clients publish screen shares through the reworked delivery path. While \
the master switch below is off nothing on this form reaches any client: every user \
keeps the screen share pipeline they have today, whatever the rest of these fields say. \
A client that is already sharing keeps the path it started on until the share ends.",
html! {
form method="post" action={(base) "/instance-config?action=update_message_hover_tracking"} {
form method="post" action={(base) "/instance-config?action=update_screen_share_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 " (message_hover_tracking.config_version)
"Config version " (screen_share_delivery.config_version)
}
}
(checkbox(
"message_hover_enabled",
"screen_share_delivery_enabled",
"true",
"Serve message hover tracking assignments to clients",
message_hover_tracking.enabled,
"Serve screen share delivery assignments to clients",
screen_share_delivery.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps its current hover behavior, so the rollout \
and targeting fields below have no effect at all."
feature is inert and keeps its current behavior, so the rollout and \
targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"message_hover_rollout_basis_points",
"screen_share_delivery_rollout_basis_points",
"Rollout (basis points)",
&message_hover_tracking.rollout_basis_points.to_string(),
&screen_share_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(
"message_hover_rollout_salt",
"screen_share_delivery_rollout_salt",
"Rollout Salt",
&message_hover_tracking.rollout_salt,
"message-hover-tracking-v1",
&screen_share_delivery.rollout_salt,
SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
@@ -1252,13 +1243,17 @@ fn message_hover_tracking_section(
}
div class="flex flex-col gap-2" {
(textarea_input(
"message_hover_included_user_ids",
"screen_share_delivery_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
screen_share_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 \
@@ -1268,13 +1263,17 @@ fn message_hover_tracking_section(
}
div class="flex flex-col gap-2" {
(textarea_input(
"message_hover_excluded_user_ids",
"screen_share_delivery_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
screen_share_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, so this is the per-user kill switch."
@@ -1282,630 +1281,7 @@ fn message_hover_tracking_section(
}
(form_actions(html! {
(submit_button("Save Message Hover Tracking Configuration"))
}))
}
}
},
)
}
fn message_keyboard_focus_section(
base: &str,
csrf_token: &str,
message_keyboard_focus: &MessageKeyboardFocusConfigResponse,
) -> Markup {
let status = if message_keyboard_focus.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = message_keyboard_focus.included_user_ids.join("\n");
let excluded_user_ids = message_keyboard_focus.excluded_user_ids.join("\n");
section_card_with_description(
"Message Keyboard Focus",
"Picks whether targeted clients run the keyboard navigation rework in the message list. \
A targeted client reaches the message list from the composer with one Tab, walks \
messages with the arrow keys through revealed blocked groups, and draws the focus ring \
inside each row. While the master switch below is off every client keeps the keyboard \
navigation it ships with, whatever the rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_message_keyboard_focus"} {
(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 " (message_keyboard_focus.config_version)
}
}
(checkbox(
"message_keyboard_focus_enabled",
"true",
"Serve message keyboard focus assignments to clients",
message_keyboard_focus.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps its current keyboard navigation, so the rollout \
and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"message_keyboard_focus_rollout_basis_points",
"Rollout (basis points)",
&message_keyboard_focus.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(
"message_keyboard_focus_rollout_salt",
"Rollout Salt",
&message_keyboard_focus.rollout_salt,
"message-keyboard-focus-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"message_keyboard_focus_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
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(
"message_keyboard_focus_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
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."
}
}
(form_actions(html! {
(submit_button("Save Message Keyboard Focus Configuration"))
}))
}
}
},
)
}
fn blocked_message_groups_section(
base: &str,
csrf_token: &str,
blocked_message_groups: &BlockedMessageGroupsConfigResponse,
) -> Markup {
let status = if blocked_message_groups.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = blocked_message_groups.included_user_ids.join("\n");
let excluded_user_ids = blocked_message_groups.excluded_user_ids.join("\n");
section_card_with_description(
"Blocked Message Groups",
"Picks how targeted clients render a revealed block of blocked or suspected spam \
messages. A targeted client draws the block full width, spaces consecutive message \
groups inside it, and keys an unread divider apart from the group below it. While the \
master switch below is off every client keeps the rendering it ships with, whatever the \
rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_blocked_message_groups"} {
(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 " (blocked_message_groups.config_version)
}
}
(checkbox(
"blocked_groups_enabled",
"true",
"Serve blocked message groups assignments to clients",
blocked_message_groups.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps its current rendering, so the rollout \
and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"blocked_groups_rollout_basis_points",
"Rollout (basis points)",
&blocked_message_groups.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(
"blocked_groups_rollout_salt",
"Rollout Salt",
&blocked_message_groups.rollout_salt,
"blocked-message-groups-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"blocked_groups_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
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(
"blocked_groups_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
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."
}
}
(form_actions(html! {
(submit_button("Save Blocked Message Groups Configuration"))
}))
}
}
},
)
}
fn guild_activity_log_presentation_section(
base: &str,
csrf_token: &str,
guild_activity_log_presentation: &GuildActivityLogPresentationConfigResponse,
) -> Markup {
let status = if guild_activity_log_presentation.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = guild_activity_log_presentation.included_user_ids.join("\n");
let excluded_user_ids = guild_activity_log_presentation.excluded_user_ids.join("\n");
section_card_with_description(
"Guild Activity Log Presentation",
"Picks which activity log rendering targeted clients run in community settings. A \
targeted client renders each activity log entry through the rewritten presenters. \
While the master switch below is off every client keeps the previous activity log \
rendering, whatever the rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_guild_activity_log_presentation"} {
(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 " (guild_activity_log_presentation.config_version)
}
}
(checkbox(
"guild_activity_log_presentation_enabled",
"true",
"Serve guild activity log presentation assignments to clients",
guild_activity_log_presentation.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps the previous activity log rendering, so the \
rollout and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"guild_activity_log_presentation_rollout_basis_points",
"Rollout (basis points)",
&guild_activity_log_presentation.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(
"guild_activity_log_presentation_rollout_salt",
"Rollout Salt",
&guild_activity_log_presentation.rollout_salt,
"guild-activity-log-presentation-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"guild_activity_log_presentation_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
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(
"guild_activity_log_presentation_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
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."
}
}
(form_actions(html! {
(submit_button("Save Guild Activity Log Presentation Configuration"))
}))
}
}
},
)
}
fn expression_info_card_section(
base: &str,
csrf_token: &str,
expression_info_card: &ExpressionInfoCardConfigResponse,
) -> Markup {
let status = if expression_info_card.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = expression_info_card.included_user_ids.join("\n");
let excluded_user_ids = expression_info_card.excluded_user_ids.join("\n");
section_card_with_description(
"Expression Info Card",
"Picks what a targeted client shows for an emoji or a sticker in a message. A targeted \
client opens a click-triggered info card that names the expression, says where it comes \
from, and offers a row for the source community the reader can open. While the master \
switch below is off every client keeps the hover tooltip it ships with, whatever the \
rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_expression_info_card"} {
(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 " (expression_info_card.config_version)
}
}
(checkbox(
"expression_card_enabled",
"true",
"Serve expression info card assignments to clients",
expression_info_card.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps its current tooltip, so the rollout \
and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"expression_card_rollout_basis_points",
"Rollout (basis points)",
&expression_info_card.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(
"expression_card_rollout_salt",
"Rollout Salt",
&expression_info_card.rollout_salt,
"expression-info-card-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"expression_card_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
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(
"expression_card_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
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."
}
}
(form_actions(html! {
(submit_button("Save Expression Info Card Configuration"))
}))
}
}
},
)
}
fn guild_header_collapse_section(
base: &str,
csrf_token: &str,
guild_header_collapse: &GuildHeaderCollapseConfigResponse,
) -> Markup {
let status = if guild_header_collapse.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = guild_header_collapse.included_user_ids.join("\n");
let excluded_user_ids = guild_header_collapse.excluded_user_ids.join("\n");
section_card_with_description(
"Guild Header Collapse",
"Picks how a targeted client draws the guild banner above the channel list. A targeted \
client reduces that banner to the height of the header as the channel list scrolls down \
and returns it to full height as the list scrolls back up. While the master switch below \
is off every client keeps the fixed banner height it ships with, whatever the rest of \
these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_guild_header_collapse"} {
(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 " (guild_header_collapse.config_version)
}
}
(checkbox(
"guild_header_collapse_enabled",
"true",
"Serve guild header collapse assignments to clients",
guild_header_collapse.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps its current banner height, so the rollout \
and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"guild_header_collapse_rollout_basis_points",
"Rollout (basis points)",
&guild_header_collapse.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(
"guild_header_collapse_rollout_salt",
"Rollout Salt",
&guild_header_collapse.rollout_salt,
"guild-header-collapse-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"guild_header_collapse_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
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(
"guild_header_collapse_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
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."
}
}
(form_actions(html! {
(submit_button("Save Guild Header Collapse Configuration"))
}))
}
}
},
)
}
fn typing_indicator_rework_section(
base: &str,
csrf_token: &str,
typing_indicator_rework: &TypingIndicatorReworkConfigResponse,
) -> Markup {
let status = if typing_indicator_rework.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = typing_indicator_rework.included_user_ids.join("\n");
let excluded_user_ids = typing_indicator_rework.excluded_user_ids.join("\n");
section_card_with_description(
"Typing Indicator Rework",
"Picks how a targeted client sends and shows typing indicators. A targeted client sends a \
typing signal 1.5 seconds after someone starts typing and then at most once every 8 \
seconds, names up to three typists, and announces those names to screen readers even \
where the visible row collapses them. While the master switch below is off every client \
keeps the typing behaviour it ships with, whatever the rest of these fields say.",
html! {
form method="post" action={(base) "/instance-config?action=update_typing_indicator_rework"} {
(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 " (typing_indicator_rework.config_version)
}
}
(checkbox(
"typing_indicator_rework_enabled",
"true",
"Serve typing indicator rework assignments to clients",
typing_indicator_rework.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state. With this unchecked every client is told the \
rollout is inert and keeps its current typing behaviour, so the rollout \
and targeting fields below have no effect at all."
}
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"typing_indicator_rework_rollout_basis_points",
"Rollout (basis points)",
&typing_indicator_rework.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(
"typing_indicator_rework_rollout_salt",
"Rollout Salt",
&typing_indicator_rework.rollout_salt,
"typing-indicator-rework-v1",
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"typing_indicator_rework_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
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(
"typing_indicator_rework_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
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."
}
}
(form_actions(html! {
(submit_button("Save Typing Indicator Rework Configuration"))
(submit_button("Save Screen Share Delivery Configuration"))
}))
}
}
@@ -1921,7 +1297,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! {
@@ -2556,10 +1932,29 @@ mod tests {
assert!(!markup.contains("at the cap"));
}
#[test]
fn screen_share_delivery_section_shows_list_counts_and_the_master_switch() {
let screen_share_delivery = ScreenShareDeliveryConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..ScreenShareDeliveryConfigResponse::default()
};
let markup =
screen_share_delivery_section("/admin", "csrf", &screen_share_delivery).into_string();
assert!(markup.contains("action=update_screen_share_delivery"));
assert!(markup.contains("screen_share_delivery_enabled"));
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()
@@ -2568,44 +1963,4 @@ mod tests {
assert!(markup.contains("1000 of 1000 stored"));
assert!(markup.contains("at the cap"));
}
#[test]
fn guild_header_collapse_section_posts_its_own_action_and_fields() {
let guild_header_collapse = GuildHeaderCollapseConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec!["1500000000000000002".to_owned()],
..GuildHeaderCollapseConfigResponse::default()
};
let markup =
guild_header_collapse_section("/admin", "csrf", &guild_header_collapse).into_string();
assert!(markup.contains("/instance-config?action=update_guild_header_collapse"));
assert!(markup.contains("Guild Header Collapse"));
assert!(markup.contains("guild-header-collapse-v1"));
assert!(markup.contains("guild_header_collapse_enabled"));
assert!(markup.contains("guild_header_collapse_rollout_basis_points"));
assert!(markup.contains("guild_header_collapse_rollout_salt"));
assert!(markup.contains("guild_header_collapse_included_user_ids"));
assert!(markup.contains("guild_header_collapse_excluded_user_ids"));
assert!(!markup.contains("expression_card_"));
}
#[test]
fn typing_indicator_rework_section_posts_its_own_action_and_fields() {
let typing_indicator_rework = TypingIndicatorReworkConfigResponse {
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec!["1500000000000000002".to_owned()],
..TypingIndicatorReworkConfigResponse::default()
};
let markup = typing_indicator_rework_section("/admin", "csrf", &typing_indicator_rework)
.into_string();
assert!(markup.contains("/instance-config?action=update_typing_indicator_rework"));
assert!(markup.contains("Typing Indicator Rework"));
assert!(markup.contains("typing-indicator-rework-v1"));
assert!(markup.contains("typing_indicator_rework_enabled"));
assert!(markup.contains("typing_indicator_rework_rollout_basis_points"));
assert!(markup.contains("typing_indicator_rework_rollout_salt"));
assert!(markup.contains("typing_indicator_rework_included_user_ids"));
assert!(markup.contains("typing_indicator_rework_excluded_user_ids"));
assert!(!markup.contains("expression_card_"));
}
}
+24 -78
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,70 +403,22 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"guild_activity_log_presentation": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 5000,
"rollout_salt": "guild-activity-log-presentation-v1",
"included_user_ids": ["1130650140672000000"],
"excluded_user_ids": [],
"future_presentation_knob": "verbose"
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"message_hover_tracking": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "message-hover-tracking-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"message_keyboard_focus": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "message-keyboard-focus-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"blocked_message_groups": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "blocked-message-groups-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"expression_info_card": {
"enabled": true,
"config_version": 3,
"rollout_basis_points": 2500,
"rollout_salt": "expression-info-card-v1",
"included_user_ids": ["1130650140672000000"],
"excluded_user_ids": ["1130958221824557056"],
"suppression_strength": 80,
"future_presentation_knob": "verbose",
"future_knob": 7,
"future_object_knob": {"nested": true},
"future_list_knob": ["a", "b"]
},
"guild_header_collapse": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "guild-header-collapse-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"typing_indicator_rework": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "typing-indicator-rework-v1",
"included_user_ids": [],
"screen_share_delivery": {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 2500,
"rollout_salt": "screen-share-delivery-v1",
"included_user_ids": ["1500000000000000001"],
"future_delivery_knob": 9,
"excluded_user_ids": []
},
"experiment_delivery": {"poll_interval_seconds": 300, "poll_jitter_percent": 15},
"registration": {
"mode": "open",
"admin_registration_urls_enabled": false,
@@ -588,28 +542,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
);
assert!(!resp.self_hosted);
assert!(resp.expression_info_card.enabled);
assert_eq!(resp.expression_info_card.config_version, 3);
assert_eq!(resp.expression_info_card.rollout_basis_points, 2500);
assert!(resp.voice_noise_suppression.enabled);
assert_eq!(resp.voice_noise_suppression.config_version, 4);
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.screen_share_delivery.enabled);
assert_eq!(resp.screen_share_delivery.config_version, 2);
assert_eq!(resp.screen_share_delivery.rollout_basis_points, 2500);
assert_eq!(
*resp.expression_info_card.rollout_salt,
"expression-info-card-v1"
*resp.screen_share_delivery.rollout_salt,
"screen-share-delivery-v1"
);
assert_eq!(resp.expression_info_card.included_user_ids.len(), 1);
assert_eq!(
*resp.expression_info_card.excluded_user_ids[0],
"1130958221824557056"
);
assert!(resp.guild_activity_log_presentation.enabled);
assert_eq!(
*resp.guild_activity_log_presentation.rollout_salt,
"guild-activity-log-presentation-v1"
);
assert!(!resp.message_hover_tracking.enabled);
assert!(!resp.message_keyboard_focus.enabled);
assert!(!resp.blocked_message_groups.enabled);
assert!(!resp.guild_header_collapse.enabled);
assert!(!resp.typing_indicator_rework.enabled);
assert_eq!(resp.screen_share_delivery.included_user_ids.len(), 1);
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));
@@ -620,6 +565,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_delivery_knob\": 9,", "")
.replace("\"future_object_knob\": {\"nested\": true},", "")
.replace("\"future_list_knob\": [\"a\", \"b\"],", "")
.replace(
+87 -19
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,8 +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_guild_header_collapse",
"/instance-config?action=update_typing_indicator_rework",
"/instance-config?action=update_screen_share_delivery",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -846,6 +878,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(),
}
}
@@ -977,6 +1028,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",
@@ -1099,22 +1176,6 @@ fn instance_config() -> Value {
"max_concurrent_guild_starts": 16,
"voice_e2ee_scope": "guild_feature_only"
},
"guild_header_collapse": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "guild-header-collapse-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"typing_indicator_rework": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "typing-indicator-rework-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"voice_noise_suppression": {
"enabled": false,
"config_version": 0,
@@ -1134,9 +1195,16 @@ fn instance_config() -> Value {
"included_user_ids": [],
"excluded_user_ids": [],
"guild_overrides": [],
"stereo_enabled": false,
"suppression_strength": 80
},
"screen_share_delivery": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_salt": "screen-share-delivery-v1",
"included_user_ids": [],
"excluded_user_ids": []
},
"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);
+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"
}
}
+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"
}
}
+1
View File
@@ -49,6 +49,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
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 -20
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,
@@ -237,6 +219,11 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
jetStreamUrl: master.services.nats?.jetstream_url ?? 'nats://127.0.0.1:4223',
authToken: master.services.nats?.auth_token ?? '',
},
storageChangeFeed: {
enabled: master.services.api.storage_change_feed?.enabled ?? false,
stream: master.services.api.storage_change_feed?.stream ?? 'STORAGE_CHANGES',
skipBuckets: master.services.api.storage_change_feed?.skip_buckets ?? [s3Buckets.uploads],
},
search: {
engine: master.integrations.search?.engine ?? 'elasticsearch',
url: master.integrations.search?.url ?? 'http://127.0.0.1:9200',
@@ -258,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: {
@@ -296,7 +286,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,
@@ -340,6 +329,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,
@@ -474,6 +469,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,
@@ -507,7 +504,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,
@@ -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);
}
@@ -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);
},
);
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, snowflakeOrUndefined} from '@app/api/admin/AdminAuditRecorder';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -16,6 +18,8 @@ import {
ListAdminAuditLogsQuery,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
const AUDIT_TARGET_TYPE_PATTERN = /^[a-z][a-z_]{0,63}$/;
export function AuditLogAdminController(app: HonoApp) {
app.get(
'/admin/audit-logs',
@@ -34,24 +38,50 @@ export function AuditLogAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const {q, admin_user_id, target_type, target_id, sort_by, sort_order, limit, offset} = ctx.req.valid('query');
if (q === undefined) {
return ctx.json(
await adminService.auditService.listAuditLogs({admin_user_id, target_type, target_id, limit, offset}),
);
}
return ctx.json(
await adminService.auditService.searchAuditLogs({
query: q,
admin_user_id,
target_type,
target_id,
sort_by,
sort_order,
const {q, admin_user_id, target_type, target_id, access, sort_by, sort_order, limit, offset} =
ctx.req.valid('query');
const response =
q === undefined
? await adminService.auditService.listAuditLogs({
admin_user_id,
target_type,
target_id,
access,
limit,
offset,
})
: await adminService.auditService.searchAuditLogs({
query: q,
admin_user_id,
target_type,
target_id,
access,
sort_by,
sort_order,
limit,
offset,
});
await recordAdminRead(ctx, {
targetType: 'audit_log',
targetId: 0n,
action: q === undefined ? AdminAuditReadActions.LIST_AUDIT_LOGS : AdminAuditReadActions.SEARCH_AUDIT_LOGS,
metadata: {
filter_admin_user_id: admin_user_id,
filter_target_type:
target_type !== undefined && AUDIT_TARGET_TYPE_PATTERN.test(target_type) ? target_type : undefined,
has_target_type_filter:
target_type !== undefined && !AUDIT_TARGET_TYPE_PATTERN.test(target_type) ? true : undefined,
filter_target_id: snowflakeOrUndefined(target_id),
access,
sort_by: q === undefined ? undefined : sort_by,
sort_order: q === undefined ? undefined : sort_order,
limit,
offset,
}),
);
result_count: response.logs.length,
total: response.total,
},
});
return ctx.json(response);
},
);
app.get(
@@ -76,6 +106,11 @@ export function AuditLogAdminController(app: HonoApp) {
if (!log) {
throw new NotFoundError({code: APIErrorCodes.NOT_FOUND});
}
await recordAdminRead(ctx, {
targetType: 'audit_log',
targetId: log_id,
action: AdminAuditReadActions.GET_AUDIT_LOG,
});
return ctx.json(log);
},
);
@@ -1,5 +1,8 @@
// 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 {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -180,6 +183,18 @@ const BLOCKLIST_TYPE_ACLS: Record<AdminBlocklistListType, {add: string; check: s
},
};
const BLOCKLIST_AUDIT_TARGET_TYPES: Record<AdminBlocklistListType, string> = {
ip: 'ip',
email: 'email',
'email-domain-suspicious': 'email_domain',
phrase: 'phrase',
url: 'url',
'url-domain': 'url_domain',
'file-sha': 'file_sha',
'avatar-hash': 'avatar_hash',
'profile-substring': 'profile_substring',
};
type BlocklistVerb = 'add' | 'check' | 'remove';
const BLOCKLIST_ACLS_BY_VERB: Record<BlocklistVerb, Array<string>> = {
@@ -229,6 +244,37 @@ async function parseBlocklistBody<T>(schema: ZodType<T>, value: unknown): Promis
return result.data;
}
async function checkBlocklistEntry(
bans: AdminBanManagementService,
listType: AdminBlocklistListType,
entryValue: string,
scope: ProfileSubstringScope | undefined,
): Promise<{banned: boolean}> {
switch (listType) {
case 'ip':
return bans.checkIpBan({ip: entryValue});
case 'email':
return bans.checkEmailBan({email: entryValue});
case 'email-domain-suspicious':
return bans.checkSuspiciousEmailDomain({domain: entryValue});
case 'phrase':
return bans.checkPhraseBan({phrase: entryValue});
case 'url':
return bans.checkUrlBan({url: entryValue});
case 'url-domain':
return bans.checkUrlDomainBan({domain: entryValue});
case 'file-sha':
return bans.checkFileShaBan({sha256_hex: entryValue});
case 'avatar-hash':
return bans.checkAvatarHashBan({hashes: [entryValue]});
case 'profile-substring':
return bans.checkProfileSubstringBan({
scope: requireProfileSubstringScope(scope),
substrings: [entryValue],
});
}
}
export function BanAdminController(app: HonoApp) {
app.get(
'/admin/blocklists',
@@ -245,6 +291,12 @@ export function BanAdminController(app: HonoApp) {
'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
}),
async (ctx) => {
await recordAdminRead(ctx, {
targetType: 'blocklist',
targetId: 0n,
action: AdminAuditReadActions.LIST_BLOCKLISTS,
metadata: {result_count: BLOCKLIST_CATALOG.length},
});
return ctx.json({items: BLOCKLIST_CATALOG});
},
);
@@ -270,14 +322,26 @@ export function BanAdminController(app: HonoApp) {
requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'check');
const {limit, after, scope} = ctx.req.valid('query');
assertBlocklistScopeAllowed(listType, scope);
return ctx.json(
await adminService.banManagementService.listBlocklistEntries({
listType,
const page = await adminService.banManagementService.listBlocklistEntries({
listType,
limit,
after: after ?? null,
scope: listType === 'profile-substring' ? requireProfileSubstringScope(scope) : null,
});
await recordAdminRead(ctx, {
targetType: BLOCKLIST_AUDIT_TARGET_TYPES[listType],
targetId: 0n,
action: AdminAuditReadActions.LIST_BLOCKLIST_ENTRIES,
metadata: {
list_type: listType,
scope,
limit,
after: after ?? null,
scope: listType === 'profile-substring' ? requireProfileSubstringScope(scope) : null,
}),
);
has_after: after === undefined ? undefined : true,
result_count: page.items.length,
has_more: page.has_more,
},
});
return ctx.json(page);
},
);
app.post(
@@ -379,6 +443,15 @@ export function BanAdminController(app: HonoApp) {
},
{requestedByUserId: adminUserId, requireLedger: true, ...(auditLogReason && {auditLogReason})},
);
await recordAdminWrite(ctx, {
targetType: 'bulk_job',
targetId: jobId,
action: 'queue_bulk_job',
metadata: {
task: 'ban_file_shas',
entity_count: body.sha256_list.length,
},
});
return ctx.json({job_id: jobId.toString()});
},
);
@@ -449,32 +522,18 @@ export function BanAdminController(app: HonoApp) {
requireBlocklistACL(ctx.get('adminUserAcls'), listType, 'check');
const {scope} = ctx.req.valid('query');
assertBlocklistScopeAllowed(listType, scope);
const bans = adminService.banManagementService;
switch (listType) {
case 'ip':
return ctx.json(await bans.checkIpBan({ip: entryValue}));
case 'email':
return ctx.json(await bans.checkEmailBan({email: entryValue}));
case 'email-domain-suspicious':
return ctx.json(await bans.checkSuspiciousEmailDomain({domain: entryValue}));
case 'phrase':
return ctx.json(await bans.checkPhraseBan({phrase: entryValue}));
case 'url':
return ctx.json(await bans.checkUrlBan({url: entryValue}));
case 'url-domain':
return ctx.json(await bans.checkUrlDomainBan({domain: entryValue}));
case 'file-sha':
return ctx.json(await bans.checkFileShaBan({sha256_hex: entryValue}));
case 'avatar-hash':
return ctx.json(await bans.checkAvatarHashBan({hashes: [entryValue]}));
case 'profile-substring':
return ctx.json(
await bans.checkProfileSubstringBan({
scope: requireProfileSubstringScope(scope),
substrings: [entryValue],
}),
);
}
const result = await checkBlocklistEntry(adminService.banManagementService, listType, entryValue, scope);
await recordAdminRead(ctx, {
targetType: BLOCKLIST_AUDIT_TARGET_TYPES[listType],
targetId: 0n,
action: AdminAuditReadActions.CHECK_BLOCKLIST_ENTRY,
metadata: {
list_type: listType,
scope,
banned: result.banned,
},
});
return ctx.json(result);
},
);
app.patch(
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import type {UserID} from '@app/api/BrandedTypes';
import {requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -136,6 +137,16 @@ export function BulkAdminController(app: HonoApp) {
throw new MissingACLError(requiredAcl);
}
const jobId = await queueBulkJob(body, adminUserId, auditLogReason);
await recordAdminWrite(ctx, {
targetType: 'bulk_job',
targetId: jobId,
action: 'queue_bulk_job',
metadata: {
task: body.task,
entity_count: 'guild_ids' in body ? body.guild_ids.length : body.user_ids.length,
guild_id: body.task === AdminBulkTaskType.ADD_GUILD_MEMBERS ? body.guild_id : undefined,
},
});
return ctx.json({job_id: jobId.toString()});
},
);
@@ -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} from '@app/api/BrandedTypes';
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
import {mapGuildFeatures} from '@app/api/guild/GuildFeatureUtils';
@@ -30,6 +32,8 @@ import {
DiscoveryCategoryListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
const DISCOVERY_LISTING_FIELDS = ['description', 'category_type', 'primary_language', 'custom_tags'] as const;
function mapRowToApplicationResponse(row: GuildDiscoveryRow) {
return {
guild_id: row.guild_id.toString(),
@@ -149,6 +153,12 @@ export function DiscoveryAdminController(app: HonoApp) {
const userRepository = ctx.get('userRepository');
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.PENDING});
const enrichment = await enrichGuilds(rows, guildService, userRepository);
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: 0n,
action: AdminAuditReadActions.LIST_DISCOVERY_APPLICATIONS,
metadata: {result_count: rows.length},
});
return ctx.json(rows.map((row) => mapPendingResponse(row, enrichment.get(row.guild_id.toString()))));
},
);
@@ -173,10 +183,16 @@ export function DiscoveryAdminController(app: HonoApp) {
const data = ctx.req.valid('json');
const adminUserId = ctx.get('adminUserId');
const discoveryService = ctx.get('discoveryService');
const row =
data.status === DiscoveryApplicationStatus.APPROVED
? await discoveryService.approve({guildId, adminUserId, reason: data.reason})
: await discoveryService.reject({guildId, adminUserId, reason: data.reason});
const approved = data.status === DiscoveryApplicationStatus.APPROVED;
const row = approved
? await discoveryService.approve({guildId, adminUserId, reason: data.reason})
: await discoveryService.reject({guildId, adminUserId, reason: data.reason});
await recordAdminWrite(ctx, {
targetType: 'guild',
targetId: guildId,
action: approved ? 'approve_discovery_application' : 'reject_discovery_application',
metadata: {status: data.status},
});
return ctx.json(mapRowToApplicationResponse(row));
},
);
@@ -195,12 +211,17 @@ export function DiscoveryAdminController(app: HonoApp) {
tags: 'Admin',
}),
async (ctx) => {
return ctx.json(
Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({
id: Number(id),
name,
})),
);
const categories = Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({
id: Number(id),
name,
}));
await recordAdminRead(ctx, {
targetType: 'discovery_category',
targetId: 0n,
action: AdminAuditReadActions.LIST_DISCOVERY_CATEGORIES,
metadata: {result_count: categories.length},
});
return ctx.json(categories);
},
);
app.get(
@@ -233,11 +254,20 @@ export function DiscoveryAdminController(app: HonoApp) {
(enrichment.get(right.guild_id.toString())?.member_count ?? 0) -
(enrichment.get(left.guild_id.toString())?.member_count ?? 0),
);
return ctx.json(
sorted
.slice(offset, offset + limit)
.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))),
);
const page = sorted.slice(offset, offset + limit);
await recordAdminRead(ctx, {
targetType: 'discovery_category',
targetId: 0n,
action: AdminAuditReadActions.LIST_DISCOVERY_CATEGORY_LISTINGS,
metadata: {
category_id,
limit,
offset,
result_count: page.length,
total: inCategory.length,
},
});
return ctx.json(page.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))));
},
);
app.get(
@@ -260,6 +290,12 @@ export function DiscoveryAdminController(app: HonoApp) {
const userRepository = ctx.get('userRepository');
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED});
const enrichment = await enrichGuilds(rows, guildService, userRepository);
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: 0n,
action: AdminAuditReadActions.LIST_DISCOVERY_LISTINGS,
metadata: {result_count: rows.length},
});
return ctx.json(rows.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))));
},
);
@@ -341,6 +377,18 @@ export function DiscoveryAdminController(app: HonoApp) {
const adminUserId = ctx.get('adminUserId');
const discoveryService = ctx.get('discoveryService');
const row = await discoveryService.editApplication({guildId, userId: adminUserId, data});
const fields = DISCOVERY_LISTING_FIELDS.filter((field) => data[field] !== undefined);
await recordAdminWrite(ctx, {
targetType: 'guild',
targetId: guildId,
action: 'update_discovery_listing',
metadata: {
fields: fields.length > 0 ? fields.join(',') : undefined,
category_type: data.category_type,
primary_language: data.primary_language,
status: row.status,
},
});
return ctx.json(mapRowToApplicationResponse(row));
},
);
@@ -366,6 +414,11 @@ export function DiscoveryAdminController(app: HonoApp) {
const adminUserId = ctx.get('adminUserId');
const discoveryService = ctx.get('discoveryService');
const row = await discoveryService.remove({guildId, adminUserId, reason: data.reason});
await recordAdminWrite(ctx, {
targetType: 'guild',
targetId: guildId,
action: 'remove_discovery_listing',
});
return ctx.json(mapRowToApplicationResponse(row));
},
);
@@ -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 {createGuildID} from '@app/api/BrandedTypes';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -34,7 +36,14 @@ export function GatewayAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.guildServiceAggregate.managementService.getNodeStats());
const stats = await adminService.guildServiceAggregate.managementService.getNodeStats();
await recordAdminRead(ctx, {
targetType: 'gateway',
targetId: 0n,
action: AdminAuditReadActions.GET_GATEWAY_STATS,
metadata: {node_count: stats.node_count},
});
return ctx.json(stats);
},
);
app.get(
@@ -54,7 +63,14 @@ export function GatewayAdminController(app: HonoApp) {
async (ctx) => {
const adminService = ctx.get('adminService');
const {limit} = ctx.req.valid('query');
return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(limit));
const stats = await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(limit);
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: 0n,
action: AdminAuditReadActions.LIST_GUILD_MEMORY_STATS,
metadata: {limit, result_count: stats.guilds.length},
});
return ctx.json(stats);
},
);
app.get(
@@ -73,7 +89,18 @@ export function GatewayAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.guildServiceAggregate.managementService.getVoiceStateCounts());
const counts = await adminService.guildServiceAggregate.managementService.getVoiceStateCounts();
await recordAdminRead(ctx, {
targetType: 'gateway',
targetId: 0n,
action: AdminAuditReadActions.GET_VOICE_STATE_COUNTS,
metadata: {
total_voice_states: counts.total_voice_states,
region_count: counts.regions.length,
server_count: counts.servers.length,
},
});
return ctx.json(counts);
},
);
app.post(
@@ -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} from '@app/api/BrandedTypes';
import {requireAdminACL, requireAnyAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -107,13 +109,24 @@ export function GuildAdminController(app: HonoApp) {
async (ctx) => {
const adminService = ctx.get('adminService');
const query = ctx.req.valid('query');
return ctx.json(
await adminService.searchService.searchGuilds({
query: query.q,
const response = await adminService.searchService.searchGuilds({
query: query.q,
limit: query.limit,
offset: query.offset,
});
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: 0n,
action: AdminAuditReadActions.SEARCH_GUILDS,
metadata: {
has_query: query.q === undefined ? undefined : true,
limit: query.limit,
offset: query.offset,
}),
);
result_count: response.guilds.length,
total: response.total,
},
});
return ctx.json(response);
},
);
app.get(
@@ -133,11 +146,15 @@ export function GuildAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(
await adminService.guildServiceAggregate.lookupService.lookupGuild({
guild_id: ctx.req.valid('param').guild_id,
}),
);
const {guild_id} = ctx.req.valid('param');
const response = await adminService.guildServiceAggregate.lookupService.lookupGuild({guild_id});
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: guild_id,
action: AdminAuditReadActions.GET_GUILD,
metadata: {found: response.guild !== null},
});
return ctx.json(response);
},
);
app.patch(
@@ -215,6 +232,16 @@ export function GuildAdminController(app: HonoApp) {
if (!guild) {
throw new UnknownGuildError();
}
const appliedFieldGroup =
body.fields !== undefined ||
hasGuildSettingsUpdate(body) ||
hasGuildFeatureUpdate(body) ||
body.name !== undefined ||
body.vanity_url_code !== undefined ||
body.new_owner_id !== undefined;
if (!appliedFieldGroup) {
await recordAdminWrite(ctx, {targetType: 'guild', targetId: guildIdRaw, action: 'update_guild'});
}
return ctx.json({
guild: {
id: guild.id,
@@ -275,14 +302,25 @@ export function GuildAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const {guild_id} = ctx.req.valid('param');
const query = ctx.req.valid('query');
return ctx.json(
await adminService.guildServiceAggregate.lookupService.listGuildMembers({
guild_id: ctx.req.valid('param').guild_id,
limit: query.limit,
offset: query.offset,
}),
);
const response = await adminService.guildServiceAggregate.lookupService.listGuildMembers({
guild_id,
limit: query.limit,
offset: query.offset,
});
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: guild_id,
action: AdminAuditReadActions.LIST_GUILD_MEMBERS,
metadata: {
limit: response.limit,
offset: response.offset,
result_count: response.members.length,
total: response.total,
},
});
return ctx.json(response);
},
);
app.put(
@@ -391,7 +429,14 @@ export function GuildAdminController(app: HonoApp) {
async (ctx) => {
const adminService = ctx.get('adminService');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildEmojis(guildId));
const response = await adminService.guildServiceAggregate.lookupService.listGuildEmojis(guildId);
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: guildId,
action: AdminAuditReadActions.LIST_GUILD_EMOJIS,
metadata: {result_count: response.emojis.length},
});
return ctx.json(response);
},
);
app.get(
@@ -412,7 +457,14 @@ export function GuildAdminController(app: HonoApp) {
async (ctx) => {
const adminService = ctx.get('adminService');
const guildId = createGuildID(ctx.req.valid('param').guild_id);
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId));
const response = await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId);
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: guildId,
action: AdminAuditReadActions.LIST_GUILD_STICKERS,
metadata: {result_count: response.stickers.length},
});
return ctx.json(response);
},
);
app.get(
@@ -433,17 +485,30 @@ export function GuildAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const {guild_id} = ctx.req.valid('param');
const query = ctx.req.valid('query');
return ctx.json(
await adminService.guildServiceAggregate.listGuildAuditLogs({
guild_id: ctx.req.valid('param').guild_id,
const response = await adminService.guildServiceAggregate.listGuildAuditLogs({
guild_id,
limit: query.limit,
before: query.before,
after: query.after,
user_id: query.user_id,
action_type: query.action_type,
});
await recordAdminRead(ctx, {
targetType: 'guild',
targetId: guild_id,
action: AdminAuditReadActions.LIST_GUILD_AUDIT_LOGS,
metadata: {
limit: query.limit,
before: query.before,
after: query.after,
user_id: query.user_id,
filter_user_id: query.user_id,
action_type: query.action_type,
}),
);
result_count: response.audit_log_entries.length,
},
});
return ctx.json(response);
},
);
app.post(
@@ -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 {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
@@ -29,16 +31,10 @@ import {
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import {ScreenShareDeliveryConfigSchema} from '@fluxer/schema/src/domains/admin/ScreenShareDeliverySchemas';
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {BlockedMessageGroupsConfigSchema} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {ExpressionInfoCardConfigSchema} from '@fluxer/schema/src/domains/experiment/ExpressionInfoCardSchemas';
import {GuildActivityLogPresentationConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildActivityLogPresentationSchemas';
import {GuildHeaderCollapseConfigSchema} from '@fluxer/schema/src/domains/experiment/GuildHeaderCollapseSchemas';
import {MessageHoverTrackingConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
import {MessageKeyboardFocusConfigSchema} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
import {TypingIndicatorReworkConfigSchema} from '@fluxer/schema/src/domains/experiment/TypingIndicatorReworkSchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import type {Context} from 'hono';
@@ -64,14 +60,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
ssoConfig,
gatewayRollout,
voiceNoiseSuppression,
guildActivityLogPresentation,
screenShareDelivery,
experimentDelivery,
messageHoverTracking,
messageKeyboardFocus,
blockedMessageGroups,
expressionInfoCard,
guildHeaderCollapse,
typingIndicatorRework,
registrationConfig,
registrationUrls,
pendingRegistrations,
@@ -79,14 +69,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
instanceConfigRepository.getGuildActivityLogPresentationConfig(),
instanceConfigRepository.getScreenShareDeliveryConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getMessageHoverTrackingConfig(),
instanceConfigRepository.getMessageKeyboardFocusConfig(),
instanceConfigRepository.getBlockedMessageGroupsConfig(),
instanceConfigRepository.getExpressionInfoCardConfig(),
instanceConfigRepository.getGuildHeaderCollapseConfig(),
instanceConfigRepository.getTypingIndicatorReworkConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
@@ -117,14 +101,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
},
gateway_rollout: gatewayRollout,
voice_noise_suppression: voiceNoiseSuppression,
guild_activity_log_presentation: guildActivityLogPresentation,
screen_share_delivery: screenShareDelivery,
experiment_delivery: experimentDelivery,
message_hover_tracking: messageHoverTracking,
message_keyboard_focus: messageKeyboardFocus,
blocked_message_groups: blockedMessageGroups,
expression_info_card: expressionInfoCard,
guild_header_collapse: guildHeaderCollapse,
typing_indicator_rework: typingIndicatorRework,
registration: {
...registrationConfig,
urls: registrationUrls,
@@ -195,16 +173,25 @@ function completesInitialSetup(data: InstanceConfigUpdateRequest, setupConfigure
);
}
async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<void> {
async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<boolean> {
const user = ctx.get('user');
if (!user || ctx.get('authTokenType') !== 'session' || hasAdminAuthenticationACL(user.acls)) {
return;
return false;
}
const nextACLs = new Set(user.acls);
nextACLs.add(AdminACLs.WILDCARD);
const updatedUser = await ctx.get('userRepository').patchUpsert(user.id, {acls: nextACLs}, user.toRow());
ctx.set('user', updatedUser);
ctx.set('adminUserAcls', updatedUser.acls);
return true;
}
function listSuppliedSections(data: InstanceConfigUpdateRequest): string | undefined {
const sections = Object.entries(data)
.filter(([, value]) => value != null)
.map(([key]) => key)
.sort();
return sections.length > 0 ? sections.join(',') : undefined;
}
export function InstanceConfigAdminController(app: HonoApp) {
@@ -224,7 +211,17 @@ export function InstanceConfigAdminController(app: HonoApp) {
tags: 'Admin',
}),
async (ctx) => {
return ctx.json(await buildInstanceConfigResponse());
const response = await buildInstanceConfigResponse();
await recordAdminRead(ctx, {
targetType: 'instance_config',
targetId: 0n,
action: AdminAuditReadActions.GET_INSTANCE_CONFIG,
metadata: {
registration_url_count: response.registration.urls.length,
pending_registration_count: response.registration.pending_registrations.length,
},
});
return ctx.json(response);
},
);
app.patch(
@@ -268,89 +265,16 @@ export function InstanceConfigAdminController(app: HonoApp) {
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
}
}
if (data.guild_activity_log_presentation) {
const patch = omitUndefinedFields(data.guild_activity_log_presentation);
if (data.screen_share_delivery) {
const patch = omitUndefinedFields(data.screen_share_delivery);
if (Object.keys(patch).length > 0) {
const currentGuildActivityLogPresentation =
await instanceConfigRepository.getGuildActivityLogPresentationConfig();
const validated = GuildActivityLogPresentationConfigSchema.parse({
...currentGuildActivityLogPresentation,
const currentScreenShareDelivery = await instanceConfigRepository.getScreenShareDeliveryConfig();
const validated = ScreenShareDeliveryConfigSchema.parse({
...currentScreenShareDelivery,
...patch,
config_version: currentGuildActivityLogPresentation.config_version + 1,
config_version: currentScreenShareDelivery.config_version + 1,
});
await instanceConfigRepository.setGuildActivityLogPresentationConfig(validated);
}
}
if (data.message_hover_tracking) {
const patch = omitUndefinedFields(data.message_hover_tracking);
if (Object.keys(patch).length > 0) {
const currentMessageHoverTracking = await instanceConfigRepository.getMessageHoverTrackingConfig();
const validated = MessageHoverTrackingConfigSchema.parse({
...currentMessageHoverTracking,
...patch,
config_version: currentMessageHoverTracking.config_version + 1,
});
await instanceConfigRepository.setMessageHoverTrackingConfig(validated);
}
}
if (data.message_keyboard_focus) {
const patch = omitUndefinedFields(data.message_keyboard_focus);
if (Object.keys(patch).length > 0) {
const currentMessageKeyboardFocus = await instanceConfigRepository.getMessageKeyboardFocusConfig();
const validated = MessageKeyboardFocusConfigSchema.parse({
...currentMessageKeyboardFocus,
...patch,
config_version: currentMessageKeyboardFocus.config_version + 1,
});
await instanceConfigRepository.setMessageKeyboardFocusConfig(validated);
}
}
if (data.blocked_message_groups) {
const patch = omitUndefinedFields(data.blocked_message_groups);
if (Object.keys(patch).length > 0) {
const currentBlockedMessageGroups = await instanceConfigRepository.getBlockedMessageGroupsConfig();
const validated = BlockedMessageGroupsConfigSchema.parse({
...currentBlockedMessageGroups,
...patch,
config_version: currentBlockedMessageGroups.config_version + 1,
});
await instanceConfigRepository.setBlockedMessageGroupsConfig(validated);
}
}
if (data.expression_info_card) {
const patch = omitUndefinedFields(data.expression_info_card);
if (Object.keys(patch).length > 0) {
const currentExpressionInfoCard = await instanceConfigRepository.getExpressionInfoCardConfig();
const validated = ExpressionInfoCardConfigSchema.parse({
...currentExpressionInfoCard,
...patch,
config_version: currentExpressionInfoCard.config_version + 1,
});
await instanceConfigRepository.setExpressionInfoCardConfig(validated);
}
}
if (data.guild_header_collapse) {
const patch = omitUndefinedFields(data.guild_header_collapse);
if (Object.keys(patch).length > 0) {
const currentGuildHeaderCollapse = await instanceConfigRepository.getGuildHeaderCollapseConfig();
const validated = GuildHeaderCollapseConfigSchema.parse({
...currentGuildHeaderCollapse,
...patch,
config_version: currentGuildHeaderCollapse.config_version + 1,
});
await instanceConfigRepository.setGuildHeaderCollapseConfig(validated);
}
}
if (data.typing_indicator_rework) {
const patch = omitUndefinedFields(data.typing_indicator_rework);
if (Object.keys(patch).length > 0) {
const currentTypingIndicatorRework = await instanceConfigRepository.getTypingIndicatorReworkConfig();
const validated = TypingIndicatorReworkConfigSchema.parse({
...currentTypingIndicatorRework,
...patch,
config_version: currentTypingIndicatorRework.config_version + 1,
});
await instanceConfigRepository.setTypingIndicatorReworkConfig(validated);
await instanceConfigRepository.setScreenShareDeliveryConfig(validated);
}
}
if (data.experiment_delivery) {
@@ -418,6 +342,11 @@ export function InstanceConfigAdminController(app: HonoApp) {
wordmark_url: readOptionalField(data.app_public.branding, 'wordmark_url'),
favicon_url: readOptionalField(data.app_public.branding, 'favicon_url'),
theme_color: readOptionalField(data.app_public.branding, 'theme_color'),
status_page_url: readOptionalField(data.app_public.branding, 'status_page_url'),
status_page_incident_history_url: readOptionalField(
data.app_public.branding,
'status_page_incident_history_url',
),
})
: undefined,
legal: data.app_public.legal
@@ -519,10 +448,20 @@ export function InstanceConfigAdminController(app: HonoApp) {
}),
});
}
let grantedSetupCompleterAdmin = false;
if (shouldGrantSetupCompleterAdmin) {
await grantSetupCompleterAdminACL(ctx);
grantedSetupCompleterAdmin = await grantSetupCompleterAdminACL(ctx);
await instanceConfigRepository.markAdminBootstrapped();
}
await recordAdminWrite(ctx, {
targetType: 'instance_config',
targetId: 0n,
action: 'update_instance_config',
metadata: {
sections: listSuppliedSections(data),
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
},
});
return ctx.json(await buildInstanceConfigResponse());
},
);
@@ -553,6 +492,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
});
const brandingPatch: Partial<InstanceBranding> = {[`${kind}_url`]: prepared.newCdnUrl};
await instanceConfigRepository.setAppPublicConfig({branding: brandingPatch});
await recordAdminWrite(ctx, {
targetType: 'instance_config',
targetId: 0n,
action: 'upload_branding_asset',
metadata: {kind, cleared: prepared.newCdnUrl === null},
});
return ctx.json(await buildInstanceConfigResponse());
},
);
@@ -573,6 +518,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
}),
async (ctx) => {
const data = ctx.req.valid('json');
let result: InstanceEmailSmtpTestResponse;
try {
const provider = new SmtpEmailProvider({
host: data.host,
@@ -585,10 +531,17 @@ export function InstanceConfigAdminController(app: HonoApp) {
socketTimeoutMs: 10000,
});
await provider.verify();
return ctx.json({ok: true, error: null});
result = {ok: true, error: null};
} catch (error) {
return ctx.json({ok: false, error: error instanceof Error ? error.message : String(error)});
result = {ok: false, error: error instanceof Error ? error.message : String(error)};
}
await recordAdminWrite(ctx, {
targetType: 'instance_config',
targetId: 0n,
action: 'test_smtp_connection',
metadata: {port: data.port, secure: data.secure, ok: result.ok},
});
return ctx.json(result);
},
);
app.post(
@@ -615,6 +568,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
maxUses: data.max_uses ?? null,
approvalRequired: data.approval_required,
});
await recordAdminWrite(ctx, {
targetType: 'registration_url',
targetId: 0n,
action: 'create_registration_url',
metadata: {approval_required: data.approval_required, max_uses: data.max_uses},
});
return ctx.json({
registration_url: created.registrationUrl,
code: created.code,
@@ -639,6 +598,11 @@ export function InstanceConfigAdminController(app: HonoApp) {
}),
async (ctx) => {
await instanceConfigRepository.revokeRegistrationUrl(ctx.req.valid('param').registration_url_id);
await recordAdminWrite(ctx, {
targetType: 'registration_url',
targetId: 0n,
action: 'revoke_registration_url',
});
return ctx.json(await buildInstanceConfigResponse());
},
);
@@ -755,6 +719,12 @@ async function updatePendingRegistrationUser(
const userRepository = ctx.get('userRepository');
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
if (!user) {
await recordAdminWrite(ctx, {
targetType: 'user',
targetId: BigInt(userId),
action: decision === 'approve' ? 'approve_registration' : 'reject_registration',
metadata: {account_found: false},
});
return;
}
const traits = new Set(user.traits);
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
@@ -114,7 +115,15 @@ export function JobsAdminController(app: HonoApp) {
}),
async (ctx) => {
const adminService = ctx.get('adminService');
return ctx.json(await adminService.jobAdminService.cancelJob(ctx.req.valid('param').job_id));
const {job_id} = ctx.req.valid('param');
const result = await adminService.jobAdminService.cancelJob(job_id);
await recordAdminWrite(ctx, {
targetType: 'bulk_job',
targetId: job_id,
action: 'cancel_job',
metadata: {cancelled: result.cancelled},
});
return ctx.json(result);
},
);
}
@@ -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 {Config} from '@app/api/Config';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
@@ -10,7 +12,7 @@ import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {LIMIT_CATEGORY_LABELS, LIMIT_KEY_METADATA, LIMIT_KEYS} from '@fluxer/constants/src/LimitConfigMetadata';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import type {LimitConfigSnapshot, LimitRule} from '@fluxer/limits/src/LimitTypes';
import {LimitConfigGetResponse, LimitConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
function formatConfig(service: LimitConfigService) {
@@ -27,6 +29,21 @@ function formatConfig(service: LimitConfigService) {
};
}
function describeLimitRule(rule: LimitRule): string {
return JSON.stringify([
rule.filters?.traits ?? [],
rule.filters?.guildFeatures ?? [],
LIMIT_KEYS.map((key) => rule.limits[key] ?? null),
]);
}
function countChangedLimitRules(before: LimitConfigSnapshot, after: LimitConfigSnapshot): number {
const previous = new Map(before.rules.map((rule) => [rule.id, describeLimitRule(rule)]));
const next = new Map(after.rules.map((rule) => [rule.id, describeLimitRule(rule)]));
const ruleIds = new Set([...previous.keys(), ...next.keys()]);
return Array.from(ruleIds).filter((ruleId) => previous.get(ruleId) !== next.get(ruleId)).length;
}
export function LimitConfigAdminController(app: HonoApp) {
app.get(
'/admin/limit-config',
@@ -44,7 +61,14 @@ export function LimitConfigAdminController(app: HonoApp) {
}),
async (ctx) => {
const limitConfigService = ctx.get('limitConfigService') as LimitConfigService;
return ctx.json(formatConfig(limitConfigService));
const response = formatConfig(limitConfigService);
await recordAdminRead(ctx, {
targetType: 'limit_config',
targetId: 0n,
action: AdminAuditReadActions.GET_LIMIT_CONFIG,
metadata: {rule_count: response.limit_config.rules.length},
});
return ctx.json(response);
},
);
app.put(
@@ -69,8 +93,20 @@ export function LimitConfigAdminController(app: HonoApp) {
...data.limit_config,
traitDefinitions: data.limit_config.traitDefinitions ?? [],
};
const previous = limitConfigService.getConfigSnapshot();
await limitConfigService.updateConfig(normalized);
return ctx.json(formatConfig(limitConfigService));
const response = formatConfig(limitConfigService);
await recordAdminWrite(ctx, {
targetType: 'limit_config',
targetId: 0n,
action: 'update_limit_config',
metadata: {
rule_count: response.limit_config.rules.length,
changed_rule_count: countChangedLimitRules(previous, response.limit_config),
trait_definition_count: response.limit_config.traitDefinitions.length,
},
});
return ctx.json(response);
},
);
}
@@ -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 {createAttachmentID, createChannelID, createMessageID, createReportID} from '@app/api/BrandedTypes';
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
@@ -57,34 +59,69 @@ export function MessageAdminController(app: HonoApp) {
const adminService = ctx.get('adminService');
const query = ctx.req.valid('query');
if (query.message_id != null) {
return ctx.json(
await adminService.messageService.lookupMessage({
const messageId = query.message_id;
const response = await adminService.messageService.lookupMessage({
channel_id: query.channel_id,
message_id: messageId,
context_limit: query.context_limit,
});
await recordAdminRead(ctx, {
targetType: 'message',
targetId: messageId,
action: AdminAuditReadActions.SEARCH_MESSAGES,
metadata: {
mode: 'message',
channel_id: query.channel_id,
message_id: query.message_id,
context_limit: query.context_limit,
}),
);
found: response.messages.some((message) => message.id === messageId.toString()),
result_count: response.messages.length,
},
});
return ctx.json(response);
}
if (query.attachment_id != null) {
if (query.filename == null) {
throw InputValidationError.fromCode('filename', ValidationErrorCodes.INVALID_FORMAT);
}
return ctx.json(
await adminService.messageService.lookupMessageByAttachment({
channel_id: query.channel_id,
attachment_id: query.attachment_id,
filename: query.filename,
context_limit: query.context_limit,
}),
);
}
return ctx.json(
await adminService.messageService.searchChannelMessages({
const response = await adminService.messageService.lookupMessageByAttachment({
channel_id: query.channel_id,
query: query.q ?? '',
attachment_id: query.attachment_id,
filename: query.filename,
context_limit: query.context_limit,
});
await recordAdminRead(ctx, {
targetType: 'channel',
targetId: query.channel_id,
action: AdminAuditReadActions.SEARCH_MESSAGES,
metadata: {
mode: 'attachment',
attachment_id: query.attachment_id,
message_id: response.message_id,
context_limit: query.context_limit,
found: response.message_id !== null,
result_count: response.messages.length,
},
});
return ctx.json(response);
}
const response = await adminService.messageService.searchChannelMessages({
channel_id: query.channel_id,
query: query.q ?? '',
limit: query.limit,
});
await recordAdminRead(ctx, {
targetType: 'channel',
targetId: query.channel_id,
action: AdminAuditReadActions.SEARCH_MESSAGES,
metadata: {
mode: 'search',
has_query: query.q === undefined ? undefined : true,
limit: query.limit,
}),
);
result_count: response.messages.length,
total: response.total,
},
});
return ctx.json(response);
},
);
app.post(
@@ -139,7 +176,14 @@ export function MessageAdminController(app: HonoApp) {
async (ctx) => {
const adminService = ctx.get('adminService');
const {job_id} = ctx.req.valid('param');
return ctx.json(await adminService.messageShredService.getMessageShredStatus(job_id.toString()));
const response = await adminService.messageShredService.getMessageShredStatus(job_id.toString());
await recordAdminRead(ctx, {
targetType: 'message_shred',
targetId: 0n,
action: AdminAuditReadActions.GET_MESSAGE_SHRED_STATUS,
metadata: {job_id, status: response.status},
});
return ctx.json(response);
},
);
app.get(
@@ -162,14 +206,25 @@ export function MessageAdminController(app: HonoApp) {
const adminService = ctx.get('adminService');
const {channel_id} = ctx.req.valid('param');
const {limit, before, after} = ctx.req.valid('query');
return ctx.json(
await adminService.messageService.browseChannel({
channel_id,
const response = await adminService.messageService.browseChannel({
channel_id,
before,
after,
limit,
});
await recordAdminRead(ctx, {
targetType: 'channel',
targetId: channel_id,
action: AdminAuditReadActions.LIST_CHANNEL_MESSAGES,
metadata: {
limit,
before,
after,
limit,
}),
);
result_count: response.messages.length,
has_more: response.has_more,
},
});
return ctx.json(response);
},
);
app.get(
@@ -192,13 +247,23 @@ export function MessageAdminController(app: HonoApp) {
const adminService = ctx.get('adminService');
const {channel_id, message_id} = ctx.req.valid('param');
const {context_limit} = ctx.req.valid('query');
return ctx.json(
await adminService.messageService.lookupMessage({
const response = await adminService.messageService.lookupMessage({
channel_id,
message_id,
context_limit,
});
await recordAdminRead(ctx, {
targetType: 'message',
targetId: message_id,
action: AdminAuditReadActions.GET_MESSAGE,
metadata: {
channel_id,
message_id,
context_limit,
}),
);
found: response.messages.some((message) => message.id === message_id.toString()),
result_count: response.messages.length,
},
});
return ctx.json(response);
},
);
app.delete(

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