mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82b2f4ec5e | ||
|
|
c92e5d03a7 | ||
|
|
91340c5c84 | ||
|
|
045dd5d027 | ||
|
|
a21b9c4659 | ||
|
|
4b1b869802 | ||
|
|
1ab7e7dfcc | ||
|
|
31c53d2dff | ||
|
|
412a1ae79d | ||
|
|
0b2306ec3d | ||
|
|
242ed3a934 | ||
|
|
70e1ce682a | ||
|
|
7601bf98ee | ||
|
|
c7ec2a0f58 | ||
|
|
6a5e0056a8 | ||
|
|
78d105b46e | ||
|
|
c68d62b8a0 | ||
|
|
df58020f4c | ||
|
|
f052ce05aa | ||
|
|
eedfd9275f | ||
|
|
416af4bec4 | ||
|
|
108d282ddd | ||
|
|
a6103244b0 | ||
|
|
38935c83c5 | ||
|
|
c157ab5752 | ||
|
|
574a93257c | ||
|
|
ba7d8781cf | ||
|
|
3256af8d92 | ||
|
|
86043212f2 | ||
|
|
5d85e88532 | ||
|
|
e2abfd476a | ||
|
|
487febac8e | ||
|
|
a3454e8245 | ||
|
|
bf7567b768 | ||
|
|
ac3450ab32 | ||
|
|
5d034becb8 | ||
|
|
f9397d0db9 | ||
|
|
9005139dc8 | ||
|
|
d93604afa2 | ||
|
|
c4f0b2ece0 | ||
|
|
98a42f612b | ||
|
|
cc75e1318d | ||
|
|
9027cbdf3e | ||
|
|
2119e10ed5 | ||
|
|
87f3eb3c81 | ||
|
|
f9bb8bd585 | ||
|
|
bc47a724af | ||
|
|
f32356801d | ||
|
|
efd677f32b | ||
|
|
3cec27ba57 | ||
|
|
1f810ba04d | ||
|
|
522cf08e61 | ||
|
|
025c01ab13 | ||
|
|
dc41b53d60 | ||
|
|
3b552e00ef | ||
|
|
56e04e7b53 | ||
|
|
deac653a9e | ||
|
|
ed9528834d | ||
|
|
4cecbf1f43 | ||
|
|
b019f4a91f |
@@ -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
|
||||
|
||||
@@ -38,7 +38,11 @@
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"erlang.includePaths": ["."],
|
||||
"search.exclude": {
|
||||
"**/_build/default/lib/fluxer_gateway": true
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"biomejs.biome",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/.direnv/
|
||||
/.fluxer/
|
||||
/.pnpm-store/
|
||||
/.vscode/
|
||||
|
||||
**/*.css.d.ts
|
||||
**/*.tsbuildinfo
|
||||
|
||||
Generated
+828
-855
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,6 @@ members = [
|
||||
"fluxer_messages",
|
||||
"fluxer_snowflakes",
|
||||
"tools/ci",
|
||||
"tools/content/update-frozen-snapshot",
|
||||
"tools/dev",
|
||||
"tools/i18n_auto",
|
||||
"fluxer_users",
|
||||
|
||||
@@ -6,18 +6,155 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="https://fluxer.app/download">
|
||||
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
|
||||
<a href="https://docs.fluxer.app">
|
||||
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
|
||||
<a href="https://fluxer.app/donate">
|
||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||
<a href="./LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
|
||||
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
|
||||
</p>
|
||||
|
||||
# Fluxer
|
||||
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
|
||||
| Windows | macOS | Linux | Android | iOS |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [APK][android-apk] | [TestFlight][ios-testflight] |
|
||||
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [Obtainium][obtainium] | |
|
||||
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | | |
|
||||
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
|
||||
| | | [tar.gz (x64)][linux-targz-x64] | | |
|
||||
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
|
||||
|
||||
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
|
||||
|
||||
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
|
||||
|
||||
## Linux package repositories
|
||||
|
||||
Every repository serves both channels. The package is `fluxer` for stable, `fluxer-canary` for canary.
|
||||
|
||||
### Flatpak
|
||||
|
||||
Stable is on [Flathub][flathub], the easiest route on most desktops:
|
||||
|
||||
```sh
|
||||
flatpak install flathub app.fluxer.Fluxer
|
||||
```
|
||||
|
||||
Flathub has stable only. For canary, or to use Fluxer's own repository, open [this reference file][flatpak-ref] and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
```
|
||||
|
||||
### Debian and Ubuntu
|
||||
|
||||
```sh
|
||||
sudo install -d -m 0755 /etc/apt/keyrings
|
||||
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
|
||||
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
|
||||
sudo apt update && sudo apt install fluxer
|
||||
```
|
||||
|
||||
### 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 their base repositories lack `libXScrnSaver`. Fedora does not.
|
||||
|
||||
### Arch Linux
|
||||
|
||||
The repository is signed, so pacman needs the key once:
|
||||
|
||||
```sh
|
||||
sudo pacman-key --init
|
||||
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
|
||||
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
|
||||
```
|
||||
|
||||
`--lsign-key` is what makes pacman trust it. Then add the repository:
|
||||
|
||||
```sh
|
||||
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
|
||||
|
||||
[fluxer]
|
||||
SigLevel = Required TrustedOnly
|
||||
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
|
||||
REPO
|
||||
sudo pacman -Syu fluxer
|
||||
```
|
||||
|
||||
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
|
||||
|
||||
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
|
||||
|
||||
## Other ways to run it
|
||||
|
||||
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
|
||||
- [Host your own instance][docs-selfhost] from this repository.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Documentation home][docs]
|
||||
- [Downloads][docs-downloads]
|
||||
- [Self-hosting][docs-selfhost]
|
||||
|
||||
## License
|
||||
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer, all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
|
||||
terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
endorsement rights.
|
||||
|
||||
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
|
||||
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
|
||||
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
|
||||
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
|
||||
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
|
||||
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
|
||||
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
|
||||
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
|
||||
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
|
||||
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
|
||||
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
|
||||
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
|
||||
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||
[obtainium]: https://obtainium.imranr.dev/
|
||||
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||
[docs]: https://docs.fluxer.app
|
||||
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
|
||||
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
|
||||
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
|
||||
|
||||
+3
-2
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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
|
||||
@@ -130,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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
+110
-186
@@ -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,81 +259,54 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
|
||||
# which is the container ceiling the indexer shares with the search process.
|
||||
# Meilisearch indexing memory. Keep it well under the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
|
||||
# SeaweedFS heap ceiling. Go collects against this value instead of against the
|
||||
# container limit, which it cannot see, so without it an upload burst grows the
|
||||
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
|
||||
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
|
||||
# together: the peak is the parts of one upload in flight at once, which is 25 MB
|
||||
# times 20 for a 500 MB attachment.
|
||||
# SeaweedFS heap ceiling. Go cannot see the container limit, so without this an
|
||||
# upload burst gets the container OOM-killed. Keep it near three quarters of
|
||||
# FLUXER_SEAWEEDFS_MEMORY_LIMIT and raise both together.
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
|
||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
||||
# outside the heap. Leave these unset unless you have a reason to pin the value.
|
||||
# Any value set here must stay well below the container limit above: a heap ceiling
|
||||
# above the container limit makes the kernel OOM-kill the container (exit 137, no
|
||||
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
|
||||
# Node sizes its heap from the container limit by default. Leave these unset
|
||||
# unless you need to pin it. A heap ceiling above the container limit gets the
|
||||
# container OOM-killed instead of reporting a heap error.
|
||||
#FLUXER_API_NODE_HEAP_MB=1792
|
||||
#FLUXER_WORKER_NODE_HEAP_MB=1792
|
||||
|
||||
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
|
||||
# budget roughly shared_buffers + (server max_connections x 12 MB) +
|
||||
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
|
||||
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
|
||||
# pool sizes used by the api, worker and shards.
|
||||
# Bundled Postgres tuning. Keep it consistent with the memory limit above. This
|
||||
# is the server setting, not the per-service pool sizes.
|
||||
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
|
||||
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
||||
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
#FLUXER_POSTGRES_WORK_MEM=8MB
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
#FLUXER_POSTGRES_SHM_SIZE=1gb
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache. The bulk message
|
||||
# deletion queue and the account deletion queue are sorted sets with no expiry,
|
||||
# and nothing else stores the first of the two. It therefore runs with an
|
||||
# append-only file on a named volume and with noeviction, so an over-limit write
|
||||
# fails loudly instead of silently deleting queued work. Distributed locks all
|
||||
# carry a TTL and are not what the durability is for. Only change the policy if
|
||||
# you have moved that durable state elsewhere.
|
||||
# The bundled Valkey holds durable state as well as cache, so it runs with an
|
||||
# append-only file and with noeviction, which fails an over-limit write instead
|
||||
# of dropping queued work. Change the policy only if that state lives elsewhere.
|
||||
#FLUXER_VALKEY_MAXMEMORY=192mb
|
||||
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
|
||||
|
||||
# The gateway derives its BEAM scheduler count from the container CPU quota,
|
||||
# clamped to this range. The floor matters: a single scheduler lets one blocking
|
||||
# operation stall every websocket on the node. The ceiling stops a large host
|
||||
# from starting far more schedulers than the container can actually use.
|
||||
# The gateway derives its scheduler count from the CPU quota, clamped here. One
|
||||
# scheduler lets a single blocking operation stall every websocket on the node.
|
||||
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the services Compose forwards it to: the users
|
||||
# and messages routers and their shards. Leave it unset and each service uses its
|
||||
# built-in default. Set it and the one value replaces that default on all of
|
||||
# them, so size it for the busiest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them. The api
|
||||
# turns that rejection into a 503 and logs "shard rejected the request because
|
||||
# it is at its concurrency limit".
|
||||
# In-flight request ceiling for the users and messages routers and their shards.
|
||||
# One value replaces the built-in default on all of them, so size it for the
|
||||
# busiest. Too low a value rejects requests rather than slowing them, and the api
|
||||
# turns that into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
# outlives the transaction, so set this to false if you put a transaction-pooling
|
||||
# connection pooler such as PgBouncer in front of Postgres. One setting governs
|
||||
# every service. The bundled compose talks to Postgres directly, where naming is
|
||||
# a win and the default is correct.
|
||||
# Named prepared statements need a session that outlives the transaction, so set
|
||||
# this to false behind a transaction-pooling connection pooler. The bundled
|
||||
# compose talks to Postgres directly, where the default is correct.
|
||||
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
|
||||
|
||||
# The api bounds how long a client may take to send a request. The header timeout
|
||||
# covers the request line and headers only, while the request timeout covers the
|
||||
# whole exchange, so a slow uploader is bounded by the second value and not by
|
||||
# the first. Raise both if you front large uploads or serve clients on high
|
||||
# latency links. The header timeout is clamped down to the request timeout, so
|
||||
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
|
||||
# How long a client may take to send a request. The header timeout covers the
|
||||
# request line and headers, the request timeout the whole exchange, and the first
|
||||
# is clamped down to the second. Milliseconds, 1000 to 3600000.
|
||||
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
|
||||
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
|
||||
|
||||
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
|
||||
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
@@ -44,7 +46,6 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
@@ -97,6 +98,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ${FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64:-}
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE: ${FLUXER_ADMIN_SECRET_KEY_BASE:?set FLUXER_ADMIN_SECRET_KEY_BASE in .env}
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
|
||||
|
||||
@@ -122,7 +124,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
|
||||
services:
|
||||
edge:
|
||||
image: caddy:2.10-alpine
|
||||
image: caddy:2.11-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -186,7 +188,7 @@ services:
|
||||
-c autovacuum_vacuum_cost_limit=2000
|
||||
-c track_io_timing=on
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
shm_size: 256mb
|
||||
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
|
||||
environment:
|
||||
POSTGRES_DB: fluxer
|
||||
POSTGRES_USER: fluxer
|
||||
@@ -200,7 +202,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8.1-alpine
|
||||
image: valkey/valkey:9.1-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -236,7 +238,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
image: getmeili/meilisearch:v1.53
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -246,6 +248,7 @@ services:
|
||||
environment:
|
||||
MEILI_ENV: production
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_UPGRADE_DB: "true"
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
volumes:
|
||||
@@ -257,7 +260,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:4.34
|
||||
image: chrislusf/seaweedfs:4.47
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -266,7 +269,7 @@ services:
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
command: ["server", "-s3", "-dir=/data"]
|
||||
command: ["server", "-s3", "-dir=/data", "-master.telemetry=false"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
healthcheck:
|
||||
@@ -277,7 +280,7 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
seaweedfs-init:
|
||||
image: chrislusf/seaweedfs:4.34
|
||||
image: chrislusf/seaweedfs:4.47
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -291,14 +294,13 @@ services:
|
||||
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
missing="$$buckets";
|
||||
for attempt in $$(seq 1 60); do
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
@@ -472,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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
+108
-33
@@ -10524,6 +10524,7 @@
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"},
|
||||
"screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
@@ -10541,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},
|
||||
@@ -10555,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,
|
||||
@@ -10598,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"}
|
||||
@@ -10855,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
|
||||
},
|
||||
@@ -10868,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
|
||||
},
|
||||
@@ -10884,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",
|
||||
@@ -10931,6 +10952,7 @@
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"screen_share_delivery",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
@@ -10949,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"}
|
||||
@@ -10967,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},
|
||||
@@ -10981,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,
|
||||
@@ -11065,6 +11087,10 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"screen_share_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
@@ -11222,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
|
||||
},
|
||||
@@ -11235,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
|
||||
}
|
||||
@@ -11272,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}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12989,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",
|
||||
@@ -13424,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": {
|
||||
@@ -13432,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"},
|
||||
@@ -13532,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"],
|
||||
@@ -13569,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": {
|
||||
@@ -13577,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"},
|
||||
@@ -13677,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"],
|
||||
@@ -13841,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": {
|
||||
@@ -13849,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",
|
||||
@@ -14018,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": {
|
||||
@@ -14230,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": {
|
||||
@@ -15102,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"},
|
||||
@@ -15112,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": {
|
||||
@@ -15154,6 +15184,24 @@
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50}
|
||||
}
|
||||
},
|
||||
"ScreenShareDeliveryConfigUpdateRequest": {
|
||||
"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}$"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15189,7 +15237,6 @@
|
||||
"required": ["guild_id", "backend"]
|
||||
}
|
||||
},
|
||||
"stereo_enabled": {"type": "boolean"},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
|
||||
}
|
||||
},
|
||||
@@ -15220,6 +15267,36 @@
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"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": "screen-share-delivery-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
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15264,7 +15341,6 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"stereo_enabled": {"default": false, "type": "boolean"},
|
||||
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
|
||||
},
|
||||
"required": [
|
||||
@@ -15278,7 +15354,6 @@
|
||||
"included_user_ids",
|
||||
"excluded_user_ids",
|
||||
"guild_overrides",
|
||||
"stereo_enabled",
|
||||
"suppression_strength"
|
||||
],
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub screen_share_delivery: ScreenShareDeliveryConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
@@ -446,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 {
|
||||
@@ -492,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,
|
||||
}
|
||||
|
||||
@@ -509,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,
|
||||
}
|
||||
}
|
||||
@@ -536,11 +537,47 @@ 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 ScreenShareDeliveryConfigResponse {
|
||||
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 ScreenShareDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: SCREEN_SHARE_DELIVERY_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ScreenShareDeliveryConfigUpdateRequest {
|
||||
#[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 ExperimentDeliveryConfigResponse {
|
||||
@@ -657,6 +694,8 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -994,18 +1033,30 @@ 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 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 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");
|
||||
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"),
|
||||
@@ -1013,6 +1064,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("ScreenShareDeliveryConfigResponse", screen_share),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
for (field, value) in value.as_object().expect("config object") {
|
||||
@@ -1024,6 +1076,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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::ScreenShareDeliveryConfigUpdateRequest>(
|
||||
value.clone(),
|
||||
)
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(ScreenShareDeliveryConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -7,20 +7,20 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
DeferredPhoneGateUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceCaptchaIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend,
|
||||
PremiumMode, RegistrationMode, ScreenShareDeliveryConfigUpdateRequest,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VoiceE2eeScope,
|
||||
VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -207,6 +207,10 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"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),
|
||||
},
|
||||
"update_experiment_delivery" => match build_experiment_delivery_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -447,10 +451,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
@@ -476,35 +480,36 @@ where
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_rollout_salt(
|
||||
fn parse_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw) = form.first("voice_ns_rollout_salt") else {
|
||||
let Some(raw) = form.first(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let salt = raw.trim();
|
||||
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
|
||||
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
|
||||
return Err(format!(
|
||||
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
fn is_experiment_snowflake(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
if !is_experiment_snowflake(candidate) {
|
||||
return Err(format!(
|
||||
"{label} entry {} must contain 1 to 20 decimal digits",
|
||||
index + 1
|
||||
@@ -513,9 +518,9 @@ fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Ve
|
||||
if ids.iter().any(|existing| existing == candidate) {
|
||||
continue;
|
||||
}
|
||||
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
|
||||
if ids.len() == EXPERIMENT_MAX_TARGETED_USERS {
|
||||
return Err(format!(
|
||||
"{label} must contain at most {VOICE_NS_MAX_TARGETED_USERS} unique IDs"
|
||||
"{label} must contain at most {EXPERIMENT_MAX_TARGETED_USERS} unique IDs"
|
||||
));
|
||||
}
|
||||
ids.push(candidate.to_owned());
|
||||
@@ -536,7 +541,7 @@ fn parse_voice_noise_suppression_guild_overrides(
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
if !is_experiment_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
@@ -602,21 +607,20 @@ fn build_voice_noise_suppression_update(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
|
||||
form.first("voice_ns_guild_overrides").unwrap_or_default(),
|
||||
)?),
|
||||
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
|
||||
suppression_strength: parse_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
@@ -629,6 +633,38 @@ fn build_voice_noise_suppression_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_screen_share_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("screen_share_delivery_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"screen_share_delivery_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(
|
||||
form,
|
||||
"screen_share_delivery_rollout_salt",
|
||||
)?,
|
||||
included_user_ids: Some(parse_experiment_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("screen_share_delivery_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
@@ -1246,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)
|
||||
@@ -1272,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": [],
|
||||
@@ -1307,9 +1341,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
@@ -1322,16 +1356,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
@@ -1341,11 +1374,8 @@ mod tests {
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.expect_err("invalid ID"),
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
@@ -1353,18 +1383,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids =
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
@@ -1557,6 +1586,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
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_screen_share_delivery_update(&form)
|
||||
.expect("valid form")
|
||||
.screen_share_delivery
|
||||
.expect("screen share delivery update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
assert_eq!(
|
||||
update.rollout_salt,
|
||||
Some("screen-share-delivery-v2".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000001".to_owned(),
|
||||
"1500000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_screen_share_delivery_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_screen_share_delivery_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"screen_share_delivery": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
AppPublicConfigResponse, EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
|
||||
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
|
||||
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
|
||||
LimitConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse,
|
||||
RegistrationUrlResponse, SCREEN_SHARE_DELIVERY_DEFAULT_SALT,
|
||||
ScreenShareDeliveryConfigResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -148,6 +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))
|
||||
(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))
|
||||
@@ -1105,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 \
|
||||
@@ -1125,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 \
|
||||
@@ -1157,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",
|
||||
@@ -1187,6 +1178,117 @@ fn voice_noise_suppression_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn screen_share_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
screen_share_delivery: &ScreenShareDeliveryConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if screen_share_delivery.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
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(
|
||||
"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_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 " (screen_share_delivery.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"screen_share_delivery_enabled",
|
||||
"true",
|
||||
"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 \
|
||||
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(
|
||||
"screen_share_delivery_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&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(
|
||||
"screen_share_delivery_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&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 \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"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 \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Screen Share Delivery Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
@@ -1195,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! {
|
||||
@@ -1830,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()
|
||||
|
||||
@@ -403,13 +403,21 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
"future_object_knob": {"nested": true},
|
||||
"future_list_knob": ["a", "b"]
|
||||
},
|
||||
"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",
|
||||
@@ -539,6 +547,14 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
assert_eq!(resp.voice_noise_suppression.rollout_basis_points, 10000);
|
||||
assert_eq!(*resp.voice_noise_suppression.rollout_salt, "voice-ns-v1");
|
||||
assert_eq!(resp.voice_noise_suppression.enabled_backends.len(), 3);
|
||||
assert!(resp.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.screen_share_delivery.rollout_salt,
|
||||
"screen-share-delivery-v1"
|
||||
);
|
||||
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));
|
||||
@@ -549,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(
|
||||
|
||||
@@ -465,6 +465,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_screen_share_delivery",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
@@ -1194,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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,8 @@ interface PostgresIpInfoOptions {
|
||||
}
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
|
||||
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
|
||||
return options.client ?? options.getClient?.() ?? null;
|
||||
@@ -34,12 +36,9 @@ async function upsertKvRow(
|
||||
partitionKey: string,
|
||||
key: string,
|
||||
row: Record<string, unknown>,
|
||||
ttlSeconds?: number,
|
||||
ttlSeconds: number,
|
||||
): Promise<void> {
|
||||
const expiresAt =
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0
|
||||
? new Date(Date.now() + ttlSeconds * 1000)
|
||||
: null;
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
|
||||
await client.query(
|
||||
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
@@ -77,7 +76,14 @@ export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInf
|
||||
try {
|
||||
const client = getClient(options);
|
||||
if (!client) return;
|
||||
await upsertKvRow(client, 'ipinfo_cache', rowKey([key]), rowKey([key]), {cache_key: key, payload}, ttlSeconds);
|
||||
await upsertKvRow(
|
||||
client,
|
||||
'ipinfo_cache',
|
||||
rowKey([key]),
|
||||
rowKey([key]),
|
||||
{cache_key: key, payload},
|
||||
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_cache_set');
|
||||
}
|
||||
@@ -124,6 +130,7 @@ export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOp
|
||||
is_residential_proxy: event.isResidentialProxy,
|
||||
metadata_json: serializeMetadata(event.metadata),
|
||||
},
|
||||
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
|
||||
);
|
||||
} catch (error) {
|
||||
options.onError?.(error, 'ipinfo_request_audit_record');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import type {Pool, PoolClient, QueryResult, QueryResultRow} from 'pg';
|
||||
import type {Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow} from 'pg';
|
||||
import pg from 'pg';
|
||||
|
||||
const MAX_DIAGNOSTIC_FIELD_LENGTH = 128;
|
||||
@@ -131,7 +131,7 @@ class PostgresClient implements IPostgresClient {
|
||||
}
|
||||
|
||||
private async openPool(): Promise<void> {
|
||||
const pool = new pg.Pool({
|
||||
const poolConfig: PoolConfig & {scramMaxIterations: number} = {
|
||||
connectionString: this.config.url || undefined,
|
||||
host: this.config.url ? undefined : (this.config.host ?? '127.0.0.1'),
|
||||
port: this.config.url ? undefined : (this.config.port ?? 5432),
|
||||
@@ -140,7 +140,9 @@ class PostgresClient implements IPostgresClient {
|
||||
password: this.config.url ? undefined : (this.config.password ?? 'fluxer'),
|
||||
ssl: this.config.ssl ? {rejectUnauthorized: true, ca: normalizePem(this.config.sslCa)} : undefined,
|
||||
max: this.config.maxConnections ?? 20,
|
||||
});
|
||||
scramMaxIterations: 0,
|
||||
};
|
||||
const pool = new pg.Pool(poolConfig);
|
||||
this.observePoolConnections(pool);
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -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/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
|
||||
@@ -92,18 +91,6 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
const countryCode = value.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/u.test(countryCode)) {
|
||||
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
|
||||
}
|
||||
normalized.add(countryCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapPushProviderApps(
|
||||
apps:
|
||||
| Array<{
|
||||
@@ -157,7 +144,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
const s3Buckets = s3Config.buckets ?? {
|
||||
cdn: '',
|
||||
uploads: '',
|
||||
downloads: '',
|
||||
reports: '',
|
||||
harvests: '',
|
||||
};
|
||||
@@ -174,10 +160,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
requestTimeoutMs: master.services.api.request_timeout_ms,
|
||||
maxInflightRequests: master.services.api.max_inflight_requests,
|
||||
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
|
||||
desktopGitHubRedirectCountries: normalizeCountryCodes(
|
||||
master.services.api.desktop_github_redirect_countries,
|
||||
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
|
||||
),
|
||||
cassandra: {
|
||||
hosts: cassandraSource?.hosts.join(',') ?? '',
|
||||
port: cassandraSource?.port ?? 9042,
|
||||
@@ -263,6 +245,9 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
tokenTtlSecs: uploadRelayConfig.token_ttl_secs,
|
||||
keepDirectCountries: uploadRelayConfig.keep_direct_countries,
|
||||
},
|
||||
attachmentUrls: {
|
||||
secretsBase64: master.services.media_proxy.attachment_urls.secrets_base64,
|
||||
},
|
||||
},
|
||||
geoip: geoipSourceConfig,
|
||||
proxy: {
|
||||
@@ -287,8 +272,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
donationProxyKey,
|
||||
},
|
||||
hosts: {
|
||||
invite: extractHostname(master.endpoints.invite),
|
||||
gift: extractHostname(master.endpoints.gift),
|
||||
marketing: extractHostname(master.endpoints.marketing),
|
||||
unfurlIgnored: master.services.api.unfurl_ignored_hosts,
|
||||
},
|
||||
@@ -301,7 +284,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
|
||||
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
|
||||
},
|
||||
s3Downloads: resolveDownloadsProvider(master),
|
||||
s3: {
|
||||
endpoint: s3Config.endpoint,
|
||||
presignedUrlBase: s3Config.presigned_url_base,
|
||||
@@ -345,6 +327,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,
|
||||
@@ -514,7 +502,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,
|
||||
|
||||
@@ -341,6 +341,7 @@ import {
|
||||
type UsersPendingDeletionRow,
|
||||
} from '@app/api/database/types/UserTypes';
|
||||
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
export const Users = defineTable<UserRow, 'user_id'>({
|
||||
name: 'users',
|
||||
@@ -499,16 +500,19 @@ export const GuildAuditLogs = defineTable<GuildAuditLogRow, 'guild_id' | 'log_id
|
||||
name: 'guild_audit_logs_v2',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByUser = defineTable<GuildAuditLogRow, 'guild_id' | 'user_id' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_user',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByAction = defineTable<GuildAuditLogRow, 'guild_id' | 'action_type' | 'log_id'>({
|
||||
name: 'guild_audit_logs_v2_by_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'action_type', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildAuditLogsByUserAction = defineTable<
|
||||
GuildAuditLogRow,
|
||||
@@ -517,6 +521,7 @@ export const GuildAuditLogsByUserAction = defineTable<
|
||||
name: 'guild_audit_logs_v2_by_user_action',
|
||||
columns: GUILD_AUDIT_LOG_COLUMNS,
|
||||
primaryKey: ['guild_id', 'user_id', 'action_type', 'log_id'],
|
||||
defaultTtlSeconds: seconds('45 days'),
|
||||
});
|
||||
export const GuildMembershipMetadata = defineTable<GuildMembershipMetadataRow, 'guild_id' | 'user_id'>({
|
||||
name: 'guild_membership_metadata',
|
||||
@@ -655,6 +660,7 @@ export const RecentMentions = defineTable<RecentMentionRow, 'user_id' | 'message
|
||||
name: 'recent_mentions',
|
||||
columns: RECENT_MENTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'message_id'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
|
||||
interface RecentMentionsByGuildRow {
|
||||
@@ -678,6 +684,7 @@ export const RecentMentionsByGuild = defineTable<RecentMentionsByGuildRow, 'user
|
||||
name: 'recent_mentions_by_guild',
|
||||
columns: RECENT_MENTIONS_BY_GUILD_COLUMNS,
|
||||
primaryKey: ['user_id', 'guild_id', 'message_id'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_id'>({
|
||||
name: 'saved_messages',
|
||||
@@ -688,6 +695,7 @@ export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 's
|
||||
name: 'push_subscriptions',
|
||||
columns: PUSH_SUBSCRIPTION_COLUMNS,
|
||||
primaryKey: ['user_id', 'subscription_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const Payments = defineTable<PaymentRow, 'checkout_session_id'>({
|
||||
name: 'payments',
|
||||
@@ -854,11 +862,13 @@ export const EmailVerificationTokens = defineTable<EmailVerificationTokenRow, 't
|
||||
name: 'email_verification_tokens',
|
||||
columns: EMAIL_VERIFICATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const PasswordResetTokens = defineTable<PasswordResetTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'password_reset_tokens',
|
||||
columns: PASSWORD_RESET_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const PasswordResetTokensByUserId = defineTable<
|
||||
{
|
||||
@@ -870,16 +880,19 @@ export const PasswordResetTokensByUserId = defineTable<
|
||||
name: 'password_reset_tokens_by_user_id',
|
||||
columns: ['user_id', 'token_'],
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('24 hours'),
|
||||
});
|
||||
export const EmailRevertTokens = defineTable<EmailRevertTokenRow, 'token_' | 'user_id'>({
|
||||
name: 'email_revert_tokens',
|
||||
columns: EMAIL_REVERT_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('48 hours'),
|
||||
});
|
||||
export const PhoneTokens = defineTable<PhoneTokenRow, 'token_'>({
|
||||
name: 'phone_tokens',
|
||||
columns: PHONE_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const AuthSessions = defineTable<AuthSessionRow, 'session_id_hash'>({
|
||||
name: 'auth_sessions',
|
||||
@@ -901,11 +914,13 @@ export const AuthSessionTombstones = defineTable<AuthSessionTombstoneRow, 'user_
|
||||
name: 'auth_session_tombstones',
|
||||
columns: AUTH_SESSION_TOMBSTONE_COLUMNS,
|
||||
primaryKey: ['user_id', 'session_id_hash'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const UserCountryHistory = defineTable<UserCountryHistoryRow, 'user_id' | 'country'>({
|
||||
name: 'user_country_history',
|
||||
columns: USER_COUNTRY_HISTORY_COLUMNS,
|
||||
primaryKey: ['user_id', 'country'],
|
||||
defaultTtlSeconds: seconds('365 days'),
|
||||
});
|
||||
export const MfaBackupCodes = defineTable<MfaBackupCodeRow, 'user_id' | 'code'>({
|
||||
name: 'mfa_backup_codes',
|
||||
@@ -932,6 +947,7 @@ export const IpAuthorizationTokens = defineTable<IpAuthorizationTokenRow, 'token
|
||||
name: 'ip_authorization_tokens',
|
||||
columns: IP_AUTHORIZATION_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_', 'user_id'],
|
||||
defaultTtlSeconds: seconds('30 minutes'),
|
||||
});
|
||||
export const AuthorizedIps = defineTable<AuthorizedIpRow, 'user_id' | 'ip'>({
|
||||
name: 'authorized_ips_v2',
|
||||
@@ -1057,26 +1073,31 @@ export const OAuth2AuthorizationCodes = defineTable<OAuth2AuthorizationCodeRow,
|
||||
name: 'oauth2_authorization_codes',
|
||||
columns: OAUTH2_AUTHORIZATION_CODE_COLUMNS,
|
||||
primaryKey: ['code'],
|
||||
defaultTtlSeconds: seconds('10 minutes'),
|
||||
});
|
||||
export const OAuth2AccessTokens = defineTable<OAuth2AccessTokenRow, 'token_'>({
|
||||
name: 'oauth2_access_tokens',
|
||||
columns: OAUTH2_ACCESS_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const OAuth2AccessTokensByUser = defineTable<OAuth2AccessTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_access_tokens_by_user',
|
||||
columns: OAUTH2_ACCESS_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const OAuth2RefreshTokens = defineTable<OAuth2RefreshTokenRow, 'token_'>({
|
||||
name: 'oauth2_refresh_tokens',
|
||||
columns: OAUTH2_REFRESH_TOKEN_COLUMNS,
|
||||
primaryKey: ['token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const OAuth2RefreshTokensByUser = defineTable<OAuth2RefreshTokenByUserRow, 'user_id' | 'token_'>({
|
||||
name: 'oauth2_refresh_tokens_by_user',
|
||||
columns: OAUTH2_REFRESH_TOKENS_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'token_'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
|
||||
interface WebhooksByChannelRow {
|
||||
@@ -1117,12 +1138,14 @@ export const JobsById = defineTable<JobByIdRow, 'job_id'>({
|
||||
name: 'jobs_by_id',
|
||||
columns: JOB_BY_ID_COLUMNS,
|
||||
primaryKey: ['job_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const JobsByDayBucket = defineTable<JobByDayBucketRow, 'bucket_day' | 'created_at' | 'job_id'>({
|
||||
name: 'jobs_by_day_bucket',
|
||||
columns: JOB_BY_DAY_BUCKET_COLUMNS,
|
||||
primaryKey: ['bucket_day', 'created_at', 'job_id'],
|
||||
partitionKey: ['bucket_day'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const JobsActive = defineTable<JobActiveRow, 'job_id'>({
|
||||
name: 'jobs_active',
|
||||
@@ -1133,11 +1156,13 @@ export const AttachmentUploadTracesByKey = defineTable<AttachmentUploadTraceByKe
|
||||
name: 'attachment_upload_traces_by_key',
|
||||
columns: ATTACHMENT_UPLOAD_TRACE_BY_KEY_COLUMNS,
|
||||
primaryKey: ['upload_key'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const AttachmentUploadTracesByAttachment = defineTable<AttachmentUploadTraceByAttachmentRow, 'attachment_id'>({
|
||||
name: 'attachment_upload_traces_by_attachment',
|
||||
columns: ATTACHMENT_UPLOAD_TRACE_BY_ATTACHMENT_COLUMNS,
|
||||
primaryKey: ['attachment_id'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const NcmecAttachmentSubmissions = defineTable<NcmecAttachmentSubmissionRow, 'attachment_id'>({
|
||||
name: 'ncmec_attachment_submissions',
|
||||
@@ -1154,6 +1179,7 @@ export const RegistrationEventsByIp = defineTable<RegistrationEventByIpRow, 'ip'
|
||||
columns: REGISTRATION_EVENT_BY_IP_COLUMNS,
|
||||
primaryKey: ['ip', 'created_at', 'user_id'],
|
||||
partitionKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsBySubnet = defineTable<
|
||||
RegistrationEventBySubnetRow,
|
||||
@@ -1164,6 +1190,7 @@ export const RegistrationEventsBySubnet = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_SUBNET_COLUMNS,
|
||||
primaryKey: ['subnet', 'created_at', 'user_id'],
|
||||
partitionKey: ['subnet'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsByEmailDomain = defineTable<
|
||||
RegistrationEventByEmailDomainRow,
|
||||
@@ -1174,6 +1201,7 @@ export const RegistrationEventsByEmailDomain = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_EMAIL_DOMAIN_COLUMNS,
|
||||
primaryKey: ['email_domain', 'created_at', 'user_id'],
|
||||
partitionKey: ['email_domain'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const RegistrationEventsByPlusAddressBase = defineTable<
|
||||
RegistrationEventByPlusAddressBaseRow,
|
||||
@@ -1184,6 +1212,7 @@ export const RegistrationEventsByPlusAddressBase = defineTable<
|
||||
columns: REGISTRATION_EVENT_BY_PLUS_ADDRESS_BASE_COLUMNS,
|
||||
primaryKey: ['plus_address_base', 'created_at', 'user_id'],
|
||||
partitionKey: ['plus_address_base'],
|
||||
defaultTtlSeconds: seconds('30 days'),
|
||||
});
|
||||
export const LatestRiskContextByUser = defineTable<LatestRiskContextByUserRow, 'user_id'>({
|
||||
name: 'latest_risk_context_by_user',
|
||||
@@ -1194,6 +1223,7 @@ export const SuspiciousIps = defineTable<SuspiciousIpRow, 'ip'>({
|
||||
name: 'suspicious_ips',
|
||||
columns: SUSPICIOUS_IP_COLUMNS,
|
||||
primaryKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_at' | 'user_id' | 'outcome_code', 'ip'>(
|
||||
{
|
||||
@@ -1201,6 +1231,7 @@ export const RiskOutcomesByIp = defineTable<RiskOutcomeByIpRow, 'ip' | 'created_
|
||||
columns: RISK_OUTCOME_BY_IP_COLUMNS,
|
||||
primaryKey: ['ip', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['ip'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
},
|
||||
);
|
||||
export const RiskOutcomesBySubnet = defineTable<
|
||||
@@ -1212,6 +1243,7 @@ export const RiskOutcomesBySubnet = defineTable<
|
||||
columns: RISK_OUTCOME_BY_SUBNET_COLUMNS,
|
||||
primaryKey: ['subnet', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['subnet'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByEmailDomain = defineTable<
|
||||
RiskOutcomeByEmailDomainRow,
|
||||
@@ -1222,6 +1254,7 @@ export const RiskOutcomesByEmailDomain = defineTable<
|
||||
columns: RISK_OUTCOME_BY_EMAIL_DOMAIN_COLUMNS,
|
||||
primaryKey: ['email_domain', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['email_domain'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskOutcomesByAsn = defineTable<
|
||||
RiskOutcomeByAsnRow,
|
||||
@@ -1232,6 +1265,7 @@ export const RiskOutcomesByAsn = defineTable<
|
||||
columns: RISK_OUTCOME_BY_ASN_COLUMNS,
|
||||
primaryKey: ['asn', 'created_at', 'user_id', 'outcome_code'],
|
||||
partitionKey: ['asn'],
|
||||
defaultTtlSeconds: seconds('180 days'),
|
||||
});
|
||||
export const RiskAssessments = defineTable<RiskAssessmentRow, 'assessment_id'>({
|
||||
name: 'risk_assessments',
|
||||
@@ -1248,6 +1282,7 @@ export const InboundSmsChallenges = defineTable<InboundSmsChallengeRow, 'challen
|
||||
name: 'inbound_sms_challenges',
|
||||
columns: INBOUND_SMS_CHALLENGE_COLUMNS,
|
||||
primaryKey: ['challenge_code'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
export const InboundSmsChallengesByUser = defineTable<
|
||||
InboundSmsChallengeByUserRow,
|
||||
@@ -1258,16 +1293,19 @@ export const InboundSmsChallengesByUser = defineTable<
|
||||
columns: INBOUND_SMS_CHALLENGE_BY_USER_COLUMNS,
|
||||
primaryKey: ['user_id', 'created_at'],
|
||||
partitionKey: ['user_id'],
|
||||
defaultTtlSeconds: seconds('15 minutes'),
|
||||
});
|
||||
export const PhoneLookupCache = defineTable<PhoneLookupCacheRow, 'phone'>({
|
||||
name: 'phone_lookup_cache',
|
||||
columns: PHONE_LOOKUP_CACHE_COLUMNS,
|
||||
primaryKey: ['phone'],
|
||||
defaultTtlSeconds: seconds('7 days'),
|
||||
});
|
||||
export const PhoneVerificationAttempts = defineTable<PhoneVerificationAttemptRow, 'attempt_id'>({
|
||||
name: 'phone_verification_attempts',
|
||||
columns: PHONE_VERIFICATION_ATTEMPT_COLUMNS,
|
||||
primaryKey: ['attempt_id'],
|
||||
defaultTtlSeconds: seconds('90 days'),
|
||||
});
|
||||
export const BillingCustomers = defineTable<BillingCustomerRow, 'provider_id'>({
|
||||
name: 'billing_customers',
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {ContentBlocklistCategory} from '@app/api/constants/ContentModeration';
|
||||
import {
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {
|
||||
AdminAuditLogRow,
|
||||
BannedAvatarHashRow,
|
||||
@@ -282,6 +290,7 @@ export class AdminRepository implements IAdminRepository {
|
||||
}
|
||||
|
||||
async isEmailDomainDisposable(domain: string): Promise<boolean> {
|
||||
if (!Config.blocklistFeeds.enabled) return false;
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
const result = await fetchOne<{
|
||||
@@ -395,6 +404,15 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(BannedFileShas.deleteByPk({sha256_hex: sha256Hex.toLowerCase()}));
|
||||
}
|
||||
|
||||
async unbanFeedFileSha(sha256Hex: string): Promise<boolean> {
|
||||
return executeConditional(
|
||||
BannedFileShas.conditionalDeleteByPk(
|
||||
{sha256_hex: sha256Hex.toLowerCase()},
|
||||
{added_by: null, category: ContentBlocklistCategory.MALWARE_BAZAAR},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>> {
|
||||
return fetchMany<BannedFileShaRow>(LOAD_ALL_BANNED_FILE_SHAS_QUERY.bind({}));
|
||||
}
|
||||
|
||||
@@ -109,6 +109,8 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract unbanFileSha(sha256Hex: string): Promise<void>;
|
||||
|
||||
abstract unbanFeedFileSha(sha256Hex: string): Promise<boolean>;
|
||||
|
||||
abstract loadAllBannedFileShas(): Promise<Array<BannedFileShaRow>>;
|
||||
|
||||
abstract isAvatarHashBanned(hashShort: string): Promise<boolean>;
|
||||
|
||||
@@ -31,6 +31,7 @@ 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 {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -59,6 +60,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
screenShareDelivery,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -67,6 +69,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getScreenShareDeliveryConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -98,6 +101,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
screen_share_delivery: screenShareDelivery,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -261,6 +265,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.screen_share_delivery) {
|
||||
const patch = omitUndefinedFields(data.screen_share_delivery);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
const currentScreenShareDelivery = await instanceConfigRepository.getScreenShareDeliveryConfig();
|
||||
const validated = ScreenShareDeliveryConfigSchema.parse({
|
||||
...currentScreenShareDelivery,
|
||||
...patch,
|
||||
config_version: currentScreenShareDelivery.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setScreenShareDeliveryConfig(validated);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
|
||||
@@ -84,7 +84,7 @@ export class AdminAuditService {
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
if (!auditLogSearchService?.isAvailable()) {
|
||||
return this.listAuditLogsFromDatabase({
|
||||
adminUserId: data.admin_user_id,
|
||||
targetType: data.target_type,
|
||||
@@ -129,7 +129,7 @@ export class AdminAuditService {
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
if (!auditLogSearchService?.isAvailable()) {
|
||||
return this.listAuditLogsFromDatabase({
|
||||
adminUserId: data.admin_user_id,
|
||||
targetType: data.target_type,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
||||
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
|
||||
|
||||
interface AdminUserUpdatePropagatorDeps {
|
||||
@@ -26,41 +25,11 @@ export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
});
|
||||
}
|
||||
|
||||
async propagateUserUpdate({
|
||||
userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
}: {
|
||||
userId: UserID;
|
||||
oldUser: User;
|
||||
updatedUser: User;
|
||||
}): Promise<void> {
|
||||
async propagateUserUpdate(params: {userId: UserID; oldUser: User; updatedUser: User}): Promise<void> {
|
||||
const {oldUser, updatedUser} = params;
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
|
||||
await this.updateUserCache(updatedUser);
|
||||
await this.propagateToGuilds(userId);
|
||||
await propagatePartialUserChange(this.deps, updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
private async propagateToGuilds(userId: UserID): Promise<void> {
|
||||
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requestCache = createRequestCache();
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAdminApiKey,
|
||||
createAdminApiKeyWithDefaultACLs,
|
||||
listAdminApiKeys,
|
||||
revokeAdminApiKey,
|
||||
} from '@app/api/admin/tests/AdminTestUtils';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
errors: Array<{
|
||||
path: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function expectInvalidKeyIdFormat(json: ValidationErrorResponse): void {
|
||||
const keyIdError = json.errors.find((error) => error.path === 'key_id');
|
||||
expect(keyIdError).toBeDefined();
|
||||
expect(keyIdError?.code).toBe('INVALID_SNOWFLAKE_FORMAT');
|
||||
}
|
||||
|
||||
describe('Admin API Key Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('API Key ACL Validation', () => {
|
||||
test('user can only grant ACLs they possess', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('user cannot grant ACLs they do not possess', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view', 'user:lookup'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('user with wildcard ACL can grant any ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['*']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Wildcard Test Key',
|
||||
acls: ['audit_log:view', 'user:lookup', 'guild:lookup', 'archive:trigger:user'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('must have admin_api_key:manage ACL to create keys', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'audit_log:view']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: ['audit_log:view'],
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('ACLs are stored and retrievable correctly', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const requestedACLs = ['audit_log:view', 'user:lookup', 'guild:lookup'];
|
||||
await createAdminApiKey(harness, admin, 'ACL Storage Test', requestedACLs, null);
|
||||
const keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
const keyACLs = keys[0]!.acls as Array<string>;
|
||||
expect(keyACLs).toHaveLength(requestedACLs.length);
|
||||
expect(keyACLs).toEqual(expect.arrayContaining(requestedACLs));
|
||||
});
|
||||
test('empty ACL list is valid', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post('/admin/api-keys')
|
||||
.body({
|
||||
name: 'Test Key',
|
||||
acls: [],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('API Key Authentication', () => {
|
||||
test('valid API key authenticates successfully', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Auth Test Key');
|
||||
await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
test('invalid API key is rejected', async () => {
|
||||
await createTestAccount(harness);
|
||||
await createBuilder(harness, 'Admin invalid_key_12345')
|
||||
.get('/admin/users/123456789')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
test('API key requires Admin prefix', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Prefix Test Key');
|
||||
await createBuilder(harness, `Bearer ${apiKey.key}`)
|
||||
.get(`/admin/users/${admin.userId}`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
test('Admin prefix is case sensitive', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Case Test Key');
|
||||
await createBuilder(harness, `admin ${apiKey.key}`)
|
||||
.get(`/admin/users/${admin.userId}`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
test('API key cannot authenticate to user endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'User Endpoint Test Key');
|
||||
await createBuilder(harness, apiKey.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
test('updates last_used_at timestamp on use', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Last Used Test Key');
|
||||
const keysBefore = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keysBefore).toHaveLength(1);
|
||||
expect(keysBefore[0]!.last_used_at).toBeNull();
|
||||
await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).execute();
|
||||
const keysAfter = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keysAfter).toHaveLength(1);
|
||||
expect(keysAfter[0]!.last_used_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
describe('API Key Authorization (ACL Restrictions)', () => {
|
||||
test('key can access endpoints with granted ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Test Key', ['audit_log:view', 'user:lookup'], null);
|
||||
await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
test('key cannot access endpoints without required ACLs', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Limited Key', ['audit_log:view'], null);
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.get(`/admin/users/${admin.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('key with wildcard ACL can access all endpoints', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['*']);
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Wildcard Key', ['*'], null);
|
||||
await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
test('multiple keys with different ACLs work independently', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const auditKey = await createAdminApiKey(harness, admin, 'Audit Log Key', ['audit_log:view'], null);
|
||||
const userKey = await createAdminApiKey(harness, admin, 'Users Key', ['user:lookup'], null);
|
||||
await createBuilder(harness, userKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute();
|
||||
await createBuilder(harness, auditKey.token)
|
||||
.get(`/admin/users/${admin.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('list API keys requires admin_api_key:manage ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
const apiKeyWithACL = await createAdminApiKey(harness, admin, 'List Test', ['admin_api_key:manage'], null);
|
||||
await createBuilder(harness, apiKeyWithACL.token).get('/admin/api-keys').expect(HTTP_STATUS.OK).execute();
|
||||
const apiKeyWithoutACL = await createAdminApiKey(harness, admin, 'List Test No ACL', [], null);
|
||||
await createBuilder(harness, apiKeyWithoutACL.token)
|
||||
.get('/admin/api-keys')
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('delete API key requires admin_api_key:manage ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete', [], null);
|
||||
const deleterKey = await createAdminApiKey(harness, admin, 'Deleter', ['admin_api_key:manage'], null);
|
||||
await createBuilder(harness, deleterKey.token)
|
||||
.delete(`/admin/api-keys/${keyToDelete.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('delete API key fails without admin_api_key:manage ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
const keyToDelete = await createAdminApiKey(harness, admin, 'To Delete 2', [], null);
|
||||
const deleterKey = await createAdminApiKey(harness, admin, 'Deleter No ACL', [], null);
|
||||
await createBuilder(harness, deleterKey.token)
|
||||
.delete(`/admin/api-keys/${keyToDelete.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('API Key Revocation', () => {
|
||||
test('basic revocation removes key from list', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Test');
|
||||
let keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(1);
|
||||
expect(keys.some((k) => k.key_id === apiKey.keyId)).toBe(true);
|
||||
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
|
||||
keys = await listAdminApiKeys(harness, admin.token);
|
||||
expect(keys).toHaveLength(0);
|
||||
});
|
||||
test('revoked key cannot be used for authentication', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin, 'Revoke Auth Test');
|
||||
await createBuilder(harness, apiKey.token).get(`/admin/users/${admin.userId}`).expect(HTTP_STATUS.OK).execute();
|
||||
await revokeAdminApiKey(harness, admin.token, apiKey.keyId);
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.get(`/admin/users/${admin.userId}`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
test('revocation of non-existent key returns 404', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.delete('/admin/api-keys/999999999999999999')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
test('get rejects a non-snowflake key id', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
const {json} = await createBuilder<ValidationErrorResponse>(harness, `${admin.token}`)
|
||||
.get('/admin/api-keys/nonexistent-id')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expectInvalidKeyIdFormat(json);
|
||||
});
|
||||
test('update rejects a non-snowflake key id', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
const {json} = await createBuilder<ValidationErrorResponse>(harness, `${admin.token}`)
|
||||
.patch('/admin/api-keys/nonexistent-id')
|
||||
.body({name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expectInvalidKeyIdFormat(json);
|
||||
});
|
||||
test('revocation rejects a non-snowflake key id', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage']);
|
||||
const {json} = await createBuilder<ValidationErrorResponse>(harness, `${admin.token}`)
|
||||
.delete('/admin/api-keys/nonexistent-id')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.executeWithResponse();
|
||||
expectInvalidKeyIdFormat(json);
|
||||
});
|
||||
test('revocation requires admin_api_key:manage ACL', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin1, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate']);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin1, 'Admin1 Key');
|
||||
await createBuilder(harness, `${admin2.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
const keys = await listAdminApiKeys(harness, admin1.token);
|
||||
expect(keys.some((k) => k.key_id === apiKey.keyId)).toBe(true);
|
||||
});
|
||||
test('cannot revoke other users keys', async () => {
|
||||
const admin1 = await createTestAccount(harness);
|
||||
const admin2 = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin1, [
|
||||
'admin:authenticate',
|
||||
'admin_api_key:manage',
|
||||
'audit_log:view',
|
||||
'user:lookup',
|
||||
'guild:lookup',
|
||||
]);
|
||||
await setUserACLs(harness, admin2, ['admin:authenticate', 'admin_api_key:manage', 'audit_log:view']);
|
||||
const apiKey = await createAdminApiKeyWithDefaultACLs(harness, admin1, 'Admin1 Key');
|
||||
await createBuilder(harness, `${admin2.token}`)
|
||||
.delete(`/admin/api-keys/${apiKey.keyId}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
const keys = await listAdminApiKeys(harness, admin1.token);
|
||||
expect(keys.some((k) => k.key_id === apiKey.keyId)).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('Setting User ACLs Requires Proper ACL', () => {
|
||||
test('setting user ACLs requires acl:set:user ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'acl:set:user']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${targetUser.userId}/acls`)
|
||||
.body({acls: ['admin:authenticate']})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('setting user ACLs fails without acl:set:user ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${targetUser.userId}/acls`)
|
||||
.body({acls: ['admin:authenticate']})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('API key can set user ACLs with acl:set:user', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'acl:set:user']);
|
||||
const apiKey = await createAdminApiKey(
|
||||
harness,
|
||||
admin,
|
||||
'ACL Setter Key',
|
||||
['admin:authenticate', 'acl:set:user'],
|
||||
null,
|
||||
);
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.put(`/admin/users/${targetUser.userId}/acls`)
|
||||
.body({acls: ['admin:authenticate']})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('API key cannot set user ACLs without acl:set:user', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'No ACL Setter Key', ['user:lookup'], null);
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.put(`/admin/users/${targetUser.userId}/acls`)
|
||||
.body({acls: ['admin:authenticate']})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('setting ACLs on non-existent user fails', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'acl:set:user']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put('/admin/users/999999999999999999/acls')
|
||||
.body({acls: ['admin:authenticate']})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('Deletion Schedule Minimum Validation', () => {
|
||||
test('schedule deletion requires user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${targetUser.userId}/deletion`)
|
||||
.body({reason_code: 1, days_until_deletion: 60})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('schedule deletion fails without user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:lookup']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${targetUser.userId}/deletion`)
|
||||
.body({reason_code: 1, days_until_deletion: 60})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('deletion schedule enforces minimum days for user requested deletion', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${targetUser.userId}/deletion`)
|
||||
.body({reason_code: 1, days_until_deletion: 1})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
test('deletion schedule enforces minimum days for standard deletion', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${targetUser.userId}/deletion`)
|
||||
.body({reason_code: 2, days_until_deletion: 1})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
});
|
||||
test('API key can schedule deletion with user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:delete']);
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'Deletion Key', ['user:delete'], null);
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.put(`/admin/users/${targetUser.userId}/deletion`)
|
||||
.body({reason_code: 1, days_until_deletion: 60})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
test('API key cannot schedule deletion without user:delete ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'admin_api_key:manage', 'user:lookup']);
|
||||
const apiKey = await createAdminApiKey(harness, admin, 'No Deletion Key', ['user:lookup'], null);
|
||||
await createBuilder(harness, apiKey.token)
|
||||
.put(`/admin/users/${targetUser.userId}/deletion`)
|
||||
.body({reason_code: 1, days_until_deletion: 60})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('schedule deletion on non-existent user fails', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.put('/admin/users/999999999999999999/deletion')
|
||||
.body({reason_code: 1, days_until_deletion: 60})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import {createTestAccount, createTotpSecret, generateTotpCode, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createRegistrationResponse,
|
||||
createWebAuthnDevice,
|
||||
registerWebAuthnCredential,
|
||||
setWebAuthnTwoFactor,
|
||||
type WebAuthnCredentialMetadata,
|
||||
type WebAuthnRegistrationOptions,
|
||||
} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
@@ -31,13 +31,15 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('removes the WebAuthn authenticator type when admin deletes the last credential', async () => {
|
||||
let admin = await createTestAccount(harness);
|
||||
admin = await setUserACLs(harness, admin, [
|
||||
async function createAdmin() {
|
||||
const admin = await createTestAccount(harness);
|
||||
return await setUserACLs(harness, admin, [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.USER_LOOKUP,
|
||||
AdminACLs.USER_UPDATE_MFA,
|
||||
]);
|
||||
}
|
||||
async function createPasskeyTarget(twoFactorEnabled: boolean) {
|
||||
const target = await createTestAccount(harness);
|
||||
const device = createWebAuthnDevice();
|
||||
const secret = createTotpSecret();
|
||||
@@ -45,28 +47,19 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: target.password})
|
||||
.execute();
|
||||
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, target.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials/registration-options')
|
||||
.body({mfa_method: 'totp', mfa_code: generateTotpCode(secret)})
|
||||
.execute();
|
||||
if (registrationOptions.rp.id) {
|
||||
device.rpId = registrationOptions.rp.id;
|
||||
}
|
||||
await createBuilder(harness, target.token)
|
||||
.post('/users/@me/mfa/webauthn/credentials')
|
||||
.body({
|
||||
response: createRegistrationResponse(device, registrationOptions, 'Admin Delete Test Passkey'),
|
||||
challenge: registrationOptions.challenge,
|
||||
name: 'Admin Delete Test Passkey',
|
||||
await registerWebAuthnCredential(
|
||||
harness,
|
||||
target.token,
|
||||
device,
|
||||
() => ({mfa_method: 'totp', mfa_code: generateTotpCode(secret)}),
|
||||
'Admin Delete Test Passkey',
|
||||
);
|
||||
if (twoFactorEnabled) {
|
||||
await setWebAuthnTwoFactor(harness, target.token, true, {
|
||||
mfa_method: 'totp',
|
||||
mfa_code: generateTotpCode(secret),
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsBeforeDelete).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
await createBuilder(harness, target.token)
|
||||
.post('/users/@me/mfa/totp/disable')
|
||||
.body({
|
||||
@@ -76,6 +69,15 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
return target;
|
||||
}
|
||||
test('removes the WebAuthn authenticator type when admin deletes the last credential of a two-factor user', async () => {
|
||||
const admin = await createAdmin();
|
||||
const target = await createPasskeyTarget(true);
|
||||
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsBeforeDelete).toHaveLength(1);
|
||||
const userBeforeDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.execute();
|
||||
@@ -93,4 +95,28 @@ describe('Admin WebAuthn credential delete', () => {
|
||||
.execute();
|
||||
expect(userAfterDelete.users[0]?.authenticator_types).toEqual([]);
|
||||
});
|
||||
test('leaves the authenticator types empty throughout for a user who never turned passkey two-factor on', async () => {
|
||||
const admin = await createAdmin();
|
||||
const target = await createPasskeyTarget(false);
|
||||
const credentialsBeforeDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsBeforeDelete).toHaveLength(1);
|
||||
const userBeforeDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.execute();
|
||||
expect(userBeforeDelete.users[0]?.authenticator_types).toEqual([]);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.delete(`/admin/users/${target.userId}/webauthn-credentials/${credentialsBeforeDelete[0]!.id}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
const credentialsAfterDelete = await createBuilder<Array<WebAuthnCredentialMetadata>>(harness, target.token)
|
||||
.get('/users/@me/mfa/webauthn/credentials')
|
||||
.execute();
|
||||
expect(credentialsAfterDelete).toHaveLength(0);
|
||||
const userAfterDelete = await createBuilder<AdminLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.execute();
|
||||
expect(userAfterDelete.users[0]?.authenticator_types).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,9 +137,11 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
await initializeRefreshCache(ipBanCache, 'IP ban cache', logger);
|
||||
await startAbuseReplicationSubscriber(kvClient);
|
||||
logger.info('Abusive-IP auto-banner replication started');
|
||||
torExitListCache.setKvClient(kvClient);
|
||||
await torExitListCache.initialize();
|
||||
logger.info('Tor exit list cache initialized');
|
||||
if (config.torExitList.enabled) {
|
||||
torExitListCache.setKvClient(kvClient);
|
||||
await torExitListCache.initialize();
|
||||
logger.info('Tor exit list cache initialized');
|
||||
}
|
||||
const {urlBlocklistCache} = await import('@app/api/middleware/UrlBlocklistCache');
|
||||
urlBlocklistCache.setRefreshSubscriber(kvClient);
|
||||
const {getStorageService} = await import('@app/api/middleware/ServiceSingletons');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {registerAdminControllers} from '@app/api/admin/controllers/index';
|
||||
import {AttachmentController} from '@app/api/attachment/AttachmentController';
|
||||
import {AuthController} from '@app/api/auth/AuthController';
|
||||
import {BlueskyOAuthController} from '@app/api/bluesky/BlueskyOAuthController';
|
||||
import {Config} from '@app/api/Config';
|
||||
@@ -45,6 +46,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
GeolocationController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
AttachmentController(routes);
|
||||
ChannelController(routes);
|
||||
ConnectionController(routes);
|
||||
BlueskyOAuthController(routes);
|
||||
|
||||
@@ -29,10 +29,19 @@ interface MiddlewarePipelineOptions {
|
||||
trustClientIpHeader: boolean;
|
||||
clientIpHeaderName?: string;
|
||||
maxInflightRequests: number;
|
||||
torExitBlockingEnabled: boolean;
|
||||
}
|
||||
|
||||
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
|
||||
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
|
||||
const {
|
||||
logger,
|
||||
nodeEnv,
|
||||
corsOrigins,
|
||||
trustClientIpHeader,
|
||||
clientIpHeaderName,
|
||||
maxInflightRequests,
|
||||
torExitBlockingEnabled,
|
||||
} = options;
|
||||
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
|
||||
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
|
||||
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
|
||||
@@ -100,7 +109,9 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
|
||||
}),
|
||||
);
|
||||
}
|
||||
routes.use(TorExitMiddleware);
|
||||
if (torExitBlockingEnabled) {
|
||||
routes.use(TorExitMiddleware);
|
||||
}
|
||||
routes.use(AuditLogMiddleware);
|
||||
routes.use(RequireClientIpMiddleware());
|
||||
routes.use(ServiceMiddleware);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
|
||||
import {TorExitMiddleware} from '@app/api/middleware/TorExitMiddleware';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function registeredHandlers(torExitBlockingEnabled: boolean): Array<unknown> {
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
configureMiddleware(routes, {
|
||||
logger: new NoopLogger(),
|
||||
nodeEnv: 'test',
|
||||
corsOrigins: ['http://localhost:3000'],
|
||||
trustClientIpHeader: true,
|
||||
clientIpHeaderName: 'x-forwarded-for',
|
||||
maxInflightRequests: 100,
|
||||
torExitBlockingEnabled,
|
||||
});
|
||||
return routes.routes.map((route) => route.handler);
|
||||
}
|
||||
|
||||
describe('tor exit blocking in the middleware pipeline', () => {
|
||||
it('registers the tor exit middleware when the switch is on', () => {
|
||||
expect(registeredHandlers(true)).toContain(TorExitMiddleware);
|
||||
});
|
||||
|
||||
it('leaves the tor exit middleware unregistered when the switch is off', () => {
|
||||
expect(registeredHandlers(false)).not.toContain(TorExitMiddleware);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {signAttachmentUrl} from '@app/api/attachment/AttachmentUrls';
|
||||
import {LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
RefreshAttachmentUrlsRequest,
|
||||
RefreshAttachmentUrlsResponse,
|
||||
} from '@fluxer/schema/src/domains/message/AttachmentSchemas';
|
||||
|
||||
export function AttachmentController(app: HonoApp) {
|
||||
app.post(
|
||||
'/attachments/refresh-urls',
|
||||
RateLimitMiddleware(RateLimitConfigs.ATTACHMENT_URLS_REFRESH),
|
||||
LoginRequired,
|
||||
Validator('json', RefreshAttachmentUrlsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'refresh_attachment_urls',
|
||||
summary: 'Refresh attachment URLs',
|
||||
responseSchema: RefreshAttachmentUrlsResponse,
|
||||
statusCode: 200,
|
||||
security: ['botToken', 'sessionToken'],
|
||||
tags: ['Messages'],
|
||||
description:
|
||||
'Reissues the expiring signature on attachment URLs. Returns one entry per requested URL, in the order they were requested, each pairing the URL exactly as it was sent with a freshly signed copy. A URL that is not an attachment URL of this instance is returned unchanged. No membership or existence check is performed.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const urls = ctx.req.valid('json').attachment_urls;
|
||||
return ctx.json({
|
||||
refreshed_urls: urls.map((original) => ({original, refreshed: signAttachmentUrl(original)})),
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AttachmentID, ChannelID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {makeAttachmentCdnUrl} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {extractTimestampBigInt} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {
|
||||
attachmentStorageKeyFromUrl,
|
||||
type SignAttachmentUrlOptions,
|
||||
signDataPackageAttachmentUrl as signDataPackageWithSecret,
|
||||
signAttachmentUrl as signWithSecret,
|
||||
stripAttachmentSignature as stripSignature,
|
||||
} from '@pkgs/media_proxy_utils/src/AttachmentUrlSignature';
|
||||
|
||||
const SNOWFLAKE_SEGMENT_REGEX = /^[0-9]{1,20}$/u;
|
||||
const STORAGE_KEY_MIN_SEGMENTS = 4;
|
||||
|
||||
function signingSecret(): Buffer | null {
|
||||
const configured = Config.mediaProxy.attachmentUrls.secretsBase64[0];
|
||||
if (!configured) return null;
|
||||
const secret = Buffer.from(configured, 'base64');
|
||||
return secret.length === 0 ? null : secret;
|
||||
}
|
||||
|
||||
function anchorSecsFromStorageKey(storageKey: string): number | null {
|
||||
const segments = storageKey.split('/');
|
||||
if (segments.length < STORAGE_KEY_MIN_SEGMENTS || segments[0] !== 'attachments') return null;
|
||||
const attachmentId = segments[2] as string;
|
||||
if (!SNOWFLAKE_SEGMENT_REGEX.test(segments[1] as string) || !SNOWFLAKE_SEGMENT_REGEX.test(attachmentId)) return null;
|
||||
return Math.floor(extractTimestampBigInt(BigInt(attachmentId)) / 1000);
|
||||
}
|
||||
|
||||
function signingOptions(url: string, nowSecs?: number): SignAttachmentUrlOptions | null {
|
||||
const secret = signingSecret();
|
||||
if (secret === null) return null;
|
||||
const mediaEndpoint = Config.endpoints.media;
|
||||
const storageKey = attachmentStorageKeyFromUrl(url, mediaEndpoint);
|
||||
if (storageKey === null) return null;
|
||||
const anchorSecs = anchorSecsFromStorageKey(storageKey);
|
||||
if (anchorSecs === null) return null;
|
||||
return {mediaEndpoint, secret, nowSecs: nowSecs ?? Math.floor(Date.now() / 1000), anchorSecs};
|
||||
}
|
||||
|
||||
export function signAttachmentUrl(url: string, nowSecs?: number): string {
|
||||
const options = signingOptions(url, nowSecs);
|
||||
return options === null ? url : signWithSecret(url, options);
|
||||
}
|
||||
|
||||
export function signDataPackageAttachmentUrl(url: string, nowSecs?: number): string {
|
||||
const options = signingOptions(url, nowSecs);
|
||||
return options === null ? url : signDataPackageWithSecret(url, options);
|
||||
}
|
||||
|
||||
export function stripOwnAttachmentSignature(url: string): string {
|
||||
return attachmentStorageKeyFromUrl(url, Config.endpoints.media) === null ? url : stripSignature(url);
|
||||
}
|
||||
|
||||
export function makeSignedAttachmentCdnUrl(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID | bigint,
|
||||
filename: string,
|
||||
): string {
|
||||
return signAttachmentUrl(makeAttachmentCdnUrl(channelId, attachmentId, filename));
|
||||
}
|
||||
|
||||
export function makeDataPackageAttachmentCdnUrl(
|
||||
channelId: ChannelID,
|
||||
attachmentId: AttachmentID | bigint,
|
||||
filename: string,
|
||||
): string {
|
||||
return signDataPackageAttachmentUrl(makeAttachmentCdnUrl(channelId, attachmentId, filename));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
|
||||
import {
|
||||
createInviteCode,
|
||||
createIpAuthorizationTicket,
|
||||
@@ -30,6 +31,7 @@ import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {IpAuthorizationRequiredError} from '@fluxer/errors/src/domains/auth/IpAuthorizationRequiredError';
|
||||
import {IpAuthorizationResendCooldownError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendCooldownError';
|
||||
import {IpAuthorizationResendLimitExceededError} from '@fluxer/errors/src/domains/auth/IpAuthorizationResendLimitExceededError';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {RegistrationPendingApprovalError} from '@fluxer/errors/src/domains/auth/RegistrationPendingApprovalError';
|
||||
import {RegistrationRejectedError} from '@fluxer/errors/src/domains/auth/RegistrationRejectedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -72,12 +74,13 @@ interface LoginTokenResult {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface LoginMfaResult {
|
||||
export interface LoginMfaResult {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
}
|
||||
|
||||
type LoginResult = LoginTokenResult | LoginMfaResult;
|
||||
@@ -323,7 +326,8 @@ export async function login(
|
||||
}
|
||||
}
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, currentUser);
|
||||
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, currentUser);
|
||||
return await createMfaTicketResponse(ctx, currentUser, webauthnIsSecondFactor);
|
||||
}
|
||||
if (data.invite_code && inviteService) {
|
||||
try {
|
||||
@@ -387,13 +391,14 @@ export async function loginMfaTotp(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
||||
const hasTotp = Boolean(user.totpSecret) && user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
if (!hasTotp && !(await AuthMfa.hasUnconsumedBackupCodes(ctx, user.id))) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
|
||||
const isValid = await AuthMfa.verifyMfaCode(ctx, {
|
||||
userId: user.id,
|
||||
mfaSecret: user.totpSecret,
|
||||
mfaSecret: hasTotp ? user.totpSecret : null,
|
||||
code,
|
||||
allowBackup: true,
|
||||
});
|
||||
@@ -424,6 +429,9 @@ export async function loginMfaWebAuthn(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
if (!(await resolveWebAuthnSecondFactor(ctx, user))) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
@@ -436,21 +444,26 @@ export async function loginMfaWebAuthn(
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
async function createMfaTicketResponse(ctx: ApiContext, user: User): Promise<LoginMfaResult> {
|
||||
const {users, cache} = ctx.services;
|
||||
export async function createMfaTicketResponse(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
webauthnIsSecondFactor: boolean,
|
||||
): Promise<LoginMfaResult> {
|
||||
const {cache} = ctx.services;
|
||||
const ticket = createMfaTicket(await AuthUtility.generateSecureToken(ctx));
|
||||
await cache.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
|
||||
const credentials = await users.listWebAuthnCredentials(user.id);
|
||||
const hasWebauthn = credentials.length > 0;
|
||||
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(ctx, user.id);
|
||||
const allowedMethods: Array<string> = [];
|
||||
if (hasTotp) allowedMethods.push('totp');
|
||||
if (hasWebauthn) allowedMethods.push('webauthn');
|
||||
if (webauthnIsSecondFactor) allowedMethods.push('webauthn');
|
||||
if (hasBackupCodes) allowedMethods.push('backup_codes');
|
||||
return {
|
||||
mfa: true,
|
||||
ticket,
|
||||
allowed_methods: allowedMethods,
|
||||
totp: hasTotp,
|
||||
webauthn: hasWebauthn,
|
||||
webauthn: webauthnIsSecondFactor,
|
||||
backup_codes: hasBackupCodes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import {timingSafeEqual} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {MfaBackupCode} from '@app/api/models/MfaBackupCode';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {WebAuthnCredential} from '@app/api/models/WebAuthnCredential';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
@@ -48,7 +50,7 @@ interface SudoMfaVerificationResult {
|
||||
|
||||
interface VerifyMfaCodeParams {
|
||||
userId: UserID;
|
||||
mfaSecret: string;
|
||||
mfaSecret: string | null;
|
||||
code: string;
|
||||
allowBackup?: boolean;
|
||||
}
|
||||
@@ -56,9 +58,15 @@ interface VerifyMfaCodeParams {
|
||||
interface AvailableMfaMethods {
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
backup_codes: boolean;
|
||||
has_mfa: boolean;
|
||||
}
|
||||
|
||||
interface SetWebAuthnTwoFactorResult {
|
||||
user: User;
|
||||
backupCodes: Array<MfaBackupCode> | null;
|
||||
}
|
||||
|
||||
function constantTimeEquals(a: string, b: string): boolean {
|
||||
const bufferA = Buffer.from(a);
|
||||
const bufferB = Buffer.from(b);
|
||||
@@ -72,24 +80,31 @@ function normalizeBackupCode(code: string): string {
|
||||
return code.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
export async function hasUnconsumedBackupCodes(ctx: ApiContext, userId: UserID): Promise<boolean> {
|
||||
const backupCodes = await ctx.services.users.listMfaBackupCodes(userId);
|
||||
return backupCodes.some((backupCode) => !backupCode.consumed);
|
||||
}
|
||||
|
||||
export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams): Promise<boolean> {
|
||||
const {userId, mfaSecret, code, allowBackup = false} = params;
|
||||
const {users, cache, config} = ctx.services;
|
||||
try {
|
||||
const totp = new TotpGenerator(mfaSecret);
|
||||
const isValidTotp = await totp.validateTotp(code);
|
||||
if (isValidTotp) {
|
||||
if (config.dev.testModeEnabled) {
|
||||
return true;
|
||||
}
|
||||
const reuseKey = `mfa-totp:${userId}:${code}`;
|
||||
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
|
||||
if (lockToken) {
|
||||
return true;
|
||||
if (mfaSecret !== null) {
|
||||
try {
|
||||
const totp = new TotpGenerator(mfaSecret);
|
||||
const isValidTotp = await totp.validateTotp(code);
|
||||
if (isValidTotp) {
|
||||
if (config.dev.testModeEnabled) {
|
||||
return true;
|
||||
}
|
||||
const reuseKey = `mfa-totp:${userId}:${code}`;
|
||||
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
|
||||
if (lockToken) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
|
||||
}
|
||||
if (allowBackup) {
|
||||
const normalizedCode = normalizeBackupCode(code);
|
||||
@@ -121,6 +136,7 @@ export async function generateWebAuthnRegistrationOptions(ctx: ApiContext, userI
|
||||
userName: user.username!,
|
||||
userDisplayName: user.username!,
|
||||
attestationType: 'none',
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
excludeCredentials: existingCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
@@ -144,8 +160,7 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedChallenge: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const {users, gateway, botMfaMirror, config} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const {users, config} = ctx.services;
|
||||
const existingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
await consumeWebAuthnChallenge(ctx, expectedChallenge, 'registration', {userId});
|
||||
if (existingCredentials.length >= 10) {
|
||||
@@ -174,6 +189,7 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpID, expectedOrigin}, 'WebAuthn verification failed');
|
||||
@@ -212,13 +228,6 @@ export async function verifyWebAuthnRegistration(
|
||||
name,
|
||||
);
|
||||
}
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
if (!authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
@@ -232,15 +241,55 @@ export async function deleteWebAuthnCredential(ctx: ApiContext, userId: UserID,
|
||||
const remainingCredentials = await users.listWebAuthnCredentials(userId);
|
||||
if (remainingCredentials.length === 0) {
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const authenticatorTypes = user.authenticatorTypes || new Set<number>();
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
if (user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
const authenticatorTypes = new Set<number>(user.authenticatorTypes ?? []);
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
if (!userHasMfa(updatedUser)) {
|
||||
await users.clearMfaBackupCodes(userId);
|
||||
}
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
}
|
||||
await dispatchWebAuthnCredentialsUpdate(ctx, userId);
|
||||
}
|
||||
|
||||
export async function setWebAuthnTwoFactor(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
enabled: boolean,
|
||||
): Promise<SetWebAuthnTwoFactorResult> {
|
||||
const {users, gateway, botMfaMirror} = ctx.services;
|
||||
const user = await users.findUniqueAssert(userId);
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
if (enabled && credentials.length === 0) {
|
||||
throw new NoPasskeysRegisteredError();
|
||||
}
|
||||
const authenticatorTypes = new Set<number>(user.authenticatorTypes ?? []);
|
||||
if (authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN) === enabled) {
|
||||
return {user, backupCodes: null};
|
||||
}
|
||||
if (enabled) {
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
} else {
|
||||
authenticatorTypes.delete(UserAuthenticatorTypes.WEBAUTHN);
|
||||
}
|
||||
const updatedUser = await users.patchUpsert(userId, {authenticator_types: authenticatorTypes}, user.toRow());
|
||||
let backupCodes: Array<MfaBackupCode> | null = null;
|
||||
if (enabled) {
|
||||
const existingBackupCodes = await users.listMfaBackupCodes(userId);
|
||||
if (existingBackupCodes.every((backupCode) => backupCode.consumed)) {
|
||||
backupCodes = await users.createMfaBackupCodes(userId, AuthUtility.generateBackupCodes(ctx));
|
||||
}
|
||||
} else if (!userHasMfa(updatedUser)) {
|
||||
await users.clearMfaBackupCodes(userId);
|
||||
}
|
||||
await gateway.dispatchPresence({userId, event: 'USER_UPDATE', data: mapUserToPrivateResponse(updatedUser)});
|
||||
await botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
return {user: updatedUser, backupCodes};
|
||||
}
|
||||
|
||||
export async function renameWebAuthnCredential(
|
||||
ctx: ApiContext,
|
||||
userId: UserID,
|
||||
@@ -428,16 +477,17 @@ export async function verifySudoMfa(
|
||||
const {users} = ctx.services;
|
||||
const {userId, method, code, webauthnResponse, webauthnChallenge} = params;
|
||||
const user = await users.findUnique(userId);
|
||||
const hasMfa =
|
||||
(user?.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
|
||||
(user?.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false);
|
||||
if (!user || !hasMfa) {
|
||||
if (!user) {
|
||||
return {success: false, error: 'MFA not enabled'};
|
||||
}
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
if (!userHasSudoCapability(user, hasPasskeyCredentials)) {
|
||||
return {success: false, error: 'MFA not enabled'};
|
||||
}
|
||||
switch (method) {
|
||||
case 'totp': {
|
||||
if (!code) return {success: false, error: 'TOTP code is required'};
|
||||
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
|
||||
await consumeSudoMfaAttempt(ctx, userId);
|
||||
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
|
||||
if (isValid) {
|
||||
@@ -449,7 +499,7 @@ export async function verifySudoMfa(
|
||||
if (!webauthnResponse || !webauthnChallenge) {
|
||||
return {success: false, error: 'WebAuthn response and challenge are required'};
|
||||
}
|
||||
if (!user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
if (!hasPasskeyCredentials) {
|
||||
return {success: false, error: 'WebAuthn is not enabled'};
|
||||
}
|
||||
try {
|
||||
@@ -465,15 +515,19 @@ export async function verifySudoMfa(
|
||||
}
|
||||
|
||||
export async function getAvailableMfaMethods(ctx: ApiContext, userId: UserID): Promise<AvailableMfaMethods> {
|
||||
const user = await ctx.services.users.findUnique(userId);
|
||||
const {users} = ctx.services;
|
||||
const user = await users.findUnique(userId);
|
||||
if (!user) {
|
||||
return {totp: false, webauthn: false, has_mfa: false};
|
||||
return {totp: false, webauthn: false, backup_codes: false, has_mfa: false};
|
||||
}
|
||||
const methods = deriveSudoMethods(user);
|
||||
const credentials = await users.listWebAuthnCredentials(userId);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const methods = deriveSudoMethods(user, hasPasskeyCredentials, await hasUnconsumedBackupCodes(ctx, userId));
|
||||
return {
|
||||
totp: methods.totp,
|
||||
webauthn: methods.webauthn,
|
||||
has_mfa: userHasMfa(user),
|
||||
backup_codes: methods.backup_codes,
|
||||
has_mfa: userHasSudoCapability(user, hasPasskeyCredentials),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {createMfaTicketResponse, type LoginMfaResult} from '@app/api/auth/AuthLogin';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {createMfaTicket, createPasswordResetToken} from '@app/api/BrandedTypes';
|
||||
import {resolveWebAuthnSecondFactor} from '@app/api/auth/services/WebAuthnSecondFactor';
|
||||
import {createPasswordResetToken} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '@app/api/utils/PasswordUtils';
|
||||
@@ -18,7 +21,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
|
||||
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;
|
||||
@@ -90,13 +93,7 @@ type ResetPasswordResult =
|
||||
user_id: string;
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
};
|
||||
| LoginMfaResult;
|
||||
|
||||
const pwnedPasswordCache = new PwnedPasswordCache(PWNED_PASSWORD_CACHE_MAX_PREFIXES, ms('1 hour'));
|
||||
|
||||
@@ -116,6 +113,9 @@ export async function verifyPassword(
|
||||
}
|
||||
|
||||
export async function isPasswordPwned(_ctx: ApiContext, password: string): Promise<boolean> {
|
||||
if (!Config.breachedPasswordCheck.enabled) {
|
||||
return false;
|
||||
}
|
||||
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
const hashPrefix = hashed.slice(0, 5);
|
||||
const hashSuffix = hashed.slice(5);
|
||||
@@ -263,22 +263,26 @@ export async function resetPassword(
|
||||
if (await isPasswordPwned(ctx, data.password)) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
|
||||
}
|
||||
const webauthnIsSecondFactor = await resolveWebAuthnSecondFactor(ctx, user);
|
||||
const hasMfa = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) || webauthnIsSecondFactor;
|
||||
const newPasswordHash = await hashPassword(ctx, data.password);
|
||||
const updatedUser = await users.patchUpsert(
|
||||
user.id,
|
||||
{
|
||||
password_hash: newPasswordHash,
|
||||
password_last_changed_at: new Date(),
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
const updates: Partial<UserRow> = {
|
||||
password_hash: newPasswordHash,
|
||||
password_last_changed_at: new Date(),
|
||||
};
|
||||
if (webauthnIsSecondFactor && !user.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN)) {
|
||||
const authenticatorTypes = new Set<number>(user.authenticatorTypes);
|
||||
authenticatorTypes.add(UserAuthenticatorTypes.WEBAUTHN);
|
||||
updates.authenticator_types = authenticatorTypes;
|
||||
}
|
||||
const updatedUser = await users.patchUpsert(user.id, updates, user.toRow());
|
||||
if (updates.authenticator_types) {
|
||||
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
await AuthSession.terminateAllUserSessions(ctx, user.id);
|
||||
await users.deletePasswordResetToken(data.token);
|
||||
const hasMfa =
|
||||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
|
||||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN);
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, updatedUser);
|
||||
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
@@ -286,31 +290,3 @@ export async function resetPassword(
|
||||
});
|
||||
return {user_id: updatedUser.id.toString(), token};
|
||||
}
|
||||
|
||||
async function createMfaTicketResponse(
|
||||
ctx: ApiContext,
|
||||
user: User,
|
||||
): Promise<{
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
allowed_methods: Array<string>;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}> {
|
||||
const {users, cache} = ctx.services;
|
||||
const ticket = createMfaTicket(await AuthUtility.generateSecureToken(ctx));
|
||||
await cache.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
|
||||
const credentials = await users.listWebAuthnCredentials(user.id);
|
||||
const hasWebauthn = credentials.length > 0;
|
||||
const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
|
||||
const allowedMethods: Array<string> = [];
|
||||
if (hasTotp) allowedMethods.push('totp');
|
||||
if (hasWebauthn) allowedMethods.push('webauthn');
|
||||
return {
|
||||
mfa: true,
|
||||
ticket: ticket,
|
||||
allowed_methods: allowedMethods,
|
||||
totp: hasTotp,
|
||||
webauthn: hasWebauthn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -417,6 +417,7 @@ export class AuthRequestService {
|
||||
...result,
|
||||
totp: allowedMethods.has('totp'),
|
||||
webauthn: allowedMethods.has('webauthn'),
|
||||
backup_codes: allowedMethods.has('backup_codes'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,19 +135,14 @@ describe('reject reasons reaching the caller through the real gate', () => {
|
||||
REJECT_REASON_CODES.invalid_number,
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
'landline',
|
||||
'tollFree',
|
||||
'premium',
|
||||
'sharedCost',
|
||||
'uan',
|
||||
'voicemail',
|
||||
'pager',
|
||||
] as const)('line_type_hard_rejected for %s says it is not a mobile', async (lineType) => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType})})).toBe(
|
||||
REJECT_REASON_CODES.line_type_hard_rejected,
|
||||
);
|
||||
});
|
||||
it.each(['landline', 'tollFree', 'premium', 'sharedCost', 'uan', 'voicemail', 'pager'] as const)(
|
||||
'line_type_hard_rejected for %s says it is not a mobile',
|
||||
async (lineType) => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType})})).toBe(
|
||||
REJECT_REASON_CODES.line_type_hard_rejected,
|
||||
);
|
||||
},
|
||||
);
|
||||
it('sms_pumping_risk_high routes to human review', async () => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({smsPumpingRiskScore: 100})})).toBe(
|
||||
REJECT_REASON_CODES.sms_pumping_risk_high,
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {SudoModeMethods} from '@fluxer/errors/src/domains/auth/SudoModeRequiredError';
|
||||
|
||||
interface SudoMethodsUser {
|
||||
totpSecret?: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
}
|
||||
|
||||
function hasTotpEnrolled(user: SudoMethodsUser): boolean {
|
||||
return (user.totpSecret ?? null) !== null && (user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false);
|
||||
}
|
||||
|
||||
export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boolean {
|
||||
return (
|
||||
(user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false) ||
|
||||
@@ -10,13 +19,18 @@ export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boo
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveSudoMethods(user: {
|
||||
totpSecret?: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
}): SudoModeMethods {
|
||||
const authenticatorTypes = user.authenticatorTypes ?? null;
|
||||
export function userHasSudoCapability(user: SudoMethodsUser, hasPasskeyCredentials: boolean): boolean {
|
||||
return hasTotpEnrolled(user) || hasPasskeyCredentials;
|
||||
}
|
||||
|
||||
export function deriveSudoMethods(
|
||||
user: SudoMethodsUser,
|
||||
hasPasskeyCredentials: boolean,
|
||||
hasBackupCodes: boolean,
|
||||
): SudoModeMethods {
|
||||
return {
|
||||
totp: (user.totpSecret ?? null) !== null && (authenticatorTypes?.has(UserAuthenticatorTypes.TOTP) ?? false),
|
||||
webauthn: authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false,
|
||||
totp: hasTotpEnrolled(user),
|
||||
webauthn: hasPasskeyCredentials,
|
||||
backup_codes: hasBackupCodes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthPassword from '@app/api/auth/AuthPassword';
|
||||
import {deriveSudoMethods, userHasMfa} from '@app/api/auth/services/SudoMethods';
|
||||
import {deriveSudoMethods, userHasMfa, userHasSudoCapability} from '@app/api/auth/services/SudoMethods';
|
||||
import {getSudoModeService} from '@app/api/auth/services/SudoModeService';
|
||||
import {SUDO_MODE_HEADER} from '@app/api/middleware/SudoModeMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
@@ -26,8 +26,9 @@ type SudoVerificationMethod = 'password' | 'mfa' | 'sudo_token';
|
||||
export function hasNoVerifiableCredential(
|
||||
user: {passwordHash: string | null; isBot: boolean},
|
||||
hasMfa: boolean,
|
||||
hasPasskeyCredentials: boolean,
|
||||
): boolean {
|
||||
if (user.isBot || hasMfa) {
|
||||
if (user.isBot || hasMfa || hasPasskeyCredentials) {
|
||||
return false;
|
||||
}
|
||||
return user.passwordHash === null;
|
||||
@@ -52,18 +53,22 @@ async function verifySudoMode(
|
||||
if (user.isBot) {
|
||||
return {verified: true, method: 'sudo_token'};
|
||||
}
|
||||
const apiContext = ctx.get('apiContext');
|
||||
const credentials = await apiContext.services.users.listWebAuthnCredentials(user.id);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const hasMfa = userHasMfa(user);
|
||||
const issueSudoToken = options.issueSudoToken ?? hasMfa;
|
||||
if (hasMfa && ctx.get('sudoModeValid')) {
|
||||
const hasSudoCapability = userHasSudoCapability(user, hasPasskeyCredentials);
|
||||
const issueSudoToken = options.issueSudoToken ?? hasSudoCapability;
|
||||
if (hasSudoCapability && ctx.get('sudoModeValid')) {
|
||||
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
|
||||
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? sudoToken : undefined};
|
||||
}
|
||||
const incomingToken = ctx.req.header(SUDO_MODE_HEADER);
|
||||
if (!hasMfa && incomingToken && ctx.get('sudoModeValid')) {
|
||||
if (!hasSudoCapability && incomingToken && ctx.get('sudoModeValid')) {
|
||||
return {verified: true, method: 'sudo_token', sudoToken: issueSudoToken ? incomingToken : undefined};
|
||||
}
|
||||
if (hasMfa && body.mfa_method) {
|
||||
const result = await AuthMfa.verifySudoMfa(ctx.get('apiContext'), {
|
||||
if (hasSudoCapability && body.mfa_method) {
|
||||
const result = await AuthMfa.verifySudoMfa(apiContext, {
|
||||
userId: user.id,
|
||||
method: body.mfa_method,
|
||||
code: body.mfa_code,
|
||||
@@ -77,14 +82,14 @@ async function verifySudoMode(
|
||||
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
||||
return {verified: true, sudoToken, method: 'mfa'};
|
||||
}
|
||||
if (hasNoVerifiableCredential(user, hasMfa)) {
|
||||
if (hasNoVerifiableCredential(user, hasMfa, hasPasskeyCredentials)) {
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
if (body.password && !hasMfa) {
|
||||
if (!user.passwordHash) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
|
||||
}
|
||||
const passwordValid = await AuthPassword.verifyPassword(ctx.get('apiContext'), {
|
||||
const passwordValid = await AuthPassword.verifyPassword(apiContext, {
|
||||
password: body.password,
|
||||
passwordHash: user.passwordHash,
|
||||
});
|
||||
@@ -93,7 +98,8 @@ async function verifySudoMode(
|
||||
}
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
throw new SudoModeRequiredError(hasMfa, deriveSudoMethods(user));
|
||||
const hasBackupCodes = await AuthMfa.hasUnconsumedBackupCodes(apiContext, user.id);
|
||||
throw new SudoModeRequiredError(hasSudoCapability, deriveSudoMethods(user, hasPasskeyCredentials, hasBackupCodes));
|
||||
}
|
||||
|
||||
function setSudoTokenHeader(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
interface WebAuthnSecondFactorUser {
|
||||
passwordHash: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
}
|
||||
|
||||
export function webAuthnIsSecondFactor(user: WebAuthnSecondFactorUser, hasPasskeyCredentials: boolean): boolean {
|
||||
return (
|
||||
(user.authenticatorTypes?.has(UserAuthenticatorTypes.WEBAUTHN) ?? false) ||
|
||||
(user.passwordHash === null && hasPasskeyCredentials)
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveWebAuthnSecondFactor(ctx: ApiContext, user: User): Promise<boolean> {
|
||||
const credentials = await ctx.services.users.listWebAuthnCredentials(user.id);
|
||||
return webAuthnIsSecondFactor(user, credentials.length > 0);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
loginUser,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function setUserSecurityFlags(harness: ApiTestHarness, userId: string, setFlags: Array<string>): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${userId}/security-flags`)
|
||||
.body({
|
||||
set_flags: setFlags,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Auth app store reviewer IP bypass', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('allows login from any IP address when APP_STORE_REVIEWER flag is set', async () => {
|
||||
const email = createUniqueEmail('app-store-reviewer');
|
||||
const username = createUniqueUsername('reviewer');
|
||||
const password = 'a-strong-password';
|
||||
const reg = await registerUser(harness, {
|
||||
email,
|
||||
username,
|
||||
global_name: 'App Store Reviewer',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
await setUserSecurityFlags(harness, reg.user_id, ['APP_STORE_REVIEWER']);
|
||||
const login = await loginUser(harness, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
if (!('mfa' in login)) {
|
||||
const nonMfaLogin = login as {
|
||||
user_id: string;
|
||||
token: string;
|
||||
};
|
||||
expect(nonMfaLogin.token).toBeTruthy();
|
||||
expect(nonMfaLogin.user_id).toBe(reg.user_id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
loginUser,
|
||||
registerUser,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
async function setUserSecurityFlags(harness: ApiTestHarness, userId: string, setFlags: Array<string>): Promise<void> {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${userId}/security-flags`)
|
||||
.body({
|
||||
set_flags: setFlags,
|
||||
})
|
||||
.expect(200)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Auth app store reviewer with other flags', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('allows login with APP_STORE_REVIEWER flag combined with other flags', async () => {
|
||||
const email = createUniqueEmail('reviewer-multi-flag');
|
||||
const username = createUniqueUsername('reviewer');
|
||||
const password = 'a-strong-password';
|
||||
const reg = await registerUser(harness, {
|
||||
email,
|
||||
username,
|
||||
global_name: 'Multi Flag Reviewer',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
await setUserSecurityFlags(harness, reg.user_id, ['APP_STORE_REVIEWER', 'STAFF']);
|
||||
const login = await loginUser(harness, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
expect('mfa' in login).toBe(false);
|
||||
if (!('mfa' in login)) {
|
||||
const nonMfaLogin = login as {
|
||||
user_id: string;
|
||||
token: string;
|
||||
};
|
||||
expect(nonMfaLogin.token).toBeTruthy();
|
||||
expect(nonMfaLogin.user_id).toBe(reg.user_id);
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user