mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-11 13:12:23 +09:00
Compare commits
264
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaee820216 | ||
|
|
564c5ae164 | ||
|
|
dd8ed6f205 | ||
|
|
ed8c412415 | ||
|
|
12417a6942 | ||
|
|
d05f6c9aaa | ||
|
|
0ca035c547 | ||
|
|
dfd46ccc2c | ||
|
|
f6df3169ca | ||
|
|
5b280898c5 | ||
|
|
2a9e25c788 | ||
|
|
463c03fb6d | ||
|
|
153dad11e1 | ||
|
|
e2d05a44a8 | ||
|
|
30ba55bd4d | ||
|
|
9def9fbef6 | ||
|
|
fa3fd0027c | ||
|
|
7e1b934637 | ||
|
|
33a118d12a | ||
|
|
01f53a168d | ||
|
|
336b8b7dcd | ||
|
|
48d0034239 | ||
|
|
677ef8491e | ||
|
|
6a6119ed1e | ||
|
|
931327d1dc | ||
|
|
858a2d9e2b | ||
|
|
841fb7af41 | ||
|
|
08e65d41c0 | ||
|
|
f76c4dc041 | ||
|
|
f1f8ba2031 | ||
|
|
5ab8d745c0 | ||
|
|
ff62bc89a4 | ||
|
|
838bbdb5ec | ||
|
|
1c36a59b2c | ||
|
|
6730a242db | ||
|
|
e62ae77643 | ||
|
|
f4f39e6a89 | ||
|
|
00bf74cef5 | ||
|
|
c1c45d835f | ||
|
|
bbfe809bef | ||
|
|
e0843ac4f5 | ||
|
|
43741cdad8 | ||
|
|
b8e3807262 | ||
|
|
3304f01a84 | ||
|
|
2ba463235b | ||
|
|
15136fed59 | ||
|
|
6013581dd9 | ||
|
|
7a91f128e9 | ||
|
|
963ffc5550 | ||
|
|
a90991612c | ||
|
|
50ad23b760 | ||
|
|
425dab983b | ||
|
|
a0825e77c4 | ||
|
|
88038a1d5b | ||
|
|
c2c0fdb445 | ||
|
|
dcd5f09d6a | ||
|
|
590b1f36fd | ||
|
|
168ac727f1 | ||
|
|
deb86dd92e | ||
|
|
7ccec4d3b8 | ||
|
|
2f38bcdf26 | ||
|
|
f2785941aa | ||
|
|
ea9f83a443 | ||
|
|
bd6ca7290e | ||
|
|
b85e975fb5 | ||
|
|
b6e504f68c | ||
|
|
5fde6eb484 | ||
|
|
b16989d567 | ||
|
|
c9754ac11a | ||
|
|
f34e4a5115 | ||
|
|
44b3615298 | ||
|
|
211e98307d | ||
|
|
18c303abf6 | ||
|
|
7021a58090 | ||
|
|
320725a587 | ||
|
|
8450edc072 | ||
|
|
a1e2bf2c8d | ||
|
|
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 | ||
|
|
34b6ecfbd2 | ||
|
|
4ef9c4c65b | ||
|
|
3276039e41 | ||
|
|
03d1354562 | ||
|
|
964845d7a7 | ||
|
|
3bc5dd8e0f | ||
|
|
17292fd6a5 | ||
|
|
f753659899 | ||
|
|
7412ec3395 | ||
|
|
570c8776c4 | ||
|
|
910db6734b | ||
|
|
9e614026d7 | ||
|
|
b38e7c6433 | ||
|
|
83c8e91955 | ||
|
|
0532dd0440 | ||
|
|
a08615e306 | ||
|
|
f4c5fee17e | ||
|
|
c5aaf65a10 | ||
|
|
951e39da3d | ||
|
|
b693d84d2b | ||
|
|
9bbf6c513b | ||
|
|
50cec92738 | ||
|
|
c212d315f4 | ||
|
|
1861432a53 | ||
|
|
d0c6146429 | ||
|
|
550e6b05a1 | ||
|
|
5b6949170f | ||
|
|
f32bc37794 | ||
|
|
8ee2279b4b | ||
|
|
6339c3b8ad | ||
|
|
7c9274847f | ||
|
|
5d1dddc093 | ||
|
|
04481d7235 | ||
|
|
a3d6cf37cb | ||
|
|
ed10f9d323 | ||
|
|
4b278a0da8 | ||
|
|
1281045648 | ||
|
|
69c42cff90 | ||
|
|
7d56481aba | ||
|
|
91a2604e9e | ||
|
|
a9dc74a520 | ||
|
|
a9cc04d277 | ||
|
|
8cb097f954 | ||
|
|
78f783f0c4 | ||
|
|
d14998ff69 | ||
|
|
ca7ddd9272 | ||
|
|
84dcf6b8a8 | ||
|
|
a59b80ce11 | ||
|
|
007f338823 | ||
|
|
7d34d25497 | ||
|
|
7375ac9d80 | ||
|
|
6af33c7188 | ||
|
|
33737e0f79 | ||
|
|
5afbf67a70 | ||
|
|
580401d2dc | ||
|
|
38b7c63431 | ||
|
|
57d08cd091 | ||
|
|
91e2d31614 | ||
|
|
d375dc7946 | ||
|
|
3fffce2a4a | ||
|
|
376c509083 | ||
|
|
156315fd5a | ||
|
|
c7b9e9b9bd | ||
|
|
12397032e3 | ||
|
|
4a285cbb11 | ||
|
|
6d600990fe | ||
|
|
e1bc6c2f7e | ||
|
|
3e79530389 | ||
|
|
d0c84b3d9b | ||
|
|
3affd295e8 | ||
|
|
7b15e5be0f | ||
|
|
adab646d1e | ||
|
|
de1fd95a99 | ||
|
|
4bc5593f9f | ||
|
|
172791316b | ||
|
|
b30a4f5d14 | ||
|
|
f254ed679b | ||
|
|
258fe6f742 | ||
|
|
cfa20b7093 | ||
|
|
569146c5bc | ||
|
|
1b1d48b05e | ||
|
|
cadca2c18e | ||
|
|
2e3f78b3c6 | ||
|
|
b57545b1a4 | ||
|
|
e490be2f35 | ||
|
|
ab07fd23cf | ||
|
|
c1fd2234b8 | ||
|
|
3af43b3366 | ||
|
|
0e470f532e | ||
|
|
c541b86c00 | ||
|
|
53b3fa2f4a | ||
|
|
67e01be34a | ||
|
|
81fd8c9aad | ||
|
|
bcef7b3123 | ||
|
|
a98d8ef679 | ||
|
|
a5af857564 | ||
|
|
2830221949 | ||
|
|
84aa8880f5 | ||
|
|
395ec1d60f | ||
|
|
e6ee3b8059 | ||
|
|
61a13e1c1a | ||
|
|
fc0e2628a4 | ||
|
|
87fdfd9c34 | ||
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 | ||
|
|
d17f320bd7 | ||
|
|
f708586c59 | ||
|
|
905af5dd5a | ||
|
|
5fea319f4e | ||
|
|
01fd11fea9 | ||
|
|
d028679b90 |
No files matched your search
@@ -1,14 +1,14 @@
|
|||||||
FROM chrislusf/seaweedfs:4.31 AS seaweedfs
|
FROM chrislusf/seaweedfs:4.47 AS seaweedfs
|
||||||
|
|
||||||
FROM erlang:28.5.0.1
|
FROM erlang:28.5.0.6
|
||||||
|
|
||||||
ARG USERNAME=vscode
|
ARG USERNAME=vscode
|
||||||
ARG USER_UID=1000
|
ARG USER_UID=1000
|
||||||
ARG USER_GID=1000
|
ARG USER_GID=1000
|
||||||
ARG NODE_MAJOR=24
|
ARG NODE_MAJOR=26
|
||||||
ARG ELP_VERSION=2026-02-27
|
ARG ELP_VERSION=2026-08-10
|
||||||
ARG PNPM_VERSION=10.29.3
|
ARG PNPM_VERSION=11.27.0
|
||||||
ARG WASM_BINDGEN_VERSION=0.2.123
|
ARG WASM_BINDGEN_VERSION=0.2.128
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
@@ -131,7 +131,8 @@ RUN apt-get update \
|
|||||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& corepack enable
|
&& npm install -g "pnpm@${PNPM_VERSION}" \
|
||||||
|
&& pnpm --version
|
||||||
|
|
||||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
||||||
|
|
||||||
@@ -167,7 +168,7 @@ RUN ARCH="$(dpkg --print-architecture)" \
|
|||||||
arm64) ELP_ARCH="aarch64" ;; \
|
arm64) ELP_ARCH="aarch64" ;; \
|
||||||
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
|
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
|
||||||
esac \
|
esac \
|
||||||
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
|
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.5.tar.gz" -o /tmp/elp.tgz \
|
||||||
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
|
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
|
||||||
&& chmod +x /usr/local/bin/elp \
|
&& chmod +x /usr/local/bin/elp \
|
||||||
&& rm /tmp/elp.tgz
|
&& rm /tmp/elp.tgz
|
||||||
@@ -194,7 +195,4 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs
|
|||||||
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
|
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
|
||||||
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
|
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
|
||||||
|
|
||||||
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
|
|
||||||
&& pnpm --version
|
|
||||||
|
|
||||||
WORKDIR /workspaces/fluxer
|
WORKDIR /workspaces/fluxer
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"DOCKER_HOST": "unix:///var/run/docker.sock"
|
"DOCKER_HOST": "unix:///var/run/docker.sock"
|
||||||
},
|
},
|
||||||
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
|
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
|
||||||
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
|
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3020, 8333],
|
||||||
"portsAttributes": {
|
"portsAttributes": {
|
||||||
"8088": {
|
"8088": {
|
||||||
"label": "Fluxer dev proxy",
|
"label": "Fluxer dev proxy",
|
||||||
@@ -38,7 +38,11 @@
|
|||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"erlang.includePaths": ["."],
|
||||||
|
"search.exclude": {
|
||||||
|
"**/_build/default/lib/fluxer_gateway": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"biomejs.biome",
|
"biomejs.biome",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
init: true
|
init: true
|
||||||
environment:
|
environment:
|
||||||
DOCKER_HOST: unix:///var/run/docker.sock
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
|
pnpm_config_store_dir: /home/vscode/.local/share/pnpm/store
|
||||||
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
|
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||||
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||||
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||||
@@ -256,7 +256,6 @@ services:
|
|||||||
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
|
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
|
||||||
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
|
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
|
||||||
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
|
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
|
||||||
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
|
|
||||||
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
|
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
|
||||||
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
|
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -293,13 +292,13 @@ services:
|
|||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
valkey:
|
valkey:
|
||||||
image: valkey/valkey:8.1.7-alpine
|
image: valkey/valkey:9.1.2-alpine
|
||||||
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
|
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
|
||||||
|
|
||||||
nats:
|
nats:
|
||||||
image: nats:2.14.2-alpine
|
image: nats:2.14.7-alpine
|
||||||
command: ["-js", "-sd", "/data", "-m", "8222"]
|
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||||||
volumes:
|
volumes:
|
||||||
- nats-data:/data
|
- nats-data:/data
|
||||||
@@ -322,9 +321,10 @@ services:
|
|||||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
|
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
|
||||||
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.12
|
image: getmeili/meilisearch:v1.53
|
||||||
environment:
|
environment:
|
||||||
MEILI_NO_ANALYTICS: "true"
|
MEILI_NO_ANALYTICS: "true"
|
||||||
|
MEILI_UPGRADE_DB: "true"
|
||||||
MEILI_MASTER_KEY: fluxer-dev-meilisearch
|
MEILI_MASTER_KEY: fluxer-dev-meilisearch
|
||||||
volumes:
|
volumes:
|
||||||
- meilisearch-data:/meili_data
|
- meilisearch-data:/meili_data
|
||||||
@@ -338,7 +338,7 @@ services:
|
|||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.30
|
image: axllent/mailpit:v1.31
|
||||||
environment:
|
environment:
|
||||||
MP_DATABASE: /data/mailpit.db
|
MP_DATABASE: /data/mailpit.db
|
||||||
MP_MAX_MESSAGES: 5000
|
MP_MAX_MESSAGES: 5000
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,6 @@
|
|||||||
**/.git/**
|
**/.git/**
|
||||||
/.github/
|
/.github/
|
||||||
/.pnpm-store/
|
/.pnpm-store/
|
||||||
/fluxer_marketing
|
|
||||||
|
|
||||||
**/.env
|
**/.env
|
||||||
**/.env.*.local
|
**/.env.*.local
|
||||||
@@ -33,6 +32,7 @@
|
|||||||
/fluxer_docs/.astro/
|
/fluxer_docs/.astro/
|
||||||
/fluxer_app/.devserver-cache.json
|
/fluxer_app/.devserver-cache.json
|
||||||
/fluxer_app/pkgs/libfluxcore/
|
/fluxer_app/pkgs/libfluxcore/
|
||||||
|
/fluxer_app/pkgs/libfluxwebp/
|
||||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||||
/fluxer_app/src/features/theme/styles/generated/
|
/fluxer_app/src/features/theme/styles/generated/
|
||||||
|
|||||||
@@ -1,7 +1,2 @@
|
|||||||
/.github/CODEOWNERS @fluxerapp/developers
|
/.github/CODEOWNERS @fluxerapp/developers
|
||||||
/.github/workflows/ @fluxerapp/developers
|
/.github/workflows/ @fluxerapp/developers
|
||||||
/fluxer_marketing @fluxerapp/developers
|
|
||||||
/.gitmodules @fluxerapp/developers
|
|
||||||
/.github/workflows/dispatch-private-marketing-build.yaml @fluxerapp/developers
|
|
||||||
/packages/i18n/marketing/ @fluxerapp/developers
|
|
||||||
/scripts/setup-private-marketing.sh @fluxerapp/developers
|
|
||||||
@@ -89,21 +89,3 @@ Submit translations through [Weblate](https://weblate.fluxer.tools), not through
|
|||||||
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||||
|
|
||||||
## Private marketing project
|
|
||||||
|
|
||||||
The marketing implementation is maintained in a private repository at the `fluxer_marketing` submodule path. The public workspace, bootstrap, checks, and development stack work without initializing it.
|
|
||||||
|
|
||||||
Authorized maintainers can initialize only that submodule and install its independent dependencies:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./scripts/setup-private-marketing.sh
|
|
||||||
pnpm --dir fluxer_marketing install --frozen-lockfile
|
|
||||||
cargo metadata --locked --manifest-path fluxer_marketing/Cargo.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
To run the private marketing service in the local development stack and direct application links to it, add this override to the ignored `config/env/local.env` file:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
|
||||||
```
|
|
||||||
@@ -22,17 +22,15 @@ f:docs:
|
|||||||
f:gateway:
|
f:gateway:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: fluxer_gateway/**/*
|
- any-glob-to-any-file: fluxer_gateway/**/*
|
||||||
f:marketing:
|
|
||||||
- changed-files:
|
|
||||||
- any-glob-to-any-file:
|
|
||||||
- fluxer_marketing
|
|
||||||
- packages/i18n/marketing/**/*
|
|
||||||
f:media_proxy:
|
f:media_proxy:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: fluxer_media_proxy/**/*
|
- any-glob-to-any-file: fluxer_media_proxy/**/*
|
||||||
f:messages:
|
f:messages:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: fluxer_messages/**/*
|
- any-glob-to-any-file: fluxer_messages/**/*
|
||||||
|
f:push:
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file: fluxer_push/**/*
|
||||||
f:snowflakes:
|
f:snowflakes:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: fluxer_snowflakes/**/*
|
- any-glob-to-any-file: fluxer_snowflakes/**/*
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
build_version: ${{ steps.vars.outputs.build_version }}
|
build_version: ${{ steps.vars.outputs.build_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- name: Create token
|
- name: Create token
|
||||||
id: create-token
|
id: create-token
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||||
@@ -101,19 +101,19 @@ jobs:
|
|||||||
- platform: arm64
|
- platform: arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: resolve source date
|
- name: resolve source date
|
||||||
id: source
|
id: source
|
||||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ github.token }}
|
password: ${{ github.token }}
|
||||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
|
||||||
with:
|
with:
|
||||||
context: ${{ inputs.context }}
|
context: ${{ inputs.context }}
|
||||||
file: ${{ inputs.dockerfile }}
|
file: ${{ inputs.dockerfile }}
|
||||||
@@ -141,15 +141,15 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
build_version: ${{ steps.vars.outputs.build_version }}
|
build_version: ${{ steps.vars.outputs.build_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- name: set variables
|
- name: set variables
|
||||||
id: vars
|
id: vars
|
||||||
run: >-
|
run: >-
|
||||||
@@ -71,20 +71,19 @@ jobs:
|
|||||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||||
PUBLIC_ASSET_BASE_URL: ""
|
PUBLIC_ASSET_BASE_URL: ""
|
||||||
BUNDLE_LOCAL_ASSETS: "true"
|
BUNDLE_LOCAL_ASSETS: "true"
|
||||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- name: prepare docker config
|
- name: prepare docker config
|
||||||
run: >-
|
run: >-
|
||||||
tools/ci/run.sh build-app-proxy
|
tools/ci/run.sh build-app-proxy
|
||||||
--step prepare_docker_config
|
--step prepare_docker_config
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- name: configure ghcr auth
|
- name: configure ghcr auth
|
||||||
env:
|
env:
|
||||||
GHCR_USERNAME: ${{ github.actor }}
|
GHCR_USERNAME: ${{ github.actor }}
|
||||||
@@ -131,19 +130,19 @@ jobs:
|
|||||||
- platform: arm64
|
- platform: arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: resolve source date
|
- name: resolve source date
|
||||||
id: source
|
id: source
|
||||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: fluxer_app_proxy/Dockerfile
|
file: fluxer_app_proxy/Dockerfile
|
||||||
@@ -155,7 +154,6 @@ jobs:
|
|||||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||||
SOURCE_SHA=${{ github.sha }}
|
SOURCE_SHA=${{ github.sha }}
|
||||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
|
||||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||||
APP_ASSETS_PLATFORM=linux/amd64
|
APP_ASSETS_PLATFORM=linux/amd64
|
||||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||||
@@ -173,15 +171,15 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -43,13 +43,13 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
build_version: ${{ steps.vars.outputs.build_version }}
|
build_version: ${{ steps.vars.outputs.build_version }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- name: set variables
|
- name: set variables
|
||||||
id: vars
|
id: vars
|
||||||
run: >-
|
run: >-
|
||||||
@@ -67,18 +67,18 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- name: prepare docker config
|
- name: prepare docker config
|
||||||
run: >-
|
run: >-
|
||||||
tools/ci/run.sh build-app-proxy
|
tools/ci/run.sh build-app-proxy
|
||||||
--step prepare_docker_config
|
--step prepare_docker_config
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- name: configure ghcr auth
|
- name: configure ghcr auth
|
||||||
env:
|
env:
|
||||||
GHCR_USERNAME: ${{ github.actor }}
|
GHCR_USERNAME: ${{ github.actor }}
|
||||||
@@ -131,13 +131,13 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- name: resolve source date
|
- name: resolve source date
|
||||||
id: source
|
id: source
|
||||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
@@ -145,7 +145,7 @@ jobs:
|
|||||||
run: >-
|
run: >-
|
||||||
tools/ci/run.sh build-app-proxy
|
tools/ci/run.sh build-app-proxy
|
||||||
--step prepare_docker_config
|
--step prepare_docker_config
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- name: configure ghcr auth
|
- name: configure ghcr auth
|
||||||
env:
|
env:
|
||||||
GHCR_USERNAME: ${{ github.actor }}
|
GHCR_USERNAME: ${{ github.actor }}
|
||||||
@@ -178,19 +178,19 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: resolve source date
|
- name: resolve source date
|
||||||
id: source
|
id: source
|
||||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: fluxer_app_proxy/Dockerfile
|
file: fluxer_app_proxy/Dockerfile
|
||||||
@@ -219,15 +219,15 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ on:
|
|||||||
- stable
|
- stable
|
||||||
- canary
|
- canary
|
||||||
default: stable
|
default: stable
|
||||||
test_build:
|
|
||||||
description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release).
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
build_version:
|
build_version:
|
||||||
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
|
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
|
||||||
required: false
|
required: false
|
||||||
@@ -32,13 +27,12 @@ permissions:
|
|||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }}
|
group: desktop-${{ inputs.channel }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CHANNEL: ${{ inputs.channel }}
|
CHANNEL: ${{ inputs.channel }}
|
||||||
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
|
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
|
||||||
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
meta:
|
meta:
|
||||||
@@ -53,19 +47,17 @@ jobs:
|
|||||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||||
channel: ${{ steps.meta.outputs.channel }}
|
channel: ${{ steps.meta.outputs.channel }}
|
||||||
build_channel: ${{ steps.meta.outputs.build_channel }}
|
build_channel: ${{ steps.meta.outputs.build_channel }}
|
||||||
test_build: ${{ steps.meta.outputs.test_build }}
|
|
||||||
s3_prefix: ${{ steps.meta.outputs.s3_prefix }}
|
|
||||||
source_sha: ${{ steps.meta.outputs.source_sha }}
|
source_sha: ${{ steps.meta.outputs.source_sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source
|
- name: Checkout source
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Create token
|
- name: Create token
|
||||||
id: create-token
|
id: create-token
|
||||||
@@ -85,7 +77,6 @@ jobs:
|
|||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||||
--step set_metadata
|
--step set_metadata
|
||||||
--channel "${{ inputs.channel }}"
|
--channel "${{ inputs.channel }}"
|
||||||
--test-build "${{ inputs.test_build }}"
|
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
name: Resolve build matrix
|
name: Resolve build matrix
|
||||||
@@ -98,12 +89,12 @@ jobs:
|
|||||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source
|
- name: Checkout source
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Build platform matrix
|
- name: Build platform matrix
|
||||||
id: set-matrix
|
id: set-matrix
|
||||||
@@ -113,7 +104,7 @@ jobs:
|
|||||||
--skip-targets "${{ inputs.skip_targets }}"
|
--skip-targets "${{ inputs.skip_targets }}"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }})
|
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||||
needs:
|
needs:
|
||||||
- meta
|
- meta
|
||||||
- matrix
|
- matrix
|
||||||
@@ -137,41 +128,34 @@ jobs:
|
|||||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
|
||||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
|
||||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||||
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
|
|
||||||
PLATFORM: ${{ matrix.platform }}
|
PLATFORM: ${{ matrix.platform }}
|
||||||
ARCH: ${{ matrix.arch }}
|
ARCH: ${{ matrix.arch }}
|
||||||
ELECTRON_ARCH: ${{ matrix.electron_arch }}
|
ELECTRON_ARCH: ${{ matrix.electron_arch }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout CI helpers
|
- name: Checkout CI helpers
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.meta.outputs.source_sha }}
|
ref: ${{ needs.meta.outputs.source_sha }}
|
||||||
path: _ci
|
path: _ci
|
||||||
|
|
||||||
- name: Checkout source
|
- name: Checkout source
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.meta.outputs.source_sha }}
|
ref: ${{ needs.meta.outputs.source_sha }}
|
||||||
path: source
|
path: source
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Set up Python (Windows)
|
- name: Set up Python (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.14"
|
||||||
|
|
||||||
- name: Ensure python3 command (Windows)
|
- name: Ensure python3 command (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -196,14 +180,14 @@ jobs:
|
|||||||
--step set_workdir_unix
|
--step set_workdir_unix
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26
|
||||||
|
|
||||||
- name: Set up pnpm via corepack
|
- name: Set up pnpm
|
||||||
run: >-
|
run: >-
|
||||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||||
--step setup_pnpm_corepack
|
--step setup_pnpm
|
||||||
|
|
||||||
- name: Resolve pnpm store path (Windows)
|
- name: Resolve pnpm store path (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -247,9 +231,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Rust toolchain (Unix)
|
- name: Set up Rust toolchain (Unix)
|
||||||
if: matrix.platform != 'windows'
|
if: matrix.platform != 'windows'
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||||
|
|
||||||
- name: Install MSVC ARM64 build tools
|
- name: Install MSVC ARM64 build tools
|
||||||
@@ -260,7 +244,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up MSVC env (Windows)
|
- name: Set up MSVC env (Windows)
|
||||||
if: matrix.platform == 'windows'
|
if: matrix.platform == 'windows'
|
||||||
uses: TheMrMilchmann/setup-msvc-dev@79dac248aac9d0059f86eae9d8b5bfab4e95e97c
|
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9
|
||||||
with:
|
with:
|
||||||
arch: ${{ matrix.arch == 'arm64' && 'amd64_arm64' || 'amd64' }}
|
arch: ${{ matrix.arch == 'arm64' && 'amd64_arm64' || 'amd64' }}
|
||||||
|
|
||||||
@@ -302,9 +286,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up .NET SDK (Windows)
|
- name: Set up .NET SDK (Windows)
|
||||||
if: matrix.platform == 'windows'
|
if: matrix.platform == 'windows'
|
||||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1
|
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68
|
||||||
with:
|
with:
|
||||||
dotnet-version: "8.0.x"
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
- name: Install Velopack CLI
|
- name: Install Velopack CLI
|
||||||
if: matrix.platform == 'windows'
|
if: matrix.platform == 'windows'
|
||||||
@@ -363,7 +347,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Azure login for Artifact Signing
|
- name: Azure login for Artifact Signing
|
||||||
if: matrix.platform == 'windows'
|
if: matrix.platform == 'windows'
|
||||||
uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43
|
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
@@ -477,6 +461,12 @@ jobs:
|
|||||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||||
--step prepare_artifacts_unix
|
--step prepare_artifacts_unix
|
||||||
|
|
||||||
|
- name: Build AppImage update feed (Linux)
|
||||||
|
if: matrix.platform == 'linux'
|
||||||
|
run: >-
|
||||||
|
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||||
|
--step build_appimage_update_feed
|
||||||
|
|
||||||
- name: Normalize updater YAML (macOS)
|
- name: Normalize updater YAML (macOS)
|
||||||
if: matrix.platform == 'macos'
|
if: matrix.platform == 'macos'
|
||||||
run: >-
|
run: >-
|
||||||
@@ -495,13 +485,23 @@ jobs:
|
|||||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||||
--step generate_checksums_windows
|
--step generate_checksums_windows
|
||||||
|
|
||||||
- name: Upload artifacts to S3 handoff
|
- name: Stage build artifacts
|
||||||
|
id: handoff
|
||||||
run: >-
|
run: >-
|
||||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||||
--step upload_handoff
|
--step stage_handoff
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||||
|
with:
|
||||||
|
name: ${{ steps.handoff.outputs.artifact_name }}
|
||||||
|
path: upload_staging
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
upload:
|
upload:
|
||||||
name: Upload to S3
|
name: Assemble desktop release assets
|
||||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||||
needs:
|
needs:
|
||||||
- meta
|
- meta
|
||||||
@@ -520,33 +520,26 @@ jobs:
|
|||||||
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||||
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
|
|
||||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
|
||||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
|
||||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
|
||||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
|
||||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source
|
- name: Checkout source
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.meta.outputs.source_sha }}
|
ref: ${{ needs.meta.outputs.source_sha }}
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Download S3 handoff artifacts
|
- name: Download build artifacts
|
||||||
run: >-
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
with:
|
||||||
--step download_handoff
|
path: artifacts
|
||||||
|
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
|
||||||
|
|
||||||
- name: Build S3 payload layout (+ manifest.json)
|
- name: Build payload layout (+ manifest.json)
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.meta.outputs.version }}
|
VERSION: ${{ needs.meta.outputs.version }}
|
||||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||||
@@ -555,42 +548,27 @@ jobs:
|
|||||||
--step build_payload
|
--step build_payload
|
||||||
|
|
||||||
- name: Prepare GitHub release assets
|
- name: Prepare GitHub release assets
|
||||||
if: needs.meta.outputs.test_build != 'true'
|
|
||||||
run: >-
|
run: >-
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||||
--step prepare_release_assets
|
--step prepare_release_assets
|
||||||
|
|
||||||
- name: Publish GitHub release descriptor
|
- name: Upload GitHub release assets
|
||||||
if: needs.meta.outputs.test_build != 'true'
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||||
run: >-
|
with:
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
name: fluxer-desktop-release-assets
|
||||||
--step publish_release_descriptor
|
path: release_assets
|
||||||
|
if-no-files-found: error
|
||||||
- name: Upload payload to S3
|
retention-days: 1
|
||||||
run: >-
|
compression-level: 0
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
|
||||||
--step upload_payload
|
|
||||||
|
|
||||||
- name: Upload GitHub release asset handoff
|
|
||||||
if: needs.meta.outputs.test_build != 'true'
|
|
||||||
run: >-
|
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
|
||||||
--step upload_release_assets
|
|
||||||
|
|
||||||
- name: Build summary
|
- name: Build summary
|
||||||
run: >-
|
run: >-
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||||
--step build_summary
|
--step build_summary
|
||||||
|
|
||||||
- name: Cleanup S3 handoff
|
|
||||||
if: ${{ success() }}
|
|
||||||
run: >-
|
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
|
||||||
--step cleanup_handoff
|
|
||||||
|
|
||||||
publish_release:
|
publish_release:
|
||||||
name: Publish GitHub desktop release
|
name: Publish GitHub desktop release
|
||||||
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
|
if: ${{ !cancelled() && needs.upload.result == 'success' }}
|
||||||
needs:
|
needs:
|
||||||
- meta
|
- meta
|
||||||
- upload
|
- upload
|
||||||
@@ -602,26 +580,22 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||||
VERSION: ${{ needs.meta.outputs.version }}
|
VERSION: ${{ needs.meta.outputs.version }}
|
||||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
|
||||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
|
||||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout source
|
- name: Checkout source
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.meta.outputs.source_sha }}
|
ref: ${{ needs.meta.outputs.source_sha }}
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Download GitHub release assets
|
- name: Download GitHub release assets
|
||||||
run: >-
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
with:
|
||||||
--step download_release_assets
|
name: fluxer-desktop-release-assets
|
||||||
|
path: release_assets
|
||||||
|
|
||||||
- name: Create token
|
- name: Create token
|
||||||
id: create-token
|
id: create-token
|
||||||
@@ -653,10 +627,3 @@ jobs:
|
|||||||
release_args+=(--prerelease)
|
release_args+=(--prerelease)
|
||||||
fi
|
fi
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||||
|
|
||||||
- name: Publish GitHub release readiness marker
|
|
||||||
env:
|
|
||||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
|
||||||
run: >-
|
|
||||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
|
||||||
--step publish_release_marker
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
name: build push
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
build-version:
|
||||||
|
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
approve:
|
||||||
|
name: approve build release
|
||||||
|
permissions: {}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
environment: builds
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: approved
|
||||||
|
run: echo "Build release approved."
|
||||||
|
|
||||||
|
image:
|
||||||
|
needs: approve
|
||||||
|
uses: ./.github/workflows/_build-image.yaml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
image: fluxer-push
|
||||||
|
dockerfile: fluxer_push/Dockerfile
|
||||||
|
build-version: ${{ inputs['build-version'] }}
|
||||||
@@ -19,22 +19,22 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout fluxer
|
- name: Checkout fluxer
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
name: Dispatch private marketing build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- fluxer_marketing
|
|
||||||
- Cargo.toml
|
|
||||||
- fluxer_common/**
|
|
||||||
- packages/fonts/manifest.json
|
|
||||||
- packages/fonts/NOTICE.md
|
|
||||||
- packages/fonts/LICENSE-IBM-PLEX.txt
|
|
||||||
- packages/fonts/css/locale-fallbacks.css
|
|
||||||
- packages/fonts/files/FluxerSans/**
|
|
||||||
- packages/fonts/files/FluxerMono/**
|
|
||||||
- packages/fonts/marketing/**
|
|
||||||
- packages/i18n/marketing/**
|
|
||||||
- fluxer_static/marketing/branding/**
|
|
||||||
- .github/workflows/dispatch-private-marketing-build.yaml
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: private-marketing-dispatch
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
metadata:
|
|
||||||
name: resolve exact private build metadata
|
|
||||||
if: github.repository == 'fluxerapp/fluxer'
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 5
|
|
||||||
outputs:
|
|
||||||
parent_sha: ${{ steps.inputs.outputs.parent_sha }}
|
|
||||||
gitlink_sha: ${{ steps.inputs.outputs.gitlink_sha }}
|
|
||||||
build_version: ${{ steps.inputs.outputs.build_version }}
|
|
||||||
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
|
|
||||||
steps:
|
|
||||||
- name: Create token
|
|
||||||
id: create-token
|
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
|
||||||
with:
|
|
||||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
|
||||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
|
||||||
owner: fluxerapp
|
|
||||||
repositories: fluxer
|
|
||||||
permission-contents: read
|
|
||||||
- name: Resolve trusted build inputs
|
|
||||||
id: inputs
|
|
||||||
env:
|
|
||||||
EVENT_AFTER: ${{ github.event.after }}
|
|
||||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
|
||||||
PARENT_SHA: ${{ github.sha }}
|
|
||||||
PUBLIC_REPOSITORY: ${{ github.repository }}
|
|
||||||
RUN_ID: ${{ github.run_id }}
|
|
||||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
|
||||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
|
||||||
[[ "$PUBLIC_REPOSITORY" == "fluxerapp/fluxer" ]]
|
|
||||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
[[ "$EVENT_AFTER" == "$PARENT_SHA" ]]
|
|
||||||
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
|
|
||||||
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
|
|
||||||
(( 10#$RUN_ATTEMPT <= 10 ))
|
|
||||||
|
|
||||||
main_sha="$(gh api "repos/$PUBLIC_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
|
|
||||||
[[ "$main_sha" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
main_comparison="$(gh api "repos/$PUBLIC_REPOSITORY/compare/$PARENT_SHA...$main_sha")"
|
|
||||||
main_status="$(jq -r .status <<<"$main_comparison")"
|
|
||||||
[[ "$main_status" == "identical" || "$main_status" == "ahead" ]]
|
|
||||||
[[ "$(jq -r .merge_base_commit.sha <<<"$main_comparison")" == "$PARENT_SHA" ]]
|
|
||||||
|
|
||||||
commit="$(gh api "repos/$PUBLIC_REPOSITORY/git/commits/$PARENT_SHA")"
|
|
||||||
[[ "$(jq -r .sha <<<"$commit")" == "$PARENT_SHA" ]]
|
|
||||||
tree_sha="$(jq -r .tree.sha <<<"$commit")"
|
|
||||||
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
entry="$(
|
|
||||||
gh api "repos/$PUBLIC_REPOSITORY/git/trees/$tree_sha" |
|
|
||||||
jq -cer '[.tree[] | select(.path == "fluxer_marketing")] | if length == 1 then .[0] else error("expected exactly one marketing gitlink") end'
|
|
||||||
)"
|
|
||||||
mode="$(jq -r .mode <<<"$entry")"
|
|
||||||
type="$(jq -r .type <<<"$entry")"
|
|
||||||
gitlink_sha="$(jq -r .sha <<<"$entry")"
|
|
||||||
path="$(jq -r .path <<<"$entry")"
|
|
||||||
if [[ "$mode" != "160000" || "$type" != "commit" || "$path" != "fluxer_marketing" || ! "$gitlink_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
||||||
echo "::error::Public parent does not contain a valid fluxer_marketing gitlink."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
run="$(gh api "repos/$PUBLIC_REPOSITORY/actions/runs/$RUN_ID")"
|
|
||||||
[[ "$(jq -r .id <<<"$run")" == "$RUN_ID" ]]
|
|
||||||
[[ "$(jq -r .run_attempt <<<"$run")" == "$RUN_ATTEMPT" ]]
|
|
||||||
[[ "$(jq -r .event <<<"$run")" == "push" ]]
|
|
||||||
[[ "$(jq -r .head_sha <<<"$run")" == "$PARENT_SHA" ]]
|
|
||||||
run_created_at="$(jq -r .created_at <<<"$run")"
|
|
||||||
[[ "$run_created_at" =~ ^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
|
||||||
run_created_epoch="$(date -u -d "$run_created_at" +%s)"
|
|
||||||
[[ "$run_created_epoch" =~ ^[1-9][0-9]*$ ]]
|
|
||||||
build_epoch=$((run_created_epoch + 10#$RUN_ATTEMPT - 1))
|
|
||||||
read -r year month day time_segment <<<"$(date -u -d "@$build_epoch" '+%Y %m %d %H%M%S')"
|
|
||||||
month="$((10#$month))"
|
|
||||||
micro="$((10#$time_segment))"
|
|
||||||
build_version="$year.$month$day.$micro"
|
|
||||||
[[ "$build_version" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
|
||||||
correlation_id="public-${RUN_ID}-${RUN_ATTEMPT}"
|
|
||||||
[[ "$correlation_id" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
|
||||||
{
|
|
||||||
echo "parent_sha=$PARENT_SHA"
|
|
||||||
echo "gitlink_sha=$gitlink_sha"
|
|
||||||
echo "build_version=$build_version"
|
|
||||||
echo "correlation_id=$correlation_id"
|
|
||||||
} >>"$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
dispatch:
|
|
||||||
name: dispatch exact private build
|
|
||||||
needs: metadata
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
timeout-minutes: 65
|
|
||||||
environment: private-marketing-dispatch
|
|
||||||
permissions: {}
|
|
||||||
steps:
|
|
||||||
- name: Validate trusted build inputs
|
|
||||||
env:
|
|
||||||
DISPATCH_ENABLED: ${{ vars.MARKETING_DISPATCH_ENABLED }}
|
|
||||||
EXPECTED_PARENT_SHA: ${{ github.sha }}
|
|
||||||
EXPECTED_CORRELATION_ID: public-${{ github.run_id }}-${{ github.run_attempt }}
|
|
||||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
|
||||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
|
||||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
|
||||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
|
||||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
|
||||||
[[ "$GITHUB_REPOSITORY" == "fluxerapp/fluxer" ]]
|
|
||||||
[[ "$PARENT_SHA" == "$EXPECTED_PARENT_SHA" ]]
|
|
||||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
[[ "$GITLINK_SHA" =~ ^[0-9a-f]{40}$ ]]
|
|
||||||
[[ "$BUILD_VERSION" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
|
||||||
[[ "$CORRELATION_ID" == "$EXPECTED_CORRELATION_ID" ]]
|
|
||||||
[[ "$CORRELATION_ID" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
|
||||||
if [[ "$DISPATCH_ENABLED" != "true" ]]; then
|
|
||||||
echo "::error::Private marketing dispatch is intentionally disabled until the package cutover guard completes."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create private dispatch token
|
|
||||||
id: private-token
|
|
||||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
|
||||||
with:
|
|
||||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
|
||||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
|
||||||
owner: fluxerapp
|
|
||||||
repositories: marketing
|
|
||||||
permission-actions: write
|
|
||||||
|
|
||||||
- name: Dispatch exact private build
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
|
||||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
|
||||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
|
||||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
|
||||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
gh api --method POST repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/dispatches \
|
|
||||||
--field ref=main \
|
|
||||||
--field "inputs[parent_sha]=$PARENT_SHA" \
|
|
||||||
--field "inputs[gitlink_sha]=$GITLINK_SHA" \
|
|
||||||
--field "inputs[build_version]=$BUILD_VERSION" \
|
|
||||||
--field "inputs[correlation_id]=$CORRELATION_ID"
|
|
||||||
|
|
||||||
- name: Wait for private build conclusion
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
|
||||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
|
||||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
|
||||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
|
||||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
expected_title="marketing-build correlation=$CORRELATION_ID parent=$PARENT_SHA gitlink=$GITLINK_SHA version=$BUILD_VERSION"
|
|
||||||
deadline=$((SECONDS + 3600))
|
|
||||||
run_id=""
|
|
||||||
while (( SECONDS < deadline )); do
|
|
||||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
|
||||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
|
||||||
count="$(jq 'length' <<<"$matches")"
|
|
||||||
if [[ "$count" == "1" ]]; then
|
|
||||||
run_id="$(jq -r '.[0].id' <<<"$matches")"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "$count" != "0" ]]; then
|
|
||||||
echo "::error::Private build correlation matched multiple workflow runs."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
if [[ -z "$run_id" ]]; then
|
|
||||||
echo "::error::Timed out waiting for the private build dispatch to appear."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
while (( SECONDS < deadline )); do
|
|
||||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
|
||||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
|
||||||
if [[ "$(jq 'length' <<<"$matches")" != "1" || "$(jq -r '.[0].id' <<<"$matches")" != "$run_id" ]]; then
|
|
||||||
echo "::error::Private build correlation is missing or ambiguous."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
run="$(jq '.[0]' <<<"$matches")"
|
|
||||||
status="$(jq -r '.status' <<<"$run")"
|
|
||||||
conclusion="$(jq -r '.conclusion // empty' <<<"$run")"
|
|
||||||
if [[ "$status" == "completed" ]]; then
|
|
||||||
if [[ "$conclusion" != "success" ]]; then
|
|
||||||
echo "::error::Private marketing build concluded with $conclusion."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Private marketing build completed successfully."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 15
|
|
||||||
done
|
|
||||||
echo "::error::Timed out waiting for the private marketing build."
|
|
||||||
exit 1
|
|
||||||
@@ -35,24 +35,24 @@ jobs:
|
|||||||
permission-pull-requests: write
|
permission-pull-requests: write
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.create-token.outputs.token }}
|
token: ${{ steps.create-token.outputs.token }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Rust toolchain
|
- name: Set up Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "26"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
permission-pull-requests: write
|
permission-pull-requests: write
|
||||||
|
|
||||||
- name: Checkout Weblate branch
|
- name: Checkout Weblate branch
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.create-token.outputs.token }}
|
token: ${{ steps.create-token.outputs.token }}
|
||||||
ref: ${{ env.WEBLATE_BRANCH }}
|
ref: ${{ env.WEBLATE_BRANCH }}
|
||||||
@@ -48,17 +48,17 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Rust toolchain
|
- name: Set up Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "26"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -77,14 +77,14 @@ jobs:
|
|||||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||||
echo "No generated catalog changes."
|
echo "No generated catalog changes."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
git config user.name "fluxer-ci[bot]"
|
git config user.name "fluxer-ci[bot]"
|
||||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||||
git add fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||||
git commit -m "i18n: compile Weblate catalogs"
|
git commit -m "i18n: compile Weblate catalogs"
|
||||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||||
git push origin "HEAD:$WEBLATE_BRANCH"
|
git push origin "HEAD:$WEBLATE_BRANCH"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
permission-pull-requests: write
|
permission-pull-requests: write
|
||||||
|
|
||||||
- name: Label pull request
|
- name: Label pull request
|
||||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213
|
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ steps.create-token.outputs.token }}
|
repo-token: ${{ steps.create-token.outputs.token }}
|
||||||
configuration-path: .github/labeller.yaml
|
configuration-path: .github/labeller.yaml
|
||||||
|
|||||||
@@ -56,15 +56,15 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
packages: read
|
packages: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
env:
|
env:
|
||||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069
|
||||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ jobs:
|
|||||||
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
targets: wasm32-unknown-unknown
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Restore ci helper
|
- name: Restore ci helper
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: target/debug/fluxer-ci
|
path: target/debug/fluxer-ci
|
||||||
key: >-
|
key: >-
|
||||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||||
|
|
||||||
- name: Build ci helper
|
- name: Build ci helper
|
||||||
@@ -55,16 +55,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: target/debug/fluxer-ci
|
path: target/debug/fluxer-ci
|
||||||
key: >-
|
key: >-
|
||||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -83,12 +83,12 @@ jobs:
|
|||||||
PNPM_TEST_WORKSPACE_CONCURRENCY: '2'
|
PNPM_TEST_WORKSPACE_CONCURRENCY: '2'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
targets: wasm32-unknown-unknown
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Restore ci helper
|
- name: Restore ci helper
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: target/debug/fluxer-ci
|
path: target/debug/fluxer-ci
|
||||||
key: >-
|
key: >-
|
||||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||||
|
|
||||||
- name: Build ci helper
|
- name: Build ci helper
|
||||||
@@ -105,12 +105,12 @@ jobs:
|
|||||||
run: cargo build --locked --package fluxer-ci
|
run: cargo build --locked --package fluxer-ci
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -123,12 +123,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
fluxer_app/pkgs/libfluxcore
|
fluxer_app/pkgs/libfluxcore
|
||||||
|
fluxer_app/pkgs/libfluxwebp
|
||||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||||
key: >-
|
key: >-
|
||||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||||
|
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||||
'packages/markdown_parser/rust/src/**') }}
|
'packages/markdown_parser/rust/src/**') }}
|
||||||
|
|
||||||
@@ -142,12 +146,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
fluxer_app/pkgs/libfluxcore
|
fluxer_app/pkgs/libfluxcore
|
||||||
|
fluxer_app/pkgs/libfluxwebp
|
||||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||||
key: >-
|
key: >-
|
||||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||||
|
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||||
'packages/markdown_parser/rust/src/**') }}
|
'packages/markdown_parser/rust/src/**') }}
|
||||||
|
|
||||||
@@ -156,21 +164,21 @@ jobs:
|
|||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
@@ -185,11 +193,14 @@ jobs:
|
|||||||
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
|
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
|
||||||
|
|
||||||
- name: Install cargo-deny
|
- name: Install cargo-deny
|
||||||
run: cargo install cargo-deny --version 0.19.6 --locked
|
run: cargo install cargo-deny --version 0.20.2 --locked
|
||||||
|
|
||||||
- name: Check Rust dependencies
|
- name: Check Rust dependencies
|
||||||
run: cargo deny --locked check -D warnings
|
run: cargo deny --locked check -D warnings
|
||||||
|
|
||||||
|
- name: Check libfluxwebp dependencies
|
||||||
|
run: cargo deny --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml --config deny.toml --locked check licenses bans sources
|
||||||
|
|
||||||
- name: Check desktop native dependencies
|
- name: Check desktop native dependencies
|
||||||
run: tools/ci/check-desktop-native-workspaces.sh dependencies
|
run: tools/ci/check-desktop-native-workspaces.sh dependencies
|
||||||
|
|
||||||
@@ -242,6 +253,9 @@ jobs:
|
|||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: cargo fmt --all -- --check
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Check formatting (libfluxwebp)
|
||||||
|
run: cargo fmt --manifest-path fluxer_app/rust/libfluxwebp/Cargo.toml -- --check
|
||||||
|
|
||||||
- name: Check formatting (desktop native workspaces)
|
- name: Check formatting (desktop native workspaces)
|
||||||
run: tools/ci/check-desktop-native-workspaces.sh fmt
|
run: tools/ci/check-desktop-native-workspaces.sh fmt
|
||||||
|
|
||||||
@@ -273,12 +287,12 @@ jobs:
|
|||||||
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
|
|
||||||
- name: Cache cargo (gateway NIFs)
|
- name: Cache cargo (gateway NIFs)
|
||||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
||||||
@@ -294,7 +308,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: target/debug/fluxer-ci
|
path: target/debug/fluxer-ci
|
||||||
key: >-
|
key: >-
|
||||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||||
|
|
||||||
- name: Build ci helper
|
- name: Build ci helper
|
||||||
@@ -305,7 +319,7 @@ jobs:
|
|||||||
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
|
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
|
||||||
with:
|
with:
|
||||||
otp-version: '28'
|
otp-version: '28'
|
||||||
rebar3-version: '3.24.0'
|
rebar3-version: '3.27.0'
|
||||||
|
|
||||||
- name: Restore rebar3 dependencies
|
- name: Restore rebar3 dependencies
|
||||||
id: rebar3-cache
|
id: rebar3-cache
|
||||||
@@ -317,10 +331,13 @@ jobs:
|
|||||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||||
key: >-
|
key: >-
|
||||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||||
'fluxer_gateway/rebar.config') }}
|
'fluxer_gateway/rebar.config') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-
|
rebar3-${{ runner.os }}-otp28-rebar3.27.0-
|
||||||
|
|
||||||
|
- name: Drop restored gateway build output
|
||||||
|
run: rm -rf fluxer_gateway/_build/default/lib/fluxer_gateway fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||||
|
|
||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: |
|
run: |
|
||||||
@@ -348,7 +365,7 @@ jobs:
|
|||||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||||
key: >-
|
key: >-
|
||||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
rebar3-${{ runner.os }}-otp28-rebar3.27.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||||
'fluxer_gateway/rebar.config') }}
|
'fluxer_gateway/rebar.config') }}
|
||||||
|
|
||||||
knip:
|
knip:
|
||||||
@@ -358,12 +375,12 @@ jobs:
|
|||||||
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
FLUXER_CI_BIN: ${{ github.workspace }}/target/debug/fluxer-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Set up Rust toolchain (CI helpers)
|
- name: Set up Rust toolchain (CI helpers)
|
||||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
|
||||||
with:
|
with:
|
||||||
toolchain: "1.93.0"
|
toolchain: "1.98.1"
|
||||||
targets: wasm32-unknown-unknown
|
targets: wasm32-unknown-unknown
|
||||||
|
|
||||||
- name: Restore ci helper
|
- name: Restore ci helper
|
||||||
@@ -372,7 +389,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: target/debug/fluxer-ci
|
path: target/debug/fluxer-ci
|
||||||
key: >-
|
key: >-
|
||||||
fluxer-ci-bin-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||||
|
|
||||||
- name: Build ci helper
|
- name: Build ci helper
|
||||||
@@ -380,12 +397,12 @@ jobs:
|
|||||||
run: cargo build --locked --package fluxer-ci
|
run: cargo build --locked --package fluxer-ci
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -398,12 +415,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
fluxer_app/pkgs/libfluxcore
|
fluxer_app/pkgs/libfluxcore
|
||||||
|
fluxer_app/pkgs/libfluxwebp
|
||||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||||
key: >-
|
key: >-
|
||||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||||
|
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||||
'packages/markdown_parser/rust/src/**') }}
|
'packages/markdown_parser/rust/src/**') }}
|
||||||
|
|
||||||
@@ -417,12 +438,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
fluxer_app/pkgs/libfluxcore
|
fluxer_app/pkgs/libfluxcore
|
||||||
|
fluxer_app/pkgs/libfluxwebp
|
||||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||||
key: >-
|
key: >-
|
||||||
app-wasm-${{ runner.os }}-1.93.0-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||||
|
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||||
|
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||||
'packages/markdown_parser/rust/src/**') }}
|
'packages/markdown_parser/rust/src/**') }}
|
||||||
|
|
||||||
@@ -431,15 +456,15 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -456,15 +481,15 @@ jobs:
|
|||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -490,15 +515,15 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '26'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -515,12 +540,12 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.14"
|
||||||
|
|
||||||
- name: Install font tooling
|
- name: Install font tooling
|
||||||
run: python3 -m pip install -r tools/fonts/requirements.txt
|
run: python3 -m pip install -r tools/fonts/requirements.txt
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
/.direnv/
|
/.direnv/
|
||||||
/.fluxer/
|
/.fluxer/
|
||||||
/.pnpm-store/
|
/.pnpm-store/
|
||||||
|
/.vscode/
|
||||||
|
|
||||||
**/*.css.d.ts
|
**/*.css.d.ts
|
||||||
**/*.tsbuildinfo
|
**/*.tsbuildinfo
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
|
|
||||||
/fluxer_app/.devserver-cache.json
|
/fluxer_app/.devserver-cache.json
|
||||||
/fluxer_app/pkgs/libfluxcore/
|
/fluxer_app/pkgs/libfluxcore/
|
||||||
|
/fluxer_app/pkgs/libfluxwebp/
|
||||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||||
/fluxer_app/src/features/theme/styles/generated/
|
/fluxer_app/src/features/theme/styles/generated/
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
[submodule "fluxer_marketing"]
|
|
||||||
path = fluxer_marketing
|
|
||||||
url = https://github.com/fluxerapp/marketing.git
|
|
||||||
update = none
|
|
||||||
Generated
+878
-863
File diff suppressed because it is too large.
Load diff
+1
-2
@@ -7,9 +7,9 @@ members = [
|
|||||||
"fluxer_gifs",
|
"fluxer_gifs",
|
||||||
"fluxer_svc",
|
"fluxer_svc",
|
||||||
"fluxer_messages",
|
"fluxer_messages",
|
||||||
|
"fluxer_push",
|
||||||
"fluxer_snowflakes",
|
"fluxer_snowflakes",
|
||||||
"tools/ci",
|
"tools/ci",
|
||||||
"tools/content/update-frozen-snapshot",
|
|
||||||
"tools/dev",
|
"tools/dev",
|
||||||
"tools/i18n_auto",
|
"tools/i18n_auto",
|
||||||
"fluxer_users",
|
"fluxer_users",
|
||||||
@@ -17,7 +17,6 @@ members = [
|
|||||||
"packages/markdown_parser/rust",
|
"packages/markdown_parser/rust",
|
||||||
]
|
]
|
||||||
exclude = [
|
exclude = [
|
||||||
"fluxer_marketing",
|
|
||||||
"packages/markdown_parser/rust/fuzz",
|
"packages/markdown_parser/rust/fuzz",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
@@ -6,18 +6,173 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://fluxer.app/donate">
|
<a href="https://fluxer.app/download">
|
||||||
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
<img src="https://img.shields.io/badge/Download-fluxer.app-4641D9" alt="Download" /></a>
|
||||||
<a href="https://docs.fluxer.app">
|
<a href="https://docs.fluxer.app">
|
||||||
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
|
<img src="https://img.shields.io/badge/Docs-docs.fluxer.app-blue" alt="Documentation" /></a>
|
||||||
|
<a href="https://fluxer.app/donate">
|
||||||
|
<img src="https://img.shields.io/badge/Donate-fluxer.app%2Fdonate-brightgreen" alt="Donate" /></a>
|
||||||
<a href="./LICENSE">
|
<a href="./LICENSE">
|
||||||
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
|
<img src="https://img.shields.io/badge/License-AGPLv3-purple" alt="AGPLv3 License" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://flathub.org/apps/app.fluxer.Fluxer">
|
||||||
|
<img src="https://dl.flathub.org/assets/badges/flathub-badge-en.svg" alt="Get it on Flathub" height="60" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
# Fluxer
|
# Fluxer
|
||||||
|
|
||||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
|
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
| Windows | macOS | Linux | Android | iOS |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| [Installer (x64)][win-setup-x64] | [Disk image][mac-dmg] | [Flathub][flathub] | [Google Play (beta)][android-play] | [TestFlight][ios-testflight] |
|
||||||
|
| [Installer (ARM64)][win-setup-arm64] | | [deb (x64)][linux-deb-x64] | [APK (beta)][android-apk] | |
|
||||||
|
| [Portable (x64)][win-portable-x64] | | [deb (ARM64)][linux-deb-arm64] | [Obtainium (beta)][obtainium] | |
|
||||||
|
| [Portable (ARM64)][win-portable-arm64] | | [rpm (x64)][linux-rpm-x64] | | |
|
||||||
|
| | | [rpm (ARM64)][linux-rpm-arm64] | | |
|
||||||
|
| | | [AppImage (x64)][linux-appimage-x64] | | |
|
||||||
|
| | | [AppImage (ARM64)][linux-appimage-arm64] | | |
|
||||||
|
| | | [tar.gz (x64)][linux-targz-x64] | | |
|
||||||
|
| | | [tar.gz (ARM64)][linux-targz-arm64] | | |
|
||||||
|
|
||||||
|
The macOS disk image runs on both Apple silicon and Intel. Windows and Linux need the build matching your processor.
|
||||||
|
|
||||||
|
On Linux, prefer a repository over a single file so Fluxer updates with the rest of your system.
|
||||||
|
|
||||||
|
## Linux package repositories
|
||||||
|
|
||||||
|
The package is `fluxer` for stable and `fluxer-canary` for canary. apt and dnf subscribe to one channel per entry file. pacman and Flatpak serve both from one repository.
|
||||||
|
|
||||||
|
### Flatpak
|
||||||
|
|
||||||
|
Stable is on [Flathub][flathub], the easiest route on most desktops:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flatpak install flathub app.fluxer.Fluxer
|
||||||
|
```
|
||||||
|
|
||||||
|
Flathub has stable only. To use Fluxer's own repository, open [the stable][flatpak-ref] or [the canary][flatpak-canary-ref] reference file and your software manager takes over. Some desktops also accept `flatpak+https://pkgs.fluxer.com/flatpak/fluxer.flatpakref` in the address bar.
|
||||||
|
|
||||||
|
From a terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flatpak install https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debian and Ubuntu
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -d -m 0755 /etc/apt/keyrings
|
||||||
|
sudo curl -fsSL -o /etc/apt/keyrings/fluxer-archive-keyring.gpg https://pkgs.fluxer.com/keys/fluxer-archive-keyring.gpg
|
||||||
|
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer.sources https://pkgs.fluxer.com/deb/fluxer.sources
|
||||||
|
sudo apt update && sudo apt install fluxer
|
||||||
|
```
|
||||||
|
|
||||||
|
For canary, use the canary entry file and package.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo curl -fsSL -o /etc/apt/sources.list.d/fluxer-canary.sources https://pkgs.fluxer.com/deb/fluxer-canary.sources
|
||||||
|
sudo apt update && sudo apt install fluxer-canary
|
||||||
|
```
|
||||||
|
|
||||||
|
A `.deb` installed from a download only updates once its channel's entry is added.
|
||||||
|
|
||||||
|
### Fedora and RHEL
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo curl -fsSL -o /etc/yum.repos.d/fluxer.repo https://pkgs.fluxer.com/rpm/fluxer.repo
|
||||||
|
sudo dnf install fluxer
|
||||||
|
```
|
||||||
|
|
||||||
|
For canary, use the canary entry file and package.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo curl -fsSL -o /etc/yum.repos.d/fluxer-canary.repo https://pkgs.fluxer.com/rpm/fluxer-canary.repo
|
||||||
|
sudo dnf install fluxer-canary
|
||||||
|
```
|
||||||
|
|
||||||
|
RHEL, Rocky, Alma and CentOS Stream need `sudo dnf install epel-release` first, because their base repositories lack `libXScrnSaver`. Fedora does not.
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
The repository is signed, so pacman needs the key once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pacman-key --init
|
||||||
|
curl -fsSL -o /tmp/fluxer-archive-keyring.asc https://pkgs.fluxer.com/keys/fluxer-archive-keyring.asc
|
||||||
|
sudo pacman-key --add /tmp/fluxer-archive-keyring.asc
|
||||||
|
sudo pacman-key --lsign-key 09D01339EE128925F75E675C855C5BDE34D205D2
|
||||||
|
```
|
||||||
|
|
||||||
|
`--lsign-key` is what makes pacman trust it. Then add the repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo tee -a /etc/pacman.conf >/dev/null <<'REPO'
|
||||||
|
|
||||||
|
[fluxer]
|
||||||
|
SigLevel = Required TrustedOnly
|
||||||
|
Server = https://pkgs.fluxer.com/arch/$repo/os/$arch
|
||||||
|
REPO
|
||||||
|
sudo pacman -Syu fluxer
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `$repo` and `$arch` literally. Both are pacman variables, not shell ones, hence the quoted heredoc.
|
||||||
|
|
||||||
|
Full setup notes, including canary, are in the [Linux repositories documentation][docs-linux].
|
||||||
|
|
||||||
|
## Other ways to run it
|
||||||
|
|
||||||
|
- [Open Fluxer in a browser](https://web.fluxer.app), no install needed.
|
||||||
|
- [Host your own instance][docs-selfhost] from this repository.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Documentation home][docs]
|
||||||
|
- [Downloads][docs-downloads]
|
||||||
|
- [Self-hosting][docs-selfhost]
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||||
|
|
||||||
|
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||||
|
imagery are copyright Fluxer, all rights reserved, as set out in
|
||||||
|
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
|
||||||
|
terms, listed in
|
||||||
|
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||||
|
|
||||||
|
Public availability of this repository does not grant trademark, brand, or
|
||||||
|
endorsement rights.
|
||||||
|
|
||||||
|
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||||
|
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||||
|
[win-portable-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/portable
|
||||||
|
[win-portable-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/portable
|
||||||
|
[mac-dmg]: https://pkgs.fluxer.com/desktop/stable/darwin/arm64/latest/dmg
|
||||||
|
[linux-deb-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/deb
|
||||||
|
[linux-deb-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/deb
|
||||||
|
[linux-rpm-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/rpm
|
||||||
|
[linux-rpm-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/rpm
|
||||||
|
[linux-appimage-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/appimage
|
||||||
|
[linux-appimage-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/appimage
|
||||||
|
[linux-targz-x64]: https://pkgs.fluxer.com/desktop/stable/linux/x64/latest/tar_gz
|
||||||
|
[linux-targz-arm64]: https://pkgs.fluxer.com/desktop/stable/linux/arm64/latest/tar_gz
|
||||||
|
[flatpak-ref]: https://pkgs.fluxer.com/flatpak/fluxer.flatpakref
|
||||||
|
[flatpak-canary-ref]: https://pkgs.fluxer.com/flatpak/fluxer-canary.flatpakref
|
||||||
|
[flathub]: https://flathub.org/apps/app.fluxer.Fluxer
|
||||||
|
[android-play]: https://play.google.com/store/apps/details?id=com.fluxer
|
||||||
|
[android-apk]: https://github.com/fluxerapp/flutter_client/releases
|
||||||
|
[obtainium]: https://obtainium.imranr.dev/
|
||||||
|
[ios-testflight]: https://testflight.apple.com/join/PKZR6pK9
|
||||||
|
[docs]: https://docs.fluxer.app
|
||||||
|
[docs-downloads]: https://docs.fluxer.app/downloads/overview/
|
||||||
|
[docs-linux]: https://docs.fluxer.app/downloads/linux-repositories/
|
||||||
|
[docs-selfhost]: https://docs.fluxer.app/operator/get-started/
|
||||||
+3
-2
@@ -48,7 +48,7 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"preset": "recommended",
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off",
|
"noForEach": "off",
|
||||||
"noImportantStyles": "off",
|
"noImportantStyles": "off",
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"useConst": "error",
|
"useConst": "error",
|
||||||
|
"noDescendingSpecificity": "off",
|
||||||
"noNonNullAssertion": "off",
|
"noNonNullAssertion": "off",
|
||||||
"noParameterAssign": "off",
|
"noParameterAssign": "off",
|
||||||
"noRestrictedImports": {
|
"noRestrictedImports": {
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"recommended": true,
|
"preset": "recommended",
|
||||||
"useAriaPropsForRole": "error",
|
"useAriaPropsForRole": "error",
|
||||||
"useValidAriaRole": "error",
|
"useValidAriaRole": "error",
|
||||||
"useValidAriaValues": "error",
|
"useValidAriaValues": "error",
|
||||||
|
|||||||
Vendored
+1
-6
@@ -52,7 +52,6 @@ FLUXER_S3_SECRET_ACCESS_KEY=fluxer-secret
|
|||||||
FLUXER_S3_FORCE_PATH_STYLE=true
|
FLUXER_S3_FORCE_PATH_STYLE=true
|
||||||
FLUXER_S3_BUCKET_CDN=fluxer
|
FLUXER_S3_BUCKET_CDN=fluxer
|
||||||
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||||
FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
|
||||||
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||||
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||||
FLUXER_S3_BUCKET_STATIC=fluxer-static
|
FLUXER_S3_BUCKET_STATIC=fluxer-static
|
||||||
@@ -68,7 +67,6 @@ FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitud
|
|||||||
FLUXER_API_PORT=8080
|
FLUXER_API_PORT=8080
|
||||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
|
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED=true
|
||||||
FLUXER_API_WORKER_MODE=all_lanes
|
FLUXER_API_WORKER_MODE=all_lanes
|
||||||
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION=true
|
|
||||||
FLUXER_APP_DEV_PORT=3000
|
FLUXER_APP_DEV_PORT=3000
|
||||||
FLUXER_APP_PROXY_PORT=8773
|
FLUXER_APP_PROXY_PORT=8773
|
||||||
FLUXER_STATIC_DIR=fluxer_app/dist
|
FLUXER_STATIC_DIR=fluxer_app/dist
|
||||||
@@ -91,10 +89,6 @@ FLUXER_ADMIN_BASE_PATH=/admin
|
|||||||
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
|
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
|
||||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
|
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
|
||||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
||||||
FLUXER_MARKETING_PORT=3010
|
|
||||||
FLUXER_MARKETING_HOST=0.0.0.0
|
|
||||||
FLUXER_MARKETING_BASE_PATH=/marketing
|
|
||||||
FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
|
|
||||||
|
|
||||||
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
||||||
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
||||||
@@ -135,6 +129,7 @@ PUBLIC_RELEASE_CHANNEL=canary
|
|||||||
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
|
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
|
||||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
|
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
|
||||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
|
||||||
|
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
|
||||||
AWS_EC2_METADATA_DISABLED=true
|
AWS_EC2_METADATA_DISABLED=true
|
||||||
AWS_ACCESS_KEY_ID=fluxer
|
AWS_ACCESS_KEY_ID=fluxer
|
||||||
AWS_SECRET_ACCESS_KEY=fluxer-secret
|
AWS_SECRET_ACCESS_KEY=fluxer-secret
|
||||||
|
|||||||
@@ -79,34 +79,42 @@ deny = [
|
|||||||
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
|
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
|
||||||
]
|
]
|
||||||
skip = [
|
skip = [
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older crypto API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older crypto API" },
|
||||||
{ crate = "[email protected].7", reason = "transitive dependency requires the older digest API" },
|
{ crate = "[email protected].6", reason = "transitive dependency requires the older digest API" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior hashbrown API" },
|
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
|
||||||
{ crate = "[email protected].6", reason = "transitive dependency requires the older randomness API" },
|
{ crate = "[email protected].8", reason = "transitive dependency requires the older randomness API" },
|
||||||
{ crate = "[email protected].4", reason = "transitive dependency requires the prior randomness API" },
|
{ crate = "[email protected].5", reason = "transitive dependency requires the prior randomness API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||||
{ crate = "[email protected].6", reason = "transitive dependency requires the older digest API" },
|
{ crate = "[email protected].7", reason = "transitive dependency requires the older digest API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||||
{ crate = "s[email protected]0", reason = "transitive dependency requires the older socket API" },
|
{ crate = "s[email protected].0", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
|
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
|
||||||
|
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
|
{ crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior Windows API" },
|
|
||||||
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI binding API" },
|
|
||||||
]
|
]
|
||||||
skip-tree = []
|
skip-tree = []
|
||||||
|
|
||||||
|
|||||||
+118
-187
@@ -1,114 +1,64 @@
|
|||||||
# Every variable docker-compose.yml reads from this file is named here:
|
# Every variable docker-compose.yml reads, uncommented when it has no default and
|
||||||
# uncommented when it has no default, commented with its default when it has one.
|
# commented with its default when it has one. Compose expands top to bottom, so a
|
||||||
# A name absent from this file is one Compose does not forward, and it reaches a
|
# line using ${...} must sit below every name it reads.
|
||||||
# service only through a Compose override file that adds it to that service's
|
|
||||||
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
|
|
||||||
# a Compose edit forgets the matching line here. Compose expands this file from
|
|
||||||
# top to bottom, so a line written with ${...} has to sit below every name it
|
|
||||||
# reads.
|
|
||||||
|
|
||||||
FLUXER_DOMAIN=chat.example.com
|
FLUXER_DOMAIN=chat.example.com
|
||||||
FLUXER_PUBLIC_SCHEME=https
|
FLUXER_PUBLIC_SCHEME=https
|
||||||
FLUXER_PUBLIC_PORT=443
|
FLUXER_PUBLIC_PORT=443
|
||||||
|
|
||||||
# The three lines above are the address browsers use, and every endpoint the
|
# The address browsers use. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below decide
|
||||||
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
|
# which host ports Fluxer binds.
|
||||||
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
|
|
||||||
# do that, and a non-default port needs the matching one set as well. Both
|
|
||||||
# complete recipes are written out beside them.
|
|
||||||
|
|
||||||
# How browsers reach this instance.
|
# By default Fluxer binds 80 and 443 and gets its own certificate. Point DNS here.
|
||||||
#
|
# Behind your own reverse proxy, uncomment this instead: Fluxer then serves plain
|
||||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
# HTTP on 127.0.0.1:8080. Keep the scheme and port above describing the public
|
||||||
# Point DNS at this host and there is nothing else to configure.
|
# address, not this one.
|
||||||
#
|
|
||||||
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
|
|
||||||
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
|
|
||||||
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
|
|
||||||
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
|
|
||||||
# proxy serves, not this local port.
|
|
||||||
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||||
|
|
||||||
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
|
# Where that plain-HTTP port binds. Use 0.0.0.0:8080 only when the proxy is on
|
||||||
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
|
# another machine, and firewall it to that machine.
|
||||||
# is on another machine, and firewall the port to that machine.
|
|
||||||
#FLUXER_EDGE_BIND=127.0.0.1:8080
|
#FLUXER_EDGE_BIND=127.0.0.1:8080
|
||||||
|
|
||||||
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
|
# Which hops may set X-Forwarded-For. The default covers private and loopback
|
||||||
# this to the real client address, so IP bans, rate limits and abuse detection
|
# addresses. Set your proxy's address if it reaches Fluxer from a public IP.
|
||||||
# see the caller rather than the proxy. The default covers proxies on private or
|
|
||||||
# loopback addresses, which is every same-host setup. Set it to your proxy's
|
|
||||||
# address if it reaches Fluxer from a public IP.
|
|
||||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||||
|
|
||||||
# The origin browsers see, without a trailing slash. Leave it unset and each
|
# The origin browsers see, no trailing slash. Set it when browsers reach the
|
||||||
# service builds one from the three values at the top of this file. Set it and it
|
# instance on a host FLUXER_DOMAIN does not name, and it wins over the three
|
||||||
# wins: every service reads the host, the scheme and the port out of it and
|
# values above. Scheme, host and optional port only. It does not move the
|
||||||
# ignores those three names. Use it when browsers reach the instance on a host
|
# published ports.
|
||||||
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
|
|
||||||
# and an optional port and nothing after them, or the services refuse to start.
|
|
||||||
# It does not move the edge listener or the published ports either, so set the
|
|
||||||
# publish below to the port written here.
|
|
||||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||||
|
|
||||||
# Overrides the address the edge listens on inside its container. Compose builds
|
# The address the edge listens on inside its container. Both proxy overlays set
|
||||||
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
|
# this themselves, so a value here is ignored under either. Include the scheme.
|
||||||
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
|
|
||||||
# site by host and ignores the port in the Host header, so a request arriving on
|
|
||||||
# a non-default published port still lands on this site. Put a port in this value
|
|
||||||
# only if you also publish that same container port below, or nothing will be
|
|
||||||
# listening where the publish points. Honoured in the default mode only:
|
|
||||||
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
|
|
||||||
# literal :80, and Compose lets the last file win, so a value here is discarded
|
|
||||||
# under either overlay with no warning. Set it for an unusual default-mode
|
|
||||||
# layout, such as serving several hostnames. Write the scheme into it: a bare
|
|
||||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
|
||||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||||
|
|
||||||
# The old name for the value above. It is read only when
|
# The old name for the line above, read only when it is unset.
|
||||||
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
|
|
||||||
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
|
|
||||||
#FLUXER_CADDY_SITE_ADDRESS=
|
#FLUXER_CADDY_SITE_ADDRESS=
|
||||||
|
|
||||||
# Host side of the edge's publishes, and the only two names that decide which
|
# Host ports. Container 80 handles the redirect and the certificate challenge,
|
||||||
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
|
# container 443 the TLS site. FLUXER_HTTPS_PORT moves TCP and UDP together, since
|
||||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
# HTTP/3 needs both. Both accept a bind address. Give them different host ports.
|
||||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
|
||||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
|
||||||
# HTTP/3 needs both on the same port. Both take an optional bind address in front
|
|
||||||
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
|
|
||||||
# them different host ports: the same host port on both is two publishes of one
|
|
||||||
# port and the edge refuses to start.
|
|
||||||
#FLUXER_HTTP_PORT=80
|
#FLUXER_HTTP_PORT=80
|
||||||
#FLUXER_HTTPS_PORT=443
|
#FLUXER_HTTPS_PORT=443
|
||||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||||
|
|
||||||
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
|
# HTTPS on 8443. Host 80 stays published for the certificate challenge, which
|
||||||
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
|
# only ever arrives on public 80 or 443. Serve your own certificate if nothing
|
||||||
# certificate is issued if a router in front forwards public 80 to this host and
|
# forwards those.
|
||||||
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
|
|
||||||
# cannot.
|
|
||||||
#FLUXER_PUBLIC_PORT=8443
|
#FLUXER_PUBLIC_PORT=8443
|
||||||
#FLUXER_HTTPS_PORT=8443
|
#FLUXER_HTTPS_PORT=8443
|
||||||
|
|
||||||
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
|
# Plain HTTP on 19080. Nothing binds host 80, and the last line parks the idle
|
||||||
# binds host 80. Under an http scheme nothing listens on container 443, so the
|
# 443 publish on loopback.
|
||||||
# last line parks that publish on loopback for a host that wants 443 for
|
|
||||||
# something else. Drop it and 443 is published and idle, which is what earlier
|
|
||||||
# releases did.
|
|
||||||
#FLUXER_PUBLIC_SCHEME=http
|
#FLUXER_PUBLIC_SCHEME=http
|
||||||
#FLUXER_PUBLIC_PORT=19080
|
#FLUXER_PUBLIC_PORT=19080
|
||||||
#FLUXER_HTTP_PORT=19080
|
#FLUXER_HTTP_PORT=19080
|
||||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||||
|
|
||||||
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
|
# A tunnel needs no HTTPS publish. tunnel.compose.yml ships beside this file and
|
||||||
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
|
# leaves one loopback HTTP publish. Needs Compose 2.24.4 or newer.
|
||||||
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
|
|
||||||
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
|
|
||||||
# still moves that one publish. Set the line below and plain docker compose
|
|
||||||
# commands pick the file up, or add it to your own -f flags if you pass any. The
|
|
||||||
# file uses the !override tag, which needs Compose 2.24.4 or newer.
|
|
||||||
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
|
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
|
||||||
|
|
||||||
FLUXER_REGISTRY_OWNER=fluxerapp
|
FLUXER_REGISTRY_OWNER=fluxerapp
|
||||||
@@ -117,11 +67,8 @@ FLUXER_IMAGE_TAG=v1
|
|||||||
|
|
||||||
POSTGRES_PASSWORD=CHANGE_ME
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
MEILI_MASTER_KEY=CHANGE_ME
|
MEILI_MASTER_KEY=CHANGE_ME
|
||||||
# The stack ships its own Postgres and its own object store, and points at both
|
# Set these to run Postgres or the object store outside the stack. Backing up a
|
||||||
# by service name. Set these to run either one outside the stack. Leave them
|
# store you moved out is yours to arrange, and an upgrade skips it.
|
||||||
# unset and the bundled services are used. Taking a service out of the stack
|
|
||||||
# means an upgrade skips the backup step that reaches into it, and backing that
|
|
||||||
# store up belongs to whoever runs it.
|
|
||||||
#FLUXER_POSTGRES_HOST=db.example.com
|
#FLUXER_POSTGRES_HOST=db.example.com
|
||||||
#FLUXER_POSTGRES_PORT=5432
|
#FLUXER_POSTGRES_PORT=5432
|
||||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||||
@@ -131,19 +78,15 @@ MEILI_MASTER_KEY=CHANGE_ME
|
|||||||
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
||||||
#FLUXER_S3_REGION=eu-central-1
|
#FLUXER_S3_REGION=eu-central-1
|
||||||
#FLUXER_S3_FORCE_PATH_STYLE=false
|
#FLUXER_S3_FORCE_PATH_STYLE=false
|
||||||
# Bucket names. The bundled object store creates whichever names these hold, so
|
# Bucket names. The bundled store creates these. An outside store needs them to
|
||||||
# the two stay in step. An object store outside the stack needs the buckets to
|
|
||||||
# exist already.
|
# exist already.
|
||||||
#FLUXER_S3_BUCKET_CDN=fluxer
|
#FLUXER_S3_BUCKET_CDN=fluxer
|
||||||
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||||
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
|
||||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||||
|
|
||||||
# The rest of the bundled services, pointed somewhere else the same way. Leave a
|
# The other bundled services, pointed elsewhere. Removing a service from the
|
||||||
# line unset and the service in the stack is used. Taking a service out of the
|
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
|
||||||
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
|
|
||||||
# replaces docker-compose.yml.
|
|
||||||
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
||||||
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
||||||
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
||||||
@@ -151,24 +94,27 @@ MEILI_MASTER_KEY=CHANGE_ME
|
|||||||
#FLUXER_SEARCH_URL=https://search.example.com
|
#FLUXER_SEARCH_URL=https://search.example.com
|
||||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||||
|
|
||||||
# Voice off. The livekit service still runs until an override file takes it out.
|
# Voice off. The livekit service still runs until an override removes it.
|
||||||
#FLUXER_LIVEKIT_ENABLED=false
|
#FLUXER_LIVEKIT_ENABLED=false
|
||||||
|
|
||||||
# Optional systems, each off unless the instance is configured for it.
|
# Optional systems, each off unless configured.
|
||||||
#FLUXER_SMS_ENABLED=false
|
#FLUXER_SMS_ENABLED=false
|
||||||
#FLUXER_STRIPE_ENABLED=false
|
#FLUXER_STRIPE_ENABLED=false
|
||||||
#FLUXER_NCMEC_ENABLED=false
|
#FLUXER_NCMEC_ENABLED=false
|
||||||
#FLUXER_CLAMAV_ENABLED=false
|
#FLUXER_CLAMAV_ENABLED=false
|
||||||
|
|
||||||
# The client address. Set the header name a proxy in front actually writes, and
|
# Outside lookups, off unless turned on. The Tor exit list comes from
|
||||||
# turn the trust off when nothing sits in front, because a trusted header an
|
# onionoo.torproject.org and the breached password check asks
|
||||||
# attacker can set is a spoofed client address.
|
# api.pwnedpasswords.com.
|
||||||
|
#FLUXER_TOR_EXIT_LIST_ENABLED=true
|
||||||
|
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
|
||||||
|
|
||||||
|
# The client address. Name the header your proxy actually writes, and turn the
|
||||||
|
# trust off when nothing sits in front.
|
||||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||||
|
|
||||||
# How much the services write. trace, debug, info, warn, error or fatal. Every
|
# How much the services write. trace, debug, info, warn, error or fatal.
|
||||||
# service names the object storage endpoint and its addressing at info on start,
|
|
||||||
# so a bucket that answers 404 is visible without raising this.
|
|
||||||
#LOG_LEVEL=debug
|
#LOG_LEVEL=debug
|
||||||
|
|
||||||
FLUXER_S3_ACCESS_KEY=fluxer
|
FLUXER_S3_ACCESS_KEY=fluxer
|
||||||
@@ -183,32 +129,49 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
|||||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||||
|
|
||||||
# The token every service sends to NATS. The bundled NATS runs without
|
# The token every service sends to NATS. The bundled NATS needs none, so this
|
||||||
# authentication, so this stays empty unless a Compose override points the stack
|
# stays empty unless an override points at an external one.
|
||||||
# at an external NATS that requires a token. Compose forwards the name to every
|
|
||||||
# container that connects.
|
|
||||||
#FLUXER_NATS_AUTH_TOKEN=
|
#FLUXER_NATS_AUTH_TOKEN=
|
||||||
|
|
||||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||||
|
|
||||||
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
|
# Defaults to admin@ followed by FLUXER_DOMAIN. Set it if that mailbox does not
|
||||||
# only if that mailbox does not exist.
|
# exist.
|
||||||
#[email protected]
|
#[email protected]
|
||||||
|
|
||||||
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
|
# Passkeys follow FLUXER_DOMAIN. Set these only if browsers use another host.
|
||||||
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
|
# Changing the RP ID invalidates every passkey registered against the old value.
|
||||||
# invalidates every passkey already registered against the old value.
|
|
||||||
#FLUXER_PASSKEY_RP_ID=chat.example.com
|
#FLUXER_PASSKEY_RP_ID=chat.example.com
|
||||||
#FLUXER_PASSKEY_RP_NAME=Fluxer
|
#FLUXER_PASSKEY_RP_NAME=Fluxer
|
||||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
|
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
|
||||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
|
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
|
||||||
|
|
||||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
|
# Notification jobs the push container holds at once, 1 to 1000000.
|
||||||
# only when a browser must reach an origin the defaults do not cover, such as a
|
#FLUXER_PUSH_SERVICE_QUEUE_CAPACITY=10000
|
||||||
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
|
# Provider requests the push container sends at once, 1 to 65536.
|
||||||
# sources with spaces or commas. Every one of them is empty by default, and the
|
#FLUXER_PUSH_SERVICE_SEND_CONCURRENCY=256
|
||||||
# three carrying a value below are illustrations, not defaults.
|
|
||||||
|
|
||||||
|
# Optional media policies, both off by default. See the operator docs.
|
||||||
|
#
|
||||||
|
# CORS limits which web origins may read media. A request with no Origin is
|
||||||
|
# always served. Add https://web.fluxer.app if people use the hosted client.
|
||||||
|
#
|
||||||
|
# Signatures make an attachment read need a signed URL, so a copied link stops
|
||||||
|
# working. Needs a secret from openssl rand -base64 32, first entry signs and
|
||||||
|
# every entry verifies.
|
||||||
|
#
|
||||||
|
# Each mode is off, report or enforce. Start at report. media-proxy reads these
|
||||||
|
# at start, so apply with docker compose up -d media-proxy.
|
||||||
|
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
|
||||||
|
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
|
||||||
|
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
|
||||||
|
#FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE=enforce
|
||||||
|
|
||||||
|
# Extra Content-Security-Policy sources, appended to the built-in ones. Set one
|
||||||
|
# only when a browser must reach an origin the defaults do not cover. Separate
|
||||||
|
# several with spaces or commas. The three values below are illustrations.
|
||||||
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
|
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
|
||||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
||||||
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
||||||
@@ -220,39 +183,29 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
|||||||
#FLUXER_CSP_EXTRA_WORKER_SRC=
|
#FLUXER_CSP_EXTRA_WORKER_SRC=
|
||||||
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
|
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
|
||||||
|
|
||||||
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
|
# One report-uri for CSP violation reports. Empty leaves the directive off.
|
||||||
# directive off the header.
|
|
||||||
#FLUXER_CSP_REPORT_URI=
|
#FLUXER_CSP_REPORT_URI=
|
||||||
|
|
||||||
# Allow the SSO identity provider to resolve to a private or internal address.
|
# Let the SSO provider resolve to a private address. Off by default, so a
|
||||||
# Off by default: the API refuses to call non-public addresses so a misconfigured
|
# misconfigured provider URL cannot reach internal services. Turn it on only for
|
||||||
# provider URL cannot be used to reach internal services. Turn it on only when the
|
# a provider on your own network.
|
||||||
# provider genuinely lives on your own network, such as split-horizon DNS or a LAN
|
|
||||||
# identity provider, and only when you trust everyone who can configure SSO.
|
|
||||||
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
||||||
|
|
||||||
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
|
# These reach both LiveKit and the api. Change them together.
|
||||||
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
|
|
||||||
LIVEKIT_API_KEY=fluxer
|
LIVEKIT_API_KEY=fluxer
|
||||||
LIVEKIT_API_SECRET=CHANGE_ME
|
LIVEKIT_API_SECRET=CHANGE_ME
|
||||||
|
|
||||||
# The URL browsers use for voice signalling. Compose builds it from
|
# The URL browsers use for voice signalling. Built from the public origin plus
|
||||||
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
|
# /livekit. Set it only when LiveKit is served from another host.
|
||||||
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
|
|
||||||
# leading http to ws itself. Set it only when LiveKit is served from another
|
|
||||||
# host.
|
|
||||||
#FLUXER_LIVEKIT_URL=
|
#FLUXER_LIVEKIT_URL=
|
||||||
|
|
||||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
# Media ports. LiveKit advertises these, so forward the same numbers.
|
||||||
# forward the same numbers.
|
|
||||||
#FLUXER_LIVEKIT_TCP_PORT=7881
|
#FLUXER_LIVEKIT_TCP_PORT=7881
|
||||||
#FLUXER_LIVEKIT_UDP_PORT=7882
|
#FLUXER_LIVEKIT_UDP_PORT=7882
|
||||||
|
|
||||||
# LiveKit finds the address browsers dial by asking a STUN server. A host that
|
# LiveKit finds its public address over STUN. A host that cannot reach one stops
|
||||||
# cannot reach one over UDP stops with "could not resolve external IP", and the
|
# with "could not resolve external IP", so set the address by hand instead, or
|
||||||
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
|
# point STUN elsewhere.
|
||||||
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
|
|
||||||
# server to keep the lookup and leave Google out of it.
|
|
||||||
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
|
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
|
||||||
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
||||||
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
|
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
|
||||||
@@ -279,11 +232,9 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
|||||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||||
FLUXER_DISCOVERY_ENABLED=true
|
FLUXER_DISCOVERY_ENABLED=true
|
||||||
|
|
||||||
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
|
# Container memory. These are ceilings, not allocations, and the defaults suit a
|
||||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
# 16 GB host. The reservations bias the kernel away from reclaiming from services
|
||||||
# The four reservations are cgroup memory.low, which biases the kernel away from
|
# whose death takes the instance down. Lower the limits on a smaller host.
|
||||||
# reclaiming from the services whose death takes the whole instance down. They do
|
|
||||||
# not reserve anything. Lower the limits on a smaller host.
|
|
||||||
#FLUXER_CADDY_MEMORY_LIMIT=256mb
|
#FLUXER_CADDY_MEMORY_LIMIT=256mb
|
||||||
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
|
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
|
||||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||||
@@ -300,6 +251,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
|||||||
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
|
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
|
||||||
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
|
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
|
||||||
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
|
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
|
||||||
|
#FLUXER_PUSH_MEMORY_LIMIT=256mb
|
||||||
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
|
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
|
||||||
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
|
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
|
||||||
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
|
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
|
||||||
@@ -314,75 +266,54 @@ FLUXER_DISCOVERY_ENABLED=true
|
|||||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||||
|
|
||||||
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
|
# Meilisearch indexing memory. Keep it well under the container limit above.
|
||||||
# which is the container ceiling the indexer shares with the search process.
|
|
||||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||||
|
|
||||||
# SeaweedFS heap ceiling. Go collects against this value instead of against the
|
# SeaweedFS heap ceiling. Go cannot see the container limit, so without this an
|
||||||
# container limit, which it cannot see, so without it an upload burst grows the
|
# upload burst gets the container OOM-killed. Keep it near three quarters of
|
||||||
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
|
# FLUXER_SEAWEEDFS_MEMORY_LIMIT and raise both together.
|
||||||
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
|
|
||||||
# together: the peak is the parts of one upload in flight at once, which is 25 MB
|
|
||||||
# times 20 for a 500 MB attachment.
|
|
||||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||||
|
|
||||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
# Node sizes its heap from the container limit by default. Leave these unset
|
||||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
# unless you need to pin it. A heap ceiling above the container limit gets the
|
||||||
# outside the heap. Leave these unset unless you have a reason to pin the value.
|
# container OOM-killed instead of reporting a heap error.
|
||||||
# Any value set here must stay well below the container limit above: a heap ceiling
|
|
||||||
# above the container limit makes the kernel OOM-kill the container (exit 137, no
|
|
||||||
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
|
|
||||||
#FLUXER_API_NODE_HEAP_MB=1792
|
#FLUXER_API_NODE_HEAP_MB=1792
|
||||||
#FLUXER_WORKER_NODE_HEAP_MB=1792
|
#FLUXER_WORKER_NODE_HEAP_MB=1792
|
||||||
|
|
||||||
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
|
# Bundled Postgres tuning. Keep it consistent with the memory limit above. This
|
||||||
# budget roughly shared_buffers + (server max_connections x 12 MB) +
|
# is the server setting, not the per-service pool sizes.
|
||||||
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
|
|
||||||
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
|
|
||||||
# pool sizes used by the api, worker and shards.
|
|
||||||
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
|
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
|
||||||
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
||||||
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||||
#FLUXER_POSTGRES_WORK_MEM=8MB
|
#FLUXER_POSTGRES_WORK_MEM=8MB
|
||||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||||
|
#FLUXER_POSTGRES_SHM_SIZE=1gb
|
||||||
|
|
||||||
# The bundled Valkey holds durable state as well as cache. The bulk message
|
# The bundled Valkey holds durable state as well as cache, so it runs with an
|
||||||
# deletion queue and the account deletion queue are sorted sets with no expiry,
|
# append-only file and with noeviction, which fails an over-limit write instead
|
||||||
# and nothing else stores the first of the two. It therefore runs with an
|
# of dropping queued work. Change the policy only if that state lives elsewhere.
|
||||||
# append-only file on a named volume and with noeviction, so an over-limit write
|
|
||||||
# fails loudly instead of silently deleting queued work. Distributed locks all
|
|
||||||
# carry a TTL and are not what the durability is for. Only change the policy if
|
|
||||||
# you have moved that durable state elsewhere.
|
|
||||||
#FLUXER_VALKEY_MAXMEMORY=192mb
|
#FLUXER_VALKEY_MAXMEMORY=192mb
|
||||||
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
|
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
|
||||||
|
|
||||||
# The gateway derives its BEAM scheduler count from the container CPU quota,
|
# The gateway derives its scheduler count from the CPU quota, clamped here. One
|
||||||
# clamped to this range. The floor matters: a single scheduler lets one blocking
|
# scheduler lets a single blocking operation stall every websocket on the node.
|
||||||
# operation stall every websocket on the node. The ceiling stops a large host
|
|
||||||
# from starting far more schedulers than the container can actually use.
|
|
||||||
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
||||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||||
|
|
||||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
# In-flight request ceiling for the users and messages routers and their shards.
|
||||||
# users and messages routers and their shards. The Rust built-in defaults are 192
|
# One value replaces the built-in default on all of them, so size it for the
|
||||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
# busiest. Too low a value rejects requests rather than slowing them, and the api
|
||||||
# Compose does not forward this to.
|
# turns that into a 503.
|
||||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||||
|
|
||||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
# Named prepared statements need a session that outlives the transaction, so set
|
||||||
# server can reuse their plans. Named prepared statements require a session that
|
# this to false behind a transaction-pooling connection pooler. The bundled
|
||||||
# outlives the transaction, so set this to false if you put a transaction-pooling
|
# compose talks to Postgres directly, where the default is correct.
|
||||||
# connection pooler such as PgBouncer in front of Postgres. One setting governs
|
|
||||||
# every service. The bundled compose talks to Postgres directly, where naming is
|
|
||||||
# a win and the default is correct.
|
|
||||||
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
|
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
|
||||||
|
|
||||||
# The api bounds how long a client may take to send a request. The header timeout
|
# How long a client may take to send a request. The header timeout covers the
|
||||||
# covers the request line and headers only, while the request timeout covers the
|
# request line and headers, the request timeout the whole exchange, and the first
|
||||||
# whole exchange, so a slow uploader is bounded by the second value and not by
|
# is clamped down to the second. Milliseconds, 1000 to 3600000.
|
||||||
# the first. Raise both if you front large uploads or serve clients on high
|
|
||||||
# latency links. The header timeout is clamped down to the request timeout, so
|
|
||||||
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
|
|
||||||
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
|
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
|
||||||
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
|
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
|
||||||
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
|
|||||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||||
|
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
|
||||||
|
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
|
||||||
|
|
||||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||||
@@ -44,7 +46,6 @@ x-fluxer-env: &fluxer-env
|
|||||||
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
||||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
|
||||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||||
@@ -97,6 +98,7 @@ x-fluxer-env: &fluxer-env
|
|||||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
|
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
|
||||||
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
|
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
|
||||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
|
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
|
||||||
|
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: ${FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64:-}
|
||||||
FLUXER_ADMIN_SECRET_KEY_BASE: ${FLUXER_ADMIN_SECRET_KEY_BASE:?set FLUXER_ADMIN_SECRET_KEY_BASE in .env}
|
FLUXER_ADMIN_SECRET_KEY_BASE: ${FLUXER_ADMIN_SECRET_KEY_BASE:?set FLUXER_ADMIN_SECRET_KEY_BASE in .env}
|
||||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
|
FLUXER_ADMIN_OAUTH_CLIENT_SECRET: ${FLUXER_ADMIN_OAUTH_CLIENT_SECRET:?set FLUXER_ADMIN_OAUTH_CLIENT_SECRET in .env}
|
||||||
|
|
||||||
@@ -122,7 +124,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
edge:
|
edge:
|
||||||
image: caddy:2.10-alpine
|
image: caddy:2.11-alpine
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -186,7 +188,7 @@ services:
|
|||||||
-c autovacuum_vacuum_cost_limit=2000
|
-c autovacuum_vacuum_cost_limit=2000
|
||||||
-c track_io_timing=on
|
-c track_io_timing=on
|
||||||
-c shared_preload_libraries=pg_stat_statements
|
-c shared_preload_libraries=pg_stat_statements
|
||||||
shm_size: 256mb
|
shm_size: ${FLUXER_POSTGRES_SHM_SIZE:-1gb}
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: fluxer
|
POSTGRES_DB: fluxer
|
||||||
POSTGRES_USER: fluxer
|
POSTGRES_USER: fluxer
|
||||||
@@ -200,7 +202,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
valkey:
|
valkey:
|
||||||
image: valkey/valkey:8.1-alpine
|
image: valkey/valkey:9.1-alpine
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -236,7 +238,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.12
|
image: getmeili/meilisearch:v1.53
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -246,6 +248,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MEILI_ENV: production
|
MEILI_ENV: production
|
||||||
MEILI_NO_ANALYTICS: "true"
|
MEILI_NO_ANALYTICS: "true"
|
||||||
|
MEILI_UPGRADE_DB: "true"
|
||||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
||||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||||
volumes:
|
volumes:
|
||||||
@@ -257,7 +260,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
seaweedfs:
|
seaweedfs:
|
||||||
image: chrislusf/seaweedfs:4.34
|
image: chrislusf/seaweedfs:4.47
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -266,7 +269,7 @@ services:
|
|||||||
networks: [fluxer]
|
networks: [fluxer]
|
||||||
environment:
|
environment:
|
||||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||||
command: ["server", "-s3", "-dir=/data"]
|
command: ["server", "-s3", "-dir=/data", "-master.telemetry=false"]
|
||||||
volumes:
|
volumes:
|
||||||
- seaweedfs-data:/data
|
- seaweedfs-data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -277,7 +280,7 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
|
|
||||||
seaweedfs-init:
|
seaweedfs-init:
|
||||||
image: chrislusf/seaweedfs:4.34
|
image: chrislusf/seaweedfs:4.47
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -291,14 +294,13 @@ services:
|
|||||||
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
|
||||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||||
entrypoint:
|
entrypoint:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
- >
|
- >
|
||||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||||
missing="$$buckets";
|
missing="$$buckets";
|
||||||
for attempt in $$(seq 1 60); do
|
for attempt in $$(seq 1 60); do
|
||||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||||
@@ -413,7 +415,6 @@ services:
|
|||||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
|
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
|
||||||
FLUXER_API_WORKER_MODE: all_lanes
|
FLUXER_API_WORKER_MODE: all_lanes
|
||||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||||
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
|
|
||||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
|
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
|
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
|
||||||
@@ -473,11 +474,38 @@ services:
|
|||||||
FLUXER_MEDIA_PROXY_MODE: upload
|
FLUXER_MEDIA_PROXY_MODE: upload
|
||||||
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
|
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
|
||||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||||
|
FLUXER_MEDIA_PROXY_CORS_MODE: ${FLUXER_MEDIA_PROXY_CORS_MODE:-off}
|
||||||
|
FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS: ${FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}}
|
||||||
|
FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE: ${FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE:-off}
|
||||||
FLUXER_S3_READ_SIGNED: "true"
|
FLUXER_S3_READ_SIGNED: "true"
|
||||||
depends_on:
|
depends_on:
|
||||||
seaweedfs-init: {condition: service_completed_successfully}
|
seaweedfs-init: {condition: service_completed_successfully}
|
||||||
nats: {condition: service_healthy}
|
nats: {condition: service_healthy}
|
||||||
|
|
||||||
|
push:
|
||||||
|
<<: *fluxer-service
|
||||||
|
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-push:${FLUXER_IMAGE_TAG:-v1}
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: ${FLUXER_PUSH_MEMORY_LIMIT:-256mb}
|
||||||
|
environment:
|
||||||
|
<<: *fluxer-env
|
||||||
|
FLUXER_PUSH_SERVICE_HOST: 0.0.0.0
|
||||||
|
FLUXER_PUSH_SERVICE_PORT: "8126"
|
||||||
|
FLUXER_PUSH_SERVICE_QUEUE_CAPACITY: "${FLUXER_PUSH_SERVICE_QUEUE_CAPACITY:-}"
|
||||||
|
FLUXER_PUSH_SERVICE_SEND_CONCURRENCY: "${FLUXER_PUSH_SERVICE_SEND_CONCURRENCY:-}"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/usr/local/bin/fluxer-push", "healthcheck"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 60s
|
||||||
|
start_interval: 1s
|
||||||
|
depends_on:
|
||||||
|
nats: {condition: service_healthy}
|
||||||
|
api: {condition: service_healthy}
|
||||||
|
|
||||||
static-proxy:
|
static-proxy:
|
||||||
<<: *fluxer-service
|
<<: *fluxer-service
|
||||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
|
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
|
||||||
@@ -565,7 +593,7 @@ services:
|
|||||||
<<: *fluxer-env
|
<<: *fluxer-env
|
||||||
FLUXER_SVC_NAME: users
|
FLUXER_SVC_NAME: users
|
||||||
FLUXER_SVC_MODE: router
|
FLUXER_SVC_MODE: router
|
||||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||||
healthcheck: *fluxer-svc-healthcheck
|
healthcheck: *fluxer-svc-healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
nats: {condition: service_healthy}
|
nats: {condition: service_healthy}
|
||||||
@@ -583,7 +611,7 @@ services:
|
|||||||
FLUXER_SVC_MODE: shard
|
FLUXER_SVC_MODE: shard
|
||||||
FLUXER_SVC_SHARD_ID: "0"
|
FLUXER_SVC_SHARD_ID: "0"
|
||||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||||
healthcheck: *fluxer-svc-healthcheck
|
healthcheck: *fluxer-svc-healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
nats: {condition: service_healthy}
|
nats: {condition: service_healthy}
|
||||||
@@ -633,7 +661,7 @@ services:
|
|||||||
<<: *fluxer-env
|
<<: *fluxer-env
|
||||||
FLUXER_SVC_NAME: messages
|
FLUXER_SVC_NAME: messages
|
||||||
FLUXER_SVC_MODE: router
|
FLUXER_SVC_MODE: router
|
||||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||||
healthcheck: *fluxer-svc-healthcheck
|
healthcheck: *fluxer-svc-healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
nats: {condition: service_healthy}
|
nats: {condition: service_healthy}
|
||||||
@@ -651,7 +679,7 @@ services:
|
|||||||
FLUXER_SVC_MODE: shard
|
FLUXER_SVC_MODE: shard
|
||||||
FLUXER_SVC_SHARD_ID: "0"
|
FLUXER_SVC_SHARD_ID: "0"
|
||||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||||
healthcheck: *fluxer-svc-healthcheck
|
healthcheck: *fluxer-svc-healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
nats: {condition: service_healthy}
|
nats: {condition: service_healthy}
|
||||||
|
|||||||
+13
-13
@@ -9,32 +9,32 @@ build = "build.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
axum = { version = "0.8.9", features = ["macros"] }
|
axum = { version = "0.8.9", features = ["macros"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.23.1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["serde"] }
|
||||||
cookie = "0.18.1"
|
cookie = "0.18.2"
|
||||||
fluxer_common = { path = "../fluxer_common" }
|
fluxer_common = { path = "../fluxer_common" }
|
||||||
hmac = "0.13.0"
|
hmac = "0.13.0"
|
||||||
maud = { version = "0.27.0", features = ["axum"] }
|
maud = { version = "0.27.0", features = ["axum"] }
|
||||||
rand = "0.10"
|
rand = "0.10"
|
||||||
regress = "0.11"
|
regress = "0.12"
|
||||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.151"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
time = { version = "0.3.47", features = ["formatting", "parsing"] }
|
time = { version = "0.3.55", features = ["formatting", "parsing"] }
|
||||||
tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal"] }
|
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal"] }
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
tower-http = { version = "0.6.11", features = ["compression-gzip", "trace"] }
|
tower-http = { version = "0.7.1", features = ["compression-gzip", "trace"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
progenitor-client = { version = "0.14.0", default-features = false }
|
progenitor-client = { version = "0.15.0", default-features = false }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
openapiv3 = "2.2.0"
|
openapiv3 = "2.2.0"
|
||||||
prettyplease = "0.2"
|
prettyplease = "0.3"
|
||||||
progenitor = { version = "0.14.0", default-features = false }
|
progenitor = { version = "0.15.0", default-features = false }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
syn = "2"
|
syn = "3"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
FROM rust:1-bookworm AS builder
|
FROM rust:1-trixie AS builder
|
||||||
|
|
||||||
ARG BUILD_VERSION=""
|
ARG BUILD_VERSION=""
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -9,7 +9,7 @@ WORKDIR /usr/src/app
|
|||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
|
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
|
||||||
&& npm install -g pnpm@10.29.3 \
|
&& npm install -g pnpm@11.27.0 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
|
RUN npm install --no-audit --no-fund @tailwindcss/[email protected] [email protected]
|
||||||
@@ -57,7 +57,7 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
|
|||||||
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
|
&& ls target/release/build/fluxer_admin-*/out/static/fonts/fonts.*.css \
|
||||||
&& echo "Latin-core fonts bundled successfully"
|
&& echo "Latin-core fonts bundled successfully"
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS runtime
|
FROM debian:trixie-slim AS runtime
|
||||||
|
|
||||||
ARG BUILD_VERSION=""
|
ARG BUILD_VERSION=""
|
||||||
ARG SOURCE_SHA=""
|
ARG SOURCE_SHA=""
|
||||||
|
|||||||
+401
-3
@@ -35,25 +35,423 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json");
|
let json_str = fs::read_to_string(&spec_path).expect("failed to read openapi-admin.json");
|
||||||
let spec: openapiv3::OpenAPI =
|
let mut spec: openapiv3::OpenAPI =
|
||||||
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
|
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
|
||||||
|
adapt_progenitor_throttled_errors(&mut spec);
|
||||||
|
relax_guild_audit_log_schemas(&mut spec);
|
||||||
|
relax_progenitor_schema_strictness(&mut spec);
|
||||||
|
|
||||||
let mut settings = progenitor::GenerationSettings::new();
|
let mut settings = progenitor::GenerationSettings::new();
|
||||||
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
||||||
|
settings.with_inner_type(
|
||||||
|
"reqwest::header::HeaderMap"
|
||||||
|
.parse()
|
||||||
|
.expect("valid generated client header type"),
|
||||||
|
);
|
||||||
|
|
||||||
let mut generator = progenitor::Generator::new(&settings);
|
let mut generator = progenitor::Generator::new(&settings);
|
||||||
let tokens = generator
|
let tokens = generator
|
||||||
.generate_tokens(&spec)
|
.generate_tokens(&spec)
|
||||||
.expect("failed to generate admin API client");
|
.expect("failed to generate admin API client");
|
||||||
|
|
||||||
let content = prettyplease::unparse(
|
let content = relax_required_nullable_fields(&prettyplease::unparse(
|
||||||
&syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens"),
|
&syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens"),
|
||||||
);
|
));
|
||||||
|
|
||||||
let output_path = out_dir.join("admin_api_generated.rs");
|
let output_path = out_dir.join("admin_api_generated.rs");
|
||||||
fs::write(&output_path, content).expect("failed to write generated API code");
|
fs::write(&output_path, content).expect("failed to write generated API code");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn adapt_progenitor_throttled_errors(spec: &mut openapiv3::OpenAPI) {
|
||||||
|
let schemas = &spec
|
||||||
|
.components
|
||||||
|
.as_ref()
|
||||||
|
.expect("missing API components")
|
||||||
|
.schemas;
|
||||||
|
let error = serde_json::to_value(schemas.get("Error").expect("missing Error schema"))
|
||||||
|
.expect("failed to inspect Error schema");
|
||||||
|
let mut throttled = serde_json::to_value(
|
||||||
|
schemas
|
||||||
|
.get("ThrottledError")
|
||||||
|
.expect("missing ThrottledError schema"),
|
||||||
|
)
|
||||||
|
.expect("failed to inspect ThrottledError schema");
|
||||||
|
assert_eq!(
|
||||||
|
error["additionalProperties"],
|
||||||
|
serde_json::json!({}),
|
||||||
|
"Progenitor error adaptation requires Error to retain all additional fields"
|
||||||
|
);
|
||||||
|
let properties = throttled["properties"]
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("ThrottledError must be an object schema");
|
||||||
|
assert_eq!(
|
||||||
|
properties
|
||||||
|
.remove("retry_after")
|
||||||
|
.expect("missing retry_after")["type"],
|
||||||
|
"number"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
properties.remove("global").expect("missing global")["type"],
|
||||||
|
"boolean"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
throttled, error,
|
||||||
|
"ThrottledError must extend the common Error schema"
|
||||||
|
);
|
||||||
|
|
||||||
|
for path in spec.paths.paths.values_mut() {
|
||||||
|
let openapiv3::ReferenceOr::Item(path) = path else {
|
||||||
|
panic!("Progenitor error adaptation requires inline API paths");
|
||||||
|
};
|
||||||
|
for operation in [
|
||||||
|
&mut path.get,
|
||||||
|
&mut path.put,
|
||||||
|
&mut path.post,
|
||||||
|
&mut path.delete,
|
||||||
|
&mut path.options,
|
||||||
|
&mut path.head,
|
||||||
|
&mut path.patch,
|
||||||
|
&mut path.trace,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(response) = operation
|
||||||
|
.responses
|
||||||
|
.responses
|
||||||
|
.get_mut(&openapiv3::StatusCode::Code(429))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let openapiv3::ReferenceOr::Item(response) = response else {
|
||||||
|
panic!("Progenitor error adaptation requires inline 429 responses");
|
||||||
|
};
|
||||||
|
let schema = &mut response
|
||||||
|
.content
|
||||||
|
.get_mut("application/json")
|
||||||
|
.expect("429 responses must return JSON")
|
||||||
|
.schema;
|
||||||
|
assert_eq!(
|
||||||
|
schema,
|
||||||
|
&Some(openapiv3::ReferenceOr::ref_(
|
||||||
|
"#/components/schemas/ThrottledError"
|
||||||
|
)),
|
||||||
|
"Progenitor only supports one error type per operation"
|
||||||
|
);
|
||||||
|
*schema = Some(openapiv3::ReferenceOr::ref_("#/components/schemas/Error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
|
||||||
|
let components = spec.components.as_mut().expect("missing API components");
|
||||||
|
|
||||||
|
let entry = object_schema_mut(components, "GuildAuditLogEntryResponse");
|
||||||
|
entry.additional_properties = None;
|
||||||
|
let openapiv3::ReferenceOr::Item(options) = entry
|
||||||
|
.properties
|
||||||
|
.get_mut("options")
|
||||||
|
.expect("GuildAuditLogEntryResponse has no options property")
|
||||||
|
else {
|
||||||
|
panic!("GuildAuditLogEntryResponse options must be an inline schema");
|
||||||
|
};
|
||||||
|
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(options)) = &mut options.schema_kind
|
||||||
|
else {
|
||||||
|
panic!("GuildAuditLogEntryResponse options must be an object schema");
|
||||||
|
};
|
||||||
|
options.additional_properties = None;
|
||||||
|
|
||||||
|
let change = object_schema_mut(components, "AuditLogChangeSchema");
|
||||||
|
change.additional_properties = None;
|
||||||
|
for property in ["old_value", "new_value"] {
|
||||||
|
change.properties.insert(
|
||||||
|
property.to_string(),
|
||||||
|
openapiv3::ReferenceOr::Item(Box::new(openapiv3::Schema {
|
||||||
|
schema_data: openapiv3::SchemaData::default(),
|
||||||
|
schema_kind: openapiv3::SchemaKind::Any(openapiv3::AnySchema::default()),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object_schema_mut<'a>(
|
||||||
|
components: &'a mut openapiv3::Components,
|
||||||
|
name: &str,
|
||||||
|
) -> &'a mut openapiv3::ObjectType {
|
||||||
|
let Some(openapiv3::ReferenceOr::Item(schema)) = components.schemas.get_mut(name) else {
|
||||||
|
panic!("missing inline {name} schema");
|
||||||
|
};
|
||||||
|
let openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) = &mut schema.schema_kind
|
||||||
|
else {
|
||||||
|
panic!("{name} must be an object schema");
|
||||||
|
};
|
||||||
|
object
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SCHEMA_REFERENCE_DEPTH: usize = 32;
|
||||||
|
|
||||||
|
fn relax_required_nullable_fields(generated: &str) -> String {
|
||||||
|
const PRESENCE_CHECK: &str =
|
||||||
|
"#[serde(deserialize_with = \"::std::option::Option::deserialize\")]";
|
||||||
|
generated
|
||||||
|
.lines()
|
||||||
|
.filter(|line| line.trim() != PRESENCE_CHECK)
|
||||||
|
.flat_map(|line| [line, "\n"])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_progenitor_schema_strictness(spec: &mut openapiv3::OpenAPI) {
|
||||||
|
let registry = spec.components.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some(components) = spec.components.as_mut() {
|
||||||
|
for schema in components.schemas.values_mut() {
|
||||||
|
relax_schema_reference(schema, ®istry);
|
||||||
|
}
|
||||||
|
for response in components.responses.values_mut() {
|
||||||
|
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||||
|
relax_response(response, ®istry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for parameter in components.parameters.values_mut() {
|
||||||
|
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||||
|
relax_parameter(parameter, ®istry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for request_body in components.request_bodies.values_mut() {
|
||||||
|
if let openapiv3::ReferenceOr::Item(request_body) = request_body {
|
||||||
|
relax_content(&mut request_body.content, ®istry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for header in components.headers.values_mut() {
|
||||||
|
if let openapiv3::ReferenceOr::Item(header) = header {
|
||||||
|
relax_parameter_format(&mut header.format, ®istry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in spec.paths.paths.values_mut() {
|
||||||
|
let openapiv3::ReferenceOr::Item(path) = path else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for parameter in &mut path.parameters {
|
||||||
|
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||||
|
relax_parameter(parameter, ®istry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for operation in [
|
||||||
|
&mut path.get,
|
||||||
|
&mut path.put,
|
||||||
|
&mut path.post,
|
||||||
|
&mut path.delete,
|
||||||
|
&mut path.options,
|
||||||
|
&mut path.head,
|
||||||
|
&mut path.patch,
|
||||||
|
&mut path.trace,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
for parameter in &mut operation.parameters {
|
||||||
|
if let openapiv3::ReferenceOr::Item(parameter) = parameter {
|
||||||
|
relax_parameter(parameter, ®istry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(openapiv3::ReferenceOr::Item(request_body)) =
|
||||||
|
operation.request_body.as_mut()
|
||||||
|
{
|
||||||
|
relax_content(&mut request_body.content, ®istry);
|
||||||
|
}
|
||||||
|
for response in operation
|
||||||
|
.responses
|
||||||
|
.responses
|
||||||
|
.values_mut()
|
||||||
|
.chain(operation.responses.default.iter_mut())
|
||||||
|
{
|
||||||
|
if let openapiv3::ReferenceOr::Item(response) = response {
|
||||||
|
relax_response(response, ®istry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_response(response: &mut openapiv3::Response, registry: &openapiv3::Components) {
|
||||||
|
relax_content(&mut response.content, registry);
|
||||||
|
for header in response.headers.values_mut() {
|
||||||
|
if let openapiv3::ReferenceOr::Item(header) = header {
|
||||||
|
relax_parameter_format(&mut header.format, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_content(content: &mut openapiv3::Content, registry: &openapiv3::Components) {
|
||||||
|
for media_type in content.values_mut() {
|
||||||
|
if let Some(schema) = media_type.schema.as_mut() {
|
||||||
|
relax_schema_reference(schema, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_parameter(parameter: &mut openapiv3::Parameter, registry: &openapiv3::Components) {
|
||||||
|
let format = match parameter {
|
||||||
|
openapiv3::Parameter::Query { parameter_data, .. }
|
||||||
|
| openapiv3::Parameter::Header { parameter_data, .. }
|
||||||
|
| openapiv3::Parameter::Path { parameter_data, .. }
|
||||||
|
| openapiv3::Parameter::Cookie { parameter_data, .. } => &mut parameter_data.format,
|
||||||
|
};
|
||||||
|
relax_parameter_format(format, registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_parameter_format(
|
||||||
|
format: &mut openapiv3::ParameterSchemaOrContent,
|
||||||
|
registry: &openapiv3::Components,
|
||||||
|
) {
|
||||||
|
match format {
|
||||||
|
openapiv3::ParameterSchemaOrContent::Schema(schema) => {
|
||||||
|
relax_schema_reference(schema, registry)
|
||||||
|
}
|
||||||
|
openapiv3::ParameterSchemaOrContent::Content(content) => relax_content(content, registry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_schema_reference(
|
||||||
|
schema: &mut openapiv3::ReferenceOr<openapiv3::Schema>,
|
||||||
|
registry: &openapiv3::Components,
|
||||||
|
) {
|
||||||
|
if let openapiv3::ReferenceOr::Item(schema) = schema {
|
||||||
|
relax_schema(schema, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_boxed_schema_reference(
|
||||||
|
schema: &mut openapiv3::ReferenceOr<Box<openapiv3::Schema>>,
|
||||||
|
registry: &openapiv3::Components,
|
||||||
|
) {
|
||||||
|
if let openapiv3::ReferenceOr::Item(schema) = schema {
|
||||||
|
relax_schema(schema, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_schema(schema: &mut openapiv3::Schema, registry: &openapiv3::Components) {
|
||||||
|
if flattens_objects_beside_scalars(&schema.schema_kind, registry) {
|
||||||
|
schema.schema_kind = openapiv3::SchemaKind::Any(openapiv3::AnySchema::default());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match &mut schema.schema_kind {
|
||||||
|
openapiv3::SchemaKind::Type(openapiv3::Type::Object(object)) => {
|
||||||
|
relax_additional_properties(&mut object.additional_properties, registry);
|
||||||
|
for property in object.properties.values_mut() {
|
||||||
|
relax_boxed_schema_reference(property, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array)) => {
|
||||||
|
if let Some(items) = array.items.as_mut() {
|
||||||
|
relax_boxed_schema_reference(items, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openapiv3::SchemaKind::Type(_) => {}
|
||||||
|
openapiv3::SchemaKind::OneOf { one_of: subschemas }
|
||||||
|
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
|
||||||
|
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => {
|
||||||
|
for subschema in subschemas {
|
||||||
|
relax_schema_reference(subschema, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openapiv3::SchemaKind::Not { not } => relax_schema_reference(not, registry),
|
||||||
|
openapiv3::SchemaKind::Any(any) => {
|
||||||
|
relax_additional_properties(&mut any.additional_properties, registry);
|
||||||
|
for property in any.properties.values_mut() {
|
||||||
|
relax_boxed_schema_reference(property, registry);
|
||||||
|
}
|
||||||
|
if let Some(items) = any.items.as_mut() {
|
||||||
|
relax_boxed_schema_reference(items, registry);
|
||||||
|
}
|
||||||
|
for subschema in any
|
||||||
|
.one_of
|
||||||
|
.iter_mut()
|
||||||
|
.chain(any.all_of.iter_mut())
|
||||||
|
.chain(any.any_of.iter_mut())
|
||||||
|
{
|
||||||
|
relax_schema_reference(subschema, registry);
|
||||||
|
}
|
||||||
|
if let Some(not) = any.not.as_mut() {
|
||||||
|
relax_schema_reference(not, registry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relax_additional_properties(
|
||||||
|
additional_properties: &mut Option<openapiv3::AdditionalProperties>,
|
||||||
|
registry: &openapiv3::Components,
|
||||||
|
) {
|
||||||
|
match additional_properties {
|
||||||
|
Some(openapiv3::AdditionalProperties::Any(false)) => *additional_properties = None,
|
||||||
|
Some(openapiv3::AdditionalProperties::Schema(schema)) => {
|
||||||
|
relax_schema_reference(schema, registry)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flattens_objects_beside_scalars(
|
||||||
|
schema_kind: &openapiv3::SchemaKind,
|
||||||
|
registry: &openapiv3::Components,
|
||||||
|
) -> bool {
|
||||||
|
let subschemas = match schema_kind {
|
||||||
|
openapiv3::SchemaKind::OneOf { one_of } => one_of,
|
||||||
|
openapiv3::SchemaKind::AnyOf { any_of } => any_of,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
let mut objects = false;
|
||||||
|
let mut scalars = false;
|
||||||
|
for subschema in subschemas {
|
||||||
|
if resolves_to_object(subschema, registry, MAX_SCHEMA_REFERENCE_DEPTH) {
|
||||||
|
objects = true;
|
||||||
|
} else {
|
||||||
|
scalars = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
objects && scalars
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolves_to_object(
|
||||||
|
schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
|
||||||
|
registry: &openapiv3::Components,
|
||||||
|
depth: usize,
|
||||||
|
) -> bool {
|
||||||
|
let Some(depth) = depth.checked_sub(1) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let schema = match schema {
|
||||||
|
openapiv3::ReferenceOr::Reference { reference } => {
|
||||||
|
let Some(target) = reference
|
||||||
|
.strip_prefix("#/components/schemas/")
|
||||||
|
.and_then(|name| registry.schemas.get(name))
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return resolves_to_object(target, registry, depth);
|
||||||
|
}
|
||||||
|
openapiv3::ReferenceOr::Item(schema) => schema,
|
||||||
|
};
|
||||||
|
match &schema.schema_kind {
|
||||||
|
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => true,
|
||||||
|
openapiv3::SchemaKind::Type(_) => false,
|
||||||
|
openapiv3::SchemaKind::OneOf { one_of: subschemas }
|
||||||
|
| openapiv3::SchemaKind::AllOf { all_of: subschemas }
|
||||||
|
| openapiv3::SchemaKind::AnyOf { any_of: subschemas } => subschemas
|
||||||
|
.iter()
|
||||||
|
.any(|subschema| resolves_to_object(subschema, registry, depth)),
|
||||||
|
openapiv3::SchemaKind::Not { .. } => false,
|
||||||
|
openapiv3::SchemaKind::Any(any) => {
|
||||||
|
any.typ.as_deref() == Some("object")
|
||||||
|
|| !any.properties.is_empty()
|
||||||
|
|| any.additional_properties.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct Face {
|
struct Face {
|
||||||
css_family: String,
|
css_family: String,
|
||||||
weight: u64,
|
weight: u64,
|
||||||
|
|||||||
+6753
-5207
File diff suppressed because it is too large.
Load diff
@@ -79,7 +79,6 @@ pub const REPORT_RESOLVE: &str = "report:resolve";
|
|||||||
pub const REPORT_VIEW: &str = "report:view";
|
pub const REPORT_VIEW: &str = "report:view";
|
||||||
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
|
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
|
||||||
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
|
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
|
||||||
pub const SYSTEM_HEAP_SNAPSHOT: &str = "system:heap_snapshot";
|
|
||||||
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
|
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
|
||||||
pub const USER_DELETE: &str = "user:delete";
|
pub const USER_DELETE: &str = "user:delete";
|
||||||
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
|
pub const USER_DISABLE_SUSPICIOUS: &str = "user:disable:suspicious";
|
||||||
@@ -192,7 +191,6 @@ pub const ALL_ACLS: &[&str] = &[
|
|||||||
REPORT_VIEW,
|
REPORT_VIEW,
|
||||||
REPORT_VIEW_REPORTER_PII,
|
REPORT_VIEW_REPORTER_PII,
|
||||||
SYSTEM_DM_SEND,
|
SYSTEM_DM_SEND,
|
||||||
SYSTEM_HEAP_SNAPSHOT,
|
|
||||||
USER_CANCEL_BULK_MESSAGE_DELETION,
|
USER_CANCEL_BULK_MESSAGE_DELETION,
|
||||||
USER_DELETE,
|
USER_DELETE,
|
||||||
USER_DISABLE_SUSPICIOUS,
|
USER_DISABLE_SUSPICIOUS,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
|
use super::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
|
||||||
@@ -35,7 +35,7 @@ impl AdminApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
|
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
|
||||||
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
|
let key_id = snowflake(key_id);
|
||||||
self.generated()
|
self.generated()
|
||||||
.delete_admin_api_key(&key_id)
|
.delete_admin_api_key(&key_id)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
|
use super::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
|
||||||
@@ -12,7 +12,7 @@ impl AdminApiClient {
|
|||||||
include_attachments: bool,
|
include_attachments: bool,
|
||||||
) -> ApiResult<Archive> {
|
) -> ApiResult<Archive> {
|
||||||
let body = generated_types::AdminArchiveCreateRequest {
|
let body = generated_types::AdminArchiveCreateRequest {
|
||||||
include_attachments: include_attachments.then_some(true),
|
include_attachments,
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
@@ -28,7 +28,7 @@ impl AdminApiClient {
|
|||||||
include_attachments: bool,
|
include_attachments: bool,
|
||||||
) -> ApiResult<Archive> {
|
) -> ApiResult<Archive> {
|
||||||
let body = generated_types::AdminArchiveCreateRequest {
|
let body = generated_types::AdminArchiveCreateRequest {
|
||||||
include_attachments: include_attachments.then_some(true),
|
include_attachments,
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
@@ -78,15 +78,17 @@ impl AdminApiClient {
|
|||||||
subject_id: &str,
|
subject_id: &str,
|
||||||
archive_id: &str,
|
archive_id: &str,
|
||||||
) -> ApiResult<ArchiveDownloadUrlResponse> {
|
) -> ApiResult<ArchiveDownloadUrlResponse> {
|
||||||
|
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
|
||||||
|
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.get_admin_archive_download(subject_type, subject_id, archive_id)
|
.get_admin_archive_download(
|
||||||
|
subject_type,
|
||||||
|
&snowflake(subject_id),
|
||||||
|
&snowflake(archive_id),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
self.generated_value(response.into_inner())
|
self.generated_value(response.into_inner())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
|
||||||
generated_types::SnowflakeType::from(value.to_owned())
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiResult};
|
use super::client::{AdminApiClient, ApiResult};
|
||||||
|
|
||||||
@@ -13,10 +13,7 @@ impl AdminApiClient {
|
|||||||
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
|
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.purge_admin_guild_assets(
|
.purge_admin_guild_assets(&snowflake(guild_id), &body)
|
||||||
&generated_types::SnowflakeType::from(guild_id.to_owned()),
|
|
||||||
&body,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
self.generated_value(response.into_inner())
|
self.generated_value(response.into_inner())
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::types as generated_types;
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
#[cfg(test)]
|
|
||||||
use super::types::AuditLogEntry;
|
|
||||||
use super::types::AuditLogsListResponse;
|
use super::types::AuditLogsListResponse;
|
||||||
|
|
||||||
pub struct SearchAuditLogsParams {
|
pub struct SearchAuditLogsParams {
|
||||||
@@ -12,10 +10,11 @@ pub struct SearchAuditLogsParams {
|
|||||||
pub admin_user_id: Option<String>,
|
pub admin_user_id: Option<String>,
|
||||||
pub target_id: Option<String>,
|
pub target_id: Option<String>,
|
||||||
pub target_type: Option<String>,
|
pub target_type: Option<String>,
|
||||||
|
pub access: Option<String>,
|
||||||
pub sort_by: Option<String>,
|
pub sort_by: Option<String>,
|
||||||
pub sort_order: Option<String>,
|
pub sort_order: Option<String>,
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
pub offset: u32,
|
pub offset: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminApiClient {
|
impl AdminApiClient {
|
||||||
@@ -23,6 +22,12 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
params: &SearchAuditLogsParams,
|
params: &SearchAuditLogsParams,
|
||||||
) -> ApiResult<AuditLogsListResponse> {
|
) -> ApiResult<AuditLogsListResponse> {
|
||||||
|
let access = params
|
||||||
|
.access
|
||||||
|
.as_deref()
|
||||||
|
.map(audit_access)
|
||||||
|
.transpose()?
|
||||||
|
.map(|value| value.to_string());
|
||||||
let sort_by = params
|
let sort_by = params
|
||||||
.sort_by
|
.sort_by
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -38,65 +43,29 @@ impl AdminApiClient {
|
|||||||
let limit = params.limit.to_string();
|
let limit = params.limit.to_string();
|
||||||
let offset = params.offset.to_string();
|
let offset = params.offset.to_string();
|
||||||
let query_params = [
|
let query_params = [
|
||||||
(
|
("q", params.query.as_deref().unwrap_or_default()),
|
||||||
"q",
|
|
||||||
nonempty_string(params.query.as_deref()).unwrap_or_default(),
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"admin_user_id",
|
"admin_user_id",
|
||||||
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
|
params.admin_user_id.as_deref().unwrap_or_default(),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"target_type",
|
"target_type",
|
||||||
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
|
params.target_type.as_deref().unwrap_or_default(),
|
||||||
),
|
),
|
||||||
(
|
("target_id", params.target_id.as_deref().unwrap_or_default()),
|
||||||
"target_id",
|
("access", access.as_deref().unwrap_or_default()),
|
||||||
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
|
("sort_by", sort_by.as_deref().unwrap_or_default()),
|
||||||
),
|
("sort_order", sort_order.as_deref().unwrap_or_default()),
|
||||||
("sort_by", sort_by.unwrap_or_default()),
|
("limit", limit.as_str()),
|
||||||
("sort_order", sort_order.unwrap_or_default()),
|
("offset", offset.as_str()),
|
||||||
("limit", limit),
|
|
||||||
("offset", offset),
|
|
||||||
];
|
];
|
||||||
let query_params: Vec<(&str, &str)> = query_params
|
|
||||||
.iter()
|
|
||||||
.map(|(key, value)| (*key, value.as_str()))
|
|
||||||
.collect();
|
|
||||||
self.get("/admin/audit-logs", Some(&query_params)).await
|
self.get("/admin/audit-logs", Some(&query_params)).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
fn audit_access(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsAccess> {
|
||||||
fn audit_logs_response(
|
generated_types::ListAdminAuditLogsAccess::try_from(value)
|
||||||
response: generated_types::AuditLogsListResponseSchema,
|
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||||
) -> ApiResult<AuditLogsListResponse> {
|
|
||||||
Ok(AuditLogsListResponse {
|
|
||||||
logs: response.logs.into_iter().map(audit_log_entry).collect(),
|
|
||||||
total: crate::api::generated::number_to_u64(response.total, "total")
|
|
||||||
.map_err(ApiError::Parse)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
|
|
||||||
AuditLogEntry {
|
|
||||||
log_id: String::from(entry.log_id),
|
|
||||||
admin_user_id: String::from(entry.admin_user_id),
|
|
||||||
admin_user: None,
|
|
||||||
action: entry.action,
|
|
||||||
target_id: entry.target_id,
|
|
||||||
target_type: entry.target_type,
|
|
||||||
target_user: None,
|
|
||||||
target_guild: None,
|
|
||||||
target_channel: None,
|
|
||||||
related_users: Default::default(),
|
|
||||||
related_guilds: Default::default(),
|
|
||||||
related_channels: Default::default(),
|
|
||||||
audit_log_reason: entry.audit_log_reason,
|
|
||||||
metadata: entry.metadata,
|
|
||||||
created_at: entry.created_at,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
||||||
@@ -113,12 +82,6 @@ fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLog
|
|||||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nonempty_string(value: Option<&str>) -> Option<String> {
|
|
||||||
value
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(std::borrow::ToOwned::to_owned)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -133,12 +96,69 @@ mod tests {
|
|||||||
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc");
|
assert_eq!(audit_sort_order("desc").unwrap().to_string(), "desc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_only_known_access_filters() {
|
||||||
|
assert_eq!(audit_access("read").unwrap().to_string(), "read");
|
||||||
|
assert_eq!(audit_access("write").unwrap().to_string(), "write");
|
||||||
|
assert!(audit_access("all").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_lossy_audit_totals() {
|
fn rejects_lossy_audit_totals() {
|
||||||
let response = generated_types::AuditLogsListResponseSchema {
|
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
|
||||||
logs: Vec::new(),
|
let response = serde_json::json!({"logs": [], "total": total});
|
||||||
total: 1.5,
|
assert!(serde_json::from_value::<AuditLogsListResponse>(response).is_err());
|
||||||
};
|
}
|
||||||
assert!(audit_logs_response(response).is_err());
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserializes_audit_fields_without_losing_generated_string_values() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"logs": [{
|
||||||
|
"log_id": "123456789012345678",
|
||||||
|
"admin_user_id": "234567890123456789",
|
||||||
|
"admin_user": null,
|
||||||
|
"action": "USER_UPDATE",
|
||||||
|
"access": "write",
|
||||||
|
"target_id": "345678901234567890",
|
||||||
|
"target_type": "user",
|
||||||
|
"target_user": null,
|
||||||
|
"target_guild": null,
|
||||||
|
"target_channel": null,
|
||||||
|
"related_users": {},
|
||||||
|
"related_guilds": {},
|
||||||
|
"related_channels": {},
|
||||||
|
"audit_log_reason": "Account review",
|
||||||
|
"metadata": {"field": "username"},
|
||||||
|
"created_at": "2026-09-11T12:00:00.000Z"
|
||||||
|
}],
|
||||||
|
"total": 1
|
||||||
|
});
|
||||||
|
let generated: generated_types::AuditLogsListResponseSchema =
|
||||||
|
serde_json::from_value(json.clone()).unwrap();
|
||||||
|
assert_eq!(generated.logs[0].action.to_string(), "USER_UPDATE");
|
||||||
|
|
||||||
|
let response: AuditLogsListResponse = serde_json::from_value(json.clone()).unwrap();
|
||||||
|
assert_eq!(response.logs[0].access.as_deref(), Some("write"));
|
||||||
|
assert_eq!(serde_json::to_value(response).unwrap(), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserializes_audit_entries_from_an_api_without_access() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"logs": [{
|
||||||
|
"log_id": "123456789012345678",
|
||||||
|
"admin_user_id": "234567890123456789",
|
||||||
|
"action": "USER_UPDATE",
|
||||||
|
"target_id": "345678901234567890",
|
||||||
|
"target_type": "user",
|
||||||
|
"audit_log_reason": null,
|
||||||
|
"metadata": {},
|
||||||
|
"created_at": "2026-09-11T12:00:00.000Z"
|
||||||
|
}],
|
||||||
|
"total": 1
|
||||||
|
});
|
||||||
|
let response: AuditLogsListResponse = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(response.logs[0].access, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+167
-97
@@ -1,56 +1,77 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||||
|
|
||||||
impl AdminApiClient {
|
impl AdminApiClient {
|
||||||
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
|
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
"email",
|
"email",
|
||||||
generated_types::BanEmailRequest {
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
email: generated_types::EmailType::from(email.to_owned()),
|
subtype_1: Some(generated_types::BanEmailRequest {
|
||||||
}
|
email: generated_types::EmailType::from(email.to_owned()),
|
||||||
.into(),
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unban_email(&self, email: &str) -> ApiResult<()> {
|
pub async fn unban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||||
self.delete_blocklist_entry("email", email, None).await
|
self.delete_blocklist_entry("email", email, None, audit_log_reason)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
|
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
|
||||||
self.check_blocklist_entry("email", email, None).await
|
self.check_blocklist_entry("email", email, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
|
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
"ip",
|
"ip",
|
||||||
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
|
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> {
|
pub async fn unban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||||
self.delete_blocklist_entry("ip", ip, None).await
|
self.delete_blocklist_entry("ip", ip, None, audit_log_reason)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
|
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
|
||||||
self.check_blocklist_entry("ip", ip, None).await
|
self.check_blocklist_entry("ip", ip, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
pub async fn add_suspicious_email_domain(
|
||||||
|
&self,
|
||||||
|
domain: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
SUSPICIOUS_EMAIL_DOMAIN_LIST,
|
SUSPICIOUS_EMAIL_DOMAIN_LIST,
|
||||||
suspicious_email_domain_request(domain)?.into(),
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
|
subtype_2: Some(suspicious_email_domain_request(domain)?),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
pub async fn remove_suspicious_email_domain(
|
||||||
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
|
&self,
|
||||||
|
domain: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,66 +80,90 @@ impl AdminApiClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
pub async fn ban_phrase(&self, phrase: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
"phrase",
|
"phrase",
|
||||||
generated_types::BanPhraseRequest {
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
phrase: phrase.to_owned(),
|
subtype_3: Some(generated_types::BanPhraseRequest {
|
||||||
}
|
phrase: phrase.to_owned(),
|
||||||
.into(),
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
pub async fn unban_phrase(
|
||||||
self.delete_blocklist_entry("phrase", phrase, None).await
|
&self,
|
||||||
|
phrase: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
self.delete_blocklist_entry("phrase", phrase, None, audit_log_reason)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
|
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
|
||||||
self.check_blocklist_entry("phrase", phrase, None).await
|
self.check_blocklist_entry("phrase", phrase, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
|
pub async fn ban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
"url",
|
"url",
|
||||||
generated_types::BanUrlRequest {
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
category: None,
|
subtype_4: Some(generated_types::BanUrlRequest {
|
||||||
notes: None,
|
category: None,
|
||||||
severity: None,
|
notes: None,
|
||||||
source_url: None,
|
severity: None,
|
||||||
url: url.to_owned(),
|
source_url: None,
|
||||||
}
|
url: url.to_owned(),
|
||||||
.into(),
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unban_url(&self, url: &str) -> ApiResult<()> {
|
pub async fn unban_url(&self, url: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||||
self.delete_blocklist_entry("url", url, None).await
|
self.delete_blocklist_entry("url", url, None, audit_log_reason)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
|
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
|
||||||
self.check_blocklist_entry("url", url, None).await
|
self.check_blocklist_entry("url", url, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
|
pub async fn ban_url_domain(
|
||||||
|
&self,
|
||||||
|
domain: &str,
|
||||||
|
match_subdomains: bool,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
"url-domain",
|
"url-domain",
|
||||||
generated_types::BanUrlDomainRequest {
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
category: None,
|
subtype_5: Some(generated_types::BanUrlDomainRequest {
|
||||||
domain: domain.to_owned(),
|
category: None,
|
||||||
match_subdomains: Some(match_subdomains),
|
domain: domain.to_owned(),
|
||||||
notes: None,
|
match_subdomains,
|
||||||
severity: None,
|
notes: None,
|
||||||
source_url: None,
|
severity: None,
|
||||||
}
|
source_url: None,
|
||||||
.into(),
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> {
|
pub async fn unban_url_domain(
|
||||||
self.delete_blocklist_entry("url-domain", domain, None)
|
&self,
|
||||||
|
domain: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
self.delete_blocklist_entry("url-domain", domain, None, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,19 +176,19 @@ impl AdminApiClient {
|
|||||||
sha256_hex: &str,
|
sha256_hex: &str,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
|
self.create_blocklist_entry(
|
||||||
generated_types::BanFileShaRequest {
|
"file-sha",
|
||||||
category: None,
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
content_type: None,
|
subtype_6: Some(generated_types::BanFileShaRequest {
|
||||||
notes: None,
|
category: None,
|
||||||
severity: None,
|
content_type: None,
|
||||||
sha256_hex: sha256_hex.to_owned(),
|
notes: None,
|
||||||
source_url: None,
|
severity: None,
|
||||||
|
sha256_hex: sha256_hex.to_owned(),
|
||||||
|
source_url: None,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
},
|
},
|
||||||
);
|
|
||||||
self.post_void_with_reason(
|
|
||||||
"/admin/blocklists/file-sha/entries",
|
|
||||||
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
|
|
||||||
audit_log_reason,
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -154,12 +199,8 @@ impl AdminApiClient {
|
|||||||
sha256_hex: &str,
|
sha256_hex: &str,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
self.delete_void_with_reason(
|
self.delete_blocklist_entry("file-sha", sha256_hex, None, audit_log_reason)
|
||||||
&blocklist_entry_path("file-sha", sha256_hex),
|
.await
|
||||||
None,
|
|
||||||
audit_log_reason,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
|
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
|
||||||
@@ -183,24 +224,35 @@ impl AdminApiClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
pub async fn ban_avatar_hash(
|
||||||
|
&self,
|
||||||
|
hash_short: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
"avatar-hash",
|
"avatar-hash",
|
||||||
generated_types::BanAvatarHashRequest {
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
category: None,
|
subtype_7: Some(generated_types::BanAvatarHashRequest {
|
||||||
hashes: vec![hash_short.to_owned()],
|
category: None,
|
||||||
notes: None,
|
hashes: vec![hash_short.to_owned()],
|
||||||
reason: None,
|
notes: None,
|
||||||
severity: None,
|
reason: None,
|
||||||
source_url: None,
|
severity: None,
|
||||||
}
|
source_url: None,
|
||||||
.into(),
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
pub async fn unban_avatar_hash(
|
||||||
self.delete_blocklist_entry("avatar-hash", hash_short, None)
|
&self,
|
||||||
|
hash_short: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
self.delete_blocklist_entry("avatar-hash", hash_short, None, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,26 +265,42 @@ impl AdminApiClient {
|
|||||||
let body = generated_types::BanUserAvatarRequest::default();
|
let body = generated_types::BanUserAvatarRequest::default();
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.ban_admin_user_avatar(
|
.ban_admin_user_avatar(&snowflake(user_id), &body)
|
||||||
&generated_types::SnowflakeType::from(user_id.to_owned()),
|
|
||||||
&body,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
self.generated_value(response.into_inner())
|
self.generated_value(response.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
pub async fn ban_profile_substring(
|
||||||
|
&self,
|
||||||
|
scope: &str,
|
||||||
|
substring: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
self.create_blocklist_entry(
|
self.create_blocklist_entry(
|
||||||
PROFILE_SUBSTRING_LIST,
|
PROFILE_SUBSTRING_LIST,
|
||||||
profile_substring_request(scope, substring)?.into(),
|
generated_types::AdminBlocklistEntryCreateRequest {
|
||||||
|
subtype_8: Some(profile_substring_request(scope, substring)?),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
audit_log_reason,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
pub async fn unban_profile_substring(
|
||||||
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
|
&self,
|
||||||
.await
|
scope: &str,
|
||||||
|
substring: &str,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<()> {
|
||||||
|
self.delete_blocklist_entry(
|
||||||
|
PROFILE_SUBSTRING_LIST,
|
||||||
|
substring,
|
||||||
|
Some(scope),
|
||||||
|
audit_log_reason,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn check_profile_substring_ban(
|
pub async fn check_profile_substring_ban(
|
||||||
@@ -248,12 +316,14 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
list_type: &str,
|
list_type: &str,
|
||||||
body: generated_types::AdminBlocklistEntryCreateRequest,
|
body: generated_types::AdminBlocklistEntryCreateRequest,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
self.generated()
|
let list_type = blocklist_list_type(list_type)?;
|
||||||
|
self.generated_with_reason(audit_log_reason)?
|
||||||
.create_admin_blocklist_entry(list_type, &body)
|
.create_admin_blocklist_entry(list_type, &body)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map(drop)
|
||||||
Ok(())
|
.map_err(|error| self.generated_error(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_blocklist_entry(
|
async fn delete_blocklist_entry(
|
||||||
@@ -261,13 +331,15 @@ impl AdminApiClient {
|
|||||||
list_type: &str,
|
list_type: &str,
|
||||||
entry_value: &str,
|
entry_value: &str,
|
||||||
scope: Option<&str>,
|
scope: Option<&str>,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
|
let list_type = blocklist_list_type(list_type)?;
|
||||||
let scope = scope.map(blocklist_delete_scope).transpose()?;
|
let scope = scope.map(blocklist_delete_scope).transpose()?;
|
||||||
self.generated()
|
self.generated_with_reason(audit_log_reason)?
|
||||||
.delete_admin_blocklist_entry(list_type, entry_value, scope)
|
.delete_admin_blocklist_entry(list_type, entry_value, scope)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map(drop)
|
||||||
Ok(())
|
.map_err(|error| self.generated_error(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_blocklist_entry(
|
async fn check_blocklist_entry(
|
||||||
@@ -276,6 +348,7 @@ impl AdminApiClient {
|
|||||||
entry_value: &str,
|
entry_value: &str,
|
||||||
scope: Option<&str>,
|
scope: Option<&str>,
|
||||||
) -> ApiResult<BanCheckResult> {
|
) -> ApiResult<BanCheckResult> {
|
||||||
|
let list_type = blocklist_list_type(list_type)?;
|
||||||
let scope = scope.map(blocklist_get_scope).transpose()?;
|
let scope = scope.map(blocklist_get_scope).transpose()?;
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
@@ -290,12 +363,9 @@ const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
|
|||||||
|
|
||||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||||
|
|
||||||
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
|
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||||
format!(
|
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||||
"/admin/blocklists/{}/entries/{}",
|
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||||
urlencoding::encode(list_type),
|
|
||||||
urlencoding::encode(entry_value)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
|
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::BulkJobResponse;
|
use super::types::BulkJobResponse;
|
||||||
@@ -13,15 +13,11 @@ impl AdminApiClient {
|
|||||||
remove_flags: &[String],
|
remove_flags: &[String],
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<BulkJobResponse> {
|
) -> ApiResult<BulkJobResponse> {
|
||||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
|
||||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
|
add_flags: user_flags(add_flags)?,
|
||||||
add_flags: user_flags(add_flags),
|
remove_flags: user_flags(remove_flags)?,
|
||||||
remove_flags: user_flags(remove_flags),
|
user_ids: snowflakes(user_ids),
|
||||||
task:
|
};
|
||||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
|
|
||||||
user_ids: snowflakes(user_ids),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -33,14 +29,11 @@ impl AdminApiClient {
|
|||||||
remove_flags: &[String],
|
remove_flags: &[String],
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<BulkJobResponse> {
|
) -> ApiResult<BulkJobResponse> {
|
||||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
|
||||||
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
|
add_flags: add_flags.to_vec(),
|
||||||
add_flags: add_flags.to_vec(),
|
remove_flags: remove_flags.to_vec(),
|
||||||
remove_flags: remove_flags.to_vec(),
|
user_ids: snowflakes(user_ids),
|
||||||
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
|
};
|
||||||
user_ids: snowflakes(user_ids),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -52,14 +45,11 @@ impl AdminApiClient {
|
|||||||
remove_features: &[String],
|
remove_features: &[String],
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<BulkJobResponse> {
|
) -> ApiResult<BulkJobResponse> {
|
||||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
|
||||||
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
|
add_features: guild_features(add_features),
|
||||||
add_features: guild_features(add_features),
|
guild_ids: snowflakes(guild_ids),
|
||||||
guild_ids: snowflakes(guild_ids),
|
remove_features: guild_features(remove_features),
|
||||||
remove_features: guild_features(remove_features),
|
};
|
||||||
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -70,14 +60,10 @@ impl AdminApiClient {
|
|||||||
user_ids: &[String],
|
user_ids: &[String],
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<BulkJobResponse> {
|
) -> ApiResult<BulkJobResponse> {
|
||||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
|
||||||
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
|
guild_id: snowflake(guild_id),
|
||||||
guild_id: snowflake(guild_id),
|
user_ids: snowflakes(user_ids),
|
||||||
task:
|
};
|
||||||
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
|
|
||||||
user_ids: snowflakes(user_ids),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -87,13 +73,9 @@ impl AdminApiClient {
|
|||||||
user_ids: &[String],
|
user_ids: &[String],
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<BulkJobResponse> {
|
) -> ApiResult<BulkJobResponse> {
|
||||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
|
user_ids: snowflakes(user_ids),
|
||||||
task:
|
};
|
||||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
|
|
||||||
user_ids: snowflakes(user_ids),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -106,44 +88,37 @@ impl AdminApiClient {
|
|||||||
public_reason: Option<&str>,
|
public_reason: Option<&str>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<BulkJobResponse> {
|
) -> ApiResult<BulkJobResponse> {
|
||||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
|
||||||
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
|
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||||
days_until_deletion: Some(
|
days_until_deletion,
|
||||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
"days_until_deletion",
|
||||||
.map_err(ApiError::Parse)?,
|
)
|
||||||
),
|
.map_err(ApiError::Parse)?
|
||||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
.into(),
|
||||||
reason_code: crate::api::generated::deletion_reason_code(
|
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
reason_code: crate::api::generated::deletion_reason_code(
|
||||||
"reason_code",
|
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||||
)
|
"reason_code",
|
||||||
.map_err(ApiError::Parse)?,
|
)
|
||||||
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
|
.map_err(ApiError::Parse)?,
|
||||||
user_ids: snowflakes(user_ids),
|
user_ids: snowflakes(user_ids),
|
||||||
},
|
};
|
||||||
);
|
|
||||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
|
||||||
generated_types::SnowflakeType::from(value.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||||
values
|
values.iter().map(|value| snowflake(value)).collect()
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(generated_types::SnowflakeType::from)
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||||
values
|
values
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.map(|value| {
|
||||||
.map(generated_types::UserFlags::from)
|
generated_types::UserFlags::try_from(value.as_str())
|
||||||
|
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+215
-131
@@ -1,6 +1,8 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use crate::api::generated::GeneratedClient;
|
||||||
use crate::{config::AdminConfig, session::Session};
|
use crate::{config::AdminConfig, session::Session};
|
||||||
|
use progenitor_client::ClientInfo;
|
||||||
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
|
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue};
|
||||||
use reqwest::{Method, RequestBuilder};
|
use reqwest::{Method, RequestBuilder};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -34,46 +36,39 @@ impl<T> ApiResultExt<T> for ApiResult<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct AdminApiClient {
|
pub struct AdminApiClient {
|
||||||
http_client: reqwest::Client,
|
generated: GeneratedClient,
|
||||||
generated: crate::api::generated::GeneratedClient,
|
|
||||||
base_url: String,
|
|
||||||
access_token: String,
|
|
||||||
proxy_client_ip_headers: HeaderMap,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AdminApiClient {
|
impl AdminApiClient {
|
||||||
pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self {
|
pub fn new(http_client: &reqwest::Client, config: &AdminConfig, session: &Session) -> Self {
|
||||||
let generated_http_client = build_generated_http_client(config, session);
|
let generated = GeneratedClient::new_with_client(
|
||||||
let generated = crate::api::generated::GeneratedClient::new_with_client(
|
|
||||||
&config.api_endpoint,
|
&config.api_endpoint,
|
||||||
generated_http_client,
|
http_client.clone(),
|
||||||
|
build_session_headers(config, session),
|
||||||
);
|
);
|
||||||
Self {
|
Self { generated }
|
||||||
http_client: http_client.clone(),
|
|
||||||
generated,
|
|
||||||
base_url: config.api_endpoint.clone(),
|
|
||||||
access_token: session.access_token.clone(),
|
|
||||||
proxy_client_ip_headers: build_proxy_client_ip_headers(config),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
|
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
|
||||||
let base = format!("{}{}", self.base_url, path);
|
let mut url = format!("{}{}", self.generated.baseurl(), path);
|
||||||
match query_params {
|
let query = query_params
|
||||||
None => base,
|
.unwrap_or_default()
|
||||||
Some(params) => {
|
.iter()
|
||||||
let filtered: Vec<_> = params.iter().filter(|(_, v)| !v.is_empty()).collect();
|
.filter(|(_, value)| !value.is_empty())
|
||||||
if filtered.is_empty() {
|
.map(|(key, value)| {
|
||||||
return base;
|
format!(
|
||||||
}
|
"{}={}",
|
||||||
let query = filtered
|
urlencoding::encode(key),
|
||||||
.iter()
|
urlencoding::encode(value)
|
||||||
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
|
)
|
||||||
.collect::<Vec<_>>()
|
})
|
||||||
.join("&");
|
.collect::<Vec<_>>()
|
||||||
format!("{base}?{query}")
|
.join("&");
|
||||||
}
|
if !query.is_empty() {
|
||||||
|
url.push('?');
|
||||||
|
url.push_str(&query);
|
||||||
}
|
}
|
||||||
|
url
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request(
|
fn request(
|
||||||
@@ -81,30 +76,26 @@ impl AdminApiClient {
|
|||||||
method: Method,
|
method: Method,
|
||||||
path: &str,
|
path: &str,
|
||||||
query_params: Option<&[(&str, &str)]>,
|
query_params: Option<&[(&str, &str)]>,
|
||||||
) -> RequestBuilder {
|
|
||||||
let url = self.build_url(path, query_params);
|
|
||||||
self.http_client
|
|
||||||
.request(method, &url)
|
|
||||||
.header("Authorization", format!("Bearer {}", self.access_token))
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.headers(self.proxy_client_ip_headers.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_audit_log_reason(
|
|
||||||
builder: RequestBuilder,
|
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> RequestBuilder {
|
) -> ApiResult<RequestBuilder> {
|
||||||
match audit_log_reason {
|
let url = self.build_url(path, query_params);
|
||||||
Some(reason) => builder.header("X-Audit-Log-Reason", reason),
|
Ok(self
|
||||||
None => builder,
|
.generated
|
||||||
}
|
.client()
|
||||||
|
.request(method, &url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.headers(self.headers_with_reason(audit_log_reason)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_json_body(builder: RequestBuilder, body: Option<&serde_json::Value>) -> RequestBuilder {
|
fn headers_with_reason(&self, audit_log_reason: Option<&str>) -> ApiResult<HeaderMap> {
|
||||||
match body {
|
let mut headers = self.generated.inner().clone();
|
||||||
Some(body) => builder.json(body),
|
if let Some(reason) = audit_log_reason {
|
||||||
None => builder,
|
let mut value = HeaderValue::from_str(reason)
|
||||||
|
.map_err(|_| ApiError::Parse("invalid audit log reason header".to_owned()))?;
|
||||||
|
value.set_sensitive(true);
|
||||||
|
headers.insert("x-audit-log-reason", value);
|
||||||
}
|
}
|
||||||
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> {
|
async fn send_request(builder: RequestBuilder) -> ApiResult<reqwest::Response> {
|
||||||
@@ -114,13 +105,29 @@ impl AdminApiClient {
|
|||||||
.map_err(|e| ApiError::Network(e.to_string()))
|
.map_err(|e| ApiError::Network(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_json<B: Serialize + ?Sized>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&B>,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<reqwest::Response> {
|
||||||
|
let builder = self.request(method, path, None, audit_log_reason)?;
|
||||||
|
let builder = match body {
|
||||||
|
Some(body) => builder.json(body),
|
||||||
|
None => builder,
|
||||||
|
};
|
||||||
|
Self::send_request(builder).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get<T: DeserializeOwned>(
|
pub async fn get<T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
query_params: Option<&[(&str, &str)]>,
|
query_params: Option<&[(&str, &str)]>,
|
||||||
) -> ApiResult<T> {
|
) -> ApiResult<T> {
|
||||||
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
|
let response =
|
||||||
self.parse_response(response).await
|
Self::send_request(self.request(Method::GET, path, query_params, None)?).await?;
|
||||||
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post<T: DeserializeOwned>(
|
pub async fn post<T: DeserializeOwned>(
|
||||||
@@ -149,10 +156,10 @@ impl AdminApiClient {
|
|||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
{
|
{
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
.send_json(Method::POST, path, Some(body), audit_log_reason)
|
||||||
let response = Self::send_request(builder.json(body)).await?;
|
.await?;
|
||||||
self.parse_response(response).await
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_with_reason<T: DeserializeOwned>(
|
pub async fn post_with_reason<T: DeserializeOwned>(
|
||||||
@@ -161,10 +168,10 @@ impl AdminApiClient {
|
|||||||
body: Option<&serde_json::Value>,
|
body: Option<&serde_json::Value>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<T> {
|
) -> ApiResult<T> {
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
.send_json(Method::POST, path, body, audit_log_reason)
|
||||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
.await?;
|
||||||
self.parse_response(response).await
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||||
@@ -177,9 +184,9 @@ impl AdminApiClient {
|
|||||||
body: Option<&serde_json::Value>,
|
body: Option<&serde_json::Value>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
.send_json(Method::POST, path, body, audit_log_reason)
|
||||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
.await?;
|
||||||
Self::parse_void_response(response).await
|
Self::parse_void_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,10 +204,10 @@ impl AdminApiClient {
|
|||||||
body: Option<&serde_json::Value>,
|
body: Option<&serde_json::Value>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<T> {
|
) -> ApiResult<T> {
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
.send_json(Method::PATCH, path, body, audit_log_reason)
|
||||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
.await?;
|
||||||
self.parse_response(response).await
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn patch_typed_with_reason<T, B>(
|
pub async fn patch_typed_with_reason<T, B>(
|
||||||
@@ -213,10 +220,10 @@ impl AdminApiClient {
|
|||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
{
|
{
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
|
||||||
let response = Self::send_request(builder.json(body)).await?;
|
.await?;
|
||||||
self.parse_response(response).await
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn put_with_reason<T: DeserializeOwned>(
|
pub async fn put_with_reason<T: DeserializeOwned>(
|
||||||
@@ -225,10 +232,10 @@ impl AdminApiClient {
|
|||||||
body: Option<&serde_json::Value>,
|
body: Option<&serde_json::Value>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<T> {
|
) -> ApiResult<T> {
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
.await?;
|
||||||
self.parse_response(response).await
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn put_typed_with_reason<T, B>(
|
pub async fn put_typed_with_reason<T, B>(
|
||||||
@@ -241,10 +248,10 @@ impl AdminApiClient {
|
|||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
B: Serialize + ?Sized,
|
B: Serialize + ?Sized,
|
||||||
{
|
{
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
.send_json(Method::PUT, path, Some(body), audit_log_reason)
|
||||||
let response = Self::send_request(builder.json(body)).await?;
|
.await?;
|
||||||
self.parse_response(response).await
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn put_void_with_reason(
|
pub async fn put_void_with_reason(
|
||||||
@@ -253,9 +260,9 @@ impl AdminApiClient {
|
|||||||
body: Option<&serde_json::Value>,
|
body: Option<&serde_json::Value>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
.await?;
|
||||||
Self::parse_void_response(response).await
|
Self::parse_void_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,9 +276,9 @@ impl AdminApiClient {
|
|||||||
body: Option<&serde_json::Value>,
|
body: Option<&serde_json::Value>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
.await?;
|
||||||
Self::parse_void_response(response).await
|
Self::parse_void_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,31 +288,42 @@ impl AdminApiClient {
|
|||||||
body: Option<&serde_json::Value>,
|
body: Option<&serde_json::Value>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<T> {
|
) -> ApiResult<T> {
|
||||||
let builder =
|
let response = self
|
||||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
.await?;
|
||||||
self.parse_response(response).await
|
Self::parse_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||||||
if response.status().is_success() {
|
Self::check_response_status(response).await.map(drop)
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let text = response.text().await.map_err(|error| {
|
|
||||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
|
||||||
})?;
|
|
||||||
Err(ApiError::Http {
|
|
||||||
status,
|
|
||||||
message: text,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn generated(&self) -> &crate::api::generated::GeneratedClient {
|
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
|
||||||
|
if response.status().is_success() {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.map_err(|error| {
|
||||||
|
ApiError::Network(format!("failed to read error response body: {error}"))
|
||||||
|
})?;
|
||||||
|
Err(ApiError::Http { status, message })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generated(&self) -> &GeneratedClient {
|
||||||
&self.generated
|
&self.generated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generated_with_reason(
|
||||||
|
&self,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
|
) -> ApiResult<GeneratedClient> {
|
||||||
|
Ok(GeneratedClient::new_with_client(
|
||||||
|
self.generated.baseurl(),
|
||||||
|
self.generated.client().clone(),
|
||||||
|
self.headers_with_reason(audit_log_reason)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn generated_value<T, U>(&self, value: U) -> ApiResult<T>
|
pub(crate) fn generated_value<T, U>(&self, value: U) -> ApiResult<T>
|
||||||
where
|
where
|
||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
@@ -328,28 +346,16 @@ impl AdminApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn parse_response<T: DeserializeOwned>(
|
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
|
||||||
&self,
|
let response = Self::check_response_status(response).await?;
|
||||||
response: reqwest::Response,
|
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||||
) -> ApiResult<T> {
|
String::new()
|
||||||
let status = response.status();
|
} else {
|
||||||
if status.as_u16() == 204 {
|
response
|
||||||
return serde_json::from_value(serde_json::Value::Null)
|
.text()
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()));
|
.await
|
||||||
}
|
.map_err(|e| ApiError::Network(e.to_string()))?
|
||||||
if !status.is_success() {
|
};
|
||||||
let text = response.text().await.map_err(|error| {
|
|
||||||
ApiError::Network(format!("failed to read error response body: {error}"))
|
|
||||||
})?;
|
|
||||||
return Err(ApiError::Http {
|
|
||||||
status: status.as_u16(),
|
|
||||||
message: text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let text = response
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| ApiError::Network(e.to_string()))?;
|
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return serde_json::from_value(serde_json::Value::Null)
|
return serde_json::from_value(serde_json::Value::Null)
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()));
|
.map_err(|e| ApiError::Parse(e.to_string()));
|
||||||
@@ -358,17 +364,14 @@ impl AdminApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_generated_http_client(config: &AdminConfig, session: &Session) -> reqwest::Client {
|
fn build_session_headers(config: &AdminConfig, session: &Session) -> HeaderMap {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
let auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
|
let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", session.access_token))
|
||||||
.expect("failed to build generated API Authorization header");
|
.expect("failed to build generated API Authorization header");
|
||||||
|
auth_value.set_sensitive(true);
|
||||||
headers.insert(AUTHORIZATION, auth_value);
|
headers.insert(AUTHORIZATION, auth_value);
|
||||||
headers.extend(build_proxy_client_ip_headers(config));
|
headers.extend(build_proxy_client_ip_headers(config));
|
||||||
reqwest::Client::builder()
|
headers
|
||||||
.user_agent(format!("FluxerAdmin/{} (Rust)", config.build_version))
|
|
||||||
.default_headers(headers)
|
|
||||||
.build()
|
|
||||||
.expect("failed to create generated API HTTP client")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn with_proxy_client_ip_header(
|
pub(crate) fn with_proxy_client_ip_header(
|
||||||
@@ -418,3 +421,84 @@ impl std::fmt::Display for ApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn response(status: u16, body: &'static str) -> reqwest::Response {
|
||||||
|
axum::http::Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(body)
|
||||||
|
.expect("valid response")
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parses_successful_json_and_empty_responses() {
|
||||||
|
for (status, body, expected) in [
|
||||||
|
(200, r#"{"value":1}"#, json!({"value": 1})),
|
||||||
|
(201, "[1,2]", json!([1, 2])),
|
||||||
|
(202, "null", Value::Null),
|
||||||
|
(200, "", Value::Null),
|
||||||
|
(204, "ignored body", Value::Null),
|
||||||
|
] {
|
||||||
|
let actual: Value = AdminApiClient::parse_response(response(status, body))
|
||||||
|
.await
|
||||||
|
.expect("valid response body");
|
||||||
|
assert_eq!(actual, expected, "HTTP {status}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_responses_preserve_null_deserialization_errors() {
|
||||||
|
let expected = serde_json::from_value::<Vec<String>>(Value::Null)
|
||||||
|
.expect_err("null is not a list")
|
||||||
|
.to_string();
|
||||||
|
for (status, body) in [(200, ""), (204, "ignored body")] {
|
||||||
|
let error = AdminApiClient::parse_response::<Vec<String>>(response(status, body))
|
||||||
|
.await
|
||||||
|
.expect_err("missing list");
|
||||||
|
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn malformed_json_preserves_deserialization_errors() {
|
||||||
|
for body in [" ", "{", "not JSON"] {
|
||||||
|
let expected = serde_json::from_str::<Value>(body)
|
||||||
|
.expect_err("malformed JSON")
|
||||||
|
.to_string();
|
||||||
|
let error = AdminApiClient::parse_response::<Value>(response(200, body))
|
||||||
|
.await
|
||||||
|
.expect_err("malformed response");
|
||||||
|
assert_eq!(error.to_string(), format!("parse error: {expected}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn void_responses_do_not_parse_successful_bodies() {
|
||||||
|
for status in [200, 201, 202, 204] {
|
||||||
|
AdminApiClient::parse_void_response(response(status, "not JSON"))
|
||||||
|
.await
|
||||||
|
.expect("successful void response");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn typed_and_void_responses_preserve_http_errors() {
|
||||||
|
for status in [302, 400, 403, 404, 500] {
|
||||||
|
for body in ["", "plain error", r#"{"code":"FORBIDDEN"}"#] {
|
||||||
|
let typed = AdminApiClient::parse_response::<Value>(response(status, body))
|
||||||
|
.await
|
||||||
|
.map(drop);
|
||||||
|
let empty = AdminApiClient::parse_void_response(response(status, body)).await;
|
||||||
|
for result in [typed, empty] {
|
||||||
|
let error = result.expect_err("unsuccessful response");
|
||||||
|
assert_eq!(error.to_string(), format!("HTTP {status}: {body}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,20 +9,20 @@ impl AdminApiClient {
|
|||||||
pub async fn generate_gift_codes(
|
pub async fn generate_gift_codes(
|
||||||
&self,
|
&self,
|
||||||
count: u32,
|
count: u32,
|
||||||
duration_type: &str,
|
duration_type: generated_types::GiftCodeDurationTypeSchema,
|
||||||
duration_quantity: u32,
|
duration_quantity: u32,
|
||||||
) -> ApiResult<CodesResponse> {
|
) -> ApiResult<CodesResponse> {
|
||||||
let body = generated_types::GenerateGiftCodesRequest {
|
let body = generated_types::GenerateGiftCodesRequest {
|
||||||
count: crate::api::generated::nonzero_u32(count, "count").map_err(ApiError::Parse)?,
|
count: crate::api::generated::nonzero_u32(count, "count")
|
||||||
|
.map_err(ApiError::Parse)?
|
||||||
|
.into(),
|
||||||
duration_quantity: crate::api::generated::nonzero_u32(
|
duration_quantity: crate::api::generated::nonzero_u32(
|
||||||
duration_quantity,
|
duration_quantity,
|
||||||
"duration_quantity",
|
"duration_quantity",
|
||||||
)
|
)
|
||||||
.map_err(ApiError::Parse)?,
|
.map_err(ApiError::Parse)?
|
||||||
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from(
|
.into(),
|
||||||
duration_type,
|
duration_type,
|
||||||
)
|
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{
|
use super::types::{
|
||||||
@@ -16,8 +16,7 @@ impl AdminApiClient {
|
|||||||
.list_admin_discovery_applications()
|
.list_admin_discovery_applications()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
response
|
Vec::from(response.into_inner())
|
||||||
.into_inner()
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(pending_discovery_application)
|
.map(pending_discovery_application)
|
||||||
.collect()
|
.collect()
|
||||||
@@ -29,8 +28,7 @@ impl AdminApiClient {
|
|||||||
.list_admin_discovery_listings()
|
.list_admin_discovery_listings()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
response
|
Vec::from(response.into_inner())
|
||||||
.into_inner()
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(listed_guild)
|
.map(listed_guild)
|
||||||
.collect()
|
.collect()
|
||||||
@@ -41,16 +39,13 @@ impl AdminApiClient {
|
|||||||
guild_id: &str,
|
guild_id: &str,
|
||||||
reason: Option<&str>,
|
reason: Option<&str>,
|
||||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
let guild_id = snowflake(guild_id);
|
||||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
|
||||||
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
|
reason: reason
|
||||||
reason: reason
|
.map(generated_types::DiscoveryReviewReason::try_from)
|
||||||
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
|
.transpose()
|
||||||
.transpose()
|
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
};
|
||||||
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.update_admin_discovery_application(&guild_id, &body)
|
.update_admin_discovery_application(&guild_id, &body)
|
||||||
@@ -64,18 +59,11 @@ impl AdminApiClient {
|
|||||||
guild_id: &str,
|
guild_id: &str,
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
let guild_id = snowflake(guild_id);
|
||||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
|
||||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
|
reason: generated_types::DiscoveryRejectionReason::try_from(reason)
|
||||||
reason:
|
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
|
};
|
||||||
reason,
|
|
||||||
)
|
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
|
||||||
status:
|
|
||||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.update_admin_discovery_application(&guild_id, &body)
|
.update_admin_discovery_application(&guild_id, &body)
|
||||||
@@ -89,7 +77,7 @@ impl AdminApiClient {
|
|||||||
guild_id: &str,
|
guild_id: &str,
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
let guild_id = snowflake(guild_id);
|
||||||
let body = generated_types::DiscoveryAdminRemoveRequest {
|
let body = generated_types::DiscoveryAdminRemoveRequest {
|
||||||
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
|
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo};
|
||||||
|
use reqwest::header::HeaderMap;
|
||||||
|
|
||||||
#[allow(
|
#[allow(
|
||||||
clippy::all,
|
clippy::all,
|
||||||
unused_imports,
|
unused_imports,
|
||||||
@@ -16,6 +19,24 @@ pub use inner::types;
|
|||||||
|
|
||||||
pub use inner::Client as GeneratedClient;
|
pub use inner::Client as GeneratedClient;
|
||||||
|
|
||||||
|
impl ClientHooks<HeaderMap> for GeneratedClient {
|
||||||
|
async fn pre<E>(
|
||||||
|
&self,
|
||||||
|
request: &mut reqwest::Request,
|
||||||
|
_info: &OperationInfo,
|
||||||
|
) -> Result<(), Error<E>> {
|
||||||
|
let headers = request.headers_mut();
|
||||||
|
for (name, value) in self.inner() {
|
||||||
|
headers.entry(name.clone()).or_insert_with(|| value.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn snowflake(value: &str) -> types::SnowflakeType {
|
||||||
|
types::SnowflakeType::Variant0(value.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn number_to_u64(value: f64, field: &str) -> Result<u64, String> {
|
pub(crate) fn number_to_u64(value: f64, field: &str) -> Result<u64, String> {
|
||||||
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
|
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
|
||||||
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
|
if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > MAX_SAFE_INTEGER {
|
||||||
@@ -126,10 +147,10 @@ mod tests {
|
|||||||
let response: SearchGuildsResponse =
|
let response: SearchGuildsResponse =
|
||||||
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
|
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
|
||||||
|
|
||||||
assert_eq!(response.total as i64, 1);
|
assert_eq!(response.total, 1.0);
|
||||||
assert_eq!(response.guilds.len(), 1);
|
assert_eq!(response.guilds.len(), 1);
|
||||||
assert_eq!(response.guilds[0].name, "Test Guild");
|
assert_eq!(response.guilds[0].name, "Test Guild");
|
||||||
assert_eq!(response.guilds[0].member_count, 42);
|
assert_eq!(*response.guilds[0].member_count, 42);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::snowflake;
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiResult};
|
use super::client::{AdminApiClient, ApiResult};
|
||||||
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
|
use super::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
|
||||||
|
|
||||||
impl AdminApiClient {
|
impl AdminApiClient {
|
||||||
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> {
|
pub async fn list_guild_emojis(&self, guild_id: &str) -> ApiResult<ListGuildEmojisResponse> {
|
||||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
let guild_id = snowflake(guild_id);
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.list_admin_guild_emojis(&guild_id)
|
.list_admin_guild_emojis(&guild_id)
|
||||||
@@ -20,7 +20,7 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
guild_id: &str,
|
guild_id: &str,
|
||||||
) -> ApiResult<ListGuildStickersResponse> {
|
) -> ApiResult<ListGuildStickersResponse> {
|
||||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
let guild_id = snowflake(guild_id);
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.list_admin_guild_stickers(&guild_id)
|
.list_admin_guild_stickers(&guild_id)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
|
use super::reports::SearchReportsParams;
|
||||||
use super::types::{
|
use super::types::{
|
||||||
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
|
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
|
||||||
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
|
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
|
||||||
@@ -15,7 +16,7 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
offset: u32,
|
offset: u64,
|
||||||
) -> ApiResult<SearchGuildsResponse> {
|
) -> ApiResult<SearchGuildsResponse> {
|
||||||
let limit = limit.to_string();
|
let limit = limit.to_string();
|
||||||
let offset = offset.to_string();
|
let offset = offset.to_string();
|
||||||
@@ -28,13 +29,8 @@ impl AdminApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
|
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
|
||||||
let response = self
|
self.lookup_guild(guild_id)
|
||||||
.generated()
|
.await?
|
||||||
.get_admin_guild(&snowflake(guild_id))
|
|
||||||
.await
|
|
||||||
.map_err(|e| self.generated_error(e))?;
|
|
||||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
|
||||||
resp.guild
|
|
||||||
.map(GuildInfo::from)
|
.map(GuildInfo::from)
|
||||||
.ok_or_else(|| super::client::ApiError::Http {
|
.ok_or_else(|| super::client::ApiError::Http {
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -101,7 +97,7 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
guild_id: &str,
|
guild_id: &str,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
offset: u32,
|
offset: u64,
|
||||||
) -> ApiResult<ListGuildMembersResponse> {
|
) -> ApiResult<ListGuildMembersResponse> {
|
||||||
let limit = limit.to_string();
|
let limit = limit.to_string();
|
||||||
let offset = offset.to_string();
|
let offset = offset.to_string();
|
||||||
@@ -144,8 +140,7 @@ impl AdminApiClient {
|
|||||||
let limit = limit
|
let limit = limit
|
||||||
.map(i32::try_from)
|
.map(i32::try_from)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||||
.map(generated_types::Int32Type::from);
|
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.list_admin_guild_audit_logs(
|
.list_admin_guild_audit_logs(
|
||||||
@@ -153,7 +148,7 @@ impl AdminApiClient {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
before.as_ref(),
|
before.as_ref(),
|
||||||
limit.as_ref(),
|
limit,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -257,39 +252,29 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
guild_id: &str,
|
guild_id: &str,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
offset: u32,
|
offset: u64,
|
||||||
) -> ApiResult<SearchReportsResponse> {
|
) -> ApiResult<SearchReportsResponse> {
|
||||||
self.search_reports(
|
self.search_reports(&SearchReportsParams {
|
||||||
None,
|
reported_guild_id: Some(guild_id),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(guild_id),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
)
|
..Default::default()
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct GuildSettingsPatch {
|
struct GuildSettingsPatch {
|
||||||
content_warning_level: Option<generated_types::ContentWarningLevel>,
|
content_warning_level: Option<generated_types::ContentWarningLevelInput>,
|
||||||
content_warning_text: Option<String>,
|
content_warning_text: Option<String>,
|
||||||
default_message_notifications: Option<generated_types::DefaultMessageNotifications>,
|
default_message_notifications: Option<generated_types::DefaultMessageNotificationsInput>,
|
||||||
disabled_operations: Option<generated_types::GuildOperations>,
|
disabled_operations: Option<generated_types::GuildOperations>,
|
||||||
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>,
|
explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
|
||||||
mfa_level: Option<generated_types::GuildMfaLevel>,
|
mfa_level: Option<generated_types::GuildMfaLevelInput>,
|
||||||
nsfw: Option<bool>,
|
nsfw: Option<bool>,
|
||||||
nsfw_level: Option<generated_types::NsfwLevel>,
|
nsfw_level: Option<generated_types::NsfwLevelInput>,
|
||||||
verification_level: Option<generated_types::GuildVerificationLevel>,
|
verification_level: Option<generated_types::GuildVerificationLevelInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn search_guilds_response(
|
fn search_guilds_response(
|
||||||
@@ -317,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
|||||||
owner_global_name: response.owner_global_name,
|
owner_global_name: response.owner_global_name,
|
||||||
owner_discriminator: response.owner_discriminator,
|
owner_discriminator: response.owner_discriminator,
|
||||||
member_count: crate::api::generated::i64_to_u64(
|
member_count: crate::api::generated::i64_to_u64(
|
||||||
i64::from(response.member_count),
|
i64::from(i32::from(response.member_count)),
|
||||||
"member_count",
|
"member_count",
|
||||||
)
|
)
|
||||||
.map_err(ApiError::Parse)?,
|
.map_err(ApiError::Parse)?,
|
||||||
@@ -325,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
|||||||
nsfw_level: response.nsfw_level.map(i32::from),
|
nsfw_level: response.nsfw_level.map(i32::from),
|
||||||
nsfw: response.nsfw,
|
nsfw: response.nsfw,
|
||||||
content_warning_level: response.content_warning_level.map(i32::from),
|
content_warning_level: response.content_warning_level.map(i32::from),
|
||||||
content_warning_text: response.content_warning_text,
|
content_warning_text: response.content_warning_text.map(String::from),
|
||||||
description: None,
|
description: None,
|
||||||
vanity_url_code: None,
|
vanity_url_code: None,
|
||||||
})
|
})
|
||||||
@@ -338,9 +323,9 @@ fn guild_update_response(
|
|||||||
Ok(GuildUpdateResponse {
|
Ok(GuildUpdateResponse {
|
||||||
guild: GuildInfo {
|
guild: GuildInfo {
|
||||||
id: String::from(guild.id),
|
id: String::from(guild.id),
|
||||||
name: guild.name,
|
name: String::from(guild.name),
|
||||||
icon: guild.icon,
|
icon: guild.icon.map(String::from),
|
||||||
banner: guild.banner,
|
banner: guild.banner.map(String::from),
|
||||||
owner_id: String::from(guild.owner_id),
|
owner_id: String::from(guild.owner_id),
|
||||||
owner_username: None,
|
owner_username: None,
|
||||||
owner_global_name: None,
|
owner_global_name: None,
|
||||||
@@ -350,11 +335,11 @@ fn guild_update_response(
|
|||||||
"member_count",
|
"member_count",
|
||||||
)
|
)
|
||||||
.map_err(ApiError::Parse)?,
|
.map_err(ApiError::Parse)?,
|
||||||
features: guild.features,
|
features: guild.features.into_iter().map(String::from).collect(),
|
||||||
nsfw_level: guild.nsfw_level.map(i32::from),
|
nsfw_level: guild.nsfw_level.map(i32::from),
|
||||||
nsfw: guild.nsfw,
|
nsfw: guild.nsfw,
|
||||||
content_warning_level: guild.content_warning_level.map(i32::from),
|
content_warning_level: guild.content_warning_level.map(i32::from),
|
||||||
content_warning_text: guild.content_warning_text,
|
content_warning_text: guild.content_warning_text.map(String::from),
|
||||||
description: None,
|
description: None,
|
||||||
vanity_url_code: None,
|
vanity_url_code: None,
|
||||||
},
|
},
|
||||||
@@ -380,10 +365,6 @@ fn guild_settings_request(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
|
||||||
generated_types::SnowflakeType::from(value.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||||
values
|
values
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use crate::api::generated::snowflake;
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiResult};
|
use super::client::{AdminApiClient, ApiResult};
|
||||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||||
|
|
||||||
@@ -22,9 +24,9 @@ impl AdminApiClient {
|
|||||||
let max_lookback_days = params.max_lookback_days.to_string();
|
let max_lookback_days = params.max_lookback_days.to_string();
|
||||||
let query_params = [
|
let query_params = [
|
||||||
("limit", limit.as_str()),
|
("limit", limit.as_str()),
|
||||||
("cursor_bucket_day", cursor_bucket_day.as_str()),
|
("cursor_bucket_day", cursor_bucket_day),
|
||||||
("cursor_created_at", cursor_created_at.as_str()),
|
("cursor_created_at", cursor_created_at),
|
||||||
("cursor_job_id", cursor_job_id.as_str()),
|
("cursor_job_id", cursor_job_id),
|
||||||
("max_lookback_days", max_lookback_days.as_str()),
|
("max_lookback_days", max_lookback_days.as_str()),
|
||||||
("status", params.status.as_deref().unwrap_or_default()),
|
("status", params.status.as_deref().unwrap_or_default()),
|
||||||
("task_type", params.task_type.as_deref().unwrap_or_default()),
|
("task_type", params.task_type.as_deref().unwrap_or_default()),
|
||||||
@@ -39,7 +41,7 @@ impl AdminApiClient {
|
|||||||
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
|
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.get_admin_job(job_id)
|
.get_admin_job(&snowflake(job_id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
self.generated_value(response.into_inner())
|
self.generated_value(response.into_inner())
|
||||||
@@ -68,10 +70,9 @@ impl AdminApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String {
|
fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
|
||||||
cursor
|
cursor
|
||||||
.and_then(|cursor| cursor.get(field))
|
.and_then(|cursor| cursor.get(field))
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_owned()
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{
|
use super::types::{
|
||||||
@@ -43,7 +43,8 @@ impl AdminApiClient {
|
|||||||
attachment_id: snowflake(attachment_id),
|
attachment_id: snowflake(attachment_id),
|
||||||
channel_id: snowflake(channel_id),
|
channel_id: snowflake(channel_id),
|
||||||
confirmed_viewed: true,
|
confirmed_viewed: true,
|
||||||
filename: filename.to_owned(),
|
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
|
||||||
|
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||||
message_id: snowflake(message_id),
|
message_id: snowflake(message_id),
|
||||||
reporter_full_name:
|
reporter_full_name:
|
||||||
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
|
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
|
||||||
@@ -119,7 +120,7 @@ impl AdminApiClient {
|
|||||||
) -> ApiResult<MessageShredStatusResponse> {
|
) -> ApiResult<MessageShredStatusResponse> {
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.get_admin_message_shred(job_id)
|
.get_admin_message_shred(&snowflake(job_id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
self.generated_value(response.into_inner())
|
self.generated_value(response.into_inner())
|
||||||
@@ -133,13 +134,15 @@ impl AdminApiClient {
|
|||||||
context_limit: u32,
|
context_limit: u32,
|
||||||
) -> ApiResult<LookupMessageResponse> {
|
) -> ApiResult<LookupMessageResponse> {
|
||||||
let context_limit = context_limit.to_string();
|
let context_limit = context_limit.to_string();
|
||||||
|
let filename = generated_types::SearchAdminMessagesFilename::try_from(filename)
|
||||||
|
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.search_admin_messages(
|
.search_admin_messages(
|
||||||
Some(&snowflake(attachment_id)),
|
Some(&snowflake(attachment_id)),
|
||||||
&snowflake(channel_id),
|
&snowflake(channel_id),
|
||||||
Some(context_limit.as_str()),
|
Some(context_limit.as_str()),
|
||||||
Some(filename),
|
Some(&filename),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -195,7 +198,3 @@ impl AdminApiClient {
|
|||||||
self.generated_value(response.into_inner())
|
self.generated_value(response.into_inner())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
|
||||||
generated_types::SnowflakeType::from(value.to_owned())
|
|
||||||
}
|
|
||||||
+122
-60
@@ -1,10 +1,30 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use crate::api::generated::snowflake;
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{
|
use super::types::{
|
||||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SearchReportsParams<'a> {
|
||||||
|
pub query: Option<&'a str>,
|
||||||
|
pub status: Option<i32>,
|
||||||
|
pub report_type: Option<i32>,
|
||||||
|
pub category: Option<&'a str>,
|
||||||
|
pub reporter_id: Option<&'a str>,
|
||||||
|
pub reported_user_id: Option<&'a str>,
|
||||||
|
pub reported_guild_id: Option<&'a str>,
|
||||||
|
pub reported_channel_id: Option<&'a str>,
|
||||||
|
pub guild_context_id: Option<&'a str>,
|
||||||
|
pub resolved_by_admin_id: Option<&'a str>,
|
||||||
|
pub sort_by: Option<&'a str>,
|
||||||
|
pub sort_order: Option<&'a str>,
|
||||||
|
pub limit: u32,
|
||||||
|
pub offset: u64,
|
||||||
|
}
|
||||||
|
|
||||||
impl AdminApiClient {
|
impl AdminApiClient {
|
||||||
pub async fn list_reports(
|
pub async fn list_reports(
|
||||||
&self,
|
&self,
|
||||||
@@ -26,7 +46,7 @@ impl AdminApiClient {
|
|||||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.get_admin_report(report_id)
|
.get_admin_report(&snowflake(report_id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
self.generated_value(response.into_inner())
|
self.generated_value(response.into_inner())
|
||||||
@@ -50,51 +70,55 @@ impl AdminApiClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn search_reports(
|
pub async fn search_reports(
|
||||||
&self,
|
&self,
|
||||||
query: Option<&str>,
|
params: &SearchReportsParams<'_>,
|
||||||
status: Option<i32>,
|
|
||||||
report_type: Option<i32>,
|
|
||||||
category: Option<&str>,
|
|
||||||
reporter_id: Option<&str>,
|
|
||||||
reported_user_id: Option<&str>,
|
|
||||||
reported_guild_id: Option<&str>,
|
|
||||||
reported_channel_id: Option<&str>,
|
|
||||||
guild_context_id: Option<&str>,
|
|
||||||
resolved_by_admin_id: Option<&str>,
|
|
||||||
sort_by: Option<&str>,
|
|
||||||
sort_order: Option<&str>,
|
|
||||||
limit: u32,
|
|
||||||
offset: u32,
|
|
||||||
) -> ApiResult<SearchReportsResponse> {
|
) -> ApiResult<SearchReportsResponse> {
|
||||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
let status = params
|
||||||
let report_type = report_type
|
.status
|
||||||
|
.map(report_status)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let report_type = params
|
||||||
|
.report_type
|
||||||
.map(report_type_name)
|
.map(report_type_name)
|
||||||
.transpose()?
|
.transpose()?
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
|
let sort_by = params
|
||||||
let limit = limit.to_string();
|
.sort_by
|
||||||
let offset = offset.to_string();
|
.map(report_sort_by)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let limit = params.limit.to_string();
|
||||||
|
let offset = params.offset.to_string();
|
||||||
let query_params = [
|
let query_params = [
|
||||||
("q", query.unwrap_or_default()),
|
("q", params.query.unwrap_or_default()),
|
||||||
("status", status),
|
("status", status),
|
||||||
("report_type", report_type),
|
("report_type", report_type),
|
||||||
("category", category.unwrap_or_default()),
|
("category", params.category.unwrap_or_default()),
|
||||||
("reporter_id", reporter_id.unwrap_or_default()),
|
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||||
("reported_user_id", reported_user_id.unwrap_or_default()),
|
(
|
||||||
("reported_guild_id", reported_guild_id.unwrap_or_default()),
|
"reported_user_id",
|
||||||
|
params.reported_user_id.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reported_guild_id",
|
||||||
|
params.reported_guild_id.unwrap_or_default(),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"reported_channel_id",
|
"reported_channel_id",
|
||||||
reported_channel_id.unwrap_or_default(),
|
params.reported_channel_id.unwrap_or_default(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"guild_context_id",
|
||||||
|
params.guild_context_id.unwrap_or_default(),
|
||||||
),
|
),
|
||||||
("guild_context_id", guild_context_id.unwrap_or_default()),
|
|
||||||
(
|
(
|
||||||
"resolved_by_admin_id",
|
"resolved_by_admin_id",
|
||||||
resolved_by_admin_id.unwrap_or_default(),
|
params.resolved_by_admin_id.unwrap_or_default(),
|
||||||
),
|
),
|
||||||
("sort_by", sort_by),
|
("sort_by", sort_by),
|
||||||
("sort_order", sort_order.unwrap_or_default()),
|
("sort_order", params.sort_order.unwrap_or_default()),
|
||||||
("limit", limit.as_str()),
|
("limit", limit.as_str()),
|
||||||
("offset", offset.as_str()),
|
("offset", offset.as_str()),
|
||||||
];
|
];
|
||||||
@@ -105,24 +129,14 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
reporter_id: &str,
|
reporter_id: &str,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
offset: u32,
|
offset: u64,
|
||||||
) -> ApiResult<SearchReportsResponse> {
|
) -> ApiResult<SearchReportsResponse> {
|
||||||
self.search_reports(
|
self.search_reports(&SearchReportsParams {
|
||||||
None,
|
reporter_id: Some(reporter_id),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(reporter_id),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
)
|
..Default::default()
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,24 +144,14 @@ impl AdminApiClient {
|
|||||||
&self,
|
&self,
|
||||||
reported_user_id: &str,
|
reported_user_id: &str,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
offset: u32,
|
offset: u64,
|
||||||
) -> ApiResult<SearchReportsResponse> {
|
) -> ApiResult<SearchReportsResponse> {
|
||||||
self.search_reports(
|
self.search_reports(&SearchReportsParams {
|
||||||
None,
|
reported_user_id: Some(reported_user_id),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
Some(reported_user_id),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
)
|
..Default::default()
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,3 +183,61 @@ fn report_sort_by(value: &str) -> ApiResult<&'static str> {
|
|||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_statuses_preserve_the_closed_wire_mapping() {
|
||||||
|
for (value, expected) in [(0, "pending"), (1, "resolved")] {
|
||||||
|
assert_eq!(report_status(value).expect("supported status"), expected);
|
||||||
|
}
|
||||||
|
for value in [-1, 2] {
|
||||||
|
assert_eq!(
|
||||||
|
report_status(value)
|
||||||
|
.expect_err("unknown status")
|
||||||
|
.to_string(),
|
||||||
|
format!("parse error: unknown report status: {value}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_types_preserve_the_closed_wire_mapping() {
|
||||||
|
for (value, expected) in [(0, "message"), (1, "user"), (2, "guild")] {
|
||||||
|
assert_eq!(report_type_name(value).expect("supported type"), expected);
|
||||||
|
}
|
||||||
|
for value in [-1, 3] {
|
||||||
|
assert_eq!(
|
||||||
|
report_type_name(value)
|
||||||
|
.expect_err("unknown type")
|
||||||
|
.to_string(),
|
||||||
|
format!("parse error: unknown report type: {value}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_sort_fields_accept_only_the_existing_aliases() {
|
||||||
|
for (field, expected) in [
|
||||||
|
("createdAt", "created_at"),
|
||||||
|
("created_at", "created_at"),
|
||||||
|
("reportedAt", "reported_at"),
|
||||||
|
("reported_at", "reported_at"),
|
||||||
|
("resolvedAt", "resolved_at"),
|
||||||
|
("resolved_at", "resolved_at"),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
report_sort_by(field).expect("supported sort field"),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
report_sort_by("unknown")
|
||||||
|
.expect_err("unknown sort field")
|
||||||
|
.to_string(),
|
||||||
|
"parse error: unknown report sort field: unknown"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
|
use super::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
|
||||||
@@ -11,8 +11,11 @@ impl AdminApiClient {
|
|||||||
index_type: &str,
|
index_type: &str,
|
||||||
guild_id: Option<&str>,
|
guild_id: Option<&str>,
|
||||||
) -> ApiResult<RefreshSearchIndexResponse> {
|
) -> ApiResult<RefreshSearchIndexResponse> {
|
||||||
|
let index_type =
|
||||||
|
generated_types::CreateAdminSearchIndexRefreshIndexName::try_from(index_type)
|
||||||
|
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||||
let body = generated_types::RefreshSearchIndexRequest {
|
let body = generated_types::RefreshSearchIndexRequest {
|
||||||
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
guild_id: guild_id.map(snowflake),
|
||||||
user_id: None,
|
user_id: None,
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiResult};
|
use super::client::{AdminApiClient, ApiResult};
|
||||||
use super::types::{
|
use super::types::{
|
||||||
@@ -24,11 +24,7 @@ impl AdminApiClient {
|
|||||||
guild_ids: &[String],
|
guild_ids: &[String],
|
||||||
) -> ApiResult<ReloadAllGuildsResponse> {
|
) -> ApiResult<ReloadAllGuildsResponse> {
|
||||||
let body = generated_types::ReloadGuildsRequest {
|
let body = generated_types::ReloadGuildsRequest {
|
||||||
guild_ids: guild_ids
|
guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(generated_types::SnowflakeType::from)
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::SendSystemDmResponse;
|
use super::types::SendSystemDmResponse;
|
||||||
@@ -14,11 +14,7 @@ impl AdminApiClient {
|
|||||||
let body = generated_types::SendSystemDmRequest {
|
let body = generated_types::SendSystemDmRequest {
|
||||||
content: generated_types::SendSystemDmRequestContent::try_from(content)
|
content: generated_types::SendSystemDmRequestContent::try_from(content)
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||||
user_ids: user_ids
|
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(generated_types::SnowflakeType::from)
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub struct AuditLogEntry {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub admin_user: Option<AuditLogUserSummary>,
|
pub admin_user: Option<AuditLogUserSummary>,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub access: Option<String>,
|
||||||
pub target_id: String,
|
pub target_id: String,
|
||||||
pub target_type: String,
|
pub target_type: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ pub fn deserialize_discriminator<'de, D: Deserializer<'de>>(d: D) -> Result<Stri
|
|||||||
let v: serde_json::Value = Deserialize::deserialize(d)?;
|
let v: serde_json::Value = Deserialize::deserialize(d)?;
|
||||||
match v {
|
match v {
|
||||||
serde_json::Value::String(s) => Ok(format!("{:0>4}", s)),
|
serde_json::Value::String(s) => Ok(format!("{:0>4}", s)),
|
||||||
serde_json::Value::Number(n) => Ok(format!("{:04}", n.as_u64().unwrap_or(0))),
|
serde_json::Value::Number(n) => n
|
||||||
_ => Ok("0000".to_owned()),
|
.as_u64()
|
||||||
|
.map(|value| format!("{value:04}"))
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("expected an unsigned integer discriminator")),
|
||||||
|
serde_json::Value::Null => Ok("0000".to_owned()),
|
||||||
|
_ => Err(serde::de::Error::custom(
|
||||||
|
"expected string or unsigned integer discriminator",
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +21,9 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
|
|||||||
let v: serde_json::Value = Deserialize::deserialize(d)?;
|
let v: serde_json::Value = Deserialize::deserialize(d)?;
|
||||||
match v {
|
match v {
|
||||||
serde_json::Value::String(s) => s.parse::<u64>().map_err(serde::de::Error::custom),
|
serde_json::Value::String(s) => s.parse::<u64>().map_err(serde::de::Error::custom),
|
||||||
serde_json::Value::Number(n) => Ok(n.as_u64().unwrap_or(0)),
|
serde_json::Value::Number(n) => n
|
||||||
|
.as_u64()
|
||||||
|
.ok_or_else(|| serde::de::Error::custom("expected an unsigned 64-bit integer")),
|
||||||
serde_json::Value::Null => Ok(0),
|
serde_json::Value::Null => Ok(0),
|
||||||
_ => Err(serde::de::Error::custom("expected string or number")),
|
_ => Err(serde::de::Error::custom("expected string or number")),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct InstanceConfigResponse {
|
pub struct InstanceConfigResponse {
|
||||||
pub sso: SsoConfigResponse,
|
pub sso: SsoConfigResponse,
|
||||||
@@ -18,6 +20,18 @@ pub struct InstanceConfigResponse {
|
|||||||
pub integrations: InstanceIntegrationsResponse,
|
pub integrations: InstanceIntegrationsResponse,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub media: InstanceMediaResponse,
|
pub media: InstanceMediaResponse,
|
||||||
|
#[serde(default)]
|
||||||
|
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||||
|
#[serde(default)]
|
||||||
|
pub push_relay: PushRelayConfigResponse,
|
||||||
|
#[serde(default)]
|
||||||
|
pub domain_migration: DomainMigrationConfigResponse,
|
||||||
|
#[serde(default)]
|
||||||
|
pub altcha_captcha: AltchaCaptchaConfigResponse,
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_timezone: ProfileTimezoneConfigResponse,
|
||||||
|
#[serde(default)]
|
||||||
|
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
@@ -320,6 +334,8 @@ pub struct AppBrandingConfigResponse {
|
|||||||
pub wordmark_url: Option<String>,
|
pub wordmark_url: Option<String>,
|
||||||
pub favicon_url: Option<String>,
|
pub favicon_url: Option<String>,
|
||||||
pub theme_color: Option<String>,
|
pub theme_color: Option<String>,
|
||||||
|
pub status_page_url: Option<String>,
|
||||||
|
pub status_page_incident_history_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppBrandingConfigResponse {
|
impl Default for AppBrandingConfigResponse {
|
||||||
@@ -332,6 +348,8 @@ impl Default for AppBrandingConfigResponse {
|
|||||||
wordmark_url: None,
|
wordmark_url: None,
|
||||||
favicon_url: None,
|
favicon_url: None,
|
||||||
theme_color: None,
|
theme_color: None,
|
||||||
|
status_page_url: None,
|
||||||
|
status_page_incident_history_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,6 +454,306 @@ impl VoiceE2eeScope {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||||
|
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||||
|
pub const ALTCHA_CAPTCHA_DEFAULT_SALT: &str = "altcha-captcha-v1";
|
||||||
|
pub const ALTCHA_CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=100_000;
|
||||||
|
pub const ALTCHA_CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=1_000_000;
|
||||||
|
pub const PROFILE_TIMEZONE_DEFAULT_SALT: &str = "profile-timezone-v1";
|
||||||
|
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||||
|
|
||||||
|
impl NoiseSuppressionBackend {
|
||||||
|
pub const ALL: [Self; 7] = [
|
||||||
|
Self::None,
|
||||||
|
Self::Standard,
|
||||||
|
Self::Gate,
|
||||||
|
Self::Speex,
|
||||||
|
Self::Rnnoise,
|
||||||
|
Self::Gtcrn,
|
||||||
|
Self::DeepFilter,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::None => "None (pass-through)",
|
||||||
|
Self::Standard => "Standard (WebRTC)",
|
||||||
|
Self::Gate => "Noise gate",
|
||||||
|
Self::Speex => "Speex",
|
||||||
|
Self::Rnnoise => "RNNoise",
|
||||||
|
Self::Gtcrn => "GTCRN",
|
||||||
|
Self::DeepFilter => "DeepFilterNet",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub backend: NoiseSuppressionBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub config_version: u64,
|
||||||
|
pub default_backend: NoiseSuppressionBackend,
|
||||||
|
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||||
|
pub allow_user_override: bool,
|
||||||
|
pub rollout_basis_points: u32,
|
||||||
|
pub rollout_salt: String,
|
||||||
|
pub included_user_ids: Vec<String>,
|
||||||
|
pub included_guild_ids: Vec<String>,
|
||||||
|
pub include_premium_users: bool,
|
||||||
|
pub excluded_user_ids: Vec<String>,
|
||||||
|
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||||
|
pub suppression_strength: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
config_version: 0,
|
||||||
|
default_backend: NoiseSuppressionBackend::Standard,
|
||||||
|
enabled_backends: NoiseSuppressionBackend::ALL.to_vec(),
|
||||||
|
allow_user_override: true,
|
||||||
|
rollout_basis_points: 0,
|
||||||
|
rollout_salt: "voice-ns-v1".to_owned(),
|
||||||
|
included_user_ids: Vec::new(),
|
||||||
|
included_guild_ids: Vec::new(),
|
||||||
|
include_premium_users: false,
|
||||||
|
excluded_user_ids: Vec::new(),
|
||||||
|
guild_overrides: Vec::new(),
|
||||||
|
suppression_strength: 80,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default_backend: Option<NoiseSuppressionBackend>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub allow_user_override: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_basis_points: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_salt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_user_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_guild_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include_premium_users: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub excluded_user_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub suppression_strength: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct PushRelayConfigResponse {
|
||||||
|
pub relay_consent_accepted: bool,
|
||||||
|
pub relay_consent_accepted_at: Option<String>,
|
||||||
|
pub relay_consent_accepted_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
pub struct PushRelayConfigUpdateRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub relay_consent_accepted: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct DomainMigrationConfigResponse {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub config_version: u64,
|
||||||
|
pub rollout_basis_points: u32,
|
||||||
|
pub rollout_salt: String,
|
||||||
|
pub included_user_ids: Vec<String>,
|
||||||
|
pub included_guild_ids: Vec<String>,
|
||||||
|
pub include_premium_users: bool,
|
||||||
|
pub excluded_user_ids: Vec<String>,
|
||||||
|
pub anonymous_rollout_basis_points: u32,
|
||||||
|
pub standalone_forwarding: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DomainMigrationConfigResponse {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
config_version: 0,
|
||||||
|
rollout_basis_points: 0,
|
||||||
|
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||||
|
included_user_ids: Vec::new(),
|
||||||
|
included_guild_ids: Vec::new(),
|
||||||
|
include_premium_users: false,
|
||||||
|
excluded_user_ids: Vec::new(),
|
||||||
|
anonymous_rollout_basis_points: 0,
|
||||||
|
standalone_forwarding: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
pub struct DomainMigrationConfigUpdateRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_basis_points: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_salt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_user_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_guild_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include_premium_users: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub excluded_user_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub anonymous_rollout_basis_points: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub standalone_forwarding: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct AltchaCaptchaConfigResponse {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub config_version: u64,
|
||||||
|
pub rollout_basis_points: u32,
|
||||||
|
pub rollout_salt: String,
|
||||||
|
pub included_user_ids: Vec<String>,
|
||||||
|
pub included_guild_ids: Vec<String>,
|
||||||
|
pub include_premium_users: bool,
|
||||||
|
pub excluded_user_ids: Vec<String>,
|
||||||
|
pub anonymous_enabled: bool,
|
||||||
|
pub cost: u32,
|
||||||
|
pub max_counter: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AltchaCaptchaConfigResponse {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
config_version: 0,
|
||||||
|
rollout_basis_points: 0,
|
||||||
|
rollout_salt: ALTCHA_CAPTCHA_DEFAULT_SALT.to_owned(),
|
||||||
|
included_user_ids: Vec::new(),
|
||||||
|
included_guild_ids: Vec::new(),
|
||||||
|
include_premium_users: false,
|
||||||
|
excluded_user_ids: Vec::new(),
|
||||||
|
anonymous_enabled: false,
|
||||||
|
cost: 5_000,
|
||||||
|
max_counter: 10_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
pub struct AltchaCaptchaConfigUpdateRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_basis_points: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_salt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_user_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_guild_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include_premium_users: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub excluded_user_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub anonymous_enabled: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cost: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_counter: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ProfileTimezoneConfigResponse {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub config_version: u64,
|
||||||
|
pub rollout_basis_points: u32,
|
||||||
|
pub rollout_salt: String,
|
||||||
|
pub included_user_ids: Vec<String>,
|
||||||
|
pub included_guild_ids: Vec<String>,
|
||||||
|
pub include_premium_users: bool,
|
||||||
|
pub excluded_user_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProfileTimezoneConfigResponse {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
config_version: 0,
|
||||||
|
rollout_basis_points: 0,
|
||||||
|
rollout_salt: PROFILE_TIMEZONE_DEFAULT_SALT.to_owned(),
|
||||||
|
included_user_ids: Vec::new(),
|
||||||
|
included_guild_ids: Vec::new(),
|
||||||
|
include_premium_users: false,
|
||||||
|
excluded_user_ids: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
pub struct ProfileTimezoneConfigUpdateRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_basis_points: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub rollout_salt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_user_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub included_guild_ids: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub include_premium_users: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub excluded_user_ids: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct ExperimentDeliveryConfigResponse {
|
||||||
|
pub poll_interval_seconds: u64,
|
||||||
|
pub poll_jitter_percent: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ExperimentDeliveryConfigResponse {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
poll_interval_seconds: 300,
|
||||||
|
poll_jitter_percent: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
|
pub struct ExperimentDeliveryConfigUpdateRequest {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub poll_interval_seconds: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub poll_jitter_percent: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
pub struct InstanceRegistrationResponse {
|
pub struct InstanceRegistrationResponse {
|
||||||
pub mode: RegistrationMode,
|
pub mode: RegistrationMode,
|
||||||
@@ -525,6 +843,18 @@ pub struct InstanceConfigUpdateRequest {
|
|||||||
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
|
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub media: Option<InstanceMediaUpdateRequest>,
|
pub media: Option<InstanceMediaUpdateRequest>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub push_relay: Option<PushRelayConfigUpdateRequest>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub altcha_captcha: Option<AltchaCaptchaConfigUpdateRequest>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub profile_timezone: Option<ProfileTimezoneConfigUpdateRequest>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
@@ -729,6 +1059,10 @@ pub struct AppBrandingConfigUpdateRequest {
|
|||||||
pub favicon_url: Option<Option<String>>,
|
pub favicon_url: Option<Option<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub theme_color: Option<Option<String>>,
|
pub theme_color: Option<Option<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_page_url: Option<Option<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_page_incident_history_url: Option<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
@@ -826,3 +1160,151 @@ pub struct CreateRegistrationUrlResponse {
|
|||||||
pub code: String,
|
pub code: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::api::generated::types as generated_types;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_suppression_backend_choices_use_the_generated_wire_contract() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(NoiseSuppressionBackend::ALL).expect("serializable backends"),
|
||||||
|
json!([
|
||||||
|
"none",
|
||||||
|
"standard",
|
||||||
|
"gate",
|
||||||
|
"speex",
|
||||||
|
"rnnoise",
|
||||||
|
"gtcrn",
|
||||||
|
"deep_filter"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_value::<NoiseSuppressionBackend>(json!("deepfilter")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_instance_experiment_config_matches_the_published_contract() {
|
||||||
|
let schema: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../../../openapi-admin.json"))
|
||||||
|
.expect("admin schema");
|
||||||
|
let noise = serde_json::from_value::<VoiceNoiseSuppressionConfigResponse>(json!({}))
|
||||||
|
.expect("default noise config");
|
||||||
|
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||||
|
.expect("default domain migration config");
|
||||||
|
let altcha_captcha = serde_json::from_value::<AltchaCaptchaConfigResponse>(json!({}))
|
||||||
|
.expect("default altcha captcha config");
|
||||||
|
let profile_timezone = serde_json::from_value::<ProfileTimezoneConfigResponse>(json!({}))
|
||||||
|
.expect("default profile timezone config");
|
||||||
|
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||||
|
.expect("default delivery config");
|
||||||
|
let noise = serde_json::to_value(noise).expect("serializable noise config");
|
||||||
|
let domain_migration =
|
||||||
|
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||||
|
let altcha_captcha =
|
||||||
|
serde_json::to_value(altcha_captcha).expect("serializable altcha captcha config");
|
||||||
|
let profile_timezone =
|
||||||
|
serde_json::to_value(profile_timezone).expect("serializable profile timezone config");
|
||||||
|
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||||
|
let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse =
|
||||||
|
serde_json::from_value(noise.clone()).expect("generated noise config contract");
|
||||||
|
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||||
|
serde_json::from_value(domain_migration.clone())
|
||||||
|
.expect("generated domain migration config contract");
|
||||||
|
let generated_altcha_captcha: generated_types::AltchaCaptchaConfigResponse =
|
||||||
|
serde_json::from_value(altcha_captcha.clone())
|
||||||
|
.expect("generated altcha captcha config contract");
|
||||||
|
let generated_profile_timezone: generated_types::ProfileTimezoneConfigResponse =
|
||||||
|
serde_json::from_value(profile_timezone.clone())
|
||||||
|
.expect("generated profile timezone config contract");
|
||||||
|
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||||
|
serde_json::from_value(delivery.clone()).expect("generated delivery config contract");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(generated_noise).expect("serializable generated noise config"),
|
||||||
|
noise
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(generated_domain_migration)
|
||||||
|
.expect("serializable generated domain migration config"),
|
||||||
|
domain_migration
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(generated_altcha_captcha)
|
||||||
|
.expect("serializable generated altcha captcha config"),
|
||||||
|
altcha_captcha
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(generated_profile_timezone)
|
||||||
|
.expect("serializable generated profile timezone config"),
|
||||||
|
profile_timezone
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(generated_delivery)
|
||||||
|
.expect("serializable generated delivery config"),
|
||||||
|
delivery
|
||||||
|
);
|
||||||
|
for (name, value) in [
|
||||||
|
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||||
|
("DomainMigrationConfigResponse", domain_migration),
|
||||||
|
("AltchaCaptchaConfigResponse", altcha_captcha),
|
||||||
|
("ProfileTimezoneConfigResponse", profile_timezone),
|
||||||
|
("ExperimentDeliveryConfigResponse", delivery),
|
||||||
|
] {
|
||||||
|
for (field, value) in value.as_object().expect("config object") {
|
||||||
|
assert_eq!(
|
||||||
|
value, &schema["components"]["schemas"][name]["properties"][field]["default"],
|
||||||
|
"{name}.{field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() {
|
||||||
|
let update = VoiceNoiseSuppressionConfigUpdateRequest {
|
||||||
|
enabled_backends: Some(Vec::new()),
|
||||||
|
included_user_ids: Some(Vec::new()),
|
||||||
|
excluded_user_ids: Some(Vec::new()),
|
||||||
|
guild_overrides: Some(Vec::new()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(update).expect("serializable update");
|
||||||
|
serde_json::from_value::<generated_types::VoiceNoiseSuppressionConfigUpdateRequest>(
|
||||||
|
value.clone(),
|
||||||
|
)
|
||||||
|
.expect("generated update contract");
|
||||||
|
assert_eq!(
|
||||||
|
value,
|
||||||
|
json!({"enabled_backends": [], "included_user_ids": [], "excluded_user_ids": [], "guild_overrides": []})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(VoiceNoiseSuppressionConfigUpdateRequest::default())
|
||||||
|
.expect("serializable update"),
|
||||||
|
json!({})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_migration_update_preserves_empty_lists_and_omitted_fields() {
|
||||||
|
let update = DomainMigrationConfigUpdateRequest {
|
||||||
|
included_user_ids: Some(Vec::new()),
|
||||||
|
excluded_user_ids: Some(Vec::new()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(update).expect("serializable update");
|
||||||
|
serde_json::from_value::<generated_types::DomainMigrationConfigUpdateRequest>(
|
||||||
|
value.clone(),
|
||||||
|
)
|
||||||
|
.expect("generated update contract");
|
||||||
|
assert_eq!(
|
||||||
|
value,
|
||||||
|
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(DomainMigrationConfigUpdateRequest::default())
|
||||||
|
.expect("serializable update"),
|
||||||
|
json!({})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ pub struct VoiceServer {
|
|||||||
pub latitude: Option<f64>,
|
pub latitude: Option<f64>,
|
||||||
pub longitude: Option<f64>,
|
pub longitude: Option<f64>,
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
|
pub soft_connection_limit: Option<i64>,
|
||||||
pub vip_only: Option<bool>,
|
pub vip_only: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub required_guild_features: Vec<String>,
|
pub required_guild_features: Vec<String>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::generated::types as generated_types;
|
use crate::api::generated::{snowflake, types as generated_types};
|
||||||
|
|
||||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||||
use super::types::{
|
use super::types::{
|
||||||
@@ -15,7 +15,7 @@ impl AdminApiClient {
|
|||||||
email: Option<&str>,
|
email: Option<&str>,
|
||||||
last_active_ip: Option<&str>,
|
last_active_ip: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
offset: u32,
|
offset: u64,
|
||||||
) -> ApiResult<SearchUsersResponse> {
|
) -> ApiResult<SearchUsersResponse> {
|
||||||
let limit = limit.to_string();
|
let limit = limit.to_string();
|
||||||
let offset = offset.to_string();
|
let offset = offset.to_string();
|
||||||
@@ -35,7 +35,8 @@ impl AdminApiClient {
|
|||||||
let response = response.into_inner();
|
let response = response.into_inner();
|
||||||
Ok(SearchUsersResponse {
|
Ok(SearchUsersResponse {
|
||||||
users: self.generated_value(response.users)?,
|
users: self.generated_value(response.users)?,
|
||||||
total: response.total as u64,
|
total: crate::api::generated::number_to_u64(response.total, "total")
|
||||||
|
.map_err(ApiError::Parse)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +95,8 @@ impl AdminApiClient {
|
|||||||
remove_flags: &[String],
|
remove_flags: &[String],
|
||||||
) -> ApiResult<AdminUser> {
|
) -> ApiResult<AdminUser> {
|
||||||
let body = generated_types::AdminUserFlagsUpdateRequest {
|
let body = generated_types::AdminUserFlagsUpdateRequest {
|
||||||
add_flags: user_flags(add_flags),
|
add_flags: user_flags(add_flags)?,
|
||||||
remove_flags: user_flags(remove_flags),
|
remove_flags: user_flags(remove_flags)?,
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
@@ -362,11 +363,8 @@ impl AdminApiClient {
|
|||||||
) -> ApiResult<AdminUser> {
|
) -> ApiResult<AdminUser> {
|
||||||
let body = generated_types::AdminUserUsernameUpdateRequest {
|
let body = generated_types::AdminUserUsernameUpdateRequest {
|
||||||
discriminator: discriminator
|
discriminator: discriminator
|
||||||
.map(generated_types::DiscriminatorType::try_from)
|
.map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
|
||||||
.transpose()
|
username: generated_types::UsernameType::from(username.to_owned()),
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
|
||||||
username: generated_types::UsernameType::try_from(username)
|
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
@@ -399,7 +397,8 @@ impl AdminApiClient {
|
|||||||
) -> ApiResult<AdminUser> {
|
) -> ApiResult<AdminUser> {
|
||||||
let body = generated_types::AdminUserBanRequest {
|
let body = generated_types::AdminUserBanRequest {
|
||||||
duration_hours: i32::try_from(duration_hours)
|
duration_hours: i32::try_from(duration_hours)
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||||
|
.into(),
|
||||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||||
};
|
};
|
||||||
let resp: UserMutationResponse = self
|
let resp: UserMutationResponse = self
|
||||||
@@ -428,21 +427,24 @@ impl AdminApiClient {
|
|||||||
reason_code: i32,
|
reason_code: i32,
|
||||||
public_reason: Option<&str>,
|
public_reason: Option<&str>,
|
||||||
days_until_deletion: u32,
|
days_until_deletion: u32,
|
||||||
|
audit_log_reason: Option<&str>,
|
||||||
) -> ApiResult<AdminUser> {
|
) -> ApiResult<AdminUser> {
|
||||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||||
days_until_deletion: Some(
|
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
days_until_deletion,
|
||||||
.map_err(ApiError::Parse)?,
|
"days_until_deletion",
|
||||||
),
|
)
|
||||||
|
.map_err(ApiError::Parse)?
|
||||||
|
.into(),
|
||||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||||
.map_err(ApiError::Parse)?,
|
.map_err(ApiError::Parse)?,
|
||||||
};
|
};
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated_with_reason(audit_log_reason)?
|
||||||
.schedule_admin_user_deletion(&snowflake(user_id), &body)
|
.schedule_admin_user_deletion(&snowflake(user_id), &body)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|error| self.generated_error(error))?;
|
||||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||||
Ok(resp.user)
|
Ok(resp.user)
|
||||||
}
|
}
|
||||||
@@ -484,10 +486,10 @@ impl AdminApiClient {
|
|||||||
target_id: &str,
|
target_id: &str,
|
||||||
category: &str,
|
category: &str,
|
||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
|
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||||
self.generated()
|
self.generated()
|
||||||
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
|
.remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.map_err(|e| self.generated_error(e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -498,7 +500,7 @@ impl AdminApiClient {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
category: &str,
|
category: &str,
|
||||||
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
|
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
|
||||||
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
|
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
@@ -565,15 +567,13 @@ fn bool_param(value: bool) -> &'static str {
|
|||||||
if value { "true" } else { "false" }
|
if value { "true" } else { "false" }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||||
generated_types::SnowflakeType::from(value.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
|
||||||
values
|
values
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.map(|value| {
|
||||||
.map(generated_types::UserFlags::from)
|
generated_types::UserFlags::try_from(value.as_str())
|
||||||
|
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+108
-21
@@ -55,15 +55,14 @@ impl AdminApiClient {
|
|||||||
params: &serde_json::Value,
|
params: &serde_json::Value,
|
||||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||||
let region_id = required_field(params, "id")?;
|
let region_id = required_field(params, "id")?;
|
||||||
let body =
|
let body = voice_request_body(params, &["id"])?;
|
||||||
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
|
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
self.patch_with_reason(
|
||||||
let response = self
|
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||||
.generated()
|
Some(&body),
|
||||||
.update_admin_voice_region(®ion_id, &body)
|
None,
|
||||||
.await
|
)
|
||||||
.map_err(|e| self.generated_error(e))?;
|
.await
|
||||||
self.generated_value(response.into_inner())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
|
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
|
||||||
@@ -103,9 +102,10 @@ impl AdminApiClient {
|
|||||||
) -> ApiResult<CreateVoiceServerResponse> {
|
) -> ApiResult<CreateVoiceServerResponse> {
|
||||||
let region_id = required_field(params, "region_id")?;
|
let region_id = required_field(params, "region_id")?;
|
||||||
paired_coordinates(params)?;
|
paired_coordinates(params)?;
|
||||||
let body =
|
let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
|
||||||
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
|
voice_request_body(params, &["region_id"])?,
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
)
|
||||||
|
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||||
let response = self
|
let response = self
|
||||||
.generated()
|
.generated()
|
||||||
.create_admin_voice_server(®ion_id, &body)
|
.create_admin_voice_server(®ion_id, &body)
|
||||||
@@ -121,15 +121,18 @@ impl AdminApiClient {
|
|||||||
let region_id = required_field(params, "region_id")?;
|
let region_id = required_field(params, "region_id")?;
|
||||||
let server_id = required_field(params, "server_id")?;
|
let server_id = required_field(params, "server_id")?;
|
||||||
paired_coordinates(params)?;
|
paired_coordinates(params)?;
|
||||||
let body =
|
let body = voice_request_body(params, &["region_id", "server_id"])?;
|
||||||
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
|
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
|
||||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
self.patch_with_reason(
|
||||||
let response = self
|
&format!(
|
||||||
.generated()
|
"/admin/voice/regions/{}/servers/{}",
|
||||||
.update_admin_voice_server(®ion_id, &server_id, &body)
|
urlencoding::encode(®ion_id),
|
||||||
.await
|
urlencoding::encode(&server_id)
|
||||||
.map_err(|e| self.generated_error(e))?;
|
),
|
||||||
self.generated_value(response.into_inner())
|
Some(&body),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_voice_server(
|
pub async fn delete_voice_server(
|
||||||
@@ -150,6 +153,26 @@ fn bool_param(value: bool) -> &'static str {
|
|||||||
if value { "true" } else { "false" }
|
if value { "true" } else { "false" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
|
||||||
|
serde_json::from_value::<T>(params.clone())
|
||||||
|
.map(drop)
|
||||||
|
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn voice_request_body(
|
||||||
|
params: &serde_json::Value,
|
||||||
|
path_fields: &[&str],
|
||||||
|
) -> ApiResult<serde_json::Value> {
|
||||||
|
let mut body = params
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| ApiError::Parse("voice request body must be an object".to_owned()))?
|
||||||
|
.clone();
|
||||||
|
for field in path_fields {
|
||||||
|
body.remove(*field);
|
||||||
|
}
|
||||||
|
Ok(body.into())
|
||||||
|
}
|
||||||
|
|
||||||
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
|
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
|
||||||
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
||||||
if has_coordinate("latitude") == has_coordinate("longitude") {
|
if has_coordinate("latitude") == has_coordinate("longitude") {
|
||||||
@@ -168,3 +191,67 @@ fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String>
|
|||||||
.map(std::borrow::ToOwned::to_owned)
|
.map(std::borrow::ToOwned::to_owned)
|
||||||
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
|
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn region_update_body_preserves_explicit_restriction_clears() {
|
||||||
|
let expected = json!({
|
||||||
|
"required_guild_features": [],
|
||||||
|
"allowed_guild_ids": [],
|
||||||
|
"allowed_user_ids": [],
|
||||||
|
});
|
||||||
|
let mut params = expected.clone();
|
||||||
|
params["id"] = json!("eu");
|
||||||
|
let body = voice_request_body(¶ms, &["id"]).expect("region body");
|
||||||
|
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)
|
||||||
|
.expect("valid region body");
|
||||||
|
assert_eq!(body, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_update_body_preserves_clears_and_omitted_restrictions() {
|
||||||
|
for expected in [
|
||||||
|
json!({
|
||||||
|
"required_guild_features": [],
|
||||||
|
"allowed_guild_ids": [],
|
||||||
|
"allowed_user_ids": [],
|
||||||
|
"soft_connection_limit": null,
|
||||||
|
"latitude": null,
|
||||||
|
"longitude": null,
|
||||||
|
}),
|
||||||
|
json!({"is_active": false}),
|
||||||
|
] {
|
||||||
|
let mut params = expected.clone();
|
||||||
|
params["region_id"] = json!("eu");
|
||||||
|
params["server_id"] = json!("primary");
|
||||||
|
let body =
|
||||||
|
voice_request_body(¶ms, &["region_id", "server_id"]).expect("server body");
|
||||||
|
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)
|
||||||
|
.expect("valid server body");
|
||||||
|
assert_eq!(body, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_server_body_keeps_the_server_id_and_rejects_missing_fields() {
|
||||||
|
let expected = json!({
|
||||||
|
"server_id": "primary",
|
||||||
|
"endpoint": "wss://voice.example.com",
|
||||||
|
"api_key": "key",
|
||||||
|
"api_secret": "secret",
|
||||||
|
});
|
||||||
|
let mut params = expected.clone();
|
||||||
|
params["region_id"] = json!("eu");
|
||||||
|
let body = voice_request_body(¶ms, &["region_id"]).expect("server body");
|
||||||
|
validate_against::<generated_types::CreateVoiceServerRequestBody>(&body)
|
||||||
|
.expect("valid server body");
|
||||||
|
assert_eq!(body, expected);
|
||||||
|
assert!(
|
||||||
|
validate_against::<generated_types::CreateVoiceServerRequestBody>(&json!({})).is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ async fn admin_api_keys_post(
|
|||||||
"create" => {
|
"create" => {
|
||||||
let name = form.clean("name").unwrap_or_default();
|
let name = form.clean("name").unwrap_or_default();
|
||||||
let acls = form.list_values_any(&["acls[]", "acls"]);
|
let acls = form.list_values_any(&["acls[]", "acls"]);
|
||||||
return match client.create_api_key(&name, &acls).await {
|
match client.create_api_key(&name, &acls).await {
|
||||||
Ok(created) => {
|
Ok(created) => {
|
||||||
let keys = client
|
let keys = client
|
||||||
.list_api_keys()
|
.list_api_keys()
|
||||||
@@ -107,29 +107,38 @@ async fn admin_api_keys_post(
|
|||||||
is_htmx,
|
is_htmx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
"revoke" => {
|
|
||||||
if let Some(key_id) = form.clean("key_id") {
|
|
||||||
let result = client.revoke_api_key(&key_id).await;
|
|
||||||
let flash = match result.log_error("revoke API key") {
|
|
||||||
Some(_) => FlashData::success("API key revoked."),
|
|
||||||
None => FlashData::error("Failed to revoke API key"),
|
|
||||||
};
|
|
||||||
return flash::redirect_with_flash(
|
|
||||||
&format!("{base}/admin-api-keys"),
|
|
||||||
flash,
|
|
||||||
config.secure_cookies(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
"revoke" => {
|
||||||
|
let Some(key_id) = form.clean("key_id") else {
|
||||||
|
return admin_api_key_flash_response(
|
||||||
|
config,
|
||||||
|
FlashData::error("API key ID is required"),
|
||||||
|
is_htmx,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let result = client.revoke_api_key(&key_id).await;
|
||||||
|
let flash = match result.log_error("revoke API key") {
|
||||||
|
Some(_) => FlashData::success("API key revoked."),
|
||||||
|
None => FlashData::error("Failed to revoke API key"),
|
||||||
|
};
|
||||||
|
flash::redirect_with_flash(
|
||||||
|
&format!("{base}/admin-api-keys"),
|
||||||
|
flash,
|
||||||
|
config.secure_cookies(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"" => admin_api_key_flash_response(
|
||||||
|
config,
|
||||||
|
FlashData::error("API key action is required"),
|
||||||
|
is_htmx,
|
||||||
|
),
|
||||||
|
_ => admin_api_key_flash_response(
|
||||||
|
config,
|
||||||
|
FlashData::error("Unknown API key action"),
|
||||||
|
is_htmx,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
flash::redirect_with_flash(
|
|
||||||
&format!("{base}/admin-api-keys"),
|
|
||||||
FlashData::success(format!("API key action '{action}' completed.")),
|
|
||||||
config.secure_cookies(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn admin_api_key_flash_response(
|
fn admin_api_key_flash_response(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use axum::{
|
|||||||
|
|
||||||
use super::ActionQuery;
|
use super::ActionQuery;
|
||||||
use super::bans_actions::{
|
use super::bans_actions::{
|
||||||
BanFormData, custom_flash, execute_ban, extract_value, flash_response, htmx_flash,
|
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
@@ -105,7 +105,7 @@ async fn generic_ban_post(
|
|||||||
form.audit_log_reason.as_deref(),
|
form.audit_log_reason.as_deref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
flash_response(config, auth, is_htmx, &level, &msg, ban_cfg, csrf_token)
|
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! ban_post {
|
macro_rules! ban_post {
|
||||||
@@ -171,19 +171,22 @@ async fn url_domain_bans_post(
|
|||||||
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
|
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
|
||||||
let form: BanFormData = match Form::from_request(request, &state).await {
|
let form: BanFormData = match Form::from_request(request, &state).await {
|
||||||
Ok(Form(f)) => f,
|
Ok(Form(f)) => f,
|
||||||
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
|
Err(_) => return render_inline_flash("error", "Invalid form data"),
|
||||||
};
|
};
|
||||||
let is_htmx = htmx::is_htmx_request(&headers);
|
let is_htmx = htmx::is_htmx_request(&headers);
|
||||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||||
let domain = form.domain.as_deref().unwrap_or("").trim().to_owned();
|
let domain = form.domain.as_deref().unwrap_or("").trim().to_owned();
|
||||||
if domain.is_empty() {
|
if domain.is_empty() {
|
||||||
return htmx_flash("error", "Domain is required", &headers);
|
return render_inline_flash("error", "Domain is required");
|
||||||
}
|
}
|
||||||
let action = aq.action.as_deref().unwrap_or("");
|
let action = aq.action.as_deref().unwrap_or("");
|
||||||
let (level, msg) = match action {
|
let (level, msg) = match action {
|
||||||
"ban" => {
|
"ban" => {
|
||||||
let m_sub = form.match_subdomains.as_deref() == Some("true");
|
let m_sub = form.match_subdomains.as_deref() == Some("true");
|
||||||
match client.ban_url_domain(&domain, m_sub).await {
|
match client
|
||||||
|
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
|
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
|
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
|
||||||
@@ -191,7 +194,10 @@ async fn url_domain_bans_post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"unban" => match client.unban_url_domain(&domain).await {
|
"unban" => match client
|
||||||
|
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => ("success", format!("Domain {domain} unbanned")),
|
Ok(()) => ("success", format!("Domain {domain} unbanned")),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
|
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
|
||||||
@@ -247,25 +253,31 @@ async fn profile_substring_bans_post(
|
|||||||
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
|
Query::try_from_uri(request.uri()).unwrap_or(Query(ActionQuery { action: None }));
|
||||||
let form: BanFormData = match Form::from_request(request, &state).await {
|
let form: BanFormData = match Form::from_request(request, &state).await {
|
||||||
Ok(Form(f)) => f,
|
Ok(Form(f)) => f,
|
||||||
Err(_) => return htmx_flash("error", "Invalid form data", &headers),
|
Err(_) => return render_inline_flash("error", "Invalid form data"),
|
||||||
};
|
};
|
||||||
let is_htmx = htmx::is_htmx_request(&headers);
|
let is_htmx = htmx::is_htmx_request(&headers);
|
||||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||||
let scope = form.scope.as_deref().unwrap_or("").trim().to_owned();
|
let scope = form.scope.as_deref().unwrap_or("").trim().to_owned();
|
||||||
let substring = form.substring.as_deref().unwrap_or("").trim().to_owned();
|
let substring = form.substring.as_deref().unwrap_or("").trim().to_owned();
|
||||||
if scope.is_empty() || substring.is_empty() {
|
if scope.is_empty() || substring.is_empty() {
|
||||||
return htmx_flash("error", "Scope and substring required", &headers);
|
return render_inline_flash("error", "Scope and substring required");
|
||||||
}
|
}
|
||||||
let action = aq.action.as_deref().unwrap_or("");
|
let action = aq.action.as_deref().unwrap_or("");
|
||||||
let (level, msg) = match action {
|
let (level, msg) = match action {
|
||||||
"ban" => match client.ban_profile_substring(&scope, &substring).await {
|
"ban" => match client
|
||||||
|
.ban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => ("success", format!("\"{substring}\" banned for {scope}")),
|
Ok(()) => ("success", format!("\"{substring}\" banned for {scope}")),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring");
|
tracing::warn!(%error, scope, substring, "admin API request failed: ban profile substring");
|
||||||
("error", format!("Failed to ban substring for {scope}"))
|
("error", format!("Failed to ban substring for {scope}"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"unban" => match client.unban_profile_substring(&scope, &substring).await {
|
"unban" => match client
|
||||||
|
.unban_profile_substring(&scope, &substring, form.audit_log_reason.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(()) => ("success", format!("\"{substring}\" unbanned for {scope}")),
|
Ok(()) => ("success", format!("\"{substring}\" unbanned for {scope}")),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring");
|
tracing::warn!(%error, scope, substring, "admin API request failed: unban profile substring");
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::api::client::AdminApiClient;
|
use crate::api::client::{AdminApiClient, ApiResult};
|
||||||
use crate::api::types::{FlashLevel, FlashMessage};
|
use crate::api::types::{FlashLevel, FlashMessage};
|
||||||
use crate::middleware::auth::AuthContext;
|
use crate::middleware::auth::AuthContext;
|
||||||
use crate::templates;
|
use crate::templates;
|
||||||
use axum::{
|
use axum::response::{Html, IntoResponse, Response};
|
||||||
http::HeaderMap,
|
|
||||||
response::{Html, IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct BanFormData {
|
pub struct BanFormData {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ip: Option<String>,
|
pub ip: Option<String>,
|
||||||
@@ -65,7 +61,7 @@ pub async fn execute_ban(
|
|||||||
bulk_hashes: Option<&str>,
|
bulk_hashes: Option<&str>,
|
||||||
bulk_sha256_list: Option<&str>,
|
bulk_sha256_list: Option<&str>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> (String, String) {
|
) -> (&'static str, String) {
|
||||||
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
|
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
|
||||||
let raw_hashes = if action == "bulk-ban-files" {
|
let raw_hashes = if action == "bulk-ban-files" {
|
||||||
bulk_sha256_list
|
bulk_sha256_list
|
||||||
@@ -75,13 +71,13 @@ pub async fn execute_ban(
|
|||||||
return execute_bulk_ban(client, raw_hashes, audit_log_reason).await;
|
return execute_bulk_ban(client, raw_hashes, audit_log_reason).await;
|
||||||
}
|
}
|
||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
return ("error".into(), "Value is required".into());
|
return ("error", "Value is required".into());
|
||||||
}
|
}
|
||||||
match action {
|
match action {
|
||||||
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
|
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
|
||||||
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
|
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
|
||||||
"check" => execute_check(client, ban_type, value).await,
|
"check" => execute_check(client, ban_type, value).await,
|
||||||
_ => ("error".into(), "Unknown action".into()),
|
_ => ("error", "Unknown action".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +85,7 @@ async fn execute_bulk_ban(
|
|||||||
client: &AdminApiClient,
|
client: &AdminApiClient,
|
||||||
bulk_hashes: Option<&str>,
|
bulk_hashes: Option<&str>,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> (String, String) {
|
) -> (&'static str, String) {
|
||||||
let hashes: Vec<String> = bulk_hashes
|
let hashes: Vec<String> = bulk_hashes
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
|
.split(|c: char| c.is_whitespace() || c == ',' || c == ';')
|
||||||
@@ -98,18 +94,15 @@ async fn execute_bulk_ban(
|
|||||||
.collect();
|
.collect();
|
||||||
if hashes.is_empty() {
|
if hashes.is_empty() {
|
||||||
return (
|
return (
|
||||||
"error".into(),
|
"error",
|
||||||
"No valid 64-character hex hashes found in input".into(),
|
"No valid 64-character hex hashes found in input".into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
match client.bulk_ban_file_shas(&hashes, audit_log_reason).await {
|
match client.bulk_ban_file_shas(&hashes, audit_log_reason).await {
|
||||||
Ok(r) => (
|
Ok(r) => ("success", format!("Bulk ban job created: {}", r.job_id)),
|
||||||
"success".into(),
|
|
||||||
format!("Bulk ban job created: {}", r.job_id),
|
|
||||||
),
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, "admin API request failed: bulk ban file SHAs");
|
tracing::warn!(%error, "admin API request failed: bulk ban file SHAs");
|
||||||
("error".into(), "Failed to enqueue bulk ban job".into())
|
("error", "Failed to enqueue bulk ban job".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,29 +112,26 @@ async fn execute_single_ban(
|
|||||||
ban_type: &str,
|
ban_type: &str,
|
||||||
value: &str,
|
value: &str,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> (String, String) {
|
) -> (&'static str, String) {
|
||||||
let success = format!("{value} banned successfully");
|
let result = match ban_type {
|
||||||
let failure = format!("Failed to ban {value}");
|
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
|
||||||
match ban_type {
|
"email-bans" => client.ban_email(value, audit_log_reason).await,
|
||||||
"ip-bans" => ban_action_result(client.ban_ip(value).await, success, failure),
|
"suspicious-email-domains" => {
|
||||||
"email-bans" => ban_action_result(client.ban_email(value).await, success, failure),
|
client
|
||||||
"suspicious-email-domains" => ban_action_result(
|
.add_suspicious_email_domain(value, audit_log_reason)
|
||||||
client.add_suspicious_email_domain(value).await,
|
.await
|
||||||
success,
|
|
||||||
failure,
|
|
||||||
),
|
|
||||||
"phrase-bans" => ban_action_result(client.ban_phrase(value).await, success, failure),
|
|
||||||
"url-bans" => ban_action_result(client.ban_url(value).await, success, failure),
|
|
||||||
"file-sha-bans" => ban_action_result(
|
|
||||||
client.ban_file_sha(value, audit_log_reason).await,
|
|
||||||
success,
|
|
||||||
failure,
|
|
||||||
),
|
|
||||||
"avatar-hash-bans" => {
|
|
||||||
ban_action_result(client.ban_avatar_hash(value).await, success, failure)
|
|
||||||
}
|
}
|
||||||
_ => ("error".into(), "Unknown ban type".into()),
|
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
|
||||||
}
|
"url-bans" => client.ban_url(value, audit_log_reason).await,
|
||||||
|
"file-sha-bans" => client.ban_file_sha(value, audit_log_reason).await,
|
||||||
|
"avatar-hash-bans" => client.ban_avatar_hash(value, audit_log_reason).await,
|
||||||
|
_ => return ("error", "Unknown ban type".into()),
|
||||||
|
};
|
||||||
|
ban_action_result(
|
||||||
|
result,
|
||||||
|
format!("{value} banned successfully"),
|
||||||
|
format!("Failed to ban {value}"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_single_unban(
|
async fn execute_single_unban(
|
||||||
@@ -149,32 +139,33 @@ async fn execute_single_unban(
|
|||||||
ban_type: &str,
|
ban_type: &str,
|
||||||
value: &str,
|
value: &str,
|
||||||
audit_log_reason: Option<&str>,
|
audit_log_reason: Option<&str>,
|
||||||
) -> (String, String) {
|
) -> (&'static str, String) {
|
||||||
let success = format!("{value} unbanned successfully");
|
let result = match ban_type {
|
||||||
let failure = format!("Failed to unban {value}");
|
"ip-bans" => client.unban_ip(value, audit_log_reason).await,
|
||||||
match ban_type {
|
"email-bans" => client.unban_email(value, audit_log_reason).await,
|
||||||
"ip-bans" => ban_action_result(client.unban_ip(value).await, success, failure),
|
"suspicious-email-domains" => {
|
||||||
"email-bans" => ban_action_result(client.unban_email(value).await, success, failure),
|
client
|
||||||
"suspicious-email-domains" => ban_action_result(
|
.remove_suspicious_email_domain(value, audit_log_reason)
|
||||||
client.remove_suspicious_email_domain(value).await,
|
.await
|
||||||
success,
|
|
||||||
failure,
|
|
||||||
),
|
|
||||||
"phrase-bans" => ban_action_result(client.unban_phrase(value).await, success, failure),
|
|
||||||
"url-bans" => ban_action_result(client.unban_url(value).await, success, failure),
|
|
||||||
"file-sha-bans" => ban_action_result(
|
|
||||||
client.unban_file_sha(value, audit_log_reason).await,
|
|
||||||
success,
|
|
||||||
failure,
|
|
||||||
),
|
|
||||||
"avatar-hash-bans" => {
|
|
||||||
ban_action_result(client.unban_avatar_hash(value).await, success, failure)
|
|
||||||
}
|
}
|
||||||
_ => ("error".into(), "Unknown ban type".into()),
|
"phrase-bans" => client.unban_phrase(value, audit_log_reason).await,
|
||||||
}
|
"url-bans" => client.unban_url(value, audit_log_reason).await,
|
||||||
|
"file-sha-bans" => client.unban_file_sha(value, audit_log_reason).await,
|
||||||
|
"avatar-hash-bans" => client.unban_avatar_hash(value, audit_log_reason).await,
|
||||||
|
_ => return ("error", "Unknown ban type".into()),
|
||||||
|
};
|
||||||
|
ban_action_result(
|
||||||
|
result,
|
||||||
|
format!("{value} unbanned successfully"),
|
||||||
|
format!("Failed to unban {value}"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) -> (String, String) {
|
async fn execute_check(
|
||||||
|
client: &AdminApiClient,
|
||||||
|
ban_type: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> (&'static str, String) {
|
||||||
let result = match ban_type {
|
let result = match ban_type {
|
||||||
"ip-bans" => client.check_ip_ban(value).await,
|
"ip-bans" => client.check_ip_ban(value).await,
|
||||||
"email-bans" => client.check_email_ban(value).await,
|
"email-bans" => client.check_email_ban(value).await,
|
||||||
@@ -183,28 +174,28 @@ async fn execute_check(client: &AdminApiClient, ban_type: &str, value: &str) ->
|
|||||||
"url-bans" => client.check_url_ban(value).await,
|
"url-bans" => client.check_url_ban(value).await,
|
||||||
"file-sha-bans" => client.check_file_sha_ban(value).await,
|
"file-sha-bans" => client.check_file_sha_ban(value).await,
|
||||||
"avatar-hash-bans" => client.check_avatar_hash_ban(value).await,
|
"avatar-hash-bans" => client.check_avatar_hash_ban(value).await,
|
||||||
_ => return ("error".into(), "Unknown ban type".into()),
|
_ => return ("error", "Unknown ban type".into()),
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(r) if r.banned => ("info".into(), format!("{value} is banned")),
|
Ok(r) if r.banned => ("info", format!("{value} is banned")),
|
||||||
Ok(_) => ("info".into(), format!("{value} is NOT banned")),
|
Ok(_) => ("info", format!("{value} is NOT banned")),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
|
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
|
||||||
("error".into(), "Error checking ban status".into())
|
("error", "Error checking ban status".into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ban_action_result<T, E: std::fmt::Display>(
|
fn ban_action_result(
|
||||||
result: Result<T, E>,
|
result: ApiResult<()>,
|
||||||
success_message: String,
|
success_message: String,
|
||||||
error_message: String,
|
error_message: String,
|
||||||
) -> (String, String) {
|
) -> (&'static str, String) {
|
||||||
match result {
|
match result {
|
||||||
Ok(_) => ("success".into(), success_message),
|
Ok(()) => ("success", success_message),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(%error, "admin API request failed: ban action");
|
tracing::warn!(%error, "admin API request failed: ban action");
|
||||||
("error".into(), error_message)
|
("error", error_message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,11 +219,6 @@ pub fn flash_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn htmx_flash(level: &str, message: &str, headers: &HeaderMap) -> Response {
|
|
||||||
let _ = headers;
|
|
||||||
render_inline_flash(level, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_inline_flash(level: &str, message: &str) -> Response {
|
pub fn render_inline_flash(level: &str, message: &str) -> Response {
|
||||||
let (border, bg, text) = match level {
|
let (border, bg, text) = match level {
|
||||||
"success" => ("border-green-300", "bg-green-50", "text-green-800"),
|
"success" => ("border-green-300", "bg-green-50", "text-green-800"),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::client::AdminApiClient,
|
api::{client::AdminApiClient, generated::types::GiftCodeDurationTypeSchema},
|
||||||
middleware::{
|
middleware::{
|
||||||
auth::AuthContext,
|
auth::AuthContext,
|
||||||
csrf::CsrfToken,
|
csrf::CsrfToken,
|
||||||
flash::{self, FlashData},
|
flash::{self, FlashData},
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
templates,
|
templates::{self, pages::gift_codes::MAX_GIFT_CODES},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Form, Router,
|
Form, Router,
|
||||||
@@ -28,11 +28,11 @@ struct GiftCodesForm {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
_csrf: Option<String>,
|
_csrf: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
count: Option<String>,
|
count: Option<u32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
duration_type: Option<String>,
|
duration_type: Option<GiftCodeDurationTypeSchema>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
duration_quantity: Option<String>,
|
duration_quantity: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
@@ -86,21 +86,17 @@ async fn gift_codes_post(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let count = form
|
let count = form.count.unwrap_or(1).clamp(1, MAX_GIFT_CODES);
|
||||||
.count
|
let duration_type = form
|
||||||
.as_deref()
|
.duration_type
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
.unwrap_or(GiftCodeDurationTypeSchema::Months);
|
||||||
.unwrap_or(1)
|
let duration_quantity = form.duration_quantity.unwrap_or(1);
|
||||||
.clamp(1, 100);
|
|
||||||
let dur_type = form.duration_type.as_deref().unwrap_or("month");
|
|
||||||
let dur_qty = form
|
|
||||||
.duration_quantity
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.unwrap_or(1);
|
|
||||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||||
let secure_cookies = config.secure_cookies();
|
let secure_cookies = config.secure_cookies();
|
||||||
match client.generate_gift_codes(count, dur_type, dur_qty).await {
|
match client
|
||||||
|
.generate_gift_codes(count, duration_type, duration_quantity)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let codes = result.codes.join(",");
|
let codes = result.codes.join(",");
|
||||||
flash::redirect_with_flash(
|
flash::redirect_with_flash(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub async fn render(
|
|||||||
let page = query.members_page.unwrap_or(0);
|
let page = query.members_page.unwrap_or(0);
|
||||||
let limit: u32 = 50;
|
let limit: u32 = 50;
|
||||||
let resp = client
|
let resp = client
|
||||||
.list_guild_members(guild_id, limit, page * limit)
|
.list_guild_members(guild_id, limit, u64::from(page) * u64::from(limit))
|
||||||
.await
|
.await
|
||||||
.log_error("load guild members")?;
|
.log_error("load guild members")?;
|
||||||
Some(tabs::members::members_tab(
|
Some(tabs::members::members_tab(
|
||||||
@@ -57,7 +57,7 @@ pub async fn render(
|
|||||||
let page = query.reports_page.unwrap_or(0);
|
let page = query.reports_page.unwrap_or(0);
|
||||||
let limit: u32 = 25;
|
let limit: u32 = 25;
|
||||||
let resp = client
|
let resp = client
|
||||||
.search_reports_by_guild(guild_id, limit, page * limit)
|
.search_reports_by_guild(guild_id, limit, u64::from(page) * u64::from(limit))
|
||||||
.await
|
.await
|
||||||
.log_error("load guild reports")?;
|
.log_error("load guild reports")?;
|
||||||
Some(tabs::reports::reports_tab(
|
Some(tabs::reports::reports_tab(
|
||||||
@@ -121,6 +121,7 @@ pub async fn render(
|
|||||||
admin_user_id: None,
|
admin_user_id: None,
|
||||||
target_id: Some(guild_id.to_owned()),
|
target_id: Some(guild_id.to_owned()),
|
||||||
target_type: Some("guild".to_owned()),
|
target_type: Some("guild".to_owned()),
|
||||||
|
access: Some("write".to_owned()),
|
||||||
sort_by: Some("created_at".to_owned()),
|
sort_by: Some("created_at".to_owned()),
|
||||||
sort_order: Some("desc".to_owned()),
|
sort_order: Some("desc".to_owned()),
|
||||||
limit: 50,
|
limit: 50,
|
||||||
@@ -134,7 +135,7 @@ pub async fn render(
|
|||||||
@if let Some(resp) = resp {
|
@if let Some(resp) = resp {
|
||||||
@if resp.logs.is_empty() {
|
@if resp.logs.is_empty() {
|
||||||
p class="text-sm text-neutral-500" {
|
p class="text-sm text-neutral-500" {
|
||||||
"No admin audit log entries for this guild."
|
"No admin write actions have been recorded for this guild."
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
(table_container(table(maud::html! {
|
(table_container(table(maud::html! {
|
||||||
|
|||||||
@@ -82,23 +82,19 @@ async fn guilds_list(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((guilds, Some(params.requested_ids.len() as u64), false))
|
Some((guilds, params.requested_ids.len() as u64))
|
||||||
} else if params.has_search() {
|
} else if params.has_search() {
|
||||||
let offset = params.page.saturating_mul(params.limit);
|
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||||
client
|
client
|
||||||
.search_guilds(params.search_query(), params.limit, offset)
|
.search_guilds(params.search_query(), params.limit, offset)
|
||||||
.await
|
.await
|
||||||
.log_error("search guilds")
|
.log_error("search guilds")
|
||||||
.map(|response| {
|
.map(|response| (response.guilds, response.total))
|
||||||
let has_more = u64::from(offset) + (response.guilds.len() as u64) < response.total;
|
|
||||||
(response.guilds, Some(response.total), has_more)
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let result_guilds = results.as_ref().map(|result| result.0.as_slice());
|
let result_guilds = results.as_ref().map(|result| result.0.as_slice());
|
||||||
let total = results.as_ref().and_then(|result| result.1);
|
let total = results.as_ref().map(|result| result.1);
|
||||||
let has_more = results.as_ref().is_some_and(|result| result.2);
|
|
||||||
|
|
||||||
let markup = templates::pages::guilds_list::guilds_list_page(
|
let markup = templates::pages::guilds_list::guilds_list_page(
|
||||||
config,
|
config,
|
||||||
@@ -106,7 +102,6 @@ async fn guilds_list(
|
|||||||
¶ms,
|
¶ms,
|
||||||
result_guilds,
|
result_guilds,
|
||||||
total,
|
total,
|
||||||
has_more,
|
|
||||||
is_results_fragment,
|
is_results_fragment,
|
||||||
);
|
);
|
||||||
Html(markup.into_string()).into_response()
|
Html(markup.into_string()).into_response()
|
||||||
@@ -324,8 +319,12 @@ async fn dispatch_guild_action(
|
|||||||
"default_message_notifications",
|
"default_message_notifications",
|
||||||
"disabled_operations",
|
"disabled_operations",
|
||||||
] {
|
] {
|
||||||
if let Some(v) = form.parse_i64(key) {
|
let value = match form.parse_value::<i64>(key) {
|
||||||
settings.insert(key.to_string(), serde_json::json!(v));
|
Ok(value) => value,
|
||||||
|
Err(_) => return FlashData::error(format!("Invalid {key} value")),
|
||||||
|
};
|
||||||
|
if let Some(value) = value {
|
||||||
|
settings.insert(key.to_string(), serde_json::json!(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if form.contains_key("nsfw_submitted") {
|
if form.contains_key("nsfw_submitted") {
|
||||||
@@ -356,11 +355,12 @@ async fn dispatch_guild_action(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"update_disabled_operations" => {
|
"update_disabled_operations" => {
|
||||||
let disabled_operations = form
|
let Ok(operations) =
|
||||||
.list_values_any(&["disabled_operations[]", "disabled_operations"])
|
form.parse_list_values::<i64>(&["disabled_operations[]", "disabled_operations"])
|
||||||
.iter()
|
else {
|
||||||
.filter_map(|value| value.parse::<i64>().ok())
|
return FlashData::error("Invalid disabled operation value");
|
||||||
.fold(0_i64, |acc, value| acc | value);
|
};
|
||||||
|
let disabled_operations = operations.into_iter().fold(0_i64, |acc, value| acc | value);
|
||||||
let settings = serde_json::json!({
|
let settings = serde_json::json!({
|
||||||
"disabled_operations": disabled_operations,
|
"disabled_operations": disabled_operations,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
struct JobsQuery {
|
struct JobsQuery {
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
task_type: Option<String>,
|
task_type: Option<String>,
|
||||||
@@ -219,49 +218,36 @@ async fn job_detail_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String {
|
fn jobs_url(config: &crate::config::AdminConfig, query: &JobsQuery) -> String {
|
||||||
let mut params = Vec::new();
|
let query = [
|
||||||
push_query(&mut params, "status", query.status.as_deref());
|
("status", query.status.as_deref()),
|
||||||
push_query(&mut params, "task_type", query.task_type.as_deref());
|
("task_type", query.task_type.as_deref()),
|
||||||
push_query(
|
(
|
||||||
&mut params,
|
"requested_by_user_id",
|
||||||
"requested_by_user_id",
|
query.requested_by_user_id.as_deref(),
|
||||||
query.requested_by_user_id.as_deref(),
|
),
|
||||||
);
|
("max_lookback_days", query.max_lookback_days.as_deref()),
|
||||||
push_query(
|
("cursor_bucket_day", query.cursor_bucket_day.as_deref()),
|
||||||
&mut params,
|
("cursor_created_at", query.cursor_created_at.as_deref()),
|
||||||
"max_lookback_days",
|
("cursor_job_id", query.cursor_job_id.as_deref()),
|
||||||
query.max_lookback_days.as_deref(),
|
]
|
||||||
);
|
.into_iter()
|
||||||
push_query(
|
.filter_map(|(key, value)| {
|
||||||
&mut params,
|
value
|
||||||
"cursor_bucket_day",
|
.filter(|value| !value.is_empty())
|
||||||
query.cursor_bucket_day.as_deref(),
|
.map(|value| (key, value))
|
||||||
);
|
})
|
||||||
push_query(
|
.map(|(key, value)| {
|
||||||
&mut params,
|
format!(
|
||||||
"cursor_created_at",
|
"{}={}",
|
||||||
query.cursor_created_at.as_deref(),
|
urlencoding::encode(key),
|
||||||
);
|
urlencoding::encode(value)
|
||||||
push_query(&mut params, "cursor_job_id", query.cursor_job_id.as_deref());
|
)
|
||||||
if params.is_empty() {
|
})
|
||||||
return format!("{}/jobs", config.base_path);
|
.collect::<Vec<_>>()
|
||||||
}
|
.join("&");
|
||||||
let query = params
|
if query.is_empty() {
|
||||||
.iter()
|
format!("{}/jobs", config.base_path)
|
||||||
.map(|(key, value)| {
|
} else {
|
||||||
format!(
|
format!("{}/jobs?{query}", config.base_path)
|
||||||
"{}={}",
|
|
||||||
urlencoding::encode(key),
|
|
||||||
urlencoding::encode(value)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("&");
|
|
||||||
format!("{}/jobs?{query}", config.base_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_query(params: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
|
|
||||||
if let Some(value) = value.filter(|value| !value.is_empty()) {
|
|
||||||
params.push((key.to_owned(), value.to_owned()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,53 +59,51 @@ pub(crate) async fn messages_post(
|
|||||||
match action {
|
match action {
|
||||||
"lookup" => {
|
"lookup" => {
|
||||||
let context_limit = parse_context_limit(form.first("context_limit"));
|
let context_limit = parse_context_limit(form.first("context_limit"));
|
||||||
return Redirect::to(&format!(
|
Redirect::to(&format!(
|
||||||
"{base}/messages?channel_id={}&message_id={}&context_limit={context_limit}",
|
"{base}/messages?channel_id={}&message_id={}&context_limit={context_limit}",
|
||||||
encode_opt(&channel_id),
|
encode_opt(&channel_id),
|
||||||
encode_opt(&message_id)
|
encode_opt(&message_id)
|
||||||
))
|
))
|
||||||
.into_response();
|
.into_response()
|
||||||
}
|
}
|
||||||
"lookup-by-attachment" => {
|
"lookup-by-attachment" => {
|
||||||
let attachment_id = form.clean("attachment_id");
|
let attachment_id = form.clean("attachment_id");
|
||||||
let filename = form.clean("filename");
|
let filename = form.clean("filename");
|
||||||
let context_limit = parse_context_limit(form.first("context_limit"));
|
let context_limit = parse_context_limit(form.first("context_limit"));
|
||||||
return Redirect::to(&format!(
|
Redirect::to(&format!(
|
||||||
"{base}/messages?channel_id={}&attachment_id={}&filename={}&context_limit={context_limit}",
|
"{base}/messages?channel_id={}&attachment_id={}&filename={}&context_limit={context_limit}",
|
||||||
encode_opt(&channel_id),
|
encode_opt(&channel_id),
|
||||||
encode_opt(&attachment_id),
|
encode_opt(&attachment_id),
|
||||||
encode_opt(&filename)
|
encode_opt(&filename)
|
||||||
))
|
))
|
||||||
.into_response();
|
.into_response()
|
||||||
}
|
|
||||||
"browse" => {
|
|
||||||
return Redirect::to(&format!(
|
|
||||||
"{base}/messages?channel_id={}",
|
|
||||||
encode_opt(&channel_id)
|
|
||||||
))
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
|
"browse" => Redirect::to(&format!(
|
||||||
|
"{base}/messages?channel_id={}",
|
||||||
|
encode_opt(&channel_id)
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
"search" => {
|
"search" => {
|
||||||
let search = form.clean("search");
|
let search = form.clean("search");
|
||||||
return Redirect::to(&format!(
|
Redirect::to(&format!(
|
||||||
"{base}/messages?channel_id={}&search={}",
|
"{base}/messages?channel_id={}&search={}",
|
||||||
encode_opt(&channel_id),
|
encode_opt(&channel_id),
|
||||||
encode_opt(&search)
|
encode_opt(&search)
|
||||||
))
|
))
|
||||||
.into_response();
|
.into_response()
|
||||||
}
|
}
|
||||||
"delete" => {
|
"delete" => {
|
||||||
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
|
let (Some(cid), Some(mid)) = (&channel_id, &message_id) else {
|
||||||
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
|
return json_error(StatusCode::BAD_REQUEST, "Missing channel_id or message_id");
|
||||||
};
|
};
|
||||||
let audit_log_reason = form.clean("audit_log_reason");
|
let audit_log_reason = form.clean("audit_log_reason");
|
||||||
return match client
|
match client
|
||||||
.delete_message(cid, mid, audit_log_reason.as_deref())
|
.delete_message(cid, mid, audit_log_reason.as_deref())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
|
Ok(()) => Json(serde_json::json!({"success": true})).into_response(),
|
||||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
"report-to-ncmec" => {
|
"report-to-ncmec" => {
|
||||||
let attachment_id = form.clean("attachment_id");
|
let attachment_id = form.clean("attachment_id");
|
||||||
@@ -131,7 +129,7 @@ pub(crate) async fn messages_post(
|
|||||||
"Missing required NCMEC report fields",
|
"Missing required NCMEC report fields",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return match client
|
match client
|
||||||
.report_attachment_to_ncmec(
|
.report_attachment_to_ncmec(
|
||||||
cid,
|
cid,
|
||||||
mid,
|
mid,
|
||||||
@@ -144,11 +142,10 @@ pub(crate) async fn messages_post(
|
|||||||
{
|
{
|
||||||
Ok(resp) => Json(resp.data).into_response(),
|
Ok(resp) => Json(resp.data).into_response(),
|
||||||
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => Redirect::to(&format!("{base}/messages")).into_response(),
|
||||||
}
|
}
|
||||||
Redirect::to(&format!("{base}/messages")).into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn system_dms_post(
|
pub(crate) async fn system_dms_post(
|
||||||
@@ -253,14 +250,22 @@ pub(crate) async fn bulk_actions_post(
|
|||||||
}
|
}
|
||||||
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
|
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
|
||||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||||
let reason_code = form.parse_u32("reason_code").unwrap_or(2);
|
let (Ok(reason_code), Ok(days)) = (
|
||||||
let days = form.parse_u32("days_until_deletion").unwrap_or(14);
|
form.parse_value::<u32>("reason_code"),
|
||||||
|
form.parse_value::<u32>("days_until_deletion"),
|
||||||
|
) else {
|
||||||
|
return flash::redirect_with_flash(
|
||||||
|
&format!("{base}/bulk-actions"),
|
||||||
|
FlashData::error("Invalid deletion reason code or delay"),
|
||||||
|
config.secure_cookies(),
|
||||||
|
);
|
||||||
|
};
|
||||||
let public_reason = form.clean("public_reason");
|
let public_reason = form.clean("public_reason");
|
||||||
client
|
client
|
||||||
.bulk_schedule_user_deletion(
|
.bulk_schedule_user_deletion(
|
||||||
&user_ids,
|
&user_ids,
|
||||||
reason_code,
|
reason_code.unwrap_or(2),
|
||||||
days,
|
days.unwrap_or(14),
|
||||||
public_reason.as_deref(),
|
public_reason.as_deref(),
|
||||||
audit_log_reason.as_deref(),
|
audit_log_reason.as_deref(),
|
||||||
)
|
)
|
||||||
@@ -357,7 +362,7 @@ pub(crate) async fn messages_browse_fragment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_context_limit(value: Option<&str>) -> u32 {
|
pub(super) fn parse_context_limit(value: Option<&str>) -> u32 {
|
||||||
value
|
value
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
.filter(|n| *n > 0)
|
.filter(|n| *n > 0)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
struct MessagesQuery {
|
struct MessagesQuery {
|
||||||
channel_id: Option<String>,
|
channel_id: Option<String>,
|
||||||
message_id: Option<String>,
|
message_id: Option<String>,
|
||||||
@@ -82,13 +81,7 @@ async fn messages_page(
|
|||||||
let before = query.before.as_deref().filter(|s| !s.is_empty());
|
let before = query.before.as_deref().filter(|s| !s.is_empty());
|
||||||
let after = query.after.as_deref().filter(|s| !s.is_empty());
|
let after = query.after.as_deref().filter(|s| !s.is_empty());
|
||||||
let search = query.search.as_deref().filter(|s| !s.is_empty());
|
let search = query.search.as_deref().filter(|s| !s.is_empty());
|
||||||
let context_limit = query
|
let context_limit = super::message_actions::parse_context_limit(query.context_limit.as_deref());
|
||||||
.context_limit
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
|
||||||
.filter(|n| *n > 0)
|
|
||||||
.unwrap_or(50)
|
|
||||||
.min(100);
|
|
||||||
let mut lookup_result = None;
|
let mut lookup_result = None;
|
||||||
let mut browse_result = None;
|
let mut browse_result = None;
|
||||||
let mut search_result = None;
|
let mut search_result = None;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::client::{AdminApiClient, ApiResultExt},
|
api::{
|
||||||
|
client::{AdminApiClient, ApiResultExt},
|
||||||
|
reports::SearchReportsParams,
|
||||||
|
},
|
||||||
config::AdminConfig,
|
config::AdminConfig,
|
||||||
middleware::{
|
middleware::{
|
||||||
auth::AuthContext,
|
auth::AuthContext,
|
||||||
@@ -23,7 +26,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
const MAX_REPORT_OFFSET: u32 = 10_000;
|
const MAX_REPORT_OFFSET: u64 = 10_000;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ReportsQuery {
|
struct ReportsQuery {
|
||||||
@@ -72,12 +75,12 @@ async fn reports_list(
|
|||||||
let config = state.config();
|
let config = state.config();
|
||||||
let page = query.page.unwrap_or(0);
|
let page = query.page.unwrap_or(0);
|
||||||
let limit = query.limit.unwrap_or(25).clamp(1, 200);
|
let limit = query.limit.unwrap_or(25).clamp(1, 200);
|
||||||
let offset = page.saturating_mul(limit);
|
let offset = u64::from(page) * u64::from(limit);
|
||||||
if offset > MAX_REPORT_OFFSET {
|
if offset > MAX_REPORT_OFFSET {
|
||||||
return reports_error_page(
|
return reports_error_page(
|
||||||
config,
|
config,
|
||||||
&auth.0,
|
&auth.0,
|
||||||
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
|
"That page is out of range. The reports search returns at most the first 10000 reports. Narrow the filters and start again.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let search_query = query.q.as_deref().and_then(clean_string);
|
let search_query = query.q.as_deref().and_then(clean_string);
|
||||||
@@ -89,22 +92,22 @@ async fn reports_list(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| s.parse::<i32>().ok());
|
.and_then(|s| s.parse::<i32>().ok());
|
||||||
let reports = client
|
let reports = client
|
||||||
.search_reports(
|
.search_reports(&SearchReportsParams {
|
||||||
search_query.as_deref(),
|
query: search_query.as_deref(),
|
||||||
status,
|
status,
|
||||||
report_type,
|
report_type,
|
||||||
query.category.as_deref(),
|
category: query.category.as_deref(),
|
||||||
query.reporter_id.as_deref(),
|
reporter_id: query.reporter_id.as_deref(),
|
||||||
query.reported_user_id.as_deref(),
|
reported_user_id: query.reported_user_id.as_deref(),
|
||||||
query.reported_guild_id.as_deref(),
|
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||||
query.reported_channel_id.as_deref(),
|
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||||
query.guild_context_id.as_deref(),
|
guild_context_id: query.guild_context_id.as_deref(),
|
||||||
query.resolved_by_admin_id.as_deref(),
|
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||||
Some(sort_by),
|
sort_by: Some(sort_by),
|
||||||
Some(sort_order),
|
sort_order: Some(sort_order),
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
)
|
})
|
||||||
.await
|
.await
|
||||||
.log_error("search reports");
|
.log_error("search reports");
|
||||||
|
|
||||||
|
|||||||
@@ -17,19 +17,18 @@ use serde::Deserialize;
|
|||||||
use super::system_actions;
|
use super::system_actions;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
struct GatewayQuery {
|
struct GatewayQuery {
|
||||||
leaderboard_limit: Option<String>,
|
leaderboard_limit: Option<String>,
|
||||||
node_stats: Option<String>,
|
node_stats: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
struct AuditLogsQuery {
|
struct AuditLogsQuery {
|
||||||
q: Option<String>,
|
q: Option<String>,
|
||||||
admin_user_id: Option<String>,
|
admin_user_id: Option<String>,
|
||||||
target_id: Option<String>,
|
target_id: Option<String>,
|
||||||
target_type: Option<String>,
|
target_type: Option<String>,
|
||||||
|
access: Option<String>,
|
||||||
sort_by: Option<String>,
|
sort_by: Option<String>,
|
||||||
sort_order: Option<String>,
|
sort_order: Option<String>,
|
||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
@@ -121,6 +120,7 @@ async fn audit_logs_page(
|
|||||||
admin_user_id: query.admin_user_id.as_deref().unwrap_or(""),
|
admin_user_id: query.admin_user_id.as_deref().unwrap_or(""),
|
||||||
target_id: query.target_id.as_deref().unwrap_or(""),
|
target_id: query.target_id.as_deref().unwrap_or(""),
|
||||||
target_type: query.target_type.as_deref().unwrap_or(""),
|
target_type: query.target_type.as_deref().unwrap_or(""),
|
||||||
|
access: query.access.as_deref().unwrap_or(""),
|
||||||
sort_by: query.sort_by.as_deref().unwrap_or("createdAt"),
|
sort_by: query.sort_by.as_deref().unwrap_or("createdAt"),
|
||||||
sort_order: query.sort_order.as_deref().unwrap_or("desc"),
|
sort_order: query.sort_order.as_deref().unwrap_or("desc"),
|
||||||
limit,
|
limit,
|
||||||
@@ -133,10 +133,11 @@ async fn audit_logs_page(
|
|||||||
admin_user_id: nonempty(params.admin_user_id),
|
admin_user_id: nonempty(params.admin_user_id),
|
||||||
target_id: nonempty(params.target_id),
|
target_id: nonempty(params.target_id),
|
||||||
target_type: nonempty(params.target_type),
|
target_type: nonempty(params.target_type),
|
||||||
|
access: nonempty(params.access),
|
||||||
sort_by: Some(params.sort_by.to_owned()),
|
sort_by: Some(params.sort_by.to_owned()),
|
||||||
sort_order: Some(params.sort_order.to_owned()),
|
sort_order: Some(params.sort_order.to_owned()),
|
||||||
limit,
|
limit,
|
||||||
offset: current_page * limit,
|
offset: u64::from(current_page) * u64::from(limit),
|
||||||
};
|
};
|
||||||
let result = client
|
let result = client
|
||||||
.search_audit_logs(&search_params)
|
.search_audit_logs(&search_params)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -60,7 +60,9 @@ pub async fn dispatch(
|
|||||||
"Failed to update user flags",
|
"Failed to update user flags",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let submitted = parse_u64_list(form, &["flags[]", "flags"]);
|
let Ok(submitted) = form.parse_list_values::<u64>(&["flags[]", "flags"]) else {
|
||||||
|
return DispatchOutcome::error("Invalid user flag value");
|
||||||
|
};
|
||||||
let selected = submitted.iter().copied().collect::<HashSet<_>>();
|
let selected = submitted.iter().copied().collect::<HashSet<_>>();
|
||||||
let user = match client.get_user_by_id(user_id).await {
|
let user = match client.get_user_by_id(user_id).await {
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
@@ -89,15 +91,22 @@ pub async fn dispatch(
|
|||||||
}
|
}
|
||||||
"update_premium_flags" => {
|
"update_premium_flags" => {
|
||||||
if has_legacy_flag_delta_fields(form) {
|
if has_legacy_flag_delta_fields(form) {
|
||||||
let add = parse_i32_list(form, &["add_flags[]", "add_flags"]);
|
let Ok(add) = form.parse_list_values::<i32>(&["add_flags[]", "add_flags"]) else {
|
||||||
let remove = parse_i32_list(form, &["remove_flags[]", "remove_flags"]);
|
return DispatchOutcome::error("Invalid premium flag value to add");
|
||||||
|
};
|
||||||
|
let Ok(remove) = form.parse_list_values::<i32>(&["remove_flags[]", "remove_flags"])
|
||||||
|
else {
|
||||||
|
return DispatchOutcome::error("Invalid premium flag value to remove");
|
||||||
|
};
|
||||||
return DispatchOutcome::from_result(
|
return DispatchOutcome::from_result(
|
||||||
client.update_premium_flags(user_id, &add, &remove).await,
|
client.update_premium_flags(user_id, &add, &remove).await,
|
||||||
"Premium flags updated successfully",
|
"Premium flags updated successfully",
|
||||||
"Failed to update premium flags",
|
"Failed to update premium flags",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let submitted = parse_i32_list(form, &["flags[]", "flags"]);
|
let Ok(submitted) = form.parse_list_values::<i32>(&["flags[]", "flags"]) else {
|
||||||
|
return DispatchOutcome::error("Invalid premium flag value");
|
||||||
|
};
|
||||||
let selected = submitted.iter().copied().collect::<HashSet<_>>();
|
let selected = submitted.iter().copied().collect::<HashSet<_>>();
|
||||||
let user = match client.get_user_by_id(user_id).await {
|
let user = match client.get_user_by_id(user_id).await {
|
||||||
Ok(user) => user,
|
Ok(user) => user,
|
||||||
@@ -124,9 +133,12 @@ pub async fn dispatch(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"update_suspicious_flags" => {
|
"update_suspicious_flags" => {
|
||||||
let flags = parse_i32_list(form, &["suspicious_flags[]", "suspicious_flags"])
|
let Ok(submitted) =
|
||||||
.into_iter()
|
form.parse_list_values::<i32>(&["suspicious_flags[]", "suspicious_flags"])
|
||||||
.fold(0, |acc, flag| acc | flag);
|
else {
|
||||||
|
return DispatchOutcome::error("Invalid suspicious activity flag value");
|
||||||
|
};
|
||||||
|
let flags = submitted.into_iter().fold(0, |acc, flag| acc | flag);
|
||||||
DispatchOutcome::from_result(
|
DispatchOutcome::from_result(
|
||||||
client.update_suspicious_flags(user_id, flags).await,
|
client.update_suspicious_flags(user_id, flags).await,
|
||||||
"Suspicious activity flags updated successfully",
|
"Suspicious activity flags updated successfully",
|
||||||
@@ -225,15 +237,19 @@ pub async fn dispatch(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"temp_ban" => {
|
"temp_ban" => {
|
||||||
let dur = form
|
let Ok(duration) = form.parse_value_any::<u32>(&["duration_hours", "duration"]) else {
|
||||||
.parse_u32("duration_hours")
|
return DispatchOutcome::error("Invalid ban duration");
|
||||||
.or_else(|| form.parse_u32("duration"))
|
};
|
||||||
.unwrap_or(24);
|
|
||||||
let reason = get("reason");
|
let reason = get("reason");
|
||||||
let private = get("private_reason");
|
let private = get("private_reason");
|
||||||
DispatchOutcome::from_result(
|
DispatchOutcome::from_result(
|
||||||
client
|
client
|
||||||
.temp_ban_user(user_id, dur, reason.as_deref(), private.as_deref())
|
.temp_ban_user(
|
||||||
|
user_id,
|
||||||
|
duration.unwrap_or(24),
|
||||||
|
reason.as_deref(),
|
||||||
|
private.as_deref(),
|
||||||
|
)
|
||||||
.await,
|
.await,
|
||||||
"User temporarily banned successfully",
|
"User temporarily banned successfully",
|
||||||
"Failed to temporarily ban user",
|
"Failed to temporarily ban user",
|
||||||
@@ -249,7 +265,7 @@ pub async fn dispatch(
|
|||||||
return DispatchOutcome::error("IP address is required");
|
return DispatchOutcome::error("IP address is required");
|
||||||
};
|
};
|
||||||
DispatchOutcome::from_result(
|
DispatchOutcome::from_result(
|
||||||
client.ban_ip(&ip).await,
|
client.ban_ip(&ip, None).await,
|
||||||
"IP banned successfully",
|
"IP banned successfully",
|
||||||
"Failed to ban IP",
|
"Failed to ban IP",
|
||||||
)
|
)
|
||||||
@@ -259,18 +275,29 @@ pub async fn dispatch(
|
|||||||
return DispatchOutcome::error("Avatar hash is required");
|
return DispatchOutcome::error("Avatar hash is required");
|
||||||
};
|
};
|
||||||
DispatchOutcome::from_result(
|
DispatchOutcome::from_result(
|
||||||
client.ban_avatar_hash(&hash).await,
|
client.ban_avatar_hash(&hash, None).await,
|
||||||
"Avatar hash banned successfully",
|
"Avatar hash banned successfully",
|
||||||
"Failed to ban avatar hash",
|
"Failed to ban avatar hash",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"schedule_deletion" => {
|
"schedule_deletion" => {
|
||||||
let reason_code = form.parse_i32("reason_code").unwrap_or(0);
|
let Ok(reason_code) = form.parse_value::<i32>("reason_code") else {
|
||||||
|
return DispatchOutcome::error("Invalid deletion reason code");
|
||||||
|
};
|
||||||
let public_reason = get("public_reason");
|
let public_reason = get("public_reason");
|
||||||
let days = form.parse_u32("days_until_deletion").unwrap_or(60);
|
let private_reason = get("private_reason");
|
||||||
|
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
|
||||||
|
return DispatchOutcome::error("Invalid deletion delay");
|
||||||
|
};
|
||||||
DispatchOutcome::from_result(
|
DispatchOutcome::from_result(
|
||||||
client
|
client
|
||||||
.schedule_deletion(user_id, reason_code, public_reason.as_deref(), days)
|
.schedule_deletion(
|
||||||
|
user_id,
|
||||||
|
reason_code.unwrap_or(0),
|
||||||
|
public_reason.as_deref(),
|
||||||
|
days.unwrap_or(60),
|
||||||
|
private_reason.as_deref(),
|
||||||
|
)
|
||||||
.await,
|
.await,
|
||||||
"User deletion scheduled successfully",
|
"User deletion scheduled successfully",
|
||||||
"Failed to schedule user deletion",
|
"Failed to schedule user deletion",
|
||||||
@@ -441,20 +468,6 @@ fn is_relationship_category(category: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_u64_list(form: &MultiValueForm, keys: &[&str]) -> Vec<u64> {
|
|
||||||
form.list_values_any(keys)
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| value.parse().ok())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_i32_list(form: &MultiValueForm, keys: &[&str]) -> Vec<i32> {
|
|
||||||
form.list_values_any(keys)
|
|
||||||
.iter()
|
|
||||||
.filter_map(|value| value.parse().ok())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_dry_run(value: Option<&str>) -> bool {
|
fn parse_dry_run(value: Option<&str>) -> bool {
|
||||||
!matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0")
|
!matches!(value.map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "false" || value == "0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,15 +73,11 @@ pub async fn render(
|
|||||||
.map(|r| r.sessions)
|
.map(|r| r.sessions)
|
||||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
|
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let webauthn_credentials = if u.authenticator_types.contains(&2) {
|
let webauthn_credentials = client
|
||||||
client
|
.list_webauthn_credentials(user_id)
|
||||||
.list_webauthn_credentials(user_id)
|
.await
|
||||||
.await
|
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
|
||||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
|
.unwrap_or_default();
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
};
|
|
||||||
Some(tabs::account::account_tab(
|
Some(tabs::account::account_tab(
|
||||||
config,
|
config,
|
||||||
&u,
|
&u,
|
||||||
@@ -164,25 +160,29 @@ pub async fn render(
|
|||||||
Some(tabs::guilds::guilds_tab(config, user_id, &g))
|
Some(tabs::guilds::guilds_tab(config, user_id, &g))
|
||||||
}
|
}
|
||||||
"reports" => {
|
"reports" => {
|
||||||
let lim = query.reports_limit.unwrap_or(25);
|
let limit = query.reports_limit.unwrap_or(25);
|
||||||
let sp = query.reports_sent_page.unwrap_or(0);
|
let sent_page = query.reports_sent_page.unwrap_or(0);
|
||||||
let rp = query.reports_received_page.unwrap_or(0);
|
let received_page = query.reports_received_page.unwrap_or(0);
|
||||||
let sent = client
|
let sent = client
|
||||||
.search_reports_by_reporter(user_id, lim, sp * lim)
|
.search_reports_by_reporter(user_id, limit, u64::from(sent_page) * u64::from(limit))
|
||||||
.await
|
.await
|
||||||
.log_error("load reports sent by user");
|
.log_error("load reports sent by user");
|
||||||
let recv = client
|
let received = client
|
||||||
.search_reports_by_reported_user(user_id, lim, rp * lim)
|
.search_reports_by_reported_user(
|
||||||
|
user_id,
|
||||||
|
limit,
|
||||||
|
u64::from(received_page) * u64::from(limit),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.log_error("load reports against user");
|
.log_error("load reports against user");
|
||||||
Some(tabs::reports::reports_tab(
|
Some(tabs::reports::reports_tab(
|
||||||
config,
|
config,
|
||||||
user_id,
|
user_id,
|
||||||
sent.as_ref(),
|
sent.as_ref(),
|
||||||
recv.as_ref(),
|
received.as_ref(),
|
||||||
sp,
|
sent_page,
|
||||||
rp,
|
received_page,
|
||||||
lim,
|
limit,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
"relationships" => {
|
"relationships" => {
|
||||||
@@ -240,11 +240,12 @@ pub async fn render(
|
|||||||
query: None,
|
query: None,
|
||||||
admin_user_id: None,
|
admin_user_id: None,
|
||||||
target_id: Some(user_id.to_owned()),
|
target_id: Some(user_id.to_owned()),
|
||||||
target_type: Some("user".to_owned()),
|
target_type: None,
|
||||||
|
access: Some("write".to_owned()),
|
||||||
sort_by: Some("created_at".to_owned()),
|
sort_by: Some("created_at".to_owned()),
|
||||||
sort_order: Some("desc".to_owned()),
|
sort_order: Some("desc".to_owned()),
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: u64::from(page) * u64::from(limit),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.log_error("load user admin audit logs")?;
|
.log_error("load user admin audit logs")?;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async fn users_list(
|
|||||||
.log_error("lookup users by ids")
|
.log_error("lookup users by ids")
|
||||||
.map(|users| (users, false))
|
.map(|users| (users, false))
|
||||||
} else if params.has_search() {
|
} else if params.has_search() {
|
||||||
let offset = params.page.saturating_mul(params.limit);
|
let offset = u64::from(params.page) * u64::from(params.limit);
|
||||||
client
|
client
|
||||||
.search_users(
|
.search_users(
|
||||||
params.search_query(),
|
params.search_query(),
|
||||||
@@ -105,7 +105,7 @@ async fn users_list(
|
|||||||
.await
|
.await
|
||||||
.log_error("search users")
|
.log_error("search users")
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let has_more = u64::from(offset) + (r.users.len() as u64) < r.total;
|
let has_more = (r.users.len() as u64) < r.total.saturating_sub(offset);
|
||||||
(r.users, has_more)
|
(r.users, has_more)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use crate::{
|
|||||||
api::client::AdminApiClient,
|
api::client::AdminApiClient,
|
||||||
middleware::{auth::AuthContext, csrf},
|
middleware::{auth::AuthContext, csrf},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
templates,
|
templates::{self, pages::voice_servers::VoiceServersPageParams},
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
@@ -13,12 +13,34 @@ use axum::{
|
|||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct VoiceServersQuery {
|
struct VoiceServersQuery {
|
||||||
region_id: Option<String>,
|
region_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
|
||||||
|
let response = match client.get_gateway_voice_state_counts().await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "admin API request failed: load voice state counts");
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
|
||||||
|
return HashMap::new();
|
||||||
|
};
|
||||||
|
servers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let server_id = entry.get("server_id")?.as_str()?.to_owned();
|
||||||
|
let count = entry.get("voice_state_count")?.as_i64()?;
|
||||||
|
Some((server_id, count))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
@@ -79,17 +101,21 @@ async fn voice_servers_page(
|
|||||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||||
config,
|
config,
|
||||||
&auth.0,
|
&auth.0,
|
||||||
None,
|
&VoiceServersPageParams {
|
||||||
None,
|
region_id: None,
|
||||||
None,
|
region_name: None,
|
||||||
None,
|
servers: None,
|
||||||
&csrf_token,
|
connection_counts: &HashMap::new(),
|
||||||
|
error: None,
|
||||||
|
csrf_token: &csrf_token,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return Html(markup.into_string()).into_response();
|
return Html(markup.into_string()).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||||
|
let connection_counts = load_server_connection_counts(&client).await;
|
||||||
|
|
||||||
let region_name = match client.get_voice_region(region_id, false).await {
|
let region_name = match client.get_voice_region(region_id, false).await {
|
||||||
Ok(resp) => resp
|
Ok(resp) => resp
|
||||||
@@ -107,11 +133,14 @@ async fn voice_servers_page(
|
|||||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||||
config,
|
config,
|
||||||
&auth.0,
|
&auth.0,
|
||||||
Some(region_id),
|
&VoiceServersPageParams {
|
||||||
Some(®ion_name),
|
region_id: Some(region_id),
|
||||||
Some(&resp.servers),
|
region_name: Some(®ion_name),
|
||||||
None,
|
servers: Some(&resp.servers),
|
||||||
&csrf_token,
|
connection_counts: &connection_counts,
|
||||||
|
error: None,
|
||||||
|
csrf_token: &csrf_token,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
Html(markup.into_string()).into_response()
|
Html(markup.into_string()).into_response()
|
||||||
}
|
}
|
||||||
@@ -120,11 +149,14 @@ async fn voice_servers_page(
|
|||||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||||
config,
|
config,
|
||||||
&auth.0,
|
&auth.0,
|
||||||
Some(region_id),
|
&VoiceServersPageParams {
|
||||||
Some(®ion_name),
|
region_id: Some(region_id),
|
||||||
None,
|
region_name: Some(®ion_name),
|
||||||
Some(&msg),
|
servers: None,
|
||||||
&csrf_token,
|
connection_counts: &connection_counts,
|
||||||
|
error: Some(&msg),
|
||||||
|
csrf_token: &csrf_token,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
Html(markup.into_string()).into_response()
|
Html(markup.into_string()).into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
body.insert("is_default".into(), form.bool_value("is_default").into());
|
body.insert("is_default".into(), form.bool_value("is_default").into());
|
||||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||||
body.insert(
|
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||||
"required_guild_features".into(),
|
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
body.insert(
|
|
||||||
"allowed_guild_ids".into(),
|
|
||||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
serde_json::Value::Object(body)
|
serde_json::Value::Object(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn insert_submitted_list(
|
||||||
|
body: &mut serde_json::Map<String, serde_json::Value>,
|
||||||
|
form: &MultiValueForm,
|
||||||
|
field: &str,
|
||||||
|
) {
|
||||||
|
let repeated = format!("{field}[]");
|
||||||
|
if !form.contains_key(&repeated) && !form.contains_key(field) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.insert(
|
||||||
|
field.to_owned(),
|
||||||
|
form.list_values_any(&[repeated.as_str(), field]).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||||
let mut body = serde_json::Map::new();
|
let mut body = serde_json::Map::new();
|
||||||
if let Some(v) = form.clean("region_id") {
|
if let Some(v) = form.clean("region_id") {
|
||||||
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
|||||||
body.insert("longitude".into(), lng.into());
|
body.insert("longitude".into(), lng.into());
|
||||||
}
|
}
|
||||||
body.insert("is_active".into(), form.bool_value("is_active").into());
|
body.insert("is_active".into(), form.bool_value("is_active").into());
|
||||||
|
if let Some(raw) = form.first("soft_connection_limit") {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
|
||||||
|
} else if let Ok(limit) = trimmed.parse::<i64>()
|
||||||
|
&& limit > 0
|
||||||
|
{
|
||||||
|
body.insert("soft_connection_limit".into(), limit.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||||
body.insert(
|
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||||
"required_guild_features".into(),
|
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
body.insert(
|
|
||||||
"allowed_guild_ids".into(),
|
|
||||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
serde_json::Value::Object(body)
|
serde_json::Value::Object(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +264,86 @@ mod tests {
|
|||||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
|
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||||
|
let form = MultiValueForm::parse(
|
||||||
|
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
|
||||||
|
);
|
||||||
|
let body = build_server_body(&form);
|
||||||
|
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||||
|
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||||
|
let form = MultiValueForm::parse(
|
||||||
|
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
|
||||||
|
);
|
||||||
|
let body = build_server_body(&form);
|
||||||
|
let object = body.as_object().unwrap();
|
||||||
|
assert!(!object.contains_key("required_guild_features"));
|
||||||
|
assert!(!object.contains_key("allowed_guild_ids"));
|
||||||
|
assert_eq!(body["is_active"], serde_json::json!(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||||
|
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
|
||||||
|
let body = build_region_body(&form);
|
||||||
|
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||||
|
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||||
|
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
|
||||||
|
let body = build_region_body(&form);
|
||||||
|
let object = body.as_object().unwrap();
|
||||||
|
assert!(!object.contains_key("required_guild_features"));
|
||||||
|
assert!(!object.contains_key("allowed_guild_ids"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
|
||||||
|
let form =
|
||||||
|
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
|
||||||
|
let body = build_server_body(&form);
|
||||||
|
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
|
||||||
|
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
|
||||||
|
let body = build_server_body(&form);
|
||||||
|
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
|
||||||
|
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
|
||||||
|
assert!(
|
||||||
|
!build_server_body(&absent)
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key("soft_connection_limit")
|
||||||
|
);
|
||||||
|
let invalid =
|
||||||
|
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
|
||||||
|
assert!(
|
||||||
|
!build_server_body(&invalid)
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key("soft_connection_limit")
|
||||||
|
);
|
||||||
|
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
|
||||||
|
assert!(
|
||||||
|
!build_server_body(&zero)
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.contains_key("soft_connection_limit")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_server_body_preserves_single_and_repeated_values() {
|
fn build_server_body_preserves_single_and_repeated_values() {
|
||||||
let form = MultiValueForm::parse(
|
let form = MultiValueForm::parse(
|
||||||
|
|||||||
@@ -41,17 +41,14 @@ pub fn archives_tab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status_text(archive: &Archive) -> String {
|
fn status_text(archive: &Archive) -> &str {
|
||||||
if archive.failed_at.is_some() {
|
if archive.failed_at.is_some() {
|
||||||
return "Failed".to_owned();
|
return "Failed";
|
||||||
}
|
}
|
||||||
if archive.completed_at.is_some() {
|
if archive.completed_at.is_some() {
|
||||||
return "Completed".to_owned();
|
return "Completed";
|
||||||
}
|
}
|
||||||
archive
|
archive.progress_step.as_deref().unwrap_or("In Progress")
|
||||||
.progress_step
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "In Progress".to_owned())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn archives_table(base: &str, archives: &[Archive]) -> Markup {
|
fn archives_table(base: &str, archives: &[Archive]) -> Markup {
|
||||||
|
|||||||
@@ -66,9 +66,15 @@ pub fn audit_logs_for_target(
|
|||||||
) -> Markup {
|
) -> Markup {
|
||||||
let has_previous = current_page > 0;
|
let has_previous = current_page > 0;
|
||||||
let offset = (current_page as u64) * (PAGE_SIZE as u64);
|
let offset = (current_page as u64) * (PAGE_SIZE as u64);
|
||||||
let has_next = offset + (entries.len() as u64) < total;
|
let next_page = current_page
|
||||||
let total_pages = ((total as f64) / (PAGE_SIZE as f64)).ceil().max(1.0) as u64;
|
.checked_add(1)
|
||||||
let all_logs_href = format!("{base_path}/audit-logs?target_id={target_id}");
|
.filter(|_| offset + (entries.len() as u64) < total);
|
||||||
|
let total_pages = total.div_ceil(u64::from(PAGE_SIZE)).max(1);
|
||||||
|
let page_number = u64::from(current_page) + 1;
|
||||||
|
let all_logs_href = format!(
|
||||||
|
"{base_path}/audit-logs?target_id={}&access=write",
|
||||||
|
urlencoding::encode(target_id)
|
||||||
|
);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" {
|
div class="rounded-lg bg-white transition-all border border-neutral-200 p-6" {
|
||||||
@@ -88,7 +94,7 @@ pub fn audit_logs_for_target(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if entries.is_empty() {
|
@if entries.is_empty() {
|
||||||
(empty_state("No admin actions have been recorded against this entity."))
|
(empty_state("No admin write actions have been recorded against this entity."))
|
||||||
} @else {
|
} @else {
|
||||||
(table_container(html! {
|
(table_container(html! {
|
||||||
(table(html! {
|
(table(html! {
|
||||||
@@ -108,7 +114,7 @@ pub fn audit_logs_for_target(
|
|||||||
}))
|
}))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@if has_previous || has_next {
|
@if has_previous || next_page.is_some() {
|
||||||
div class="flex items-center justify-center gap-2" {
|
div class="flex items-center justify-center gap-2" {
|
||||||
@if has_previous {
|
@if has_previous {
|
||||||
a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)}
|
a href={(tab_href_base) "&audit_logs_page=" (current_page - 1)}
|
||||||
@@ -119,10 +125,10 @@ pub fn audit_logs_for_target(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
span class="text-sm text-neutral-500" {
|
span class="text-sm text-neutral-500" {
|
||||||
"Page " (current_page + 1) " of " (total_pages)
|
"Page " (page_number) " of " (total_pages)
|
||||||
}
|
}
|
||||||
@if has_next {
|
@if let Some(page) = next_page {
|
||||||
a href={(tab_href_base) "&audit_logs_page=" (current_page + 1)}
|
a href={(tab_href_base) "&audit_logs_page=" (page)}
|
||||||
class="inline-flex items-center justify-center gap-2 font-medium \
|
class="inline-flex items-center justify-center gap-2 font-medium \
|
||||||
rounded-lg bg-neutral-50 text-neutral-700 border \
|
rounded-lg bg-neutral-50 text-neutral-700 border \
|
||||||
border-neutral-300 px-3 py-1.5 text-sm" {
|
border-neutral-300 px-3 py-1.5 text-sm" {
|
||||||
|
|||||||
@@ -81,20 +81,15 @@ pub fn guild_asset_url(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn initials(name: &str) -> String {
|
pub fn initials(name: &str) -> String {
|
||||||
let parts = name
|
let mut parts = name.split_whitespace();
|
||||||
.split_whitespace()
|
let Some(first) = parts.next() else {
|
||||||
.filter(|part| !part.is_empty())
|
return "?".to_owned();
|
||||||
.collect::<Vec<_>>();
|
};
|
||||||
match parts.as_slice() {
|
std::iter::once(first)
|
||||||
[] => "?".to_owned(),
|
.chain(parts.next_back())
|
||||||
[part] => part.chars().next().unwrap_or('?').to_uppercase().collect(),
|
.flat_map(|part| part.chars().take(1))
|
||||||
[first, .., last] => {
|
.flat_map(char::to_uppercase)
|
||||||
let mut value = String::new();
|
.collect()
|
||||||
value.extend(first.chars().next().unwrap_or('?').to_uppercase());
|
|
||||||
value.extend(last.chars().next().unwrap_or('?').to_uppercase());
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn media_asset_url(
|
fn media_asset_url(
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct Attachment {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub nsfw: Option<bool>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
pub ncmec_status: String,
|
||||||
|
pub ncmec_report_id: Option<String>,
|
||||||
|
pub ncmec_failure_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: String,
|
||||||
|
pub author_id: String,
|
||||||
|
pub author_username: String,
|
||||||
|
pub author_global_name: Option<String>,
|
||||||
|
pub author_discriminator: String,
|
||||||
|
pub author_avatar: Option<String>,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub channel_nsfw: Option<bool>,
|
||||||
|
pub channel_content_warning_level: Option<i32>,
|
||||||
|
pub channel_content_warning_text: Option<String>,
|
||||||
|
pub guild_nsfw: Option<bool>,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||||
|
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
|
||||||
|
messages.sort_by(compare_message_ids);
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_from_value(value: &Value) -> Message {
|
||||||
|
let attachments = value["attachments"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(attachment_from_value)
|
||||||
|
.collect();
|
||||||
|
Message {
|
||||||
|
id: value_id(&value["id"]).unwrap_or_default(),
|
||||||
|
content: value["content"].as_str().unwrap_or("").to_owned(),
|
||||||
|
timestamp: value["timestamp"].as_str().unwrap_or("").to_owned(),
|
||||||
|
author_id: value_id(&value["author_id"]).unwrap_or_default(),
|
||||||
|
author_username: value["author_username"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
.to_owned(),
|
||||||
|
author_global_name: value["author_global_name"].as_str().map(ToOwned::to_owned),
|
||||||
|
author_discriminator: value_id(&value["author_discriminator"])
|
||||||
|
.unwrap_or_else(|| "0000".to_owned()),
|
||||||
|
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
|
||||||
|
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
|
||||||
|
channel_nsfw: value["channel_nsfw"].as_bool(),
|
||||||
|
channel_content_warning_level: value["channel_content_warning_level"]
|
||||||
|
.as_i64()
|
||||||
|
.map(|n| n as i32),
|
||||||
|
channel_content_warning_text: value["channel_content_warning_text"]
|
||||||
|
.as_str()
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||||
|
attachments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_from_value(value: &Value) -> Attachment {
|
||||||
|
Attachment {
|
||||||
|
id: value_id(&value["id"]).unwrap_or_default(),
|
||||||
|
url: value["url"].as_str().unwrap_or("").to_owned(),
|
||||||
|
filename: value["filename"].as_str().unwrap_or("").to_owned(),
|
||||||
|
nsfw: value["nsfw"].as_bool(),
|
||||||
|
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
|
||||||
|
width: value["width"].as_u64().map(|n| n as u32),
|
||||||
|
height: value["height"].as_u64().map(|n| n as u32),
|
||||||
|
size: value["size"].as_u64(),
|
||||||
|
ncmec_status: value["ncmec_status"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("not_submitted")
|
||||||
|
.to_owned(),
|
||||||
|
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
|
||||||
|
ncmec_failure_reason: value["ncmec_failure_reason"]
|
||||||
|
.as_str()
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn value_id(value: &Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::String(s) => Some(s.clone()),
|
||||||
|
Value::Number(n) => Some(n.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
||||||
|
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
||||||
|
(Ok(l), Ok(r)) => l.cmp(&r),
|
||||||
|
_ => left.id.cmp(&right.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_message_and_attachment_fields() {
|
||||||
|
let value = json!({
|
||||||
|
"id": "9007199254740993",
|
||||||
|
"content": "Message content",
|
||||||
|
"timestamp": "2026-09-11T12:00:00Z",
|
||||||
|
"author_id": 42,
|
||||||
|
"author_username": "alice",
|
||||||
|
"author_global_name": "Alice",
|
||||||
|
"author_discriminator": 1234,
|
||||||
|
"author_avatar": "avatar-hash",
|
||||||
|
"channel_id": "100",
|
||||||
|
"channel_nsfw": false,
|
||||||
|
"channel_content_warning_level": 2,
|
||||||
|
"channel_content_warning_text": "Content warning",
|
||||||
|
"guild_nsfw": true,
|
||||||
|
"attachments": [{
|
||||||
|
"id": 9007199254740993_u64,
|
||||||
|
"url": "https://cdn.example.com/image.png",
|
||||||
|
"filename": "image.png",
|
||||||
|
"nsfw": true,
|
||||||
|
"content_type": "image/png",
|
||||||
|
"width": 640,
|
||||||
|
"height": 480,
|
||||||
|
"size": 4096,
|
||||||
|
"ncmec_status": "submitted",
|
||||||
|
"ncmec_report_id": "report-id",
|
||||||
|
"ncmec_failure_reason": "previous failure"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
message_from_value(&value),
|
||||||
|
Message {
|
||||||
|
id: "9007199254740993".into(),
|
||||||
|
content: "Message content".into(),
|
||||||
|
timestamp: "2026-09-11T12:00:00Z".into(),
|
||||||
|
author_id: "42".into(),
|
||||||
|
author_username: "alice".into(),
|
||||||
|
author_global_name: Some("Alice".into()),
|
||||||
|
author_discriminator: "1234".into(),
|
||||||
|
author_avatar: Some("avatar-hash".into()),
|
||||||
|
channel_id: "100".into(),
|
||||||
|
channel_nsfw: Some(false),
|
||||||
|
channel_content_warning_level: Some(2),
|
||||||
|
channel_content_warning_text: Some("Content warning".into()),
|
||||||
|
guild_nsfw: Some(true),
|
||||||
|
attachments: vec![Attachment {
|
||||||
|
id: "9007199254740993".into(),
|
||||||
|
url: "https://cdn.example.com/image.png".into(),
|
||||||
|
filename: "image.png".into(),
|
||||||
|
nsfw: Some(true),
|
||||||
|
content_type: Some("image/png".into()),
|
||||||
|
width: Some(640),
|
||||||
|
height: Some(480),
|
||||||
|
size: Some(4096),
|
||||||
|
ncmec_status: "submitted".into(),
|
||||||
|
ncmec_report_id: Some("report-id".into()),
|
||||||
|
ncmec_failure_reason: Some("previous failure".into()),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retains_display_defaults_for_incomplete_snapshots() {
|
||||||
|
let message = message_from_value(&json!({"attachments": [{}]}));
|
||||||
|
|
||||||
|
assert_eq!(message.author_username, "Unknown");
|
||||||
|
assert_eq!(message.author_discriminator, "0000");
|
||||||
|
assert_eq!(message.content, "");
|
||||||
|
assert_eq!(message.author_global_name, None);
|
||||||
|
assert_eq!(message.channel_nsfw, None);
|
||||||
|
assert_eq!(
|
||||||
|
message.attachments,
|
||||||
|
vec![Attachment {
|
||||||
|
id: String::new(),
|
||||||
|
url: String::new(),
|
||||||
|
filename: String::new(),
|
||||||
|
nsfw: None,
|
||||||
|
content_type: None,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
size: None,
|
||||||
|
ncmec_status: "not_submitted".into(),
|
||||||
|
ncmec_report_id: None,
|
||||||
|
ncmec_failure_reason: None,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orders_string_and_numeric_snowflakes_without_rounding() {
|
||||||
|
let values = vec![
|
||||||
|
json!({"id": "9007199254740993"}),
|
||||||
|
json!({"id": "10"}),
|
||||||
|
json!({"id": 2}),
|
||||||
|
json!({"id": 9007199254740992_u64}),
|
||||||
|
];
|
||||||
|
let messages = ordered_messages(&values);
|
||||||
|
let ids: Vec<&str> = messages.iter().map(|message| message.id.as_str()).collect();
|
||||||
|
|
||||||
|
assert_eq!(ids, ["2", "10", "9007199254740992", "9007199254740993"]);
|
||||||
|
assert_eq!(values[0]["id"], "9007199254740993");
|
||||||
|
assert!(ordered_messages(&[]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_message_order_when_ids_are_equal() {
|
||||||
|
let values = [
|
||||||
|
json!({"id": "10", "content": "first"}),
|
||||||
|
json!({"id": 10, "content": "second"}),
|
||||||
|
];
|
||||||
|
let messages = ordered_messages(&values);
|
||||||
|
|
||||||
|
assert_eq!(messages[0].content, "first");
|
||||||
|
assert_eq!(messages[1].content, "second");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,36 +9,7 @@ use super::user_display::format_user_display;
|
|||||||
use crate::config::AdminConfig;
|
use crate::config::AdminConfig;
|
||||||
use crate::routes::auth::json_string;
|
use crate::routes::auth::json_string;
|
||||||
|
|
||||||
pub struct Attachment {
|
use super::message_data::{Attachment, Message};
|
||||||
pub id: String,
|
|
||||||
pub url: String,
|
|
||||||
pub filename: String,
|
|
||||||
pub nsfw: Option<bool>,
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub width: Option<u32>,
|
|
||||||
pub height: Option<u32>,
|
|
||||||
pub size: Option<u64>,
|
|
||||||
pub ncmec_status: String,
|
|
||||||
pub ncmec_report_id: Option<String>,
|
|
||||||
pub ncmec_failure_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Message {
|
|
||||||
pub id: String,
|
|
||||||
pub content: String,
|
|
||||||
pub timestamp: String,
|
|
||||||
pub author_id: String,
|
|
||||||
pub author_username: String,
|
|
||||||
pub author_global_name: Option<String>,
|
|
||||||
pub author_discriminator: String,
|
|
||||||
pub author_avatar: Option<String>,
|
|
||||||
pub channel_id: String,
|
|
||||||
pub channel_nsfw: Option<bool>,
|
|
||||||
pub channel_content_warning_level: Option<i32>,
|
|
||||||
pub channel_content_warning_text: Option<String>,
|
|
||||||
pub guild_nsfw: Option<bool>,
|
|
||||||
pub attachments: Vec<Attachment>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_image(att: &Attachment) -> bool {
|
fn is_image(att: &Attachment) -> bool {
|
||||||
att.content_type
|
att.content_type
|
||||||
@@ -74,8 +45,8 @@ fn ncmec_badge(att: &Attachment) -> Markup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||||
let images: Vec<&Attachment> = msg.attachments.iter().filter(|a| is_image(a)).collect();
|
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
|
||||||
if images.is_empty() {
|
if images.peek().is_none() {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
let spacer = if !msg.content.is_empty() {
|
let spacer = if !msg.content.is_empty() {
|
||||||
@@ -85,7 +56,7 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
|||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
div class=(spacer) {
|
div class=(spacer) {
|
||||||
@for att in &images {
|
@for att in images {
|
||||||
div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" {
|
div class="max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50" {
|
||||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||||
class="block overflow-hidden bg-neutral-100" {
|
class="block overflow-hidden bg-neutral-100" {
|
||||||
@@ -145,8 +116,8 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup {
|
fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Markup {
|
||||||
let others: Vec<&Attachment> = msg.attachments.iter().filter(|a| !is_image(a)).collect();
|
let mut others = msg.attachments.iter().filter(|a| !is_image(a)).peekable();
|
||||||
if others.is_empty() {
|
if others.peek().is_none() {
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
let spacer = if has_content_or_images {
|
let spacer = if has_content_or_images {
|
||||||
@@ -156,7 +127,7 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
|
|||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
div class=(spacer) {
|
div class=(spacer) {
|
||||||
@for att in &others {
|
@for att in others {
|
||||||
div class="flex flex-wrap items-center gap-2 text-xs" {
|
div class="flex flex-wrap items-center gap-2 text-xs" {
|
||||||
(paperclip_icon("text-neutral-400"))
|
(paperclip_icon("text-neutral-400"))
|
||||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ pub mod error_display;
|
|||||||
pub mod form;
|
pub mod form;
|
||||||
pub mod icons;
|
pub mod icons;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod message_data;
|
||||||
pub mod message_list;
|
pub mod message_list;
|
||||||
pub mod nsfw_indicators;
|
pub mod nsfw_indicators;
|
||||||
pub mod page_container;
|
pub mod page_container;
|
||||||
pub mod pagination;
|
|
||||||
pub mod resource_link;
|
pub mod resource_link;
|
||||||
pub mod section_card;
|
pub mod section_card;
|
||||||
pub mod stack;
|
pub mod stack;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pub fn adult_content_badge(is_adult: bool, label: Option<&str>) -> Markup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn truncate(text: &str, max: usize) -> String {
|
fn truncate(text: &str, max: usize) -> String {
|
||||||
if text.len() <= max {
|
if text.chars().nth(max).is_none() {
|
||||||
text.to_owned()
|
text.to_owned()
|
||||||
} else {
|
} else {
|
||||||
let boundary = max.saturating_sub(1);
|
let boundary = max.saturating_sub(1);
|
||||||
@@ -29,10 +29,7 @@ fn truncate(text: &str, max: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn content_warning_badge(level: Option<i32>, text: Option<&str>, inline_text: bool) -> Markup {
|
pub fn content_warning_badge(level: Option<i32>, text: Option<&str>, inline_text: bool) -> Markup {
|
||||||
let Some(lvl) = level else {
|
if level != Some(CONTENT_WARNING_LEVEL) {
|
||||||
return html! {};
|
|
||||||
};
|
|
||||||
if lvl != CONTENT_WARNING_LEVEL {
|
|
||||||
return html! {};
|
return html! {};
|
||||||
}
|
}
|
||||||
let default_text = "This contains sensitive content.";
|
let default_text = "This contains sensitive content.";
|
||||||
@@ -82,13 +79,8 @@ fn resolve_nsfw_source(
|
|||||||
|
|
||||||
fn source_label(source: NsfwSource, explicit: Option<bool>) -> &'static str {
|
fn source_label(source: NsfwSource, explicit: Option<bool>) -> &'static str {
|
||||||
match source {
|
match source {
|
||||||
NsfwSource::Channel => {
|
NsfwSource::Channel if explicit == Some(true) => "(override on)",
|
||||||
if explicit == Some(true) {
|
NsfwSource::Channel => "(override off)",
|
||||||
"(override on)"
|
|
||||||
} else {
|
|
||||||
"(override off)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NsfwSource::Category => "(from category)",
|
NsfwSource::Category => "(from category)",
|
||||||
NsfwSource::Community => "(from community)",
|
NsfwSource::Community => "(from community)",
|
||||||
NsfwSource::None => "",
|
NsfwSource::None => "",
|
||||||
@@ -110,11 +102,7 @@ pub fn channel_nsfw_state_badge(
|
|||||||
}
|
}
|
||||||
let has_context =
|
let has_context =
|
||||||
nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some();
|
nsfw_override.is_some() || category_override.is_some() || guild_nsfw.is_some();
|
||||||
let (source, explicit) = if has_context {
|
let (source, explicit) = resolve_nsfw_source(nsfw_override, category_override, guild_nsfw);
|
||||||
resolve_nsfw_source(nsfw_override, category_override, guild_nsfw)
|
|
||||||
} else {
|
|
||||||
(NsfwSource::None, None)
|
|
||||||
};
|
|
||||||
html! {
|
html! {
|
||||||
span class="inline-flex flex-wrap items-center gap-1" {
|
span class="inline-flex flex-wrap items-center gap-1" {
|
||||||
@if is_nsfw {
|
@if is_nsfw {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
use maud::{Markup, PreEscaped, html};
|
|
||||||
|
|
||||||
pub fn pagination(base_url: &str, current_page: u32, has_more: bool, extra_params: &str) -> Markup {
|
|
||||||
let sep = if base_url.contains('?') { "&" } else { "?" };
|
|
||||||
let has_previous = current_page > 1;
|
|
||||||
html! {
|
|
||||||
div class="mt-4 flex items-center justify-between" {
|
|
||||||
@if has_previous {
|
|
||||||
p class="text-sm font-normal text-neutral-500" {
|
|
||||||
a href={
|
|
||||||
(base_url) (sep) "page=" (current_page - 1) (extra_params)
|
|
||||||
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
|
|
||||||
(PreEscaped("← Previous"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
span {}
|
|
||||||
}
|
|
||||||
@if has_more {
|
|
||||||
p class="text-sm font-normal text-neutral-500" {
|
|
||||||
a href={
|
|
||||||
(base_url) (sep) "page=" (current_page + 1) (extra_params)
|
|
||||||
} class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 hover:text-neutral-900" {
|
|
||||||
(PreEscaped("Next →"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
span {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,8 +12,6 @@ const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral
|
|||||||
hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
|
hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
|
||||||
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
|
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
|
||||||
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
|
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
|
||||||
const TEXT_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
|
|
||||||
hover:text-neutral-600 hover:decoration-neutral-500";
|
|
||||||
|
|
||||||
impl ResourceType {
|
impl ResourceType {
|
||||||
fn path_segment(self) -> &'static str {
|
fn path_segment(self) -> &'static str {
|
||||||
@@ -91,18 +89,3 @@ pub fn nav_link(href: &str, content: Markup) -> Markup {
|
|||||||
a href=(href) class=(NAV_LINK_CLASS) { (content) }
|
a href=(href) class=(NAV_LINK_CLASS) { (content) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn text_link(href: &str, content: Markup, external: bool, _mono: bool) -> Markup {
|
|
||||||
if external {
|
|
||||||
html! {
|
|
||||||
a href=(href) class=(TEXT_LINK_CLASS)
|
|
||||||
target="_blank" rel="noopener noreferrer" {
|
|
||||||
(content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {
|
|
||||||
a href=(href) class=(TEXT_LINK_CLASS) { (content) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,6 +54,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
|||||||
"bulk-actions",
|
"bulk-actions",
|
||||||
[
|
[
|
||||||
acl::BULK_UPDATE_USER_FLAGS,
|
acl::BULK_UPDATE_USER_FLAGS,
|
||||||
|
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||||
acl::BULK_ADD_GUILD_MEMBERS,
|
acl::BULK_ADD_GUILD_MEMBERS,
|
||||||
acl::BULK_DELETE_USERS,
|
acl::BULK_DELETE_USERS,
|
||||||
@@ -264,3 +265,27 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
|||||||
)],
|
)],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
|
||||||
|
let item = NAV_SECTIONS
|
||||||
|
.iter()
|
||||||
|
.flat_map(|section| section.items)
|
||||||
|
.find(|item| item.active_key == "bulk-actions")
|
||||||
|
.expect("bulk actions nav item");
|
||||||
|
for required in [
|
||||||
|
acl::BULK_UPDATE_USER_FLAGS,
|
||||||
|
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||||
|
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||||
|
acl::BULK_ADD_GUILD_MEMBERS,
|
||||||
|
acl::BULK_DELETE_USERS,
|
||||||
|
acl::BULK_DELETE_USER_MESSAGES,
|
||||||
|
] {
|
||||||
|
assert!(item.required_acls.contains(&required), "{required}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ pub struct AuditLogsParams<'a> {
|
|||||||
pub admin_user_id: &'a str,
|
pub admin_user_id: &'a str,
|
||||||
pub target_id: &'a str,
|
pub target_id: &'a str,
|
||||||
pub target_type: &'a str,
|
pub target_type: &'a str,
|
||||||
|
pub access: &'a str,
|
||||||
pub sort_by: &'a str,
|
pub sort_by: &'a str,
|
||||||
pub sort_order: &'a str,
|
pub sort_order: &'a str,
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
@@ -33,6 +34,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
|||||||
("", "Any"),
|
("", "Any"),
|
||||||
("user", "User"),
|
("user", "User"),
|
||||||
("guild", "Guild"),
|
("guild", "Guild"),
|
||||||
|
("bulk_job", "Bulk job"),
|
||||||
("email_domain", "Email domain"),
|
("email_domain", "Email domain"),
|
||||||
("ip", "IP"),
|
("ip", "IP"),
|
||||||
("phrase", "Phrase"),
|
("phrase", "Phrase"),
|
||||||
@@ -41,6 +43,11 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
|||||||
("file_sha", "File SHA"),
|
("file_sha", "File SHA"),
|
||||||
("email", "Email"),
|
("email", "Email"),
|
||||||
];
|
];
|
||||||
|
let access_options: &[(&str, &str)] = &[
|
||||||
|
("", "All entries"),
|
||||||
|
("write", "Writes only"),
|
||||||
|
("read", "Reads only"),
|
||||||
|
];
|
||||||
let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")];
|
let sort_options: &[(&str, &str)] = &[("createdAt", "Created at"), ("relevance", "Relevance")];
|
||||||
let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")];
|
let order_options: &[(&str, &str)] = &[("desc", "Newest first"), ("asc", "Oldest first")];
|
||||||
let limit_options: &[(&str, &str)] =
|
let limit_options: &[(&str, &str)] =
|
||||||
@@ -59,6 +66,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
|||||||
"Filter by admin user ID..."))
|
"Filter by admin user ID..."))
|
||||||
(select_input("target_type", "Target type",
|
(select_input("target_type", "Target type",
|
||||||
target_type_options, params.target_type))
|
target_type_options, params.target_type))
|
||||||
|
(select_input("access", "Access", access_options, params.access))
|
||||||
(select_input("sort_by", "Sort by", sort_options, params.sort_by))
|
(select_input("sort_by", "Sort by", sort_options, params.sort_by))
|
||||||
(select_input("sort_order", "Order", order_options, params.sort_order))
|
(select_input("sort_order", "Order", order_options, params.sort_order))
|
||||||
(select_input("limit", "Page size", limit_options, &limit_str))
|
(select_input("limit", "Page size", limit_options, &limit_str))
|
||||||
@@ -72,27 +80,23 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String {
|
fn build_pagination_url(base: &str, page: u32, params: &AuditLogsParams<'_>) -> String {
|
||||||
let mut url = format!("{base}/audit-logs?page={page}");
|
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||||
if !params.query.is_empty() {
|
query.append_pair("page", &page.to_string());
|
||||||
url.push_str(&format!("&q={}", params.query));
|
query.extend_pairs(
|
||||||
}
|
[
|
||||||
if !params.admin_user_id.is_empty() {
|
("q", params.query),
|
||||||
url.push_str(&format!("&admin_user_id={}", params.admin_user_id));
|
("admin_user_id", params.admin_user_id),
|
||||||
}
|
("target_id", params.target_id),
|
||||||
if !params.target_id.is_empty() {
|
("target_type", params.target_type),
|
||||||
url.push_str(&format!("&target_id={}", params.target_id));
|
("access", params.access),
|
||||||
}
|
("sort_by", params.sort_by),
|
||||||
if !params.target_type.is_empty() {
|
("sort_order", params.sort_order),
|
||||||
url.push_str(&format!("&target_type={}", params.target_type));
|
]
|
||||||
}
|
.into_iter()
|
||||||
if !params.sort_by.is_empty() {
|
.filter(|(_, value)| !value.is_empty()),
|
||||||
url.push_str(&format!("&sort_by={}", params.sort_by));
|
);
|
||||||
}
|
query.append_pair("limit", ¶ms.limit.to_string());
|
||||||
if !params.sort_order.is_empty() {
|
format!("{base}/audit-logs?{}", query.finish())
|
||||||
url.push_str(&format!("&sort_order={}", params.sort_order));
|
|
||||||
}
|
|
||||||
url.push_str(&format!("&limit={}", params.limit));
|
|
||||||
url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn audit_logs_page(
|
pub fn audit_logs_page(
|
||||||
@@ -107,10 +111,15 @@ pub fn audit_logs_page(
|
|||||||
let total = data.total;
|
let total = data.total;
|
||||||
let entries = &data.logs;
|
let entries = &data.logs;
|
||||||
let total_pages = if params.limit > 0 {
|
let total_pages = if params.limit > 0 {
|
||||||
((total as f64) / (params.limit as f64)).ceil().max(1.0) as u64
|
total.div_ceil(u64::from(params.limit)).max(1)
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
};
|
};
|
||||||
|
let page_number = u64::from(params.current_page) + 1;
|
||||||
|
let next_page = params
|
||||||
|
.current_page
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|page| u64::from(*page) < total_pages);
|
||||||
let showing = format!("Showing {} of {} entries", entries.len(), total);
|
let showing = format!("Showing {} of {} entries", entries.len(), total);
|
||||||
html! {
|
html! {
|
||||||
(page_header_with_actions("Audit Logs", None, html! {
|
(page_header_with_actions("Audit Logs", None, html! {
|
||||||
@@ -134,10 +143,10 @@ pub fn audit_logs_page(
|
|||||||
}
|
}
|
||||||
} @else { span {} }
|
} @else { span {} }
|
||||||
span class="text-sm text-neutral-500" {
|
span class="text-sm text-neutral-500" {
|
||||||
"Page " (params.current_page + 1) " of " (total_pages)
|
"Page " (page_number) " of " (total_pages)
|
||||||
}
|
}
|
||||||
@if (params.current_page + 1) < total_pages as u32 {
|
@if let Some(page) = next_page {
|
||||||
a href=(build_pagination_url(base, params.current_page + 1, params))
|
a href=(build_pagination_url(base, page, params))
|
||||||
class="text-sm text-neutral-900 underline" {
|
class="text-sm text-neutral-900 underline" {
|
||||||
(PreEscaped("Next →"))
|
(PreEscaped("Next →"))
|
||||||
}
|
}
|
||||||
@@ -156,3 +165,48 @@ pub fn audit_logs_page(
|
|||||||
};
|
};
|
||||||
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
|
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn target_type_filter_offers_bulk_jobs() {
|
||||||
|
let params = AuditLogsParams {
|
||||||
|
query: "",
|
||||||
|
admin_user_id: "",
|
||||||
|
target_id: "",
|
||||||
|
target_type: "bulk_job",
|
||||||
|
access: "",
|
||||||
|
sort_by: "createdAt",
|
||||||
|
sort_order: "desc",
|
||||||
|
limit: 50,
|
||||||
|
current_page: 0,
|
||||||
|
};
|
||||||
|
let markup = filters_section("/admin", ¶ms).into_string();
|
||||||
|
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
|
||||||
|
assert!(markup.contains(r#"<option value="" selected>All entries</option>"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_filter_survives_form_and_pagination() {
|
||||||
|
let params = AuditLogsParams {
|
||||||
|
query: "",
|
||||||
|
admin_user_id: "",
|
||||||
|
target_id: "1500000000000000001",
|
||||||
|
target_type: "",
|
||||||
|
access: "read",
|
||||||
|
sort_by: "createdAt",
|
||||||
|
sort_order: "desc",
|
||||||
|
limit: 50,
|
||||||
|
current_page: 0,
|
||||||
|
};
|
||||||
|
let markup = filters_section("/admin", ¶ms).into_string();
|
||||||
|
assert!(markup.contains(r#"<select id="access" name="access""#));
|
||||||
|
assert!(markup.contains(r#"<option value="read" selected>Reads only</option>"#));
|
||||||
|
assert_eq!(
|
||||||
|
build_pagination_url("/admin", 1, ¶ms),
|
||||||
|
"/admin/audit-logs?page=1&target_id=1500000000000000001&access=read&sort_by=createdAt&sort_order=desc&limit=50"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,7 @@ use crate::{
|
|||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
|
|
||||||
pub fn format_action(action: &str) -> String {
|
pub fn format_action(action: &str) -> String {
|
||||||
let replaced = action.replace('_', " ");
|
capitalise(&action.replace('_', " "))
|
||||||
let mut chars = replaced.chars();
|
|
||||||
match chars.next() {
|
|
||||||
None => String::new(),
|
|
||||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn action_badge_variant(action: &str) -> BadgeVariant {
|
pub fn action_badge_variant(action: &str) -> BadgeVariant {
|
||||||
@@ -92,10 +87,8 @@ pub fn admin_user_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> String {
|
fn target_guild_label(guild: Option<&AuditLogGuildSummary>) -> &str {
|
||||||
guild
|
guild.map(|guild| guild.name.as_str()).unwrap_or("Guild")
|
||||||
.map(|guild| guild.name.clone())
|
|
||||||
.unwrap_or_else(|| "Guild".to_owned())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
||||||
@@ -133,6 +126,14 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
"bulk_job" => html! {
|
||||||
|
(job_link(base, &entry.target_id, html! {
|
||||||
|
div class="flex flex-col" {
|
||||||
|
span class="text-sm font-medium" { "Bulk job" }
|
||||||
|
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
},
|
||||||
"message" => html! {
|
"message" => html! {
|
||||||
div class="flex flex-col" {
|
div class="flex flex-col" {
|
||||||
span class="text-sm font-medium" { "Message" }
|
span class="text-sm font-medium" { "Message" }
|
||||||
@@ -154,6 +155,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn job_link(base: &str, job_id: &str, display: Markup) -> Markup {
|
||||||
|
html! {
|
||||||
|
a href={(base) "/jobs/" (job_id)} title={"Job " (job_id)}
|
||||||
|
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
|
||||||
|
(display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn channel_href(base: &str, channel_id: &str) -> String {
|
fn channel_href(base: &str, channel_id: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{base}/messages?channel_id={}&context_limit=50",
|
"{base}/messages?channel_id={}&context_limit=50",
|
||||||
@@ -317,3 +327,36 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"log_id": "1900000000000000001",
|
||||||
|
"admin_user_id": "1500000000000000001",
|
||||||
|
"action": "bulk_schedule_deletion",
|
||||||
|
"target_id": target_id,
|
||||||
|
"target_type": target_type,
|
||||||
|
"audit_log_reason": "Raid cleanup",
|
||||||
|
"created_at": "2026-09-14T12:00:00.000Z"
|
||||||
|
}))
|
||||||
|
.expect("audit log fixture must deserialize")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bulk_job_targets_link_to_the_job_detail_page() {
|
||||||
|
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
|
||||||
|
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
|
||||||
|
assert!(markup.contains("Bulk job"));
|
||||||
|
assert!(!markup.contains("/admin/users/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_target_types_stay_unlinked() {
|
||||||
|
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
|
||||||
|
assert!(!markup.contains("<a "));
|
||||||
|
assert!(markup.contains("Email domain"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ use crate::{
|
|||||||
templates::{
|
templates::{
|
||||||
components::{
|
components::{
|
||||||
form::{
|
form::{
|
||||||
checkbox, csrf_input, danger_button, form_actions, form_field_group, select_input,
|
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
||||||
submit_button, text_input, textarea_input,
|
form_field_group, select_chevron, submit_button, text_input, textarea_input,
|
||||||
},
|
},
|
||||||
page_container::page_header,
|
page_container::page_header,
|
||||||
section_card::section_card_simple,
|
section_card::section_card_simple,
|
||||||
@@ -147,9 +147,14 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
|
|||||||
const GUILD_FEATURES: &[&str] = &[
|
const GUILD_FEATURES: &[&str] = &[
|
||||||
"ANIMATED_ICON",
|
"ANIMATED_ICON",
|
||||||
"ANIMATED_BANNER",
|
"ANIMATED_BANNER",
|
||||||
|
"AUDIO_BITRATE_128_KBPS",
|
||||||
|
"AUDIO_BITRATE_256_KBPS",
|
||||||
|
"AUDIO_BITRATE_384_KBPS",
|
||||||
"BANNER",
|
"BANNER",
|
||||||
"CLONE_EMOJI_DISABLED",
|
"CLONE_EMOJI_DISABLED",
|
||||||
|
"CLONE_EMOJI_ENABLED",
|
||||||
"CLONE_STICKER_DISABLED",
|
"CLONE_STICKER_DISABLED",
|
||||||
|
"CLONE_STICKER_ENABLED",
|
||||||
"DETACHED_BANNER",
|
"DETACHED_BANNER",
|
||||||
"INVITE_SPLASH",
|
"INVITE_SPLASH",
|
||||||
"INVITES_DISABLED",
|
"INVITES_DISABLED",
|
||||||
@@ -175,6 +180,16 @@ const GUILD_FEATURES: &[&str] = &[
|
|||||||
"VERY_LARGE_GUILD",
|
"VERY_LARGE_GUILD",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||||
|
|
||||||
|
fn guild_feature_label(feature: &str) -> String {
|
||||||
|
if DEPRECATED_GUILD_FEATURES.contains(&feature) {
|
||||||
|
format!("{feature} (deprecated, removal only)")
|
||||||
|
} else {
|
||||||
|
feature.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
|
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
|
||||||
let base = &config.base_path;
|
let base = &config.base_path;
|
||||||
let admin_acls = auth
|
let admin_acls = auth
|
||||||
@@ -219,6 +234,18 @@ fn flag_checkbox_grid(prefix: &str, flags: &[&str]) -> Markup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn guild_feature_checkbox_grid(prefix: &str, include_deprecated: bool) -> Markup {
|
||||||
|
html! {
|
||||||
|
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||||
|
@for feature in GUILD_FEATURES {
|
||||||
|
@if include_deprecated || !DEPRECATED_GUILD_FEATURES.contains(feature) {
|
||||||
|
(checkbox(prefix, feature, &guild_feature_label(feature), false, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
|
fn user_flag_checkbox_grid(prefix: &str) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||||
@@ -301,13 +328,13 @@ fn bulk_update_guild_features_section(base: &str, csrf_token: &str) -> Markup {
|
|||||||
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
||||||
"Features to Add"
|
"Features to Add"
|
||||||
}
|
}
|
||||||
(flag_checkbox_grid("add_features[]", GUILD_FEATURES))
|
(guild_feature_checkbox_grid("add_features[]", false))
|
||||||
}
|
}
|
||||||
div {
|
div {
|
||||||
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
||||||
"Features to Remove"
|
"Features to Remove"
|
||||||
}
|
}
|
||||||
(flag_checkbox_grid("remove_features[]", GUILD_FEATURES))
|
(guild_feature_checkbox_grid("remove_features[]", true))
|
||||||
}
|
}
|
||||||
(form_field_group("Custom features to add", "custom_add_features", false, None,
|
(form_field_group("Custom features to add", "custom_add_features", false, None,
|
||||||
Some("Comma-separated list of custom features not in the standard set."),
|
Some("Comma-separated list of custom features not in the standard set."),
|
||||||
@@ -368,16 +395,33 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
|||||||
(csrf_input(csrf_token))
|
(csrf_input(csrf_token))
|
||||||
div class="space-y-4" {
|
div class="space-y-4" {
|
||||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||||
(select_input("reason_code", "Deletion Reason", DELETION_REASONS, "1"))
|
(form_field_group("Deletion Reason", "reason_code", true, None,
|
||||||
|
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
|
||||||
|
html! {
|
||||||
|
div class="relative" {
|
||||||
|
select id="reason_code" name="reason_code" required
|
||||||
|
class=(FORM_SELECT_CLASS) {
|
||||||
|
option value="" selected { "Select a reason" }
|
||||||
|
@for &(value, label) in DELETION_REASONS {
|
||||||
|
option value=(value) { (label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(select_chevron())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
|
||||||
(form_field_group("Days Until Deletion", "days_until_deletion", true, None, None, html! {
|
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
|
||||||
input type="number" id="days_until_deletion" name="days_until_deletion"
|
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
|
||||||
value="14" min="14" required
|
html! {
|
||||||
class="w-full rounded-lg border border-neutral-300 bg-white \
|
input type="number" id="days_until_deletion" name="days_until_deletion"
|
||||||
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
value="60" min="14" max="365" required
|
||||||
focus:border-brand-primary focus:outline-none \
|
class="w-full rounded-lg border border-neutral-300 bg-white \
|
||||||
focus:ring-2 focus:ring-brand-primary/20";
|
text-neutral-900 text-sm h-8 px-3 py-1.5 \
|
||||||
}))
|
focus:border-brand-primary focus:outline-none \
|
||||||
|
focus:ring-2 focus:ring-brand-primary/20";
|
||||||
|
},
|
||||||
|
))
|
||||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||||
(form_actions(html! {
|
(form_actions(html! {
|
||||||
(danger_button("Schedule Deletion"))
|
(danger_button("Schedule Deletion"))
|
||||||
@@ -408,3 +452,41 @@ fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_grid_offers_only_the_opt_in_clone_features() {
|
||||||
|
let markup = guild_feature_checkbox_grid("add_features[]", false).into_string();
|
||||||
|
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
|
||||||
|
assert!(markup.contains(r#"value="CLONE_STICKER_ENABLED""#));
|
||||||
|
assert!(!markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
|
||||||
|
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletion_form_has_no_preselected_reason() {
|
||||||
|
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||||
|
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
|
||||||
|
for (value, _) in DELETION_REASONS {
|
||||||
|
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deletion_form_defaults_to_the_moderation_retention_floor() {
|
||||||
|
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
|
||||||
|
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_grid_can_clear_the_deprecated_clone_features() {
|
||||||
|
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
|
||||||
|
assert!(markup.contains(r#"value="CLONE_EMOJI_DISABLED""#));
|
||||||
|
assert!(markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
|
||||||
|
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, removal only)"));
|
||||||
|
assert!(markup.contains(r#"value="CLONE_EMOJI_ENABLED""#));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
|
|
||||||
const MAX_GIFT_CODES: u32 = 100;
|
pub const MAX_GIFT_CODES: u32 = 100;
|
||||||
const DEFAULT_GIFT_COUNT: u32 = 10;
|
const DEFAULT_GIFT_COUNT: u32 = 10;
|
||||||
|
|
||||||
pub fn gift_codes_page(
|
pub fn gift_codes_page(
|
||||||
@@ -44,12 +44,12 @@ pub fn gift_codes_page(
|
|||||||
(csrf_input(csrf_token))
|
(csrf_input(csrf_token))
|
||||||
div class="flex flex-col gap-4" {
|
div class="flex flex-col gap-4" {
|
||||||
(form_field_group(
|
(form_field_group(
|
||||||
"Number of codes", "gift-count-slider", false, None,
|
"Number of codes", "gift-count-slider", true, None,
|
||||||
Some(&format!("Range: 1-{MAX_GIFT_CODES}")),
|
Some(&format!("Range: 1-{MAX_GIFT_CODES}")),
|
||||||
html! {
|
html! {
|
||||||
input type="number" id="gift-count-slider" name="count"
|
input type="number" id="gift-count-slider" name="count"
|
||||||
value=(DEFAULT_GIFT_COUNT) min="1"
|
value=(DEFAULT_GIFT_COUNT) min="1"
|
||||||
max=(MAX_GIFT_CODES)
|
max=(MAX_GIFT_CODES) required
|
||||||
class=(FORM_INPUT_CLASS);
|
class=(FORM_INPUT_CLASS);
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -14,9 +14,14 @@ use maud::{Markup, html};
|
|||||||
const GUILD_FEATURES: &[&str] = &[
|
const GUILD_FEATURES: &[&str] = &[
|
||||||
"ANIMATED_ICON",
|
"ANIMATED_ICON",
|
||||||
"ANIMATED_BANNER",
|
"ANIMATED_BANNER",
|
||||||
|
"AUDIO_BITRATE_128_KBPS",
|
||||||
|
"AUDIO_BITRATE_256_KBPS",
|
||||||
|
"AUDIO_BITRATE_384_KBPS",
|
||||||
"BANNER",
|
"BANNER",
|
||||||
"CLONE_EMOJI_DISABLED",
|
"CLONE_EMOJI_DISABLED",
|
||||||
|
"CLONE_EMOJI_ENABLED",
|
||||||
"CLONE_STICKER_DISABLED",
|
"CLONE_STICKER_DISABLED",
|
||||||
|
"CLONE_STICKER_ENABLED",
|
||||||
"DETACHED_BANNER",
|
"DETACHED_BANNER",
|
||||||
"INVITE_SPLASH",
|
"INVITE_SPLASH",
|
||||||
"INVITES_DISABLED",
|
"INVITES_DISABLED",
|
||||||
@@ -44,6 +49,16 @@ const GUILD_FEATURES: &[&str] = &[
|
|||||||
|
|
||||||
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
|
const HOSTED_ONLY: &[&str] = &["VISIONARY", "VIP_VOICE"];
|
||||||
|
|
||||||
|
const DEPRECATED_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||||
|
|
||||||
|
fn feature_label(feature: &str) -> String {
|
||||||
|
if DEPRECATED_FEATURES.contains(&feature) {
|
||||||
|
format!("{feature} (deprecated, no longer enforced)")
|
||||||
|
} else {
|
||||||
|
feature.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn features_tab(
|
pub fn features_tab(
|
||||||
config: &AdminConfig,
|
config: &AdminConfig,
|
||||||
guild: &GuildInfo,
|
guild: &GuildInfo,
|
||||||
@@ -85,7 +100,7 @@ pub fn features_tab(
|
|||||||
(checkbox(
|
(checkbox(
|
||||||
"features[]",
|
"features[]",
|
||||||
feature,
|
feature,
|
||||||
feature,
|
&feature_label(feature),
|
||||||
guild.features.iter().any(|f| f == feature),
|
guild.features.iter().any(|f| f == feature),
|
||||||
true,
|
true,
|
||||||
))
|
))
|
||||||
@@ -139,7 +154,7 @@ fn features_tab_readonly(guild: &GuildInfo, features_list: &[&str]) -> Markup {
|
|||||||
@for feature in &enabled {
|
@for feature in &enabled {
|
||||||
span class="inline-flex items-center rounded-full bg-green-100 \
|
span class="inline-flex items-center rounded-full bg-green-100 \
|
||||||
px-2.5 py-0.5 text-xs font-medium text-green-800" {
|
px-2.5 py-0.5 text-xs font-medium text-green-800" {
|
||||||
(feature)
|
(feature_label(feature))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@for feature in &custom {
|
@for feature in &custom {
|
||||||
@@ -166,3 +181,56 @@ fn filtered_features() -> Vec<&'static str> {
|
|||||||
.copied()
|
.copied()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn guild_with_features(features: &[&str]) -> GuildInfo {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "1600000000000000001",
|
||||||
|
"name": "Test Guild",
|
||||||
|
"icon": null,
|
||||||
|
"banner": null,
|
||||||
|
"owner_id": "1500000000000000001",
|
||||||
|
"owner_username": null,
|
||||||
|
"owner_global_name": null,
|
||||||
|
"owner_discriminator": null,
|
||||||
|
"features": features,
|
||||||
|
"nsfw_level": null,
|
||||||
|
"nsfw": null,
|
||||||
|
"content_warning_level": null,
|
||||||
|
"content_warning_text": null,
|
||||||
|
"description": null,
|
||||||
|
"vanity_url_code": null,
|
||||||
|
}))
|
||||||
|
.expect("guild fixture")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_offers_the_opt_in_clone_features() {
|
||||||
|
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
|
||||||
|
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
|
||||||
|
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_EMOJI_ENABLED"));
|
||||||
|
assert!(!DEPRECATED_FEATURES.contains(&"CLONE_STICKER_ENABLED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_keeps_the_deprecated_clone_features_clearable() {
|
||||||
|
assert!(GUILD_FEATURES.contains(&"CLONE_EMOJI_DISABLED"));
|
||||||
|
assert!(GUILD_FEATURES.contains(&"CLONE_STICKER_DISABLED"));
|
||||||
|
assert_eq!(
|
||||||
|
feature_label("CLONE_EMOJI_DISABLED"),
|
||||||
|
"CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"
|
||||||
|
);
|
||||||
|
assert_eq!(feature_label("CLONE_EMOJI_ENABLED"), "CLONE_EMOJI_ENABLED");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readonly_view_marks_a_stale_flag_as_deprecated() {
|
||||||
|
let guild = guild_with_features(&["CLONE_EMOJI_DISABLED", "CLONE_STICKER_ENABLED"]);
|
||||||
|
let markup = features_tab_readonly(&guild, GUILD_FEATURES).into_string();
|
||||||
|
assert!(markup.contains("CLONE_EMOJI_DISABLED (deprecated, no longer enforced)"));
|
||||||
|
assert!(markup.contains("CLONE_STICKER_ENABLED"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ pub fn members_tab(
|
|||||||
let total = response.total;
|
let total = response.total;
|
||||||
let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 };
|
let total_pages = if limit > 0 { total.div_ceil(limit) } else { 1 };
|
||||||
let has_previous = page > 0;
|
let has_previous = page > 0;
|
||||||
let has_next = (page as u64) < total_pages.saturating_sub(1);
|
let next_page = page
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|page| u64::from(*page) < total_pages);
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
div class="space-y-4" {
|
div class="space-y-4" {
|
||||||
@@ -34,12 +36,16 @@ pub fn members_tab(
|
|||||||
"Guild Members (" (total) ")"
|
"Guild Members (" (total) ")"
|
||||||
}
|
}
|
||||||
p class="text-sm text-neutral-500" {
|
p class="text-sm text-neutral-500" {
|
||||||
@let start = response.offset + 1;
|
@if response.members.is_empty() {
|
||||||
@let end = std::cmp::min(
|
"Showing 0 of " (total)
|
||||||
response.offset + response.members.len() as u64,
|
} @else {
|
||||||
total,
|
@let start = u128::from(response.offset) + 1;
|
||||||
);
|
@let end = std::cmp::min(
|
||||||
"Showing " (start) "-" (end) " of " (total)
|
u128::from(response.offset) + response.members.len() as u128,
|
||||||
|
u128::from(total),
|
||||||
|
);
|
||||||
|
"Showing " (start) "-" (end) " of " (total)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +76,10 @@ pub fn members_tab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
p class="text-sm text-neutral-500" {
|
p class="text-sm text-neutral-500" {
|
||||||
"Page " (page + 1) " of " (total_pages)
|
"Page " (u64::from(page) + 1) " of " (total_pages)
|
||||||
}
|
}
|
||||||
@if has_next {
|
@if let Some(next_page) = next_page {
|
||||||
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (page + 1)}
|
a href={(base) "/guilds/" (guild.id) "?tab=members&page=" (next_page)}
|
||||||
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
|
class="inline-flex items-center rounded-md bg-brand-primary px-3 \
|
||||||
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
|
py-2 text-sm font-medium text-white hover:bg-brand-primary-dark" {
|
||||||
"Next \u{2192}"
|
"Next \u{2192}"
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ fn channel_type_label(channel_type: i32) -> &'static str {
|
|||||||
pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup {
|
pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &str) -> Markup {
|
||||||
let base = &config.base_path;
|
let base = &config.base_path;
|
||||||
|
|
||||||
let mut sorted_channels = guild.channels.clone();
|
let mut sorted_channels: Vec<_> = guild.channels.iter().collect();
|
||||||
sorted_channels.sort_by_key(|c| c.position);
|
sorted_channels.sort_by_key(|c| c.position);
|
||||||
|
|
||||||
let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> =
|
let channels_by_id: std::collections::HashMap<&str, &crate::api::types::GuildChannelSummary> =
|
||||||
guild.channels.iter().map(|c| (c.id.as_str(), c)).collect();
|
guild.channels.iter().map(|c| (c.id.as_str(), c)).collect();
|
||||||
|
|
||||||
let mut sorted_roles = guild.roles.clone();
|
let mut sorted_roles: Vec<_> = guild.roles.iter().collect();
|
||||||
sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position));
|
sorted_roles.sort_by_key(|role| std::cmp::Reverse(role.position));
|
||||||
|
|
||||||
let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true);
|
let icon_url = guild_icon_url(config, &guild.id, guild.icon.as_deref(), 256, true);
|
||||||
@@ -102,16 +102,10 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
|
|||||||
p class="text-sm font-semibold text-neutral-500" {
|
p class="text-sm font-semibold text-neutral-500" {
|
||||||
"Custom warning text"
|
"Custom warning text"
|
||||||
}
|
}
|
||||||
@if let Some(ref text) = guild.content_warning_text {
|
@if let Some(text) = guild.content_warning_text.as_deref().filter(|text| !text.trim().is_empty()) {
|
||||||
@if !text.trim().is_empty() {
|
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
|
||||||
blockquote class="border-amber-300 border-l-2 bg-amber-50 \
|
px-3 py-2 text-neutral-800 text-sm italic" {
|
||||||
px-3 py-2 text-neutral-800 text-sm italic" {
|
(text)
|
||||||
(text)
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
p class="text-sm text-neutral-500" {
|
|
||||||
"\u{2014} (default fallback shown to users)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-sm text-neutral-500" {
|
p class="text-sm text-neutral-500" {
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ pub fn reports_tab(
|
|||||||
let base = &config.base_path;
|
let base = &config.base_path;
|
||||||
let page_size: u64 = 25;
|
let page_size: u64 = 25;
|
||||||
let has_previous = page > 0;
|
let has_previous = page > 0;
|
||||||
let has_next = (page as u64) * page_size + reports.len() as u64 > 0
|
let next_page = page.checked_add(1).filter(|next_page| {
|
||||||
&& (page as u64 + 1) * page_size < total;
|
(page > 0 || !reports.is_empty()) && u64::from(*next_page) * page_size < total
|
||||||
|
});
|
||||||
html! {
|
html! {
|
||||||
div class="space-y-4" {
|
div class="space-y-4" {
|
||||||
div class="flex items-center justify-between" {
|
div class="flex items-center justify-between" {
|
||||||
@@ -48,7 +49,7 @@ pub fn reports_tab(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@if has_previous || has_next {
|
@if has_previous || next_page.is_some() {
|
||||||
div class="flex justify-center gap-2" {
|
div class="flex justify-center gap-2" {
|
||||||
@if has_previous {
|
@if has_previous {
|
||||||
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)}
|
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page - 1)}
|
||||||
@@ -58,8 +59,8 @@ pub fn reports_tab(
|
|||||||
"\u{2190} Newer"
|
"\u{2190} Newer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if has_next {
|
@if let Some(next_page) = next_page {
|
||||||
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (page + 1)}
|
a href={(base) "/guilds/" (guild.id) "?tab=reports&reports_page=" (next_page)}
|
||||||
class="inline-flex items-center rounded-md border border-neutral-300 \
|
class="inline-flex items-center rounded-md border border-neutral-300 \
|
||||||
bg-white px-3 py-2 text-sm font-medium text-neutral-700 \
|
bg-white px-3 py-2 text-sm font-medium text-neutral-700 \
|
||||||
hover:bg-neutral-50" {
|
hover:bg-neutral-50" {
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ impl GuildsListParams {
|
|||||||
pub struct GuildsListResults<'a> {
|
pub struct GuildsListResults<'a> {
|
||||||
pub guilds: Option<&'a [GuildInfo]>,
|
pub guilds: Option<&'a [GuildInfo]>,
|
||||||
pub total: Option<u64>,
|
pub total: Option<u64>,
|
||||||
pub has_more: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn guilds_list_page(
|
pub fn guilds_list_page(
|
||||||
@@ -73,14 +72,12 @@ pub fn guilds_list_page(
|
|||||||
params: &GuildsListParams,
|
params: &GuildsListParams,
|
||||||
results: Option<&[GuildInfo]>,
|
results: Option<&[GuildInfo]>,
|
||||||
total: Option<u64>,
|
total: Option<u64>,
|
||||||
has_more: bool,
|
|
||||||
is_htmx: bool,
|
is_htmx: bool,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let base = &config.base_path;
|
let base = &config.base_path;
|
||||||
let result_state = GuildsListResults {
|
let result_state = GuildsListResults {
|
||||||
guilds: results,
|
guilds: results,
|
||||||
total,
|
total,
|
||||||
has_more,
|
|
||||||
};
|
};
|
||||||
let has_results = results.is_some_and(|guilds| !guilds.is_empty());
|
let has_results = results.is_some_and(|guilds| !guilds.is_empty());
|
||||||
let header = html! {
|
let header = html! {
|
||||||
@@ -164,7 +161,10 @@ fn render_results(
|
|||||||
fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup {
|
fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Markup {
|
||||||
let total_pages = total.div_ceil(u64::from(params.limit)).max(1);
|
let total_pages = total.div_ceil(u64::from(params.limit)).max(1);
|
||||||
let has_previous = params.page > 0;
|
let has_previous = params.page > 0;
|
||||||
let has_next = u64::from(params.page) < total_pages.saturating_sub(1);
|
let next_page = params
|
||||||
|
.page
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|page| u64::from(*page) < total_pages);
|
||||||
html! {
|
html! {
|
||||||
div class="mt-6 flex items-center justify-center gap-3" {
|
div class="mt-6 flex items-center justify-center gap-3" {
|
||||||
@if has_previous {
|
@if has_previous {
|
||||||
@@ -181,10 +181,10 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
span class="text-neutral-600 text-sm" {
|
span class="text-neutral-600 text-sm" {
|
||||||
"Page " (params.page + 1) " of " (total_pages)
|
"Page " (u64::from(params.page) + 1) " of " (total_pages)
|
||||||
}
|
}
|
||||||
@if has_next {
|
@if let Some(next_page) = next_page {
|
||||||
a href=(pagination_url(&config.base_path, params, params.page + 1))
|
a href=(pagination_url(&config.base_path, params, next_page))
|
||||||
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \
|
class="rounded-lg bg-neutral-900 px-6 py-2 font-medium text-sm \
|
||||||
text-white no-underline transition-colors hover:bg-neutral-800" {
|
text-white no-underline transition-colors hover:bg-neutral-800" {
|
||||||
"Next \u{2192}"
|
"Next \u{2192}"
|
||||||
@@ -200,8 +200,7 @@ fn pagination(config: &AdminConfig, params: &GuildsListParams, total: u64) -> Ma
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
|
fn pagination_url(base: &str, params: &GuildsListParams, page: u32) -> String {
|
||||||
let mut parts = Vec::new();
|
let mut parts = vec![format!("page={page}"), format!("limit={}", params.limit)];
|
||||||
parts.push(format!("page={page}"));
|
|
||||||
if !params.q.is_empty() {
|
if !params.q.is_empty() {
|
||||||
parts.push(format!("q={}", urlencoding::encode(¶ms.q)));
|
parts.push(format!("q={}", urlencoding::encode(¶ms.q)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::types::{
|
api::types::{
|
||||||
AppPublicConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
ALTCHA_CAPTCHA_COST_RANGE, ALTCHA_CAPTCHA_DEFAULT_SALT, ALTCHA_CAPTCHA_MAX_COUNTER_RANGE,
|
||||||
|
AltchaCaptchaConfigResponse, AppPublicConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||||
|
DomainMigrationConfigResponse, EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
InstanceRegistrationResponse, LimitConfigResponse, NoiseSuppressionBackend,
|
||||||
RegistrationUrlResponse, SsoConfigResponse,
|
PROFILE_TIMEZONE_DEFAULT_SALT, PendingRegistrationResponse, ProfileTimezoneConfigResponse,
|
||||||
|
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse,
|
||||||
|
VOICE_NS_MAX_GUILD_OVERRIDES, VoiceNoiseSuppressionConfigResponse,
|
||||||
},
|
},
|
||||||
config::AdminConfig,
|
config::AdminConfig,
|
||||||
middleware::auth::AuthContext,
|
middleware::auth::AuthContext,
|
||||||
@@ -42,6 +47,17 @@ fn format_decimal(value: f64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn entry_count_hint(count: usize, cap: usize) -> Markup {
|
||||||
|
html! {
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
(count) " of " (cap) " stored"
|
||||||
|
@if count >= cap {
|
||||||
|
" (at the cap; remove an entry before adding another)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn number_field(
|
fn number_field(
|
||||||
name: &str,
|
name: &str,
|
||||||
label: &str,
|
label: &str,
|
||||||
@@ -122,6 +138,13 @@ pub fn instance_config_page(
|
|||||||
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
(integrations_config_section(base, csrf_token, &instance_config.integrations))
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
(config_group(
|
||||||
|
"Push notifications",
|
||||||
|
"Consent for the relay that delivers official mobile app notifications.",
|
||||||
|
html! {
|
||||||
|
(push_relay_section(base, csrf_token, &instance_config.push_relay))
|
||||||
|
},
|
||||||
|
))
|
||||||
(config_group(
|
(config_group(
|
||||||
"Media & retention",
|
"Media & retention",
|
||||||
"Attachment expiry rules that can be changed without editing environment variables.",
|
"Attachment expiry rules that can be changed without editing environment variables.",
|
||||||
@@ -134,6 +157,11 @@ pub fn instance_config_page(
|
|||||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||||
html! {
|
html! {
|
||||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||||
|
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||||
|
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
||||||
|
(altcha_captcha_section(base, csrf_token, &instance_config.altcha_captcha))
|
||||||
|
(profile_timezone_section(base, csrf_token, &instance_config.profile_timezone))
|
||||||
|
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||||
@if let Some(limit_config) = limit_config {
|
@if let Some(limit_config) = limit_config {
|
||||||
(limit_config_section(base, limit_config))
|
(limit_config_section(base, limit_config))
|
||||||
} @else {
|
} @else {
|
||||||
@@ -833,6 +861,18 @@ fn app_public_config_section(
|
|||||||
app_public.branding.favicon_url.as_deref().unwrap_or(""),
|
app_public.branding.favicon_url.as_deref().unwrap_or(""),
|
||||||
"https://example.com/favicon.ico",
|
"https://example.com/favicon.ico",
|
||||||
))
|
))
|
||||||
|
(text_input(
|
||||||
|
"app_status_page_url",
|
||||||
|
"Status page URL",
|
||||||
|
app_public.branding.status_page_url.as_deref().unwrap_or(""),
|
||||||
|
"https://fluxerstatus.com",
|
||||||
|
))
|
||||||
|
(text_input(
|
||||||
|
"app_status_page_incident_history_url",
|
||||||
|
"Status page history URL",
|
||||||
|
app_public.branding.status_page_incident_history_url.as_deref().unwrap_or(""),
|
||||||
|
"https://fluxerstatus.com/history",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
div class="space-y-2" {
|
div class="space-y-2" {
|
||||||
(checkbox(
|
(checkbox(
|
||||||
@@ -953,6 +993,818 @@ fn gateway_rollout_section(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn voice_noise_suppression_section(
|
||||||
|
base: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||||
|
) -> Markup {
|
||||||
|
let status = if voice_noise_suppression.enabled {
|
||||||
|
("Live", BadgeVariant::Success)
|
||||||
|
} else {
|
||||||
|
("Inert", BadgeVariant::Default)
|
||||||
|
};
|
||||||
|
let backend_labels =
|
||||||
|
NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label()));
|
||||||
|
let backend_options = backend_labels
|
||||||
|
.iter()
|
||||||
|
.map(|(value, label)| (value.as_str(), *label))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
|
||||||
|
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
|
||||||
|
let guild_overrides = voice_noise_suppression
|
||||||
|
.guild_overrides
|
||||||
|
.iter()
|
||||||
|
.map(|entry| format!("{}={}", entry.guild_id, entry.backend))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
section_card_with_description(
|
||||||
|
"Voice Noise Suppression",
|
||||||
|
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
|
||||||
|
of them are targeted. While the master switch below is off nothing on this form reaches \
|
||||||
|
any client: every user keeps the audio pipeline they have today, whatever the rest of \
|
||||||
|
these fields say.",
|
||||||
|
html! {
|
||||||
|
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
div class="space-y-6" {
|
||||||
|
div class="flex flex-wrap items-center gap-2" {
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||||
|
(badge(status.0, status.1))
|
||||||
|
span class="text-xs text-neutral-500" {
|
||||||
|
"Config version " (voice_noise_suppression.config_version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(checkbox(
|
||||||
|
"voice_ns_enabled",
|
||||||
|
"true",
|
||||||
|
"Serve noise suppression assignments to clients",
|
||||||
|
voice_noise_suppression.enabled,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Off is the safe state. With this unchecked every client is told the \
|
||||||
|
feature is inert and keeps its current behavior, so the rollout, targeting \
|
||||||
|
and override fields below have no effect at all."
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
|
||||||
|
(select_input(
|
||||||
|
"voice_ns_default_backend",
|
||||||
|
"Default Backend",
|
||||||
|
&backend_options,
|
||||||
|
&voice_noise_suppression.default_backend.to_string(),
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"The backend assigned by always-on user rules and the canary. A default \
|
||||||
|
that is not ticked below is unavailable, but per-guild overrides can \
|
||||||
|
still target users."
|
||||||
|
}
|
||||||
|
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
|
||||||
|
@for backend in NoiseSuppressionBackend::ALL {
|
||||||
|
(checkbox(
|
||||||
|
"voice_ns_enabled_backends[]",
|
||||||
|
&backend.to_string(),
|
||||||
|
backend.label(),
|
||||||
|
voice_noise_suppression.enabled_backends.contains(&backend),
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Backends clients are allowed to load. Unticking one withdraws it from \
|
||||||
|
every user, including anyone who picked it themselves."
|
||||||
|
}
|
||||||
|
(checkbox(
|
||||||
|
"voice_ns_allow_user_override",
|
||||||
|
"true",
|
||||||
|
"Let users pick their own backend from the ticked list",
|
||||||
|
voice_noise_suppression.allow_user_override,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Applies only to users who are already targeted. It never pulls anyone \
|
||||||
|
into the rollout."
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||||
|
(number_field(
|
||||||
|
"voice_ns_rollout_basis_points",
|
||||||
|
"Rollout (basis points)",
|
||||||
|
&voice_noise_suppression.rollout_basis_points.to_string(),
|
||||||
|
Some(0), Some(10000), "1",
|
||||||
|
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||||
|
))
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(text_input(
|
||||||
|
"voice_ns_rollout_salt",
|
||||||
|
"Rollout Salt",
|
||||||
|
&voice_noise_suppression.rollout_salt,
|
||||||
|
"voice-ns-v1",
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||||
|
inside the percentage above. Leave it alone to keep the current \
|
||||||
|
cohort stable."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"voice_ns_included_user_ids",
|
||||||
|
"Always-on User IDs",
|
||||||
|
"1500000000000000001\n1500000000000000002",
|
||||||
|
&included_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
voice_noise_suppression.included_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"One snowflake per line, or comma separated. These users are targeted \
|
||||||
|
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||||
|
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||||
|
IDs are ignored."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(checkbox(
|
||||||
|
"voice_ns_include_premium_users",
|
||||||
|
"true",
|
||||||
|
"Include premium users",
|
||||||
|
voice_noise_suppression.include_premium_users,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Includes every account with active premium perks, regardless of the \
|
||||||
|
percentage above. The never-on list still wins."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"voice_ns_included_guild_ids",
|
||||||
|
"Always-on Guild IDs",
|
||||||
|
"1500000000000000005\n1500000000000000006",
|
||||||
|
&voice_noise_suppression.included_guild_ids.join("\n"),
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
voice_noise_suppression.included_guild_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format, with guild IDs. Every member of a listed guild is \
|
||||||
|
included regardless of the percentage above, unless the user is \
|
||||||
|
in the never-on list."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"voice_ns_excluded_user_ids",
|
||||||
|
"Never-on User IDs",
|
||||||
|
"1500000000000000003\n1500000000000000004",
|
||||||
|
&excluded_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
voice_noise_suppression.excluded_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format. Exclusion wins over both the always-on list and the \
|
||||||
|
percentage. This is the per-user kill switch."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"voice_ns_guild_overrides",
|
||||||
|
"Guild Overrides",
|
||||||
|
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
|
||||||
|
&guild_overrides,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
voice_noise_suppression.guild_overrides.len(),
|
||||||
|
VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"One per line as guild_id=backend. A guild \
|
||||||
|
rule targets callers even outside the canary. Always-on user rules \
|
||||||
|
take precedence, and excluded users stay off. Invalid lines and \
|
||||||
|
conflicting rules for the same guild prevent the save. \
|
||||||
|
Unticked backends stay stored but are inactive."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
|
||||||
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||||
|
(number_field(
|
||||||
|
"voice_ns_suppression_strength",
|
||||||
|
"Suppression Strength",
|
||||||
|
&voice_noise_suppression.suppression_strength.to_string(),
|
||||||
|
Some(0), Some(100), "1",
|
||||||
|
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
(form_actions(html! {
|
||||||
|
(submit_button("Save Voice Noise Suppression Configuration"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_relay_section(
|
||||||
|
base: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
push_relay: &PushRelayConfigResponse,
|
||||||
|
) -> Markup {
|
||||||
|
let status = if push_relay.relay_consent_accepted {
|
||||||
|
("Accepted", BadgeVariant::Success)
|
||||||
|
} else {
|
||||||
|
("Not accepted", BadgeVariant::Default)
|
||||||
|
};
|
||||||
|
let accepted_at =
|
||||||
|
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
|
||||||
|
let accepted_by = push_relay
|
||||||
|
.relay_consent_accepted_by
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Nobody");
|
||||||
|
section_card_with_description(
|
||||||
|
"Push Relay",
|
||||||
|
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
|
||||||
|
The relay delivers them only after an operator accepts its privacy notice.",
|
||||||
|
html! {
|
||||||
|
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
div class="space-y-6" {
|
||||||
|
div class="flex flex-wrap items-center gap-2" {
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
|
||||||
|
(badge(status.0, status.1))
|
||||||
|
}
|
||||||
|
(checkbox(
|
||||||
|
"push_relay_consent_accepted",
|
||||||
|
"true",
|
||||||
|
"Accept the push relay supplemental privacy notice",
|
||||||
|
push_relay.relay_consent_accepted,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Until this is accepted official mobile app notifications are dropped. \
|
||||||
|
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
|
||||||
|
unaffected. "
|
||||||
|
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
|
||||||
|
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||||
|
"Read the notice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||||
|
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
|
||||||
|
html! {
|
||||||
|
input type="text" id="push_relay_consent_accepted_at"
|
||||||
|
value=(accepted_at)
|
||||||
|
disabled class=(FORM_INPUT_CLASS);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
|
||||||
|
html! {
|
||||||
|
input type="text" id="push_relay_consent_accepted_by"
|
||||||
|
value=(accepted_by)
|
||||||
|
disabled class=(FORM_INPUT_CLASS);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
(form_actions(html! {
|
||||||
|
(submit_button("Save Push Relay Settings"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_migration_section(
|
||||||
|
base: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
domain_migration: &DomainMigrationConfigResponse,
|
||||||
|
) -> Markup {
|
||||||
|
let status = if domain_migration.enabled {
|
||||||
|
("Live", BadgeVariant::Success)
|
||||||
|
} else {
|
||||||
|
("Inert", BadgeVariant::Default)
|
||||||
|
};
|
||||||
|
let included_user_ids = domain_migration.included_user_ids.join("\n");
|
||||||
|
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
|
||||||
|
section_card_with_description(
|
||||||
|
"Domain Migration",
|
||||||
|
"Moves web clients of the official instance from the legacy web app origin to the new \
|
||||||
|
one. Selected accounts copy their local data across and continue on the new origin. \
|
||||||
|
Clients of other instances read this configuration and ignore it.",
|
||||||
|
html! {
|
||||||
|
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
div class="space-y-6" {
|
||||||
|
div class="flex flex-wrap items-center gap-2" {
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||||
|
(badge(status.0, status.1))
|
||||||
|
span class="text-xs text-neutral-500" {
|
||||||
|
"Config version " (domain_migration.config_version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(checkbox(
|
||||||
|
"domain_migration_enabled",
|
||||||
|
"true",
|
||||||
|
"Move selected web clients to the new origin",
|
||||||
|
domain_migration.enabled,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Off is the safe state and the kill switch. With this unchecked no client \
|
||||||
|
starts a migration and clients that already migrated stop forwarding the \
|
||||||
|
legacy origin, so the rollout and targeting fields below have no effect at all."
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
|
||||||
|
(checkbox(
|
||||||
|
"domain_migration_standalone_forwarding",
|
||||||
|
"true",
|
||||||
|
"Forward installed desktop web apps to the new origin",
|
||||||
|
domain_migration.standalone_forwarding,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Leave this off until the manifest scope extension and the association file \
|
||||||
|
are live and verified. While it is off, installed Chromium desktop apps copy \
|
||||||
|
their data across but stay on the legacy origin and offer to install the new \
|
||||||
|
app. Installed mobile and Safari apps never forward either way."
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||||
|
(number_field(
|
||||||
|
"domain_migration_rollout_basis_points",
|
||||||
|
"Rollout (basis points)",
|
||||||
|
&domain_migration.rollout_basis_points.to_string(),
|
||||||
|
Some(0), Some(10000), "1",
|
||||||
|
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||||
|
))
|
||||||
|
(number_field(
|
||||||
|
"domain_migration_anonymous_rollout_basis_points",
|
||||||
|
"Anonymous rollout (basis points)",
|
||||||
|
&domain_migration.anonymous_rollout_basis_points.to_string(),
|
||||||
|
Some(0), Some(10000), "1",
|
||||||
|
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
||||||
|
))
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(text_input(
|
||||||
|
"domain_migration_rollout_salt",
|
||||||
|
"Rollout Salt",
|
||||||
|
&domain_migration.rollout_salt,
|
||||||
|
DOMAIN_MIGRATION_DEFAULT_SALT,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
|
||||||
|
which users and devices fall inside the percentages above. Leave it \
|
||||||
|
alone to keep the current cohort stable."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"domain_migration_included_user_ids",
|
||||||
|
"Always-on User IDs",
|
||||||
|
"1500000000000000001\n1500000000000000002",
|
||||||
|
&included_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
domain_migration.included_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"One snowflake per line, or comma separated. These users are targeted \
|
||||||
|
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||||
|
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
||||||
|
IDs are ignored."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(checkbox(
|
||||||
|
"domain_migration_include_premium_users",
|
||||||
|
"true",
|
||||||
|
"Include premium users",
|
||||||
|
domain_migration.include_premium_users,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Includes every account with active premium perks, regardless of the \
|
||||||
|
percentage above. The never-on list still wins."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"domain_migration_included_guild_ids",
|
||||||
|
"Always-on Guild IDs",
|
||||||
|
"1500000000000000005\n1500000000000000006",
|
||||||
|
&domain_migration.included_guild_ids.join("\n"),
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
domain_migration.included_guild_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format, with guild IDs. Every member of a listed guild is \
|
||||||
|
included regardless of the percentage above, unless the user is \
|
||||||
|
in the never-on list."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"domain_migration_excluded_user_ids",
|
||||||
|
"Never-on User IDs",
|
||||||
|
"1500000000000000003\n1500000000000000004",
|
||||||
|
&excluded_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
domain_migration.excluded_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format. Exclusion wins over both the always-on list and the \
|
||||||
|
percentage. It stops new migrations only. A user who already moved \
|
||||||
|
stays on the new origin."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(form_actions(html! {
|
||||||
|
(submit_button("Save Domain Migration Configuration"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn altcha_captcha_section(
|
||||||
|
base: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
altcha_captcha: &AltchaCaptchaConfigResponse,
|
||||||
|
) -> Markup {
|
||||||
|
let status = if altcha_captcha.enabled {
|
||||||
|
("Live", BadgeVariant::Success)
|
||||||
|
} else {
|
||||||
|
("Inert", BadgeVariant::Default)
|
||||||
|
};
|
||||||
|
let included_user_ids = altcha_captcha.included_user_ids.join("\n");
|
||||||
|
let excluded_user_ids = altcha_captcha.excluded_user_ids.join("\n");
|
||||||
|
section_card_with_description(
|
||||||
|
"ALTCHA Captcha",
|
||||||
|
"Replaces the configured captcha provider with an ALTCHA proof-of-work check for the \
|
||||||
|
selected requesters. The API issues and verifies every challenge itself, so no third \
|
||||||
|
party is involved. Requests only need a captcha where one is already required, so this \
|
||||||
|
does nothing while captcha is off for the instance.",
|
||||||
|
html! {
|
||||||
|
form method="post" action={(base) "/instance-config?action=update_altcha_captcha"} {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
div class="space-y-6" {
|
||||||
|
div class="flex flex-wrap items-center gap-2" {
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||||
|
(badge(status.0, status.1))
|
||||||
|
span class="text-xs text-neutral-500" {
|
||||||
|
"Config version " (altcha_captcha.config_version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(checkbox(
|
||||||
|
"altcha_captcha_enabled",
|
||||||
|
"true",
|
||||||
|
"Serve ALTCHA to the selected requesters",
|
||||||
|
altcha_captcha.enabled,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Off is the safe state and the kill switch. With this unchecked every \
|
||||||
|
requester gets the configured provider and ALTCHA answers are rejected."
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Logged-out requests" }
|
||||||
|
(checkbox(
|
||||||
|
"altcha_captcha_anonymous_enabled",
|
||||||
|
"true",
|
||||||
|
"Serve ALTCHA to logged-out requests",
|
||||||
|
altcha_captcha.anonymous_enabled,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Covers registration, login and password reset. These requests have no \
|
||||||
|
account to bucket, so this switch applies to all of them at once."
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||||
|
(number_field(
|
||||||
|
"altcha_captcha_rollout_basis_points",
|
||||||
|
"Rollout (basis points)",
|
||||||
|
&altcha_captcha.rollout_basis_points.to_string(),
|
||||||
|
Some(0), Some(10000), "1",
|
||||||
|
Some("Share of logged-in users bucketed into ALTCHA, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||||
|
))
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(text_input(
|
||||||
|
"altcha_captcha_rollout_salt",
|
||||||
|
"Rollout Salt",
|
||||||
|
&altcha_captcha.rollout_salt,
|
||||||
|
ALTCHA_CAPTCHA_DEFAULT_SALT,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||||
|
inside the percentage above."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"altcha_captcha_included_user_ids",
|
||||||
|
"Always-on User IDs",
|
||||||
|
"1500000000000000001\n1500000000000000002",
|
||||||
|
&included_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
altcha_captcha.included_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"One snowflake per line, or comma separated. These users get ALTCHA \
|
||||||
|
regardless of the percentage above. Invalid entries prevent the save."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(checkbox(
|
||||||
|
"altcha_captcha_include_premium_users",
|
||||||
|
"true",
|
||||||
|
"Include premium users",
|
||||||
|
altcha_captcha.include_premium_users,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Includes every account with active premium perks, regardless of the \
|
||||||
|
percentage above. The never-on list still wins."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"altcha_captcha_included_guild_ids",
|
||||||
|
"Always-on Guild IDs",
|
||||||
|
"1500000000000000005\n1500000000000000006",
|
||||||
|
&altcha_captcha.included_guild_ids.join("\n"),
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
altcha_captcha.included_guild_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format, with guild IDs. Every member of a listed guild is \
|
||||||
|
included regardless of the percentage above, unless the user is \
|
||||||
|
in the never-on list."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"altcha_captcha_excluded_user_ids",
|
||||||
|
"Never-on User IDs",
|
||||||
|
"1500000000000000003\n1500000000000000004",
|
||||||
|
&excluded_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
altcha_captcha.excluded_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format. Exclusion wins over both the always-on list and the percentage."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Difficulty" }
|
||||||
|
(number_field(
|
||||||
|
"altcha_captcha_cost",
|
||||||
|
"Cost (PBKDF2 iterations per attempt)",
|
||||||
|
&altcha_captcha.cost.to_string(),
|
||||||
|
Some(*ALTCHA_CAPTCHA_COST_RANGE.start()),
|
||||||
|
Some(*ALTCHA_CAPTCHA_COST_RANGE.end()),
|
||||||
|
"1",
|
||||||
|
Some("The API spends one attempt at this cost to issue each challenge."),
|
||||||
|
))
|
||||||
|
(number_field(
|
||||||
|
"altcha_captcha_max_counter",
|
||||||
|
"Maximum counter",
|
||||||
|
&altcha_captcha.max_counter.to_string(),
|
||||||
|
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.start()),
|
||||||
|
Some(*ALTCHA_CAPTCHA_MAX_COUNTER_RANGE.end()),
|
||||||
|
"1",
|
||||||
|
Some("Each challenge hides its answer between half this value and this value. The client tries counters from 0 until it finds it, so solve time grows with cost times this value. At the defaults a recent laptop takes about 3 seconds."),
|
||||||
|
))
|
||||||
|
|
||||||
|
(form_actions(html! {
|
||||||
|
(submit_button("Save ALTCHA Configuration"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_timezone_section(
|
||||||
|
base: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
profile_timezone: &ProfileTimezoneConfigResponse,
|
||||||
|
) -> Markup {
|
||||||
|
let status = if profile_timezone.enabled {
|
||||||
|
("Live", BadgeVariant::Success)
|
||||||
|
} else {
|
||||||
|
("Inert", BadgeVariant::Default)
|
||||||
|
};
|
||||||
|
let included_user_ids = profile_timezone.included_user_ids.join("\n");
|
||||||
|
let excluded_user_ids = profile_timezone.excluded_user_ids.join("\n");
|
||||||
|
section_card_with_description(
|
||||||
|
"Profile Timezone",
|
||||||
|
"Lets the selected users save a time zone in profile settings and show their local time \
|
||||||
|
on their profile. Users outside the rollout cannot change it, and a saved time zone \
|
||||||
|
stays hidden from everyone while its owner is outside the rollout.",
|
||||||
|
html! {
|
||||||
|
form method="post" action={(base) "/instance-config?action=update_profile_timezone"} {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
div class="space-y-6" {
|
||||||
|
div class="flex flex-wrap items-center gap-2" {
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||||
|
(badge(status.0, status.1))
|
||||||
|
span class="text-xs text-neutral-500" {
|
||||||
|
"Config version " (profile_timezone.config_version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(checkbox(
|
||||||
|
"profile_timezone_enabled",
|
||||||
|
"true",
|
||||||
|
"Serve profile timezone to the selected users",
|
||||||
|
profile_timezone.enabled,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Off is the safe state and the kill switch. With this unchecked nobody \
|
||||||
|
sees the setting and every saved time zone is hidden."
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||||
|
(number_field(
|
||||||
|
"profile_timezone_rollout_basis_points",
|
||||||
|
"Rollout (basis points)",
|
||||||
|
&profile_timezone.rollout_basis_points.to_string(),
|
||||||
|
Some(0), Some(10000), "1",
|
||||||
|
Some("Share of users bucketed into profile timezone, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||||
|
))
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(text_input(
|
||||||
|
"profile_timezone_rollout_salt",
|
||||||
|
"Rollout Salt",
|
||||||
|
&profile_timezone.rollout_salt,
|
||||||
|
PROFILE_TIMEZONE_DEFAULT_SALT,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||||
|
inside the percentage above."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"profile_timezone_included_user_ids",
|
||||||
|
"Always-on User IDs",
|
||||||
|
"1500000000000000001\n1500000000000000002",
|
||||||
|
&included_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
profile_timezone.included_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"One snowflake per line, or comma separated. These users get profile \
|
||||||
|
timezone regardless of the percentage above. Invalid entries prevent the save."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(checkbox(
|
||||||
|
"profile_timezone_include_premium_users",
|
||||||
|
"true",
|
||||||
|
"Include premium users",
|
||||||
|
profile_timezone.include_premium_users,
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Includes every account with active premium perks, regardless of the \
|
||||||
|
percentage above. The never-on list still wins."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"profile_timezone_included_guild_ids",
|
||||||
|
"Always-on Guild IDs",
|
||||||
|
"1500000000000000005\n1500000000000000006",
|
||||||
|
&profile_timezone.included_guild_ids.join("\n"),
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
profile_timezone.included_guild_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format, with guild IDs. Every member of a listed guild is \
|
||||||
|
included regardless of the percentage above, unless the user is \
|
||||||
|
in the never-on list."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="flex flex-col gap-2" {
|
||||||
|
(textarea_input(
|
||||||
|
"profile_timezone_excluded_user_ids",
|
||||||
|
"Never-on User IDs",
|
||||||
|
"1500000000000000003\n1500000000000000004",
|
||||||
|
&excluded_user_ids,
|
||||||
|
4,
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
(entry_count_hint(
|
||||||
|
profile_timezone.excluded_user_ids.len(),
|
||||||
|
EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
|
))
|
||||||
|
p class="text-xs text-neutral-500" {
|
||||||
|
"Same format. Exclusion wins over both the always-on list and the percentage."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(form_actions(html! {
|
||||||
|
(submit_button("Save Profile Timezone Configuration"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn experiment_delivery_section(
|
||||||
|
base: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
experiment_delivery: &ExperimentDeliveryConfigResponse,
|
||||||
|
) -> Markup {
|
||||||
|
section_card_with_description(
|
||||||
|
"Experiment Delivery",
|
||||||
|
"How often every client revalidates its experiment assignments. This is instance-wide \
|
||||||
|
and covers every experiment, not just the ones above. Raising the interval sheds \
|
||||||
|
request volume and makes a change take longer to reach a client. Raising the jitter \
|
||||||
|
spreads a fleet that has synchronised on one tick back out across the interval.",
|
||||||
|
html! {
|
||||||
|
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
div class="space-y-6" {
|
||||||
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||||
|
(number_field(
|
||||||
|
"experiment_delivery_poll_interval_seconds",
|
||||||
|
"Assignment Poll Interval (s)",
|
||||||
|
&experiment_delivery.poll_interval_seconds.to_string(),
|
||||||
|
Some(60), Some(86400), "1",
|
||||||
|
Some("How often a client re-reads its assignments, 60 to 86400 seconds. Lower values pick up changes sooner at the cost of more requests."),
|
||||||
|
))
|
||||||
|
(number_field(
|
||||||
|
"experiment_delivery_poll_jitter_percent",
|
||||||
|
"Assignment Poll Jitter (%)",
|
||||||
|
&experiment_delivery.poll_jitter_percent.to_string(),
|
||||||
|
Some(0), Some(50), "1",
|
||||||
|
Some("How far each client spreads its poll around the interval, 0 to 50 percent. Raise it to break up a fleet that polls on the same tick, set it to 0 for an exact interval."),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
(form_actions(html! {
|
||||||
|
(submit_button("Save Experiment Delivery Configuration"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn registration_config_section(
|
fn registration_config_section(
|
||||||
config: &AdminConfig,
|
config: &AdminConfig,
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
@@ -1522,3 +2374,96 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
|
||||||
|
|
||||||
|
fn rendered_voice_noise_suppression_section(
|
||||||
|
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||||
|
) -> String {
|
||||||
|
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
|
||||||
|
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||||
|
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||||
|
excluded_user_ids: vec![
|
||||||
|
"1500000000000000002".to_owned(),
|
||||||
|
"1500000000000000003".to_owned(),
|
||||||
|
],
|
||||||
|
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
|
||||||
|
guild_id: "1600000000000000001".to_owned(),
|
||||||
|
backend: NoiseSuppressionBackend::Rnnoise,
|
||||||
|
}],
|
||||||
|
..VoiceNoiseSuppressionConfigResponse::default()
|
||||||
|
};
|
||||||
|
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||||
|
assert!(markup.contains("1 of 1000 stored"));
|
||||||
|
assert!(markup.contains("2 of 1000 stored"));
|
||||||
|
assert!(markup.contains("1 of 200 stored"));
|
||||||
|
assert!(!markup.contains("at the cap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
||||||
|
let domain_migration = DomainMigrationConfigResponse {
|
||||||
|
anonymous_rollout_basis_points: 250,
|
||||||
|
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||||
|
excluded_user_ids: vec![
|
||||||
|
"1500000000000000002".to_owned(),
|
||||||
|
"1500000000000000003".to_owned(),
|
||||||
|
],
|
||||||
|
..DomainMigrationConfigResponse::default()
|
||||||
|
};
|
||||||
|
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
||||||
|
assert!(markup.contains("action=update_domain_migration"));
|
||||||
|
assert!(markup.contains("domain_migration_enabled"));
|
||||||
|
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
||||||
|
assert!(markup.contains("value=\"250\""));
|
||||||
|
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
||||||
|
assert!(markup.contains("1 of 1000 stored"));
|
||||||
|
assert!(markup.contains("2 of 1000 stored"));
|
||||||
|
assert!(!markup.contains("at the cap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_relay_section_shows_the_consent_toggle() {
|
||||||
|
let accepted = PushRelayConfigResponse {
|
||||||
|
relay_consent_accepted: true,
|
||||||
|
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
||||||
|
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
||||||
|
};
|
||||||
|
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
||||||
|
assert!(markup.contains("action=update_push_relay"));
|
||||||
|
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
||||||
|
assert!(markup.contains("https://fluxer.com/push-relay"));
|
||||||
|
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
||||||
|
assert!(markup.contains("value=\"1130650140672000000\""));
|
||||||
|
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
||||||
|
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
||||||
|
assert!(!markup.to_lowercase().contains("rollout"));
|
||||||
|
|
||||||
|
let unaccepted =
|
||||||
|
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
||||||
|
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
||||||
|
assert!(unaccepted.contains("Not accepted"));
|
||||||
|
assert!(unaccepted.contains("value=\"Never\""));
|
||||||
|
assert!(unaccepted.contains("value=\"Nobody\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||||
|
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||||
|
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||||
|
.map(|index| index.to_string())
|
||||||
|
.collect(),
|
||||||
|
..VoiceNoiseSuppressionConfigResponse::default()
|
||||||
|
};
|
||||||
|
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||||
|
assert!(markup.contains("1000 of 1000 stored"));
|
||||||
|
assert!(markup.contains("at the cap"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ use crate::{
|
|||||||
templates::{
|
templates::{
|
||||||
components::{
|
components::{
|
||||||
auto_refresh::auto_refresh,
|
auto_refresh::auto_refresh,
|
||||||
badge::{BadgeVariant, badge},
|
|
||||||
data_field::{data_field_mono, data_field_text, data_grid},
|
data_field::{data_field_mono, data_field_text, data_grid},
|
||||||
form::{csrf_input, danger_button, form_field_group},
|
form::{csrf_input, danger_button, form_field_group},
|
||||||
page_container::{page_header_with_actions, page_header_with_back},
|
page_container::{page_header_with_actions, page_header_with_back},
|
||||||
@@ -18,17 +17,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
|
|
||||||
fn status_badge(status: &str) -> Markup {
|
use super::jobs_list_helpers::status_badge;
|
||||||
let variant = match status {
|
|
||||||
"queued" => BadgeVariant::Default,
|
|
||||||
"running" => BadgeVariant::Info,
|
|
||||||
"succeeded" => BadgeVariant::Success,
|
|
||||||
"failed" | "deadletter" => BadgeVariant::Danger,
|
|
||||||
"cancelled" => BadgeVariant::Warning,
|
|
||||||
_ => BadgeVariant::Default,
|
|
||||||
};
|
|
||||||
badge(status, variant)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
|
fn val_str<'a>(job: &'a serde_json::Value, key: &str) -> &'a str {
|
||||||
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
|
job.get(key).and_then(|v| v.as_str()).unwrap_or("")
|
||||||
@@ -71,7 +60,7 @@ fn progress_bar(job: &serde_json::Value) -> Markup {
|
|||||||
}
|
}
|
||||||
(cur, Some(tot)) => {
|
(cur, Some(tot)) => {
|
||||||
let c = cur.unwrap_or(0);
|
let c = cur.unwrap_or(0);
|
||||||
let pct = (c * 100 / tot).min(100);
|
let pct = (u128::from(c) * 100 / u128::from(tot)).min(100);
|
||||||
html! {
|
html! {
|
||||||
div role="progressbar" aria-valuenow=(pct) aria-valuemin="0"
|
div role="progressbar" aria-valuenow=(pct) aria-valuemin="0"
|
||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
|
|||||||
@@ -33,25 +33,26 @@ pub(crate) fn format_progress(job: &serde_json::Value) -> String {
|
|||||||
(Some(cur), None | Some(0)) => format!("{cur}"),
|
(Some(cur), None | Some(0)) => format!("{cur}"),
|
||||||
(cur, Some(tot)) => {
|
(cur, Some(tot)) => {
|
||||||
let c = cur.unwrap_or(0);
|
let c = cur.unwrap_or(0);
|
||||||
let pct = c.saturating_mul(100).checked_div(tot).unwrap_or(0);
|
let pct = (u128::from(c) * 100)
|
||||||
|
.checked_div(u128::from(tot))
|
||||||
|
.unwrap_or(0);
|
||||||
format!("{c} / {tot} ({pct}%)")
|
format!("{c} / {tot} ({pct}%)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
fn job_id_text(value: &serde_json::Value) -> String {
|
||||||
let job_id = job
|
value
|
||||||
.get("job_id")
|
|
||||||
.and_then(|v| v.as_str().or_else(|| v.as_u64().map(|_| "")))
|
|
||||||
.unwrap_or("");
|
|
||||||
let job_id_display = job
|
|
||||||
.get("job_id")
|
.get("job_id")
|
||||||
.map(|v| match v {
|
.map(|v| match v {
|
||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
serde_json::Value::Number(n) => n.to_string(),
|
|
||||||
_ => v.to_string(),
|
_ => v.to_string(),
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
||||||
|
let job_id = job_id_text(job);
|
||||||
let task_type = job
|
let task_type = job
|
||||||
.get("task_type")
|
.get("task_type")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -79,23 +80,17 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
|||||||
.unwrap_or_else(|| "cron".to_owned());
|
.unwrap_or_else(|| "cron".to_owned());
|
||||||
let progress = format_progress(job);
|
let progress = format_progress(job);
|
||||||
|
|
||||||
let link_id = if job_id.is_empty() {
|
|
||||||
&job_id_display
|
|
||||||
} else {
|
|
||||||
job_id
|
|
||||||
};
|
|
||||||
|
|
||||||
table_row(html! {
|
table_row(html! {
|
||||||
(table_cell(true, html! {
|
(table_cell(true, html! {
|
||||||
span class="whitespace-nowrap text-sm" { (created_at) }
|
span class="whitespace-nowrap text-sm" { (created_at) }
|
||||||
}))
|
}))
|
||||||
(table_cell(false, html! {
|
(table_cell(false, html! {
|
||||||
a href={(base) "/jobs/" (link_id)}
|
a href={(base) "/jobs/" (job_id)}
|
||||||
hx-target="#main-content"
|
hx-target="#main-content"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
class="text-blue-600 text-sm hover:underline" {
|
class="text-blue-600 text-sm hover:underline" {
|
||||||
(job_id_display)
|
(job_id)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
(table_cell(false, html! {
|
(table_cell(false, html! {
|
||||||
@@ -103,7 +98,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
|||||||
}))
|
}))
|
||||||
(table_cell(false, html! { (status_badge(status)) }))
|
(table_cell(false, html! { (status_badge(status)) }))
|
||||||
(table_cell(false, html! {
|
(table_cell(false, html! {
|
||||||
span class="text-sm" data-job-progress=(link_id) { (progress) }
|
span class="text-sm" data-job-progress=(job_id) { (progress) }
|
||||||
}))
|
}))
|
||||||
(table_cell(true, html! {
|
(table_cell(true, html! {
|
||||||
span class="text-sm" { (attempts) "/" (max_attempts) }
|
span class="text-sm" { (attempts) "/" (max_attempts) }
|
||||||
@@ -112,7 +107,7 @@ pub(crate) fn job_row(base: &str, job: &serde_json::Value) -> Markup {
|
|||||||
span class="text-sm" { (requester) }
|
span class="text-sm" { (requester) }
|
||||||
}))
|
}))
|
||||||
(table_cell(false, html! {
|
(table_cell(false, html! {
|
||||||
a href={(base) "/jobs/" (link_id)}
|
a href={(base) "/jobs/" (job_id)}
|
||||||
hx-target="#main-content"
|
hx-target="#main-content"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
@@ -159,14 +154,7 @@ pub(crate) fn next_page_link(
|
|||||||
.get("created_at")
|
.get("created_at")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let job_id = cursor
|
let job_id = job_id_text(cursor);
|
||||||
.get("job_id")
|
|
||||||
.map(|v| match v {
|
|
||||||
serde_json::Value::String(s) => s.clone(),
|
|
||||||
serde_json::Value::Number(n) => n.to_string(),
|
|
||||||
_ => v.to_string(),
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut params = vec![
|
let mut params = vec![
|
||||||
format!("cursor_bucket_day={bucket_day}"),
|
format!("cursor_bucket_day={bucket_day}"),
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ use crate::{
|
|||||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
|
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
|
||||||
submit_button,
|
submit_button,
|
||||||
},
|
},
|
||||||
message_list::{Attachment, Message, message_deletion_script, message_list},
|
message_data::{Message, ordered_messages, value_id},
|
||||||
|
message_list::{message_deletion_script, message_list},
|
||||||
page_container::{card, page_header},
|
page_container::{card, page_header},
|
||||||
},
|
},
|
||||||
layout::LayoutOptions,
|
layout::LayoutOptions,
|
||||||
@@ -19,7 +20,6 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::cmp::Ordering;
|
|
||||||
|
|
||||||
const MESSAGE_BROWSE_SCRIPT: &str = r#"
|
const MESSAGE_BROWSE_SCRIPT: &str = r#"
|
||||||
(function () {
|
(function () {
|
||||||
@@ -263,7 +263,7 @@ pub fn browse_messages_fragment(
|
|||||||
show_delete: bool,
|
show_delete: bool,
|
||||||
highlight_message_id: Option<&str>,
|
highlight_message_id: Option<&str>,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let messages = ordered_messages(result);
|
let messages = response_messages(result);
|
||||||
let has_more = result
|
let has_more = result
|
||||||
.get("has_more")
|
.get("has_more")
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
@@ -295,7 +295,7 @@ fn browse_result_card(
|
|||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
context_limit: u32,
|
context_limit: u32,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let messages = ordered_messages(result);
|
let messages = response_messages(result);
|
||||||
let has_more = result
|
let has_more = result
|
||||||
.get("has_more")
|
.get("has_more")
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
@@ -345,7 +345,7 @@ fn search_result_card(
|
|||||||
query_text: &str,
|
query_text: &str,
|
||||||
show_delete: bool,
|
show_delete: bool,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let messages = ordered_messages(result);
|
let messages = response_messages(result);
|
||||||
let total = result
|
let total = result
|
||||||
.get("total")
|
.get("total")
|
||||||
.and_then(Value::as_u64)
|
.and_then(Value::as_u64)
|
||||||
@@ -382,7 +382,7 @@ fn lookup_result_card(
|
|||||||
show_delete: bool,
|
show_delete: bool,
|
||||||
context_limit: u32,
|
context_limit: u32,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let messages = ordered_messages(result);
|
let messages = response_messages(result);
|
||||||
let channel_id = messages
|
let channel_id = messages
|
||||||
.first()
|
.first()
|
||||||
.map(|m| m.channel_id.as_str())
|
.map(|m| m.channel_id.as_str())
|
||||||
@@ -508,130 +508,12 @@ fn empty_state(text: &str) -> Markup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ordered_messages(result: &Value) -> Vec<Message> {
|
fn response_messages(result: &Value) -> Vec<Message> {
|
||||||
let mut messages: Vec<Message> = result
|
let values = result["messages"]
|
||||||
.get("messages")
|
.as_array()
|
||||||
.and_then(Value::as_array)
|
.map(Vec::as_slice)
|
||||||
.into_iter()
|
.unwrap_or_default();
|
||||||
.flatten()
|
ordered_messages(values)
|
||||||
.map(message_from_value)
|
|
||||||
.collect();
|
|
||||||
messages.sort_by(compare_message_ids);
|
|
||||||
messages
|
|
||||||
}
|
|
||||||
|
|
||||||
fn message_from_value(value: &Value) -> Message {
|
|
||||||
let attachments = value
|
|
||||||
.get("attachments")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.map(attachment_from_value)
|
|
||||||
.collect();
|
|
||||||
Message {
|
|
||||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
|
||||||
content: value
|
|
||||||
.get("content")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
timestamp: value
|
|
||||||
.get("timestamp")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
author_id: value
|
|
||||||
.get("author_id")
|
|
||||||
.and_then(value_id)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
author_username: value
|
|
||||||
.get("author_username")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("Unknown")
|
|
||||||
.to_owned(),
|
|
||||||
author_global_name: value
|
|
||||||
.get("author_global_name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
author_discriminator: value
|
|
||||||
.get("author_discriminator")
|
|
||||||
.and_then(value_id)
|
|
||||||
.unwrap_or_else(|| "0000".to_owned()),
|
|
||||||
author_avatar: value
|
|
||||||
.get("author_avatar")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
channel_id: value
|
|
||||||
.get("channel_id")
|
|
||||||
.and_then(value_id)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
|
|
||||||
channel_content_warning_level: value
|
|
||||||
.get("channel_content_warning_level")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.map(|n| n as i32),
|
|
||||||
channel_content_warning_text: value
|
|
||||||
.get("channel_content_warning_text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
|
|
||||||
attachments,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attachment_from_value(value: &Value) -> Attachment {
|
|
||||||
Attachment {
|
|
||||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
|
||||||
url: value
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
filename: value
|
|
||||||
.get("filename")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
nsfw: value.get("nsfw").and_then(Value::as_bool),
|
|
||||||
content_type: value
|
|
||||||
.get("content_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
|
|
||||||
height: value
|
|
||||||
.get("height")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|n| n as u32),
|
|
||||||
size: value.get("size").and_then(Value::as_u64),
|
|
||||||
ncmec_status: value
|
|
||||||
.get("ncmec_status")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("not_submitted")
|
|
||||||
.to_owned(),
|
|
||||||
ncmec_report_id: value
|
|
||||||
.get("ncmec_report_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
ncmec_failure_reason: value
|
|
||||||
.get("ncmec_failure_reason")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value_id(value: &Value) -> Option<String> {
|
|
||||||
match value {
|
|
||||||
Value::String(s) => Some(s.clone()),
|
|
||||||
Value::Number(n) => Some(n.to_string()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
|
||||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
|
||||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
|
||||||
_ => left.id.cmp(&right.id),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
|
fn browse_channel_form(config: &AdminConfig, csrf_token: &str, prefill: Option<&str>) -> Markup {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use std::cmp::Ordering;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
acl,
|
acl,
|
||||||
api::types::ReportEntry,
|
api::types::ReportEntry,
|
||||||
@@ -13,7 +11,8 @@ use crate::{
|
|||||||
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
|
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
|
||||||
form::csrf_input,
|
form::csrf_input,
|
||||||
media::{guild_icon_url, initials, user_avatar_url},
|
media::{guild_icon_url, initials, user_avatar_url},
|
||||||
message_list::{Attachment, Message, message_deletion_script, message_list},
|
message_data::ordered_messages,
|
||||||
|
message_list::{message_deletion_script, message_list},
|
||||||
nsfw_indicators::{
|
nsfw_indicators::{
|
||||||
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
|
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
|
||||||
},
|
},
|
||||||
@@ -26,7 +25,6 @@ use crate::{
|
|||||||
utils::timestamps::format_admin_timestamp,
|
utils::timestamps::format_admin_timestamp,
|
||||||
};
|
};
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, html};
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
fn status_badge(status: i32) -> Markup {
|
fn status_badge(status: i32) -> Markup {
|
||||||
let (label, variant) = match status {
|
let (label, variant) = match status {
|
||||||
@@ -534,123 +532,3 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
|
||||||
let mut messages: Vec<Message> = values.iter().map(message_from_value).collect();
|
|
||||||
messages.sort_by(compare_message_ids);
|
|
||||||
messages
|
|
||||||
}
|
|
||||||
|
|
||||||
fn message_from_value(value: &Value) -> Message {
|
|
||||||
let attachments = value
|
|
||||||
.get("attachments")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.map(attachment_from_value)
|
|
||||||
.collect();
|
|
||||||
Message {
|
|
||||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
|
||||||
content: value
|
|
||||||
.get("content")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
timestamp: value
|
|
||||||
.get("timestamp")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
author_id: value
|
|
||||||
.get("author_id")
|
|
||||||
.and_then(value_id)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
author_username: value
|
|
||||||
.get("author_username")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("Unknown")
|
|
||||||
.to_owned(),
|
|
||||||
author_global_name: value
|
|
||||||
.get("author_global_name")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
author_discriminator: value
|
|
||||||
.get("author_discriminator")
|
|
||||||
.and_then(value_id)
|
|
||||||
.unwrap_or_else(|| "0000".to_owned()),
|
|
||||||
author_avatar: value
|
|
||||||
.get("author_avatar")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
channel_id: value
|
|
||||||
.get("channel_id")
|
|
||||||
.and_then(value_id)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
channel_nsfw: value.get("channel_nsfw").and_then(Value::as_bool),
|
|
||||||
channel_content_warning_level: value
|
|
||||||
.get("channel_content_warning_level")
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.map(|n| n as i32),
|
|
||||||
channel_content_warning_text: value
|
|
||||||
.get("channel_content_warning_text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
guild_nsfw: value.get("guild_nsfw").and_then(Value::as_bool),
|
|
||||||
attachments,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attachment_from_value(value: &Value) -> Attachment {
|
|
||||||
Attachment {
|
|
||||||
id: value.get("id").and_then(value_id).unwrap_or_default(),
|
|
||||||
url: value
|
|
||||||
.get("url")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
filename: value
|
|
||||||
.get("filename")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_owned(),
|
|
||||||
nsfw: value.get("nsfw").and_then(Value::as_bool),
|
|
||||||
content_type: value
|
|
||||||
.get("content_type")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
width: value.get("width").and_then(Value::as_u64).map(|n| n as u32),
|
|
||||||
height: value
|
|
||||||
.get("height")
|
|
||||||
.and_then(Value::as_u64)
|
|
||||||
.map(|n| n as u32),
|
|
||||||
size: value.get("size").and_then(Value::as_u64),
|
|
||||||
ncmec_status: value
|
|
||||||
.get("ncmec_status")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.unwrap_or("not_submitted")
|
|
||||||
.to_owned(),
|
|
||||||
ncmec_report_id: value
|
|
||||||
.get("ncmec_report_id")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
ncmec_failure_reason: value
|
|
||||||
.get("ncmec_failure_reason")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn value_id(value: &Value) -> Option<String> {
|
|
||||||
match value {
|
|
||||||
Value::String(s) => Some(s.clone()),
|
|
||||||
Value::Number(n) => Some(n.to_string()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_message_ids(left: &Message, right: &Message) -> Ordering {
|
|
||||||
match (left.id.parse::<u128>(), right.id.parse::<u128>()) {
|
|
||||||
(Ok(l), Ok(r)) => l.cmp(&r),
|
|
||||||
_ => left.id.cmp(&right.id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -466,7 +466,10 @@ fn reports_pagination(
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
total: u64,
|
total: u64,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let total_pages = ((total + u64::from(limit).saturating_sub(1)) / u64::from(limit)).max(1);
|
let total_pages = total.div_ceil(u64::from(limit)).max(1);
|
||||||
|
let next_page = page
|
||||||
|
.checked_add(1)
|
||||||
|
.filter(|page| u64::from(*page) < total_pages);
|
||||||
html! {
|
html! {
|
||||||
div class="mt-4 flex items-center justify-between" {
|
div class="mt-4 flex items-center justify-between" {
|
||||||
@if page > 0 {
|
@if page > 0 {
|
||||||
@@ -478,10 +481,10 @@ fn reports_pagination(
|
|||||||
span {}
|
span {}
|
||||||
}
|
}
|
||||||
span class="text-neutral-500 text-sm" {
|
span class="text-neutral-500 text-sm" {
|
||||||
"Page " (page + 1) " of " (total_pages)
|
"Page " (u64::from(page) + 1) " of " (total_pages)
|
||||||
}
|
}
|
||||||
@if u64::from(page + 1) < total_pages {
|
@if let Some(next_page) = next_page {
|
||||||
a href=(reports_url(config, filters, page + 1, limit))
|
a href=(reports_url(config, filters, next_page, limit))
|
||||||
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||||
(PreEscaped("Next →"))
|
(PreEscaped("Next →"))
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 4379 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user