mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
521
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4a93b677af | ||
|
|
d79cd99050 | ||
|
|
167862a8a6 | ||
|
|
48b569b9d4 | ||
|
|
75be6aa492 | ||
|
|
9fe65d5036 | ||
|
|
bfa9bf221d | ||
|
|
184eeb0846 | ||
|
|
0eff26a1c9 | ||
|
|
d729f641ff | ||
|
|
044a2c101d | ||
|
|
38e2c8db3e | ||
|
|
1b22d14f3d | ||
|
|
98cceae59d | ||
|
|
3e32414849 | ||
|
|
7707b9531c | ||
|
|
86745e01e9 | ||
|
|
098830a95a | ||
|
|
cf83f66911 | ||
|
|
c577b97f35 | ||
|
|
8cc485cf81 | ||
|
|
6c36d934f7 | ||
|
|
63e3be5750 | ||
|
|
cdecda7f78 | ||
|
|
4ad2858773 | ||
|
|
fda41bb57a | ||
|
|
ce08f82a92 | ||
|
|
ef067f36c6 | ||
|
|
fc2b6b5299 | ||
|
|
55846b24ea | ||
|
|
20a15ac11d | ||
|
|
667ac7da8e | ||
|
|
1b81c14c48 | ||
|
|
ceec183d38 | ||
|
|
69ddc07ebb | ||
|
|
2f008b8653 | ||
|
|
3d38d3f694 | ||
|
|
ad86a04e67 | ||
|
|
600c15e17d | ||
|
|
08c9fe9886 | ||
|
|
43924e3ac5 | ||
|
|
824b5c86c9 | ||
|
|
a2a68847fd | ||
|
|
2019909a5e | ||
|
|
d46c8d49c6 | ||
|
|
45530ebbf5 | ||
|
|
9cdad046b1 | ||
|
|
b6c6928073 | ||
|
|
dd1ee999a4 | ||
|
|
c506d6d5e3 | ||
|
|
8a65832a65 | ||
|
|
fd6ae4abd7 | ||
|
|
24b84c419c | ||
|
|
746a75187a | ||
|
|
10ba2ca896 | ||
|
|
977b6767cd | ||
|
|
f00c6ee47a | ||
|
|
82859dc2f6 | ||
|
|
328dc06ab0 | ||
|
|
ea6e4a75db | ||
|
|
69ca462930 | ||
|
|
f38619d974 | ||
|
|
6c0ce9369b | ||
|
|
00c1b19809 | ||
|
|
2fd5daf104 | ||
|
|
fbf0f6adfe | ||
|
|
d91b5bec66 | ||
|
|
0f24cfb6ef | ||
|
|
7a6691cdbe | ||
|
|
73d3a4f843 | ||
|
|
091755fe78 | ||
|
|
1fb2790bb9 | ||
|
|
798e64b224 | ||
|
|
a2d6477b42 | ||
|
|
a2ca24eeb4 | ||
|
|
8dcd00a8fe | ||
|
|
be8a52c823 | ||
|
|
43e420b0ab | ||
|
|
f8947adf62 | ||
|
|
81fccaf0ab | ||
|
|
7a42291baf | ||
|
|
d9f983b08e | ||
|
|
2f159852a7 | ||
|
|
5ef402b8ee | ||
|
|
a8d6e5ab73 | ||
|
|
e805a3797f | ||
|
|
8f4fa82a9e | ||
|
|
d2438b2fdd | ||
|
|
133640ef2b | ||
|
|
8e0516a8c3 | ||
|
|
d784c0692e | ||
|
|
fffa265117 | ||
|
|
5367c0ab42 | ||
|
|
7f8f09ee51 | ||
|
|
a70924d4b0 | ||
|
|
1a5925f9cb | ||
|
|
43c778aae4 | ||
|
|
34cf8f821f | ||
|
|
226cfd062e | ||
|
|
e8f4e35c32 | ||
|
|
ef559f3d8c | ||
|
|
ee7206ac66 | ||
|
|
1ba9592308 | ||
|
|
d07f520b13 | ||
|
|
632f4c7b6c | ||
|
|
1f627c9cc5 | ||
|
|
cc110b9f5a | ||
|
|
f06d65db54 | ||
|
|
f8a04b8985 | ||
|
|
908e1b8bd4 | ||
|
|
f392636857 | ||
|
|
150115cc0c | ||
|
|
710a6f1c5d | ||
|
|
7c3e722085 | ||
|
|
d8f2aa3184 | ||
|
|
e828398e06 | ||
|
|
31d7cb81d6 | ||
|
|
214d19d45a | ||
|
|
d3170fc320 | ||
|
|
9a229c1b73 | ||
|
|
a036d9a2e1 | ||
|
|
d5bfa5a73d | ||
|
|
4534822355 | ||
|
|
bff29d8f07 | ||
|
|
bec34ea147 | ||
|
|
3be4171256 | ||
|
|
5bcaa7cfac | ||
|
|
ea93ef5352 | ||
|
|
8734956d86 | ||
|
|
519b3a6127 | ||
|
|
9b3773c1e6 | ||
|
|
e12b60078a | ||
|
|
7cb8f9f4ae | ||
|
|
622bd124b9 | ||
|
|
fed8b2d089 | ||
|
|
12718eabbc | ||
|
|
ab68b61653 | ||
|
|
639ade3802 | ||
|
|
3f1f899b23 | ||
|
|
51cb750502 | ||
|
|
1e6c332eae | ||
|
|
18ae2e563e | ||
|
|
a4d039c910 | ||
|
|
6163fd5644 | ||
|
|
3e2ddaca4f | ||
|
|
054a59e622 | ||
|
|
84d7290ed9 | ||
|
|
f4c1254d91 | ||
|
|
c01d22dc05 | ||
|
|
4c0f02d8a5 | ||
|
|
2770482baf | ||
|
|
8e39a00e34 | ||
|
|
7bd0d3a962 | ||
|
|
bc7f701e87 | ||
|
|
0d8116d73e | ||
|
|
255cbc1248 | ||
|
|
098aeef412 | ||
|
|
baa18aed5b | ||
|
|
b7c8dab019 | ||
|
|
587324fa38 | ||
|
|
cc3a9c8613 | ||
|
|
b0645300ec | ||
|
|
d7d4e8da03 | ||
|
|
16ae98e189 | ||
|
|
add0a3dfc6 | ||
|
|
90c349392b | ||
|
|
eb4562b6a6 | ||
|
|
6c634b686f | ||
|
|
48e03edccc | ||
|
|
cef6f11fd0 | ||
|
|
2757659989 | ||
|
|
f090395c21 | ||
|
|
dd1d554cc6 | ||
|
|
9c1b38aeaf | ||
|
|
902dd60ff9 | ||
|
|
2426a5769d | ||
|
|
66517c925b | ||
|
|
5f90e7d535 | ||
|
|
9d63eb15a9 | ||
|
|
c97bc53342 | ||
|
|
f5f60c66e7 | ||
|
|
3e15b97c8c | ||
|
|
6c08813f9b | ||
|
|
8158d44732 | ||
|
|
9bd0019759 | ||
|
|
a74f1b0e7b | ||
|
|
2b12f5db6c | ||
|
|
af4a52173c | ||
|
|
5bc1f21d46 | ||
|
|
917e437939 | ||
|
|
7ee4fb37d6 | ||
|
|
6155eec804 | ||
|
|
43d8637153 | ||
|
|
250db2fdab | ||
|
|
7bd021cd5c | ||
|
|
adb9a689f0 | ||
|
|
d8e0c2ec20 | ||
|
|
f98a74170a | ||
|
|
17b9821879 | ||
|
|
4b5bdefcb9 | ||
|
|
45cbabd94d | ||
|
|
8402eb53c8 | ||
|
|
d04ace5789 | ||
|
|
e94f587535 | ||
|
|
e73285060e | ||
|
|
f1734704ef | ||
|
|
59f6217267 | ||
|
|
90f4a222b7 | ||
|
|
749d2091eb | ||
|
|
8d34c6bcaa | ||
|
|
62577b25bb | ||
|
|
475f5a7dae | ||
|
|
1772b3aad0 | ||
|
|
7263b21a06 | ||
|
|
501adff13d | ||
|
|
12c6fb7b7f | ||
|
|
376168c922 | ||
|
|
cadab239a2 | ||
|
|
f57dc77c6d | ||
|
|
497a494c37 | ||
|
|
02069e8e5d | ||
|
|
626293392c | ||
|
|
dfe42ae3e3 | ||
|
|
453abfa145 | ||
|
|
8ebc9400ce | ||
|
|
1598f48edd | ||
|
|
cc92f37f0c | ||
|
|
9322aca6cb | ||
|
|
ee8fbd6f4f | ||
|
|
1acd61a112 | ||
|
|
e836686a71 | ||
|
|
ea6c417378 | ||
|
|
16cc9a9e69 | ||
|
|
6b5316fa84 | ||
|
|
a53f5d1289 | ||
|
|
de2ea99928 | ||
|
|
25f4332b9e | ||
|
|
f703969e80 | ||
|
|
b82681b77a | ||
|
|
ef248a8515 | ||
|
|
73a2345c26 | ||
|
|
9f33177eab | ||
|
|
d5a752c338 | ||
|
|
4021a2d697 | ||
|
|
bea4a6dcbc | ||
|
|
4f48e04cad | ||
|
|
5719dfe8a3 | ||
|
|
4fb14e85e8 | ||
|
|
1d84689b45 | ||
|
|
693aec2b4d | ||
|
|
c79c0ee138 | ||
|
|
e86e24a2db | ||
|
|
0f7ad484ce | ||
|
|
bcd95b2af9 | ||
|
|
32dcd5ed1c | ||
|
|
5119febb5b | ||
|
|
20cdfd3009 | ||
|
|
9f739427c4 | ||
|
|
0201cafd7e | ||
|
|
37f57bb29f | ||
|
|
ebd723679b | ||
|
|
961fa1f007 | ||
|
|
7900a4da0c | ||
|
|
8a24730884 | ||
|
|
1688e7dc50 | ||
|
|
aa267b54ec | ||
|
|
bc40073a02 | ||
|
|
a93f9dd0af | ||
|
|
53a9fdc4b6 | ||
|
|
cef600277c | ||
|
|
bdac438329 | ||
|
|
2d77f36a0b | ||
|
|
90aa810ce4 | ||
|
|
cf3af50464 | ||
|
|
3b5b20c139 | ||
|
|
24cd163acd | ||
|
|
49f76e5b40 | ||
|
|
871788f0a9 | ||
|
|
24138b70f1 | ||
|
|
da3332e711 | ||
|
|
06e5cf2032 | ||
|
|
d4b1923c23 | ||
|
|
42df4f6731 | ||
|
|
f1e6e94041 | ||
|
|
0cd12b2f32 | ||
|
|
5da4d24d38 | ||
|
|
7806d2ac02 | ||
|
|
2c4d182d1f | ||
|
|
dcd5f88d65 | ||
|
|
662f4ac93b | ||
|
|
a2480c6a02 | ||
|
|
c49460a44f | ||
|
|
6786dfe7e3 | ||
|
|
7d710d881a | ||
|
|
2ea2e79f6f | ||
|
|
cd42dd8ca7 | ||
|
|
f2eddeae4d | ||
|
|
8e1a8fc7e3 | ||
|
|
87c08b051f | ||
|
|
9d95a80857 | ||
|
|
9371b6d5de | ||
|
|
bdcf4b25c0 | ||
|
|
3dc344be65 | ||
|
|
17ed0f70aa | ||
|
|
be3e12e60d | ||
|
|
4261cc2ea5 | ||
|
|
88dbc27019 | ||
|
|
f38fc80c31 | ||
|
|
803fdaf443 | ||
|
|
55d85db401 | ||
|
|
7ce3d71c44 | ||
|
|
04e150e4bf | ||
|
|
0f6b118921 | ||
|
|
44277e6aa2 | ||
|
|
bb7e8cc6f1 | ||
|
|
32a64fb097 | ||
|
|
c4594397e7 | ||
|
|
6a188a4cdf | ||
|
|
0ca0defd24 | ||
|
|
b0b84f9c98 | ||
|
|
ef8d1225b5 | ||
|
|
240b7e4388 | ||
|
|
bd205d2250 | ||
|
|
e5e5bcccee | ||
|
|
a5395b0109 | ||
|
|
09cea4394f | ||
|
|
afeaddea22 | ||
|
|
45f694310a | ||
|
|
995f5118b2 | ||
|
|
415888a615 | ||
|
|
542fb9176a | ||
|
|
b8f8d8d859 | ||
|
|
0eef611b6d | ||
|
|
f0612ee860 | ||
|
|
8c85cce75c | ||
|
|
5036ac3efa | ||
|
|
9025e03422 | ||
|
|
e82e8529bf | ||
|
|
eb1ed69489 | ||
|
|
e81f3f7eae | ||
|
|
4b9964bc89 | ||
|
|
b4a2af75d0 | ||
|
|
8c3e3285f7 | ||
|
|
0a4f6ff9fb | ||
|
|
bfa1367ca2 | ||
|
|
bb81a2f165 | ||
|
|
7f448b1cab | ||
|
|
2dd35c0d6e | ||
|
|
0e73346c5f | ||
|
|
8476595507 | ||
|
|
7b9284edb9 | ||
|
|
c982b33212 | ||
|
|
3c8466d714 | ||
|
|
eeea391b63 | ||
|
|
5c2dca1c51 | ||
|
|
e6e4c6f7b5 | ||
|
|
5f6f9428ac | ||
|
|
ba54b61dcf | ||
|
|
8dc2bad843 | ||
|
|
3594cbd5ca | ||
|
|
21b1e4e719 | ||
|
|
50a17b6263 | ||
|
|
0a920def2b | ||
|
|
c4b1471923 | ||
|
|
ab08ed0d7c | ||
|
|
34c13a747d | ||
|
|
d559d8853d | ||
|
|
a96d9cd075 | ||
|
|
b163888cf3 | ||
|
|
b07e2c397c | ||
|
|
3eeba1da2b | ||
|
|
e160b1bf07 | ||
|
|
1199b36d1a | ||
|
|
7a506478c7 | ||
|
|
6faa40e0c2 | ||
|
|
768657d7e5 | ||
|
|
7157cca22f | ||
|
|
7aec79d3ad | ||
|
|
4357d5ec5d | ||
|
|
7c1c8b2749 | ||
|
|
15656bd5c8 | ||
|
|
c4897a7026 | ||
|
|
e993a47720 | ||
|
|
0d4c65ad79 | ||
|
|
f09bdb2b00 | ||
|
|
f1400ae58e | ||
|
|
b5496097d2 | ||
|
|
d5fb495e19 | ||
|
|
00e716bc3f | ||
|
|
ea4edd668f | ||
|
|
a22db125a9 | ||
|
|
e7f68c2e20 | ||
|
|
933b13f3fa | ||
|
|
0be6c9c734 | ||
|
|
5aac331368 | ||
|
|
57ec484626 | ||
|
|
9cd832bfa9 | ||
|
|
299cc40ff5 | ||
|
|
6d305bacdf | ||
|
|
6fd3177844 | ||
|
|
5586d34293 | ||
|
|
d43d242b16 | ||
|
|
9f620e8c4b | ||
|
|
6c9afcc734 | ||
|
|
43d6c85f7e | ||
|
|
78056e0041 | ||
|
|
e83a2d6aec | ||
|
|
bac06fe182 | ||
|
|
8ff6518797 | ||
|
|
f0e7c25e4c | ||
|
|
0da94965dc | ||
|
|
d82eed16b7 | ||
|
|
b26748a2a7 | ||
|
|
a2d7f5e8cc | ||
|
|
72ffa3bd9a | ||
|
|
e2fccaee74 | ||
|
|
e41b209cb8 | ||
|
|
2517caf674 | ||
|
|
b9ec0d5f53 | ||
|
|
02e614632f | ||
|
|
3ca73901c9 | ||
|
|
c379eed266 | ||
|
|
5bac4fd719 | ||
|
|
dd610e4c0f | ||
|
|
bf080cb001 | ||
|
|
169088df26 | ||
|
|
4a2a29f154 | ||
|
|
1054962008 | ||
|
|
8bc8603460 | ||
|
|
6538b0bfeb | ||
|
|
9d2339bb3b | ||
|
|
096f38d365 | ||
|
|
1046edd903 | ||
|
|
cbf504dbb8 | ||
|
|
8491872908 | ||
|
|
c207918e90 | ||
|
|
12717f692b | ||
|
|
36d630b37b | ||
|
|
5333fe7c3a | ||
|
|
efae78056e | ||
|
|
6eca64a8f7 | ||
|
|
fcb629ca06 | ||
|
|
289f1af253 | ||
|
|
8adc3ecb0b | ||
|
|
e328c001a1 | ||
|
|
f796a31613 | ||
|
|
e1bab2e353 | ||
|
|
1c135176d2 | ||
|
|
723f0d6e6e | ||
|
|
e14d193b43 | ||
|
|
9d1733bdb3 | ||
|
|
e06436d6f5 | ||
|
|
4f67e2b362 | ||
|
|
8aa3415d73 | ||
|
|
74f22e89ce | ||
|
|
7d826d1602 | ||
|
|
8aa39bc7db | ||
|
|
36dcf51024 | ||
|
|
3e74180bdc | ||
|
|
45ed740575 | ||
|
|
8092ad8c4d | ||
|
|
b4d9cdc584 | ||
|
|
36b85512c6 |
@@ -8,7 +8,7 @@ ARG USER_GID=1000
|
||||
ARG NODE_MAJOR=24
|
||||
ARG ELP_VERSION=2026-02-27
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
ARG WASM_BINDGEN_VERSION=0.2.122
|
||||
ARG WASM_BINDGEN_VERSION=0.2.123
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -32,6 +32,7 @@ RUN apt-get update \
|
||||
jq \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libaom-dev \
|
||||
libavcodec-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
@@ -51,9 +52,15 @@ RUN apt-get update \
|
||||
libcurl4-openssl-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libdav1d-dev \
|
||||
libde265-dev \
|
||||
liblcms2-dev \
|
||||
libvips-dev \
|
||||
libyuv-dev \
|
||||
libwayland-dev \
|
||||
libwebp-dev \
|
||||
nasm \
|
||||
yasm \
|
||||
libssl-dev \
|
||||
libx11-xcb1 \
|
||||
libxcb-dri3-0 \
|
||||
@@ -78,6 +85,7 @@ RUN apt-get update \
|
||||
unzip \
|
||||
xz-utils \
|
||||
xdg-utils \
|
||||
zlib1g-dev \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
||||
|
||||
COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh
|
||||
RUN /tmp/fluxer-install-native-deps.sh /usr/local \
|
||||
&& rm /tmp/fluxer-install-native-deps.sh
|
||||
|
||||
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig
|
||||
ENV LD_LIBRARY_PATH=/usr/local/lib
|
||||
|
||||
RUN printf '%s\n' \
|
||||
'#include <libheif/heif.h>' \
|
||||
'#include <string.h>' \
|
||||
'#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \
|
||||
'#error the source-built libheif headers must win the include search' \
|
||||
'#endif' \
|
||||
'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \
|
||||
>/tmp/fluxer-heif-probe.c \
|
||||
&& cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \
|
||||
&& /tmp/fluxer-heif-probe \
|
||||
&& [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \
|
||||
&& rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe
|
||||
|
||||
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
|
||||
&& rm /tmp/fluxer-fonts-requirements.txt \
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: fluxer-dev
|
||||
|
||||
services:
|
||||
workspace:
|
||||
build:
|
||||
@@ -241,7 +243,6 @@ services:
|
||||
volume:
|
||||
nocopy: true
|
||||
- pnpm-store:/home/vscode/.local/share/pnpm/store
|
||||
- docs-venv:/workspaces/fluxer/fluxer_docs/.venv
|
||||
- cargo-registry:/home/vscode/.cargo/registry
|
||||
- cargo-git:/home/vscode/.cargo/git
|
||||
- rust-target:/workspaces/fluxer/target
|
||||
@@ -349,7 +350,6 @@ services:
|
||||
|
||||
volumes:
|
||||
pnpm-store:
|
||||
docs-venv:
|
||||
root-node-modules:
|
||||
fluxer-api-node-modules:
|
||||
fluxer-app-node-modules:
|
||||
|
||||
@@ -28,8 +28,7 @@ for path in \
|
||||
/home/vscode/.cargo/registry \
|
||||
/home/vscode/.cargo/git \
|
||||
/home/vscode/.local \
|
||||
/home/vscode/.local/share/pnpm/store \
|
||||
/workspaces/fluxer/fluxer_docs/.venv; do
|
||||
/home/vscode/.local/share/pnpm/store; do
|
||||
repair_tree "$path"
|
||||
done
|
||||
|
||||
|
||||
+11
-4
@@ -7,10 +7,16 @@ QUICK=0
|
||||
SKIP_INSTALL=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--quick) QUICK=1 ;;
|
||||
--skip-install) SKIP_INSTALL=1 ;;
|
||||
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
|
||||
*) echo "unknown argument: $arg" >&2; exit 2 ;;
|
||||
--quick) QUICK=1 ;;
|
||||
--skip-install) SKIP_INSTALL=1 ;;
|
||||
-h | --help)
|
||||
sed -n '2,25p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown argument: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -64,6 +70,7 @@ stage "app: typecheck" pnpm --filter fluxer_app typecheck
|
||||
stage "app: unit tests" pnpm --filter fluxer_app exec vitest run
|
||||
|
||||
if [ "$QUICK" -eq 0 ]; then
|
||||
stage "desktop: typecheck" pnpm --filter fluxer_desktop typecheck
|
||||
stage "app: production build" pnpm --filter fluxer_app build
|
||||
fi
|
||||
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@
|
||||
**/node_modules/
|
||||
**/target/
|
||||
**/test-results.json
|
||||
/fluxer_docs/site/
|
||||
/fluxer_docs/dist/
|
||||
/fluxer_docs/.astro/
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
|
||||
@@ -36,8 +36,7 @@ body:
|
||||
label: Build information
|
||||
description: >-
|
||||
Open User Settings, scroll to the bottom of the left sidebar, and select
|
||||
the build information. Fluxer copies it to the clipboard. On mobile,
|
||||
select the build information at the bottom of the settings list.
|
||||
the build information. Fluxer copies it to the clipboard.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Mobile client bugs
|
||||
url: https://github.com/fluxerapp/flutter_client#bug-reporting
|
||||
about: Read the reporting instructions for the Fluxer mobile client.
|
||||
- name: Account and billing support
|
||||
url: https://fluxer.app/help
|
||||
about: Find account help and support contact details.
|
||||
- name: Feature proposals
|
||||
url: https://github.com/orgs/fluxerapp/discussions
|
||||
about: Propose a feature in a discussion.
|
||||
- name: Security vulnerabilities
|
||||
url: https://github.com/fluxerapp/fluxer/security/advisories/new
|
||||
about: Submit a private vulnerability report.
|
||||
- name: Translations
|
||||
url: https://weblate.fluxer.tools
|
||||
about: Improve an existing locale or start a new one.
|
||||
|
||||
@@ -104,6 +104,9 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
@@ -115,11 +118,13 @@ jobs:
|
||||
context: ${{ inputs.context }}
|
||||
file: ${{ inputs.dockerfile }}
|
||||
push: true
|
||||
provenance: false
|
||||
provenance: mode=min
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
${{ inputs.extra-build-args }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
@@ -185,17 +190,12 @@ jobs:
|
||||
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
MOVING_TAGS: ${{ inputs.moving-tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_args=()
|
||||
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
|
||||
for raw in "${moving[@]}"; do
|
||||
tag="$(echo "$raw" | xargs)"
|
||||
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
|
||||
done
|
||||
if (( ${#tag_args[@]} > 0 )); then
|
||||
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
|
||||
fi
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
tools/ci/run.sh image-set
|
||||
promote
|
||||
--component "${{ inputs.image }}"
|
||||
--build-version "${VERSION}"
|
||||
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
|
||||
--moving-tags "${MOVING_TAGS}"
|
||||
|
||||
@@ -15,6 +15,13 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-fluxer-app-proxy-self-hosted
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
@@ -26,13 +33,209 @@ jobs:
|
||||
- name: approved
|
||||
run: echo "Build release approved."
|
||||
|
||||
build:
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-app-proxy-self-hosted
|
||||
dockerfile: fluxer_app_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
extra-build-args: |
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: set variables
|
||||
id: vars
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step set_metadata
|
||||
--build-version "${{ inputs['build-version'] }}"
|
||||
|
||||
dist:
|
||||
name: build the canonical asset tree
|
||||
needs: meta
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/fluxer-app-proxy-self-hosted
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL: ""
|
||||
BUNDLE_LOCAL_ASSETS: "true"
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: prepare docker config
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step prepare_docker_config
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- name: configure ghcr auth
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step configure_ghcr_auth
|
||||
|
||||
- name: build the dist once and publish it as the canonical asset image
|
||||
env:
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step build_dist
|
||||
|
||||
- name: generate asset manifest
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step generate_asset_manifest
|
||||
|
||||
- name: verify every manifest asset ships in the image
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_published_assets
|
||||
|
||||
build:
|
||||
name: build ${{ matrix.platform }}
|
||||
needs: [meta, dist]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: amd64
|
||||
runner: ubuntu-24.04
|
||||
- platform: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: .
|
||||
file: fluxer_app_proxy/Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: linux/${{ matrix.platform }}
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
merge:
|
||||
name: merge multi-arch manifest
|
||||
needs: [meta, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: verify cross-architecture asset parity
|
||||
env:
|
||||
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-amd64
|
||||
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-arm64
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_asset_parity
|
||||
|
||||
- name: create and push multi-arch manifest
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- 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: write
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
tools/ci/run.sh release
|
||||
publish
|
||||
--component fluxer-app-proxy-self-hosted
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
tools/ci/run.sh image-set
|
||||
promote
|
||||
--component fluxer-app-proxy-self-hosted
|
||||
--build-version "${VERSION}"
|
||||
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
|
||||
--moving-tags v1,latest
|
||||
|
||||
@@ -57,11 +57,11 @@ jobs:
|
||||
--step set_metadata
|
||||
--build-version "${{ inputs['build-version'] }}"
|
||||
|
||||
build:
|
||||
name: build app-proxy (amd64)
|
||||
dist:
|
||||
name: build and publish the canonical asset tree
|
||||
needs: meta
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -87,17 +87,17 @@ jobs:
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step configure_ghcr_auth
|
||||
|
||||
- name: build and push image + extract assets
|
||||
- name: build the dist once and publish it as the canonical asset image
|
||||
env:
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step build_and_extract
|
||||
--step build_dist
|
||||
|
||||
- name: generate asset manifest
|
||||
run: >-
|
||||
@@ -114,9 +114,63 @@ jobs:
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step upload_assets
|
||||
|
||||
- name: verify every uploaded asset is readable
|
||||
env:
|
||||
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_published_assets
|
||||
|
||||
build:
|
||||
name: build app-proxy (amd64)
|
||||
needs: [meta, dist]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: prepare docker config
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step prepare_docker_config
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- name: configure ghcr auth
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step configure_ghcr_auth
|
||||
|
||||
- name: build and push image
|
||||
env:
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
SOURCE_DATE: ${{ steps.source.outputs.date }}
|
||||
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
|
||||
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
|
||||
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step build_image
|
||||
|
||||
build-arm64:
|
||||
name: build app-proxy (arm64)
|
||||
needs: meta
|
||||
needs: [meta, dist]
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
@@ -127,6 +181,9 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: resolve source date
|
||||
id: source
|
||||
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
@@ -143,8 +200,10 @@ jobs:
|
||||
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
|
||||
BUNDLE_LOCAL_ASSETS=false
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
env:
|
||||
@@ -155,7 +214,7 @@ jobs:
|
||||
name: merge multi-arch manifest
|
||||
needs: [meta, build, build-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -173,6 +232,15 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: verify cross-architecture asset parity
|
||||
env:
|
||||
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
|
||||
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
|
||||
run: >-
|
||||
tools/ci/run.sh build-app-proxy
|
||||
--step verify_asset_parity
|
||||
|
||||
- name: fuse amd64 + arm64 into a multi-arch manifest
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
||||
@@ -212,10 +280,11 @@ jobs:
|
||||
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
docker buildx imagetools create
|
||||
-t "${IMAGE}:v1"
|
||||
-t "${IMAGE}:latest"
|
||||
"${IMAGE}:${VERSION}"
|
||||
tools/ci/run.sh image-set
|
||||
promote
|
||||
--component fluxer-app-proxy
|
||||
--build-version "${VERSION}"
|
||||
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
|
||||
--moving-tags v1,latest
|
||||
|
||||
@@ -525,6 +525,7 @@ jobs:
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -602,7 +603,9 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -660,3 +663,8 @@ jobs:
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
- name: Publish payload metadata to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_payload_metadata
|
||||
|
||||
@@ -33,5 +33,4 @@ jobs:
|
||||
with:
|
||||
image: fluxer-docs
|
||||
dockerfile: fluxer_docs/Dockerfile
|
||||
context: fluxer_docs
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: release image set
|
||||
|
||||
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: ""
|
||||
from-tag:
|
||||
description: "Image tag every component is read from (v1 snapshots today's moving tags, a CalVer pins a coordinated build)"
|
||||
type: string
|
||||
required: false
|
||||
default: "v1"
|
||||
component-versions:
|
||||
description: "Per-component overrides, one <image>=<version> entry per line (for example fluxer-api=2026.830.191141)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: release-image-set
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve image set release
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Image set release approved."
|
||||
|
||||
manifest:
|
||||
name: resolve and publish the image set
|
||||
needs: approve
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
- 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: write
|
||||
permission-packages: read
|
||||
|
||||
- name: set variables
|
||||
id: vars
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: >-
|
||||
tools/ci/run.sh resolve-calver
|
||||
--github-output
|
||||
|
||||
- name: resolve release image set
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
VERSION: ${{ steps.vars.outputs.build_version }}
|
||||
FROM_TAG: ${{ inputs['from-tag'] }}
|
||||
COMPONENT_VERSIONS: ${{ inputs['component-versions'] }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(
|
||||
image-set resolve
|
||||
--version "${VERSION}"
|
||||
--registry "ghcr.io/${GHCR_OWNER}"
|
||||
--from-tag "${FROM_TAG}"
|
||||
--out-dir release-out
|
||||
--github-output
|
||||
)
|
||||
while IFS= read -r entry; do
|
||||
entry="$(echo "$entry" | xargs)"
|
||||
if [ -n "$entry" ]; then
|
||||
args+=( --component-version "$entry" )
|
||||
fi
|
||||
done <<< "${COMPONENT_VERSIONS}"
|
||||
tools/ci/run.sh "${args[@]}"
|
||||
|
||||
- name: verify release image set
|
||||
env:
|
||||
VERSION: ${{ steps.vars.outputs.build_version }}
|
||||
run: >-
|
||||
tools/ci/run.sh image-set verify
|
||||
--manifest "release-out/fluxer-release-${VERSION}.json"
|
||||
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
VERSION: ${{ steps.vars.outputs.build_version }}
|
||||
BUNDLE_COMMIT: ${{ steps.resolve.outputs.bundle_commit }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${BUNDLE_COMMIT}" ]; then
|
||||
echo "image-set resolve reported no bundle commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release create "fluxer-release@${VERSION}" \
|
||||
--repo fluxerapp/fluxer \
|
||||
--target "${BUNDLE_COMMIT}" \
|
||||
--title "fluxer-release ${VERSION}" \
|
||||
--latest=true \
|
||||
--notes "Immutable image set for ${VERSION}. Every image in the set contains ${BUNDLE_COMMIT}, the commit this tag points at, so the bundle here is never newer than the images. Pin with: docker compose -f docker-compose.yml -f fluxer-release-${VERSION}.yml up -d" \
|
||||
"release-out/fluxer-release-${VERSION}.json" \
|
||||
"release-out/fluxer-release-${VERSION}.yml"
|
||||
+126
-21
@@ -152,8 +152,8 @@ jobs:
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
rust:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -174,24 +174,67 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
workspaces: |
|
||||
. -> target
|
||||
fluxer_desktop/native/rust -> target
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny --version 0.19.6 --locked
|
||||
|
||||
- name: Check Rust dependencies
|
||||
run: cargo deny --locked check -D warnings
|
||||
|
||||
- name: Check desktop native dependencies
|
||||
run: tools/ci/check-desktop-native-workspaces.sh dependencies
|
||||
|
||||
- name: Cache native media dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: /opt/fluxer-native
|
||||
key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}
|
||||
|
||||
- name: Install native dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
build-essential \
|
||||
libcurl4-openssl-dev \
|
||||
libvips-dev \
|
||||
binutils \
|
||||
clang \
|
||||
cmake \
|
||||
curl \
|
||||
libaom-dev \
|
||||
libavfilter-dev \
|
||||
libclang-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libdav1d-dev \
|
||||
libde265-dev \
|
||||
libfido2-dev \
|
||||
libheif-dev \
|
||||
libwebp-dev
|
||||
liblcms2-dev \
|
||||
libpipewire-0.3-dev \
|
||||
libspa-0.2-dev \
|
||||
libssl-dev \
|
||||
libudev-dev \
|
||||
libvips-dev \
|
||||
libwebp-dev \
|
||||
libyuv-dev \
|
||||
meson \
|
||||
nasm \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
xz-utils \
|
||||
yasm \
|
||||
zlib1g-dev
|
||||
sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native
|
||||
echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV"
|
||||
echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV"
|
||||
echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm --filter fluxer_admin install
|
||||
@@ -199,17 +242,29 @@ jobs:
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Check formatting (desktop native)
|
||||
run: cargo fmt --manifest-path fluxer_desktop/native/rust/Cargo.toml --all -- --check
|
||||
- name: Check formatting (desktop native workspaces)
|
||||
run: tools/ci/check-desktop-native-workspaces.sh fmt
|
||||
|
||||
- name: Clippy (warnings as errors)
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
|
||||
run: tools/ci/check-desktop-native-workspaces.sh clippy
|
||||
|
||||
- name: Verify the source-built ffmpeg CLI is on PATH
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v ffmpeg
|
||||
test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg
|
||||
ffmpeg -hide_banner -version
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace
|
||||
env:
|
||||
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
|
||||
run: cargo test --workspace --all-features --locked
|
||||
|
||||
- name: Run desktop native tests
|
||||
run: cargo test --manifest-path fluxer_desktop/native/rust/Cargo.toml
|
||||
- name: Run desktop native workspace tests on Linux
|
||||
run: tools/ci/check-desktop-native-workspaces.sh test
|
||||
|
||||
gateway:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -259,8 +314,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -290,8 +345,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -371,6 +426,31 @@ jobs:
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check formatting and lint
|
||||
run: pnpm exec biome ci .
|
||||
|
||||
- name: Lint JSX for browser-translation safety
|
||||
run: pnpm exec eslint . --max-warnings 0
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
@@ -405,6 +485,31 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --filter fluxer_docs...
|
||||
|
||||
- name: Verify documentation matches the live API
|
||||
run: pnpm --filter fluxer_docs verify
|
||||
|
||||
- name: Build documentation
|
||||
run: pnpm --filter fluxer_docs build
|
||||
|
||||
fonts:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
Generated
+82
-370
@@ -49,14 +49,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.1.2"
|
||||
version = "4.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
|
||||
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
|
||||
dependencies = [
|
||||
"cssparser 0.35.0",
|
||||
"html5ever 0.35.0",
|
||||
"cssparser",
|
||||
"html5ever",
|
||||
"maplit",
|
||||
"tendril 0.4.3",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -127,9 +126,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
@@ -182,7 +181,7 @@ dependencies = [
|
||||
"ring",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.13",
|
||||
"rustls-webpki",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_nanos",
|
||||
@@ -190,7 +189,7 @@ dependencies = [
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tokio-websockets",
|
||||
@@ -528,23 +527,17 @@ dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"h2 0.3.27",
|
||||
"h2 0.4.14",
|
||||
"http 0.2.12",
|
||||
"h2",
|
||||
"http 1.4.2",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper 1.10.1",
|
||||
"hyper-rustls 0.24.2",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls 0.21.12",
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tracing",
|
||||
]
|
||||
@@ -709,7 +702,7 @@ dependencies = [
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
@@ -933,9 +926,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -1218,9 +1211,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -1268,42 +1261,19 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
|
||||
dependencies = [
|
||||
"cssparser-macros 0.6.1",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.11.3",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
dependencies = [
|
||||
"cssparser-macros 0.7.0",
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.13.1",
|
||||
"phf",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.7.0"
|
||||
@@ -1652,7 +1622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1779,7 +1749,7 @@ dependencies = [
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"image",
|
||||
"libc",
|
||||
@@ -1839,6 +1809,7 @@ dependencies = [
|
||||
"clap",
|
||||
"criterion",
|
||||
"fluxer_common",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.4.2",
|
||||
@@ -1923,8 +1894,7 @@ dependencies = [
|
||||
"libc",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"rustls 0.23.40",
|
||||
"rustls-pemfile",
|
||||
"rustls",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1970,6 +1940,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
@@ -1989,6 +1960,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"cookie",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"maud",
|
||||
"openapiv3",
|
||||
@@ -2019,16 +1991,14 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hex",
|
||||
"moka",
|
||||
"rand 0.10.1",
|
||||
"reqwest",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
@@ -2100,16 +2070,6 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
@@ -2282,28 +2242,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.27"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2399,17 +2340,6 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever 0.35.0",
|
||||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
@@ -2417,7 +2347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever 0.39.0",
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2496,30 +2426,6 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
@@ -2530,7 +2436,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.14",
|
||||
"h2",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
@@ -2542,21 +2448,6 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.24.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.32",
|
||||
"log",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
@@ -2564,12 +2455,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http 1.4.2",
|
||||
"hyper 1.10.1",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
@@ -2585,12 +2476,12 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.10.1",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -2984,29 +2875,12 @@ dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril 0.4.3",
|
||||
"web_atoms 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
@@ -3014,19 +2888,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril 0.5.0",
|
||||
"web_atoms 0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "match_token"
|
||||
version = "0.35.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"tendril",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3191,7 +3054,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3362,55 +3225,25 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.6",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3420,20 +3253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3442,22 +3262,13 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
@@ -3758,8 +3569,8 @@ dependencies = [
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.40",
|
||||
"socket2 0.6.3",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3779,7 +3590,7 @@ dependencies = [
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
@@ -3797,7 +3608,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -4015,15 +3826,15 @@ dependencies = [
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.10.1",
|
||||
"hyper-rustls 0.27.9",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
@@ -4031,7 +3842,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4068,7 +3879,7 @@ dependencies = [
|
||||
"futures",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.4.2",
|
||||
"hyper 1.10.1",
|
||||
"hyper",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"retry-policies",
|
||||
@@ -4155,19 +3966,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.21.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sct",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4181,7 +3980,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.13",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -4198,15 +3997,6 @@ dependencies = [
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.1"
|
||||
@@ -4228,14 +4018,14 @@ dependencies = [
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki 0.103.13",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4244,16 +4034,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
@@ -4346,23 +4126,13 @@ version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||
dependencies = [
|
||||
"cssparser 0.37.0",
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"getopts",
|
||||
"html5ever 0.39.0",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4466,12 +4236,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser 0.37.0",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"servo_arc",
|
||||
@@ -4765,9 +4535,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
|
||||
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
@@ -4785,19 +4555,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared 0.11.3",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@@ -4806,30 +4563,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
@@ -4904,18 +4649,7 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5086,7 +4820,7 @@ dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf 0.13.1",
|
||||
"phf",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
@@ -5103,32 +4837,22 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb"
|
||||
dependencies = [
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -5172,7 +4896,7 @@ dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
@@ -5713,28 +5437,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
"phf_codegen 0.11.3",
|
||||
"string_cache 0.8.9",
|
||||
"string_cache_codegen 0.5.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
"string_cache 0.9.0",
|
||||
"string_cache_codegen 0.6.1",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5805,7 +5517,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -25,3 +25,8 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
|
||||
+35
-2
@@ -20,7 +20,7 @@
|
||||
"bracketSpacing": false,
|
||||
"bracketSameLine": false
|
||||
},
|
||||
"globals": ["React"]
|
||||
"globals": ["React", "__webpack_base_uri__"]
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
@@ -84,7 +84,18 @@
|
||||
},
|
||||
"useConst": "error",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off"
|
||||
"noParameterAssign": "off",
|
||||
"noRestrictedImports": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"paths": {
|
||||
"@lingui/react": {
|
||||
"importNames": ["I18nProvider"],
|
||||
"message": "Use AppI18nProvider from @app/features/i18n/components/AppI18nProvider so <Trans> output stays safe under page translation."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": true,
|
||||
@@ -115,6 +126,28 @@
|
||||
}
|
||||
},
|
||||
"assist": {"actions": {"source": {"organizeImports": "on"}}},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["fluxer_app/src/**/*.tsx"],
|
||||
"plugins": ["./tools/lint/no-adjacent-jsx-text.grit"]
|
||||
},
|
||||
{
|
||||
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
|
||||
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.tsx",
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.test.tsx"
|
||||
],
|
||||
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
"assist": {"actions": {"source": {"organizeImports": "off"}}}
|
||||
}
|
||||
],
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
Vendored
+2
-4
@@ -43,7 +43,6 @@ FLUXER_SVC_NATS_URL=nats://nats:4222
|
||||
FLUXER_SVC_SHARD_COUNT=1
|
||||
FLUXER_SVC_CACHE_TTL_MS=30000
|
||||
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS=64
|
||||
|
||||
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
|
||||
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
|
||||
@@ -90,7 +89,6 @@ FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
|
||||
FLUXER_ADMIN_PORT=3020
|
||||
FLUXER_ADMIN_BASE_PATH=/admin
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_ID=1234567890123456789
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
||||
FLUXER_MARKETING_PORT=3010
|
||||
@@ -100,8 +98,8 @@ FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
||||
FLUXER_VAPID_PUBLIC_KEY=dev-vapid-public-key
|
||||
FLUXER_VAPID_PRIVATE_KEY=dev-vapid-private-key
|
||||
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
|
||||
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
|
||||
FLUXER_VAPID_EMAIL=dev@localhost
|
||||
FLUXER_PASSKEY_RP_NAME='Fluxer Dev'
|
||||
FLUXER_PASSKEY_RP_ID=localhost
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
# cargo-deny configuration for the Fluxer workspace.
|
||||
#
|
||||
# Applies to the root workspace (Cargo.toml at the repo root) AND to every
|
||||
# per-addon crate under fluxer_desktop/native/* (each addon has its own
|
||||
# [workspace], so we invoke cargo-deny with --config pointing here).
|
||||
#
|
||||
# Used by the native desktop security gate in CI.
|
||||
# Applies to the root workspace (Cargo.toml at the repo root).
|
||||
|
||||
[graph]
|
||||
all-features = false
|
||||
all-features = true
|
||||
no-default-features = false
|
||||
|
||||
[output]
|
||||
@@ -44,13 +40,11 @@ allow = [
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"MPL-2.0",
|
||||
"Unicode-DFS-2016",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"CC0-1.0",
|
||||
"AGPL-3.0-or-later",
|
||||
"BSL-1.0",
|
||||
"OpenSSL",
|
||||
"CDLA-Permissive-2.0",
|
||||
]
|
||||
# Explicitly deny GPL-only / strong-copyleft licenses that don't compose with
|
||||
@@ -72,21 +66,48 @@ license-files = [
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
wildcards = "deny"
|
||||
# Per-addon crates path-depend on ../rust (the shared `fluxer_desktop_native`
|
||||
# crate) without a version. cargo-deny flags that as a wildcard; we allow it
|
||||
# because path deps can't realistically pin a SemVer range, and this only
|
||||
# affects intra-repo workspace links (registry wildcards remain denied).
|
||||
# Internal workspace crates use path dependencies without registry versions.
|
||||
# Registry wildcards remain denied.
|
||||
allow-wildcard-paths = true
|
||||
highlight = "all"
|
||||
workspace-default-features = "allow"
|
||||
external-default-features = "allow"
|
||||
# Keep desktop packaging and native addons away from the obsolete libfuse2 stack.
|
||||
# Keep workspace artifacts away from the obsolete libfuse2 stack.
|
||||
# AppImage packaging must use the static electron-builder runtime instead.
|
||||
deny = [
|
||||
{ crate = "fuse", reason = "libfuse2-based Rust wrapper; use a maintained FUSE3-native crate only if Fluxer ever needs FUSE directly" },
|
||||
{ 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 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 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 prior randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior hashbrown API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
|
||||
{ crate = "[email protected]", reason = "transitive dependency requires the older socket API" },
|
||||
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI 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 = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,115 @@
|
||||
# Every variable docker-compose.yml reads from this file is named here:
|
||||
# uncommented when it has no default, commented with its default when it has one.
|
||||
# A name absent from this file is one Compose does not forward, and it reaches a
|
||||
# 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_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
|
||||
|
||||
# The three lines above are the address browsers use, and every endpoint the
|
||||
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
|
||||
# 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.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
# Point DNS at this host and there is nothing else to configure.
|
||||
#
|
||||
# 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
|
||||
|
||||
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
|
||||
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
|
||||
# is on another machine, and firewall the port to that machine.
|
||||
#FLUXER_EDGE_BIND=127.0.0.1:8080
|
||||
|
||||
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
|
||||
# this to the real client address, so IP bans, rate limits and abuse detection
|
||||
# see the caller rather than the proxy. The default covers proxies on private or
|
||||
# loopback addresses, which is every same-host setup. Set it to your proxy's
|
||||
# address if it reaches Fluxer from a public IP.
|
||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||
|
||||
# The origin browsers see, without a trailing slash. Leave it unset and each
|
||||
# service builds one from the three values at the top of this file. Set it and it
|
||||
# wins: every service reads the host, the scheme and the port out of it and
|
||||
# ignores those three names. Use it when browsers reach the instance on a host
|
||||
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
|
||||
# and an optional port and nothing after them, or the services refuse to start.
|
||||
# It does not move the edge listener or the published ports either, so set the
|
||||
# publish below to the port written here.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address the edge listens on inside its container. Compose builds
|
||||
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
|
||||
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
|
||||
# site by host and ignores the port in the Host header, so a request arriving on
|
||||
# a non-default published port still lands on this site. Put a port in this value
|
||||
# only if you also publish that same container port below, or nothing will be
|
||||
# listening where the publish points. Honoured in the default mode only:
|
||||
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
|
||||
# literal :80, and Compose lets the last file win, so a value here is discarded
|
||||
# under either overlay with no warning. Set it for an unusual default-mode
|
||||
# layout, such as serving several hostnames. Write the scheme into it: a bare
|
||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||
|
||||
# The old name for the value above. It is read only when
|
||||
# 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=
|
||||
|
||||
# Host side of the edge's publishes, and the only two names that decide which
|
||||
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
||||
# HTTP/3 needs both on the same port. Both take an optional bind address in front
|
||||
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
|
||||
# them different host ports: the same host port on both is two publishes of one
|
||||
# port and the edge refuses to start.
|
||||
#FLUXER_HTTP_PORT=80
|
||||
#FLUXER_HTTPS_PORT=443
|
||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
|
||||
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
|
||||
# certificate is issued if a router in front forwards public 80 to this host and
|
||||
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
|
||||
# cannot.
|
||||
#FLUXER_PUBLIC_PORT=8443
|
||||
#FLUXER_HTTPS_PORT=8443
|
||||
|
||||
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
|
||||
# binds host 80. Under an http scheme nothing listens on container 443, so the
|
||||
# last line parks that publish on loopback for a host that wants 443 for
|
||||
# something else. Drop it and 443 is published and idle, which is what earlier
|
||||
# releases did.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
|
||||
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
|
||||
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
|
||||
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
|
||||
# still moves that one publish. Set the line below and plain docker compose
|
||||
# commands pick the file up, or add it to your own -f flags if you pass any. The
|
||||
# file uses the !override tag, which needs Compose 2.24.4 or newer.
|
||||
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
|
||||
|
||||
FLUXER_REGISTRY_OWNER=fluxerapp
|
||||
FLUXER_REGISTRY=ghcr.io/${FLUXER_REGISTRY_OWNER}
|
||||
@@ -9,20 +117,84 @@ FLUXER_IMAGE_TAG=v1
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
# The stack ships its own Postgres and its own object store, and points at both
|
||||
# by service name. Set these to run either one outside the stack. Leave them
|
||||
# unset and the bundled services are used. Taking a service out of the stack
|
||||
# means an upgrade skips the backup step that reaches into it, and backing that
|
||||
# store up belongs to whoever runs it.
|
||||
#FLUXER_POSTGRES_HOST=db.example.com
|
||||
#FLUXER_POSTGRES_PORT=5432
|
||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||
#FLUXER_POSTGRES_USERNAME=fluxer
|
||||
#FLUXER_POSTGRES_SSL=true
|
||||
#FLUXER_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
|
||||
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
||||
#FLUXER_S3_REGION=eu-central-1
|
||||
#FLUXER_S3_FORCE_PATH_STYLE=false
|
||||
# Bucket names. The bundled object store creates whichever names these hold, so
|
||||
# the two stay in step. An object store outside the stack needs the buckets to
|
||||
# exist already.
|
||||
#FLUXER_S3_BUCKET_CDN=fluxer
|
||||
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
|
||||
# The rest of the bundled services, pointed somewhere else the same way. Leave a
|
||||
# line unset and the service in the stack is used. Taking a service out of the
|
||||
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
|
||||
# replaces docker-compose.yml.
|
||||
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
||||
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SEARCH_URL=https://search.example.com
|
||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||
|
||||
# Voice off. The livekit service still runs until an override file takes it out.
|
||||
#FLUXER_LIVEKIT_ENABLED=false
|
||||
|
||||
# Optional systems, each off unless the instance is configured for it.
|
||||
#FLUXER_SMS_ENABLED=false
|
||||
#FLUXER_STRIPE_ENABLED=false
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
|
||||
# The client address. Set the header name a proxy in front actually writes, and
|
||||
# turn the trust off when nothing sits in front, because a trusted header an
|
||||
# attacker can set is a spoofed client address.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
|
||||
# How much the services write. trace, debug, info, warn, error or fatal. Every
|
||||
# service names the object storage endpoint and its addressing at info on start,
|
||||
# so a bucket that answers 404 is visible without raising this.
|
||||
#LOG_LEVEL=debug
|
||||
|
||||
FLUXER_S3_ACCESS_KEY=fluxer
|
||||
FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
|
||||
FLUXER_ERLANG_COOKIE=CHANGE_ME
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# The token every service sends to NATS. The bundled NATS runs without
|
||||
# authentication, so this stays empty unless a Compose override points the stack
|
||||
# at an external NATS that requires a token. Compose forwards the name to every
|
||||
# container that connects.
|
||||
#FLUXER_NATS_AUTH_TOKEN=
|
||||
|
||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_EMAIL=[email protected]
|
||||
|
||||
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
|
||||
# only if that mailbox does not exist.
|
||||
#[email protected]
|
||||
|
||||
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
|
||||
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
|
||||
@@ -30,14 +202,27 @@ [email protected]
|
||||
#FLUXER_PASSKEY_RP_ID=chat.example.com
|
||||
#FLUXER_PASSKEY_RP_NAME=Fluxer
|
||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
|
||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
|
||||
|
||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
|
||||
# only when a browser must reach an origin the defaults do not cover, such as a
|
||||
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
|
||||
# sources with spaces or commas.
|
||||
# sources with spaces or commas. Every one of them is empty by default, and the
|
||||
# three carrying a value below are illustrations, not defaults.
|
||||
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
|
||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
||||
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
||||
#FLUXER_CSP_EXTRA_MEDIA_SRC=
|
||||
#FLUXER_CSP_EXTRA_FONT_SRC=
|
||||
#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com
|
||||
#FLUXER_CSP_EXTRA_STYLE_SRC=
|
||||
#FLUXER_CSP_EXTRA_FRAME_SRC=
|
||||
#FLUXER_CSP_EXTRA_WORKER_SRC=
|
||||
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
|
||||
|
||||
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
|
||||
# directive off the header.
|
||||
#FLUXER_CSP_REPORT_URI=
|
||||
|
||||
# Allow the SSO identity provider to resolve to a private or internal address.
|
||||
# Off by default: the API refuses to call non-public addresses so a misconfigured
|
||||
@@ -46,9 +231,33 @@ [email protected]
|
||||
# identity provider, and only when you trust everyone who can configure SSO.
|
||||
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
||||
|
||||
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
|
||||
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# The URL browsers use for voice signalling. Compose builds it from
|
||||
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
|
||||
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
|
||||
# leading http to ws itself. Set it only when LiveKit is served from another
|
||||
# host.
|
||||
#FLUXER_LIVEKIT_URL=
|
||||
|
||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
||||
# forward the same numbers.
|
||||
#FLUXER_LIVEKIT_TCP_PORT=7881
|
||||
#FLUXER_LIVEKIT_UDP_PORT=7882
|
||||
|
||||
# LiveKit finds the address browsers dial by asking a STUN server. A host that
|
||||
# cannot reach one over UDP stops with "could not resolve external IP", and the
|
||||
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
|
||||
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the two STUN entries at another
|
||||
# server to keep the lookup and leave Google out of it.
|
||||
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
|
||||
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
||||
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
|
||||
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
|
||||
|
||||
FLUXER_KLIPY_API_KEY=
|
||||
|
||||
FLUXER_EMAIL_ENABLED=false
|
||||
@@ -64,4 +273,122 @@ FLUXER_EMAIL_SMTP_SECURE=true
|
||||
|
||||
FLUXER_CAPTCHA_ENABLED=false
|
||||
FLUXER_CAPTCHA_PROVIDER=none
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
|
||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
||||
# The four reservations are cgroup memory.low, which biases the kernel away from
|
||||
# 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_POSTGRES_MEMORY_LIMIT=5gb
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_NATS_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
|
||||
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
|
||||
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
|
||||
#FLUXER_API_MEMORY_LIMIT=2560mb
|
||||
#FLUXER_API_MEMORY_RESERVATION=1gb
|
||||
#FLUXER_WORKER_MEMORY_LIMIT=2560mb
|
||||
#FLUXER_WORKER_MEMORY_RESERVATION=1gb
|
||||
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
|
||||
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
|
||||
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
|
||||
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
|
||||
#FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_USERS_MEMORY_LIMIT=128mb
|
||||
#FLUXER_USERS_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_GIFS_MEMORY_LIMIT=128mb
|
||||
#FLUXER_GIFS_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MESSAGES_MEMORY_LIMIT=128mb
|
||||
#FLUXER_MESSAGES_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_UNFURL_MEMORY_LIMIT=128mb
|
||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
|
||||
# which is the container ceiling the indexer shares with the search process.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
|
||||
# SeaweedFS heap ceiling. Go collects against this value instead of against the
|
||||
# container limit, which it cannot see, so without it an upload burst grows the
|
||||
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
|
||||
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
|
||||
# together: the peak is the parts of one upload in flight at once, which is 25 MB
|
||||
# times 20 for a 500 MB attachment.
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
|
||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
||||
# outside the heap. Leave these unset unless you have a reason to pin the value.
|
||||
# Any value set here must stay well below the container limit above: a heap ceiling
|
||||
# above the container limit makes the kernel OOM-kill the container (exit 137, no
|
||||
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
|
||||
#FLUXER_API_NODE_HEAP_MB=1792
|
||||
#FLUXER_WORKER_NODE_HEAP_MB=1792
|
||||
|
||||
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
|
||||
# budget roughly shared_buffers + (server max_connections x 12 MB) +
|
||||
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
|
||||
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
|
||||
# pool sizes used by the api, worker and shards.
|
||||
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
|
||||
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
||||
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
#FLUXER_POSTGRES_WORK_MEM=8MB
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache. The bulk message
|
||||
# deletion queue and the account deletion queue are sorted sets with no expiry,
|
||||
# and nothing else stores the first of the two. It therefore runs with an
|
||||
# append-only file on a named volume and with noeviction, so an over-limit write
|
||||
# fails loudly instead of silently deleting queued work. Distributed locks all
|
||||
# carry a TTL and are not what the durability is for. Only change the policy if
|
||||
# you have moved that durable state elsewhere.
|
||||
#FLUXER_VALKEY_MAXMEMORY=192mb
|
||||
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
|
||||
|
||||
# The gateway derives its BEAM scheduler count from the container CPU quota,
|
||||
# clamped to this range. The floor matters: a single scheduler lets one blocking
|
||||
# operation stall every websocket on the node. The ceiling stops a large host
|
||||
# from starting far more schedulers than the container can actually use.
|
||||
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
||||
# users and messages routers and their shards. Leave it unset and each service
|
||||
# uses its own built-in default, which is what the numbers below describe. Set it
|
||||
# and the one value replaces the built-in default on all four, so size it for the
|
||||
# busiest of them rather than for the smallest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them, and the api
|
||||
# turns that rejection into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
# outlives the transaction, so set this to false if you put a transaction-pooling
|
||||
# connection pooler such as PgBouncer in front of Postgres. One setting governs
|
||||
# every service. The bundled compose talks to Postgres directly, where naming is
|
||||
# a win and the default is correct.
|
||||
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
|
||||
|
||||
# The api bounds how long a client may take to send a request. The header timeout
|
||||
# covers the request line and headers only, while the request timeout covers the
|
||||
# whole exchange, so a slow uploader is bounded by the second value and not by
|
||||
# the first. Raise both if you front large uploads or serve clients on high
|
||||
# latency links. The header timeout is clamped down to the request timeout, so
|
||||
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
|
||||
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
|
||||
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{
|
||||
servers {
|
||||
trusted_proxies static private_ranges
|
||||
trusted_proxies static {$FLUXER_EDGE_TRUSTED_PROXIES:private_ranges}
|
||||
trusted_proxies_strict
|
||||
}
|
||||
}
|
||||
|
||||
{$FLUXER_CADDY_SITE_ADDRESS} {
|
||||
{$FLUXER_EDGE_SITE_ADDRESS} {
|
||||
encode zstd gzip
|
||||
|
||||
handle /_health {
|
||||
respond "OK" 200
|
||||
}
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
@@ -52,7 +57,7 @@
|
||||
}
|
||||
|
||||
:8088 {
|
||||
handle_path /api/* {
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Overlay for running Fluxer behind your own reverse proxy.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
|
||||
#
|
||||
# Or set this once in .env and keep using plain `docker compose up -d`:
|
||||
#
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||
#
|
||||
# Fluxer stops binding 80 and 443 and serves plain HTTP on one port instead.
|
||||
# That port already does all internal routing, so the proxy in front needs a
|
||||
# single rule: send everything to it. Terminate TLS there.
|
||||
services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_EDGE_BIND:-127.0.0.1:8080}:8080"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ":8080"
|
||||
@@ -1,55 +1,64 @@
|
||||
name: fluxer
|
||||
|
||||
x-fluxer-postgres-env: &fluxer-postgres-env
|
||||
FLUXER_DATABASE_BACKEND: postgres
|
||||
FLUXER_POSTGRES_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
|
||||
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
|
||||
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
|
||||
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
|
||||
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
FLUXER_POSTGRES_SSL: "${FLUXER_POSTGRES_SSL:-false}"
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
|
||||
|
||||
x-fluxer-env: &fluxer-env
|
||||
<<: *fluxer-postgres-env
|
||||
FLUXER_ENV: production
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
|
||||
FLUXER_DATABASE_BACKEND: postgres
|
||||
FLUXER_POSTGRES_HOST: postgres
|
||||
FLUXER_POSTGRES_PORT: "5432"
|
||||
FLUXER_POSTGRES_DATABASE: fluxer
|
||||
FLUXER_POSTGRES_USERNAME: fluxer
|
||||
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
FLUXER_POSTGRES_SSL: "false"
|
||||
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
|
||||
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_SVC_SHARD_COUNT: "1"
|
||||
|
||||
FLUXER_SEARCH_ENGINE: meilisearch
|
||||
FLUXER_SEARCH_URL: http://meilisearch:7700
|
||||
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
|
||||
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
|
||||
FLUXER_S3_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_REGION: us-east-1
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
|
||||
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "true"
|
||||
FLUXER_S3_BUCKET_CDN: fluxer
|
||||
FLUXER_S3_BUCKET_UPLOADS: fluxer-uploads
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
|
||||
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
|
||||
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
AWS_DEFAULT_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
|
||||
FLUXER_LIVEKIT_ENABLED: "true"
|
||||
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
|
||||
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
|
||||
@@ -64,12 +73,16 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
|
||||
|
||||
FLUXER_SMS_ENABLED: "false"
|
||||
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
|
||||
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
|
||||
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
|
||||
FLUXER_STRIPE_ENABLED: "false"
|
||||
FLUXER_NCMEC_ENABLED: "false"
|
||||
FLUXER_CLAMAV_ENABLED: "false"
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
|
||||
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
|
||||
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
|
||||
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
@@ -80,7 +93,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
|
||||
@@ -90,36 +103,90 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
|
||||
FLUXER_INTERNAL_GATEWAY_ENDPOINT: http://gateway:8080
|
||||
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
|
||||
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
|
||||
x-fluxer-service: &fluxer-service
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
|
||||
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
start_interval: 1s
|
||||
|
||||
services:
|
||||
caddy:
|
||||
edge:
|
||||
image: caddy:2.10-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_CADDY_MEMORY_LIMIT:-256mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
- "${FLUXER_HTTP_PORT:-80}:80"
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443"
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
|
||||
environment:
|
||||
FLUXER_CADDY_SITE_ADDRESS: ${FLUXER_CADDY_SITE_ADDRESS:?set FLUXER_CADDY_SITE_ADDRESS in .env}
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
depends_on: [api, gateway, media-proxy, static-proxy, admin]
|
||||
- edge-data:/data
|
||||
- edge-config:/config
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
depends_on:
|
||||
api: {condition: service_started}
|
||||
gateway: {condition: service_healthy}
|
||||
media-proxy: {condition: service_started}
|
||||
static-proxy: {condition: service_started}
|
||||
admin: {condition: service_started}
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_POSTGRES_MEMORY_LIMIT:-5gb}
|
||||
reservations:
|
||||
memory: ${FLUXER_POSTGRES_MEMORY_RESERVATION:-3gb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: >
|
||||
postgres
|
||||
-c max_connections=${FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS:-150}
|
||||
-c shared_buffers=${FLUXER_POSTGRES_SHARED_BUFFERS:-512MB}
|
||||
-c effective_cache_size=${FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE:-2GB}
|
||||
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
|
||||
-c maintenance_work_mem=${FLUXER_POSTGRES_MAINTENANCE_WORK_MEM:-256MB}
|
||||
-c autovacuum_work_mem=${FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM:-128MB}
|
||||
-c random_page_cost=1.1
|
||||
-c effective_io_concurrency=200
|
||||
-c default_statistics_target=200
|
||||
-c jit=off
|
||||
-c min_wal_size=512MB
|
||||
-c max_wal_size=2GB
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_buffers=16MB
|
||||
-c wal_compression=zstd
|
||||
-c bgwriter_delay=50ms
|
||||
-c bgwriter_lru_maxpages=1000
|
||||
-c autovacuum_vacuum_scale_factor=0.05
|
||||
-c autovacuum_analyze_scale_factor=0.02
|
||||
-c autovacuum_vacuum_cost_limit=2000
|
||||
-c track_io_timing=on
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
shm_size: 256mb
|
||||
environment:
|
||||
POSTGRES_DB: fluxer
|
||||
POSTGRES_USER: fluxer
|
||||
@@ -134,9 +201,17 @@ services:
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8.1-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_VALKEY_MEMORY_LIMIT:-256mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
||||
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data",
|
||||
"--maxmemory", "${FLUXER_VALKEY_MAXMEMORY:-192mb}",
|
||||
"--maxmemory-policy", "${FLUXER_VALKEY_MAXMEMORY_POLICY:-noeviction}"]
|
||||
volumes:
|
||||
- valkey-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -145,41 +220,85 @@ services:
|
||||
|
||||
nats:
|
||||
image: nats:2.14-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_NATS_MEMORY_LIMIT:-256mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
MEILI_ENV: production
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7700/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:4.34
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
command: ["server", "-s3", "-dir=/data"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 60s
|
||||
|
||||
seaweedfs-init:
|
||||
image: chrislusf/seaweedfs:4.34
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT:-128mb}
|
||||
networks: [fluxer]
|
||||
depends_on: [seaweedfs]
|
||||
depends_on:
|
||||
seaweedfs: {condition: service_healthy}
|
||||
restart: "no"
|
||||
environment:
|
||||
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_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_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
buckets="fluxer fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests";
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
missing="$$buckets";
|
||||
for attempt in $$(seq 1 60); do
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
@@ -192,6 +311,10 @@ services:
|
||||
echo "$$listed" | grep -q "^[[:space:]]*$$b[[:space:]]" || missing="$${missing:+$$missing }$$b";
|
||||
done;
|
||||
if [ -z "$$missing" ]; then
|
||||
if ! echo "s3.configure -user=fluxer -access_key=$$FLUXER_S3_ACCESS_KEY -secret_key=$$FLUXER_S3_SECRET_KEY -actions=Admin,Read,Write,List,Tagging -apply" | timeout 10 weed shell -master=seaweedfs:9333 >/dev/null 2>&1; then
|
||||
echo "seaweedfs-init could not configure the S3 identity" >&2;
|
||||
exit 1;
|
||||
fi;
|
||||
echo "buckets ready";
|
||||
exit 0;
|
||||
fi;
|
||||
@@ -205,105 +328,186 @@ services:
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.12.0
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: ["--config", "/etc/livekit.yaml"]
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}"
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
LIVEKIT_CONFIG: |
|
||||
port: 7880
|
||||
log_level: info
|
||||
rtc:
|
||||
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
|
||||
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
|
||||
use_external_ip: ${FLUXER_LIVEKIT_USE_EXTERNAL_IP:-true}
|
||||
node_ip: "${FLUXER_LIVEKIT_NODE_IP:-}"
|
||||
stun_servers:
|
||||
- ${FLUXER_LIVEKIT_STUN_PRIMARY:-stun.l.google.com:19302}
|
||||
- ${FLUXER_LIVEKIT_STUN_SECONDARY:-stun1.l.google.com:19302}
|
||||
webhook:
|
||||
api_key: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
urls:
|
||||
- http://api:8080/webhooks/livekit
|
||||
ports:
|
||||
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:7881"
|
||||
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:7882/udp"
|
||||
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:${FLUXER_LIVEKIT_TCP_PORT:-7881}"
|
||||
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:${FLUXER_LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_API_MEMORY_LIMIT:-2560mb}
|
||||
reservations:
|
||||
memory: ${FLUXER_API_MEMORY_RESERVATION:-1gb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_API_PORT: "8080"
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}
|
||||
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "true"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 90s
|
||||
start_interval: 1s
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
valkey: {condition: service_healthy}
|
||||
nats: {condition: service_started}
|
||||
meilisearch: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
meilisearch: {condition: service_healthy}
|
||||
seaweedfs-init: {condition: service_completed_successfully}
|
||||
gifs: {condition: service_started}
|
||||
gifs-shard: {condition: service_started}
|
||||
snowflakes: {condition: service_started}
|
||||
snowflakes-shard: {condition: service_started}
|
||||
messages: {condition: service_started}
|
||||
messages-shard: {condition: service_started}
|
||||
users: {condition: service_started}
|
||||
users-shard: {condition: service_started}
|
||||
gifs: {condition: service_healthy}
|
||||
gifs-shard: {condition: service_healthy}
|
||||
snowflakes: {condition: service_healthy}
|
||||
snowflakes-shard: {condition: service_healthy}
|
||||
messages: {condition: service_healthy}
|
||||
messages-shard: {condition: service_healthy}
|
||||
users: {condition: service_healthy}
|
||||
users-shard: {condition: service_healthy}
|
||||
|
||||
worker:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_WORKER_MEMORY_LIMIT:-2560mb}
|
||||
reservations:
|
||||
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
|
||||
working_dir: /usr/src/app/fluxer_api
|
||||
command: ["./node_modules/.bin/tsx", "src/WorkerEntrypoint.ts"]
|
||||
command: ["sh", "-c", "if [ -f dist/WorkerEntrypoint.js ]; then exec node dist/WorkerEntrypoint.js; else exec ./node_modules/.bin/tsx src/WorkerEntrypoint.ts; fi"]
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
|
||||
FLUXER_API_WORKER_MODE: all_lanes
|
||||
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
|
||||
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
start_interval: 1s
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
valkey: {condition: service_healthy}
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
seaweedfs-init: {condition: service_completed_successfully}
|
||||
snowflakes-shard: {condition: service_started}
|
||||
messages-shard: {condition: service_started}
|
||||
users-shard: {condition: service_started}
|
||||
snowflakes-shard: {condition: service_healthy}
|
||||
messages-shard: {condition: service_healthy}
|
||||
users-shard: {condition: service_healthy}
|
||||
|
||||
gateway:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gateway:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_GATEWAY_MEMORY_LIMIT:-1gb}
|
||||
reservations:
|
||||
memory: ${FLUXER_GATEWAY_MEMORY_RESERVATION:-384mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_GATEWAY_PORT: "8080"
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_GATEWAY_LOGGER_LEVEL: info
|
||||
FLUXER_ERLANG_COOKIE: ${FLUXER_ERLANG_COOKIE:?set FLUXER_ERLANG_COOKIE in .env}
|
||||
FLUXER_ERLANG_SCHEDULERS_MIN: "${FLUXER_ERLANG_SCHEDULERS_MIN:-2}"
|
||||
FLUXER_ERLANG_SCHEDULERS_MAX: "${FLUXER_ERLANG_SCHEDULERS_MAX:-16}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 90s
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
valkey: {condition: service_healthy}
|
||||
|
||||
media-proxy:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-media-proxy:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_MEDIA_PROXY_MEMORY_LIMIT:-512mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_MEDIA_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_MEDIA_PROXY_PORT: "8080"
|
||||
FLUXER_MEDIA_PROXY_MODE: upload
|
||||
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
|
||||
healthcheck:
|
||||
disable: true
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_S3_READ_SIGNED: "true"
|
||||
depends_on:
|
||||
seaweedfs-init: {condition: service_completed_successfully}
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
static-proxy:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_STATIC_PROXY_MEMORY_LIMIT:-256mb}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/avatars/0.png"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
app-proxy:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-app-proxy-self-hosted:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
FLUXER_APP_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
|
||||
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
|
||||
@@ -317,124 +521,202 @@ services:
|
||||
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
caddy: {condition: service_started}
|
||||
postgres: {condition: service_healthy}
|
||||
edge: {condition: service_healthy}
|
||||
|
||||
snowflakes:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_SNOWFLAKES_MEMORY_LIMIT:-128mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: snowflakes
|
||||
FLUXER_SVC_MODE: router
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
snowflakes-shard:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: snowflakes
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
users:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_USERS_MEMORY_LIMIT:-128mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
users-shard:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_USERS_SHARD_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
postgres: {condition: service_healthy}
|
||||
|
||||
gifs:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_GIFS_MEMORY_LIMIT:-128mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: gifs
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
gifs-shard:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_GIFS_SHARD_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: gifs
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
messages:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_MESSAGES_MEMORY_LIMIT:-128mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
messages-shard:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_MESSAGES_SHARD_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
postgres: {condition: service_healthy}
|
||||
|
||||
unfurl:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_UNFURL_MEMORY_LIMIT:-128mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: unfurl
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
unfurl-shard:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_UNFURL_SHARD_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: unfurl
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
admin:
|
||||
<<: *fluxer-service
|
||||
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-admin:${FLUXER_IMAGE_TAG:-v1}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_ADMIN_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_ADMIN_HOST: 0.0.0.0
|
||||
FLUXER_ADMIN_PORT: "8080"
|
||||
FLUXER_ADMIN_BASE_PATH: /admin
|
||||
FLUXER_API_ENDPOINT: http://api:8080
|
||||
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin
|
||||
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin/oauth2_callback
|
||||
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin
|
||||
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin/oauth2_callback
|
||||
healthcheck:
|
||||
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 60s
|
||||
start_interval: 1s
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
|
||||
@@ -443,9 +725,10 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
edge-data:
|
||||
edge-config:
|
||||
postgres-data:
|
||||
valkey-data:
|
||||
nats-data:
|
||||
meilisearch-data:
|
||||
seaweedfs-data:
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
port: 7880
|
||||
log_level: info
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
use_external_ip: true
|
||||
stun_servers:
|
||||
- stun.l.google.com:19302
|
||||
- stun1.l.google.com:19302
|
||||
|
||||
webhook:
|
||||
api_key: fluxer
|
||||
urls:
|
||||
- http://api:8080/webhooks/livekit
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ":80"
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import reactGoogleTranslate from 'eslint-plugin-react-google-translate';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/coverage/**',
|
||||
'**/*.generated.*',
|
||||
'fluxer_app/src/features/i18n/locales/*/messages.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['fluxer_app/src/**/*.tsx'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './fluxer_app/tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {'react-google-translate': reactGoogleTranslate},
|
||||
rules: {
|
||||
'react-google-translate/no-conditional-text-nodes-with-siblings': [
|
||||
'error',
|
||||
{ignoreParents: ['Trans', 'Plural', 'Select', 'SelectOrdinal']},
|
||||
],
|
||||
'react-google-translate/no-return-text-nodes': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -3,14 +3,16 @@ name = "fluxer_admin"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
anyhow = "1.0.104"
|
||||
axum = { version = "0.8.9", features = ["macros"] }
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["serde"] }
|
||||
cookie = "0.18.1"
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
hmac = "0.13.0"
|
||||
maud = { version = "0.27.0", features = ["axum"] }
|
||||
rand = "0.10"
|
||||
|
||||
+21
-1
@@ -24,6 +24,7 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
|
||||
|
||||
COPY Cargo.lock Cargo.lock
|
||||
COPY fluxer_admin fluxer_admin
|
||||
COPY fluxer_common fluxer_common
|
||||
COPY packages/fonts/manifest.json packages/fonts/manifest.json
|
||||
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
|
||||
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
|
||||
@@ -31,12 +32,17 @@ COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
|
||||
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
|
||||
RUN printf '%s\n' \
|
||||
'[workspace]' \
|
||||
'members = ["fluxer_admin"]' \
|
||||
'members = ["fluxer_admin", "fluxer_common"]' \
|
||||
'resolver = "2"' \
|
||||
'' \
|
||||
'[workspace.package]' \
|
||||
'edition = "2024"' \
|
||||
'license = "AGPL-3.0-or-later"' \
|
||||
'' \
|
||||
'[profile.release]' \
|
||||
'lto = "fat"' \
|
||||
'codegen-units = 1' \
|
||||
'strip = "symbols"' \
|
||||
> Cargo.toml
|
||||
|
||||
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
|
||||
@@ -54,6 +60,20 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
ARG SOURCE_SHA=""
|
||||
ARG SOURCE_DATE=""
|
||||
|
||||
LABEL org.opencontainers.image.title="fluxer-admin"
|
||||
LABEL org.opencontainers.image.description="Fluxer admin console"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.vendor="Fluxer"
|
||||
LABEL org.opencontainers.image.url="https://fluxer.app"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
|
||||
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
|
||||
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
|
||||
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
|
||||
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
|
||||
LABEL app.fluxer.build-version="${BUILD_VERSION}"
|
||||
|
||||
WORKDIR /usr/local/bin
|
||||
|
||||
|
||||
+83
-1
@@ -35,8 +35,9 @@ 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 spec: openapiv3::OpenAPI =
|
||||
let mut spec: openapiv3::OpenAPI =
|
||||
serde_json::from_str(&json_str).expect("failed to parse openapi-admin.json");
|
||||
adapt_progenitor_throttled_errors(&mut spec);
|
||||
|
||||
let mut settings = progenitor::GenerationSettings::new();
|
||||
settings.with_interface(progenitor::InterfaceStyle::Positional);
|
||||
@@ -54,6 +55,87 @@ fn generate_admin_api(manifest_dir: &Path, out_dir: &Path) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Face {
|
||||
css_family: String,
|
||||
weight: u64,
|
||||
|
||||
+10407
-11067
File diff suppressed because it is too large
Load Diff
@@ -41,11 +41,9 @@ pub const BAN_AVATAR_HASH_REMOVE: &str = "ban:avatar_hash:remove";
|
||||
pub const BAN_PROFILE_SUBSTRING_ADD: &str = "ban:profile_substring:add";
|
||||
pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
|
||||
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
|
||||
pub const BILLING_MANAGE_SUBSCRIPTION: &str = "billing:manage_subscription";
|
||||
pub const BILLING_REFUND: &str = "billing:refund";
|
||||
pub const BILLING_VIEW: &str = "billing:view";
|
||||
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
|
||||
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
|
||||
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
|
||||
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
|
||||
pub const BULK_UPDATE_SUSPICIOUS_ACTIVITY: &str = "bulk:update:suspicious_activity";
|
||||
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
|
||||
@@ -124,6 +122,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
ARCHIVE_TRIGGER_GUILD,
|
||||
ARCHIVE_TRIGGER_USER,
|
||||
ARCHIVE_VIEW_ALL,
|
||||
ASSET_PURGE,
|
||||
AUDIT_LOG_VIEW,
|
||||
AUTHENTICATE,
|
||||
JOBS_VIEW,
|
||||
@@ -155,11 +154,9 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
BAN_PROFILE_SUBSTRING_ADD,
|
||||
BAN_PROFILE_SUBSTRING_CHECK,
|
||||
BAN_PROFILE_SUBSTRING_REMOVE,
|
||||
BILLING_MANAGE_SUBSCRIPTION,
|
||||
BILLING_REFUND,
|
||||
BILLING_VIEW,
|
||||
BULK_ADD_GUILD_MEMBERS,
|
||||
BULK_DELETE_USERS,
|
||||
BULK_DELETE_USER_MESSAGES,
|
||||
BULK_UPDATE_GUILD_FEATURES,
|
||||
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
BULK_UPDATE_USER_FLAGS,
|
||||
|
||||
@@ -12,7 +12,6 @@ pub struct I32Flag {
|
||||
|
||||
pub mod user_flag_bits {
|
||||
pub const STAFF: u64 = 1 << 0;
|
||||
pub const CTP_MEMBER: u64 = 1 << 1;
|
||||
pub const PARTNER: u64 = 1 << 2;
|
||||
pub const BUG_HUNTER: u64 = 1 << 3;
|
||||
pub const FRIENDLY_BOT: u64 = 1 << 4;
|
||||
@@ -40,10 +39,6 @@ pub const USER_FLAGS: &[U64Flag] = &[
|
||||
name: "STAFF",
|
||||
value: user_flag_bits::STAFF,
|
||||
},
|
||||
U64Flag {
|
||||
name: "CTP_MEMBER",
|
||||
value: user_flag_bits::CTP_MEMBER,
|
||||
},
|
||||
U64Flag {
|
||||
name: "PARTNER",
|
||||
value: user_flag_bits::PARTNER,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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::types::{CreateAdminApiKeyResponse, ListAdminApiKeyEntry};
|
||||
@@ -12,7 +12,7 @@ impl AdminApiClient {
|
||||
acls: &[String],
|
||||
) -> ApiResult<CreateAdminApiKeyResponse> {
|
||||
let body = generated_types::CreateAdminApiKeyRequest {
|
||||
acls: acls.to_vec(),
|
||||
acls: parse_acls(acls)?,
|
||||
expires_in_days: None,
|
||||
name: generated_types::CreateAdminApiKeyRequestName::try_from(name)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
@@ -35,10 +35,20 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
|
||||
let key_id = snowflake(key_id);
|
||||
self.generated()
|
||||
.delete_admin_api_key(key_id)
|
||||
.delete_admin_api_key(&key_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_acls(acls: &[String]) -> ApiResult<Vec<generated_types::AdminAclType>> {
|
||||
acls.iter()
|
||||
.map(|acl| {
|
||||
generated_types::AdminAclType::try_from(acl.as_str())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -4,35 +4,36 @@ use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LookupApplicationRequest<'a> {
|
||||
application_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ListUserApplicationsRequest<'a> {
|
||||
user_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TransferApplicationOwnershipRequest<'a> {
|
||||
application_id: &'a str,
|
||||
new_owner_id: &'a str,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn lookup_application(&self, application_id: &str) -> ApiResult<Option<Application>> {
|
||||
let body = LookupApplicationRequest { application_id };
|
||||
let resp: LookupApplicationResponse =
|
||||
self.post_typed("/admin/applications/lookup", &body).await?;
|
||||
let resp: LookupApplicationResponse = self
|
||||
.get(
|
||||
&format!(
|
||||
"/admin/applications/{}",
|
||||
urlencoding::encode(application_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.application)
|
||||
}
|
||||
|
||||
pub async fn list_user_applications(&self, user_id: &str) -> ApiResult<Vec<Application>> {
|
||||
let body = ListUserApplicationsRequest { user_id };
|
||||
let resp: super::types::ListUserApplicationsResponse = self
|
||||
.post_typed("/admin/applications/list-by-owner", &body)
|
||||
.await?;
|
||||
let query_params = [("owner_id", user_id)];
|
||||
let resp: super::types::ListUserApplicationsResponse =
|
||||
self.get("/admin/applications", Some(&query_params)).await?;
|
||||
Ok(resp.applications)
|
||||
}
|
||||
|
||||
pub async fn list_guild_applications(&self, guild_id: &str) -> ApiResult<Vec<Application>> {
|
||||
let query_params = [("guild_id", guild_id)];
|
||||
let resp: super::types::ListUserApplicationsResponse =
|
||||
self.get("/admin/applications", Some(&query_params)).await?;
|
||||
Ok(resp.applications)
|
||||
}
|
||||
|
||||
@@ -41,11 +42,15 @@ impl AdminApiClient {
|
||||
application_id: &str,
|
||||
new_owner_id: &str,
|
||||
) -> ApiResult<ApplicationUpdateResponse> {
|
||||
let body = TransferApplicationOwnershipRequest {
|
||||
application_id,
|
||||
new_owner_id,
|
||||
};
|
||||
self.post_typed("/admin/applications/transfer-ownership", &body)
|
||||
.await
|
||||
let body = TransferApplicationOwnershipRequest { new_owner_id };
|
||||
self.patch_typed_with_reason(
|
||||
&format!(
|
||||
"/admin/applications/{}",
|
||||
urlencoding::encode(application_id)
|
||||
),
|
||||
&body,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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::types::{Archive, ArchiveDownloadUrlResponse, ListArchivesResponse};
|
||||
@@ -11,13 +11,12 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::TriggerUserArchiveRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
user_id: snowflake(user_id),
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.trigger_user_archive(&body)
|
||||
.create_admin_user_archive(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -28,13 +27,12 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::TriggerGuildArchiveRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.trigger_guild_archive(&body)
|
||||
.create_admin_guild_archive(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -47,22 +45,31 @@ impl AdminApiClient {
|
||||
include_expired: bool,
|
||||
requested_by: Option<&str>,
|
||||
) -> ApiResult<ListArchivesResponse> {
|
||||
let body = generated_types::ListArchivesRequest {
|
||||
include_expired: Some(include_expired),
|
||||
limit: None,
|
||||
requested_by: requested_by.map(snowflake),
|
||||
subject_id: subject_id.map(snowflake),
|
||||
subject_type: Some(
|
||||
generated_types::ListArchivesRequestSubjectType::try_from(subject_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
),
|
||||
let subject_id = subject_id.filter(|id| !id.is_empty());
|
||||
let search_every_subject_type = subject_type == "all" && subject_id.is_some();
|
||||
let subject_types: &[&str] = if search_every_subject_type {
|
||||
&["user", "guild"]
|
||||
} else {
|
||||
std::slice::from_ref(&subject_type)
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_archives(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let mut archives = Vec::new();
|
||||
for &subject_type in subject_types {
|
||||
let query_params = [
|
||||
("subject_type", subject_type),
|
||||
("subject_id", subject_id.unwrap_or_default()),
|
||||
("requested_by", requested_by.unwrap_or_default()),
|
||||
(
|
||||
"include_expired",
|
||||
if include_expired { "true" } else { "false" },
|
||||
),
|
||||
];
|
||||
match self.get("/admin/archives", Some(&query_params)).await {
|
||||
Ok(ListArchivesResponse { archives: page }) => archives.extend(page),
|
||||
Err(ApiError::Http { status: 403, .. }) if search_every_subject_type => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(ListArchivesResponse { archives })
|
||||
}
|
||||
|
||||
pub async fn get_archive_download_url(
|
||||
@@ -71,15 +78,17 @@ impl AdminApiClient {
|
||||
subject_id: &str,
|
||||
archive_id: &str,
|
||||
) -> ApiResult<ArchiveDownloadUrlResponse> {
|
||||
let subject_type = generated_types::ArchiveSubjectTypeSchema::try_from(subject_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.get_archive_download_url(subject_type, subject_id, archive_id)
|
||||
.get_admin_archive_download(
|
||||
subject_type,
|
||||
&snowflake(subject_id),
|
||||
&snowflake(archive_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// 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};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn purge_assets(&self, ids: &[String]) -> ApiResult<serde_json::Value> {
|
||||
pub async fn purge_assets(
|
||||
&self,
|
||||
guild_id: &str,
|
||||
ids: &[String],
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
|
||||
let response = self
|
||||
.generated()
|
||||
.purge_guild_assets(&body)
|
||||
.purge_admin_guild_assets(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
#[cfg(test)]
|
||||
use super::types::AuditLogEntry;
|
||||
use super::types::AuditLogsListResponse;
|
||||
|
||||
pub struct SearchAuditLogsParams {
|
||||
@@ -23,81 +21,54 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &SearchAuditLogsParams,
|
||||
) -> ApiResult<AuditLogsListResponse> {
|
||||
let body = generated_types::SearchAuditLogsRequest {
|
||||
admin_user_id: nonempty_string(params.admin_user_id.as_deref())
|
||||
.map(generated_types::SnowflakeType::from),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(params.limit, "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
let sort_by = params
|
||||
.sort_by
|
||||
.as_deref()
|
||||
.map(audit_sort_by)
|
||||
.transpose()?
|
||||
.map(|value| value.to_string());
|
||||
let sort_order = params
|
||||
.sort_order
|
||||
.as_deref()
|
||||
.map(audit_sort_order)
|
||||
.transpose()?
|
||||
.map(|value| value.to_string());
|
||||
let limit = params.limit.to_string();
|
||||
let offset = params.offset.to_string();
|
||||
let query_params = [
|
||||
("q", params.query.as_deref().unwrap_or_default()),
|
||||
(
|
||||
"admin_user_id",
|
||||
params.admin_user_id.as_deref().unwrap_or_default(),
|
||||
),
|
||||
offset: Some(i64::from(params.offset)),
|
||||
query: nonempty_string(params.query.as_deref()),
|
||||
sort_by: params.sort_by.as_deref().map(audit_sort_by).transpose()?,
|
||||
sort_order: params
|
||||
.sort_order
|
||||
.as_deref()
|
||||
.map(audit_sort_order)
|
||||
.transpose()?,
|
||||
target_id: nonempty_string(params.target_id.as_deref()),
|
||||
target_type: nonempty_string(params.target_type.as_deref()),
|
||||
};
|
||||
let body = serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
self.post("/admin/audit-logs/search", Some(&body)).await
|
||||
(
|
||||
"target_type",
|
||||
params.target_type.as_deref().unwrap_or_default(),
|
||||
),
|
||||
("target_id", 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()),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/audit-logs", Some(&query_params)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audit_logs_response(
|
||||
response: generated_types::AuditLogsListResponseSchema,
|
||||
) -> 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::AuditLogsListResponseSchemaLogsItem) -> 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::SearchAuditLogsRequestSortBy> {
|
||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
||||
let value = match value {
|
||||
"created_at" => "createdAt",
|
||||
value => value,
|
||||
};
|
||||
generated_types::SearchAuditLogsRequestSortBy::try_from(value)
|
||||
generated_types::ListAdminAuditLogsSortBy::try_from(value)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn audit_sort_order(value: &str) -> ApiResult<generated_types::SearchAuditLogsRequestSortOrder> {
|
||||
generated_types::SearchAuditLogsRequestSortOrder::try_from(value)
|
||||
fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortOrder> {
|
||||
generated_types::ListAdminAuditLogsSortOrder::try_from(value)
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -114,10 +85,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_lossy_audit_totals() {
|
||||
let response = generated_types::AuditLogsListResponseSchema {
|
||||
logs: Vec::new(),
|
||||
total: 1.5,
|
||||
};
|
||||
assert!(audit_logs_response(response).is_err());
|
||||
for total in [serde_json::json!(1.5), serde_json::json!(-1)] {
|
||||
let response = serde_json::json!({"logs": [], "total": total});
|
||||
assert!(serde_json::from_value::<AuditLogsListResponse>(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",
|
||||
"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!(serde_json::to_value(response).unwrap(), json);
|
||||
}
|
||||
}
|
||||
|
||||
+204
-219
@@ -1,215 +1,143 @@
|
||||
// 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::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
};
|
||||
self.generated()
|
||||
.add_email_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"email",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_1: Some(generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_email(&self, email: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
};
|
||||
self.generated()
|
||||
.remove_email_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_email_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
|
||||
self.generated()
|
||||
.add_ip_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_0: Some(generated_types::BanIpRequest { ip: ip.to_owned() }),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
|
||||
self.generated()
|
||||
.remove_ip_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("ip", ip, None).await
|
||||
}
|
||||
|
||||
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
|
||||
let response = self
|
||||
.generated()
|
||||
.check_ip_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("ip", ip, None).await
|
||||
}
|
||||
|
||||
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
let body = suspicious_email_domain_request(domain)?;
|
||||
self.generated()
|
||||
.add_suspicious_email_domain(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
SUSPICIOUS_EMAIL_DOMAIN_LIST,
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_2: Some(suspicious_email_domain_request(domain)?),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
let body = suspicious_email_domain_request(domain)?;
|
||||
self.generated()
|
||||
.remove_suspicious_email_domain(&body)
|
||||
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = suspicious_email_domain_request(domain)?;
|
||||
let response = self
|
||||
.generated()
|
||||
.check_suspicious_email_domain(&body)
|
||||
self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.add_phrase_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"phrase",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_3: Some(generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.remove_phrase_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("phrase", phrase, None).await
|
||||
}
|
||||
|
||||
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_phrase_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("phrase", phrase, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.add_url_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"url",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_4: Some(generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_url(&self, url: &str) -> ApiResult<()> {
|
||||
let body = generated_types::UnbanUrlRequest {
|
||||
url: url.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.remove_url_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("url", url, None).await
|
||||
}
|
||||
|
||||
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::CheckUrlBlocklistRequest {
|
||||
url: url.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_url_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("url", url, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
|
||||
let body = generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
};
|
||||
self.generated()
|
||||
.add_url_domain_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"url-domain",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_5: Some(generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
let body = generated_types::UnbanUrlDomainRequest {
|
||||
domain: domain.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.remove_url_domain_ban(&body)
|
||||
self.delete_blocklist_entry("url-domain", domain, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_url_domain_ban(&self, domain: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_url_domain_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("url-domain", domain, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_file_sha(
|
||||
@@ -217,16 +145,23 @@ impl AdminApiClient {
|
||||
sha256_hex: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
let body = generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_6: Some(generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/add", &body, audit_log_reason)
|
||||
.await
|
||||
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,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_file_sha(
|
||||
@@ -234,23 +169,17 @@ impl AdminApiClient {
|
||||
sha256_hex: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::UnbanFileShaRequest {
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
};
|
||||
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/remove", &body, audit_log_reason)
|
||||
.await
|
||||
self.delete_void_with_reason(
|
||||
&blocklist_entry_path("file-sha", sha256_hex),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::CheckFileShaRequest {
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_file_sha_ban_status(&body)
|
||||
self.check_blocklist_entry("file-sha", sha256_hex, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn bulk_ban_file_shas(
|
||||
@@ -261,78 +190,66 @@ impl AdminApiClient {
|
||||
let body = generated_types::BulkBanFileShasRequest {
|
||||
sha256_list: sha256_list.to_vec(),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bans/file-sha/bulk-add", &body, audit_log_reason)
|
||||
.await
|
||||
self.put_typed_with_reason(
|
||||
"/admin/blocklists/file-sha/entries",
|
||||
&body,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
};
|
||||
self.generated()
|
||||
.add_avatar_hash_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"avatar-hash",
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_7: Some(generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
||||
let body = generated_types::CheckAvatarHashRequest {
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
};
|
||||
self.generated()
|
||||
.remove_avatar_hash_ban(&body)
|
||||
self.delete_blocklist_entry("avatar-hash", hash_short, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_avatar_hash_ban(&self, hash_short: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::CheckAvatarHashRequest {
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_avatar_hash_ban_status(&body)
|
||||
self.check_blocklist_entry("avatar-hash", hash_short, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
|
||||
let body = generated_types::BanUserAvatarRequest::default();
|
||||
let response = self
|
||||
.generated()
|
||||
.ban_user_avatar(
|
||||
&generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
.ban_admin_user_avatar(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
||||
let body = profile_substring_request(scope, substring)?;
|
||||
self.generated()
|
||||
.add_profile_substring_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
PROFILE_SUBSTRING_LIST,
|
||||
generated_types::AdminBlocklistEntryCreateRequest {
|
||||
subtype_8: Some(profile_substring_request(scope, substring)?),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
||||
let body = profile_substring_request(scope, substring)?;
|
||||
self.generated()
|
||||
.remove_profile_substring_ban(&body)
|
||||
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_profile_substring_ban(
|
||||
@@ -340,16 +257,84 @@ impl AdminApiClient {
|
||||
scope: &str,
|
||||
substring: &str,
|
||||
) -> ApiResult<BanCheckResult> {
|
||||
let body = profile_substring_request(scope, substring)?;
|
||||
self.check_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_blocklist_entry(
|
||||
&self,
|
||||
list_type: &str,
|
||||
body: generated_types::AdminBlocklistEntryCreateRequest,
|
||||
) -> ApiResult<()> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
self.generated()
|
||||
.create_admin_blocklist_entry(list_type, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_blocklist_entry(
|
||||
&self,
|
||||
list_type: &str,
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
let scope = scope.map(blocklist_delete_scope).transpose()?;
|
||||
self.generated()
|
||||
.delete_admin_blocklist_entry(list_type, entry_value, scope)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_blocklist_entry(
|
||||
&self,
|
||||
list_type: &str,
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
) -> ApiResult<BanCheckResult> {
|
||||
let list_type = blocklist_list_type(list_type)?;
|
||||
let scope = scope.map(blocklist_get_scope).transpose()?;
|
||||
let response = self
|
||||
.generated()
|
||||
.check_profile_substring_ban_status(&body)
|
||||
.get_admin_blocklist_entry(list_type, entry_value, scope)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
|
||||
format!(
|
||||
"/admin/blocklists/{}/entries/{}",
|
||||
urlencoding::encode(list_type),
|
||||
urlencoding::encode(entry_value)
|
||||
)
|
||||
}
|
||||
|
||||
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
|
||||
generated_types::GetAdminBlocklistEntryScope::try_from(scope)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn blocklist_delete_scope(
|
||||
scope: &str,
|
||||
) -> ApiResult<generated_types::DeleteAdminBlocklistEntryScope> {
|
||||
generated_types::DeleteAdminBlocklistEntryScope::try_from(scope)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn suspicious_email_domain_request(
|
||||
domain: &str,
|
||||
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
BillingOverview, InvoiceListResponse, PaymentListResponse, PaymentMethodListResponse,
|
||||
RefundCancelResponse, SubscriptionResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn get_billing_overview(&self, user_id: &str) -> ApiResult<BillingOverview> {
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_billing_overview(user_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_user_payments(&self, user_id: &str) -> ApiResult<PaymentListResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_billing_list_payments(user_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_user_subscription(&self, user_id: &str) -> ApiResult<SubscriptionResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_billing_get_subscription(user_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_user_payment_methods(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<PaymentMethodListResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_billing_list_payment_methods(user_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_user_invoices(
|
||||
&self,
|
||||
user_id: &str,
|
||||
limit: u32,
|
||||
starting_after: Option<&str>,
|
||||
) -> ApiResult<InvoiceListResponse> {
|
||||
let limit_str = limit.to_string();
|
||||
let mut params: Vec<(&str, &str)> = vec![("limit", &limit_str)];
|
||||
if let Some(sa) = starting_after {
|
||||
params.push(("starting_after", sa));
|
||||
}
|
||||
self.get(
|
||||
&format!("/admin/billing/users/{user_id}/invoices"),
|
||||
Some(¶ms),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn issue_refund(
|
||||
&self,
|
||||
user_id: &str,
|
||||
payment_intent_id: &str,
|
||||
amount_cents: Option<u64>,
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::AdminBillingRefundRequest {
|
||||
amount_cents: amount_cents
|
||||
.map(|value| crate::api::generated::nonzero_u64(value, "amount_cents"))
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
payment_intent_id: payment_intent_id.to_owned(),
|
||||
reason: reason
|
||||
.map(generated_types::AdminBillingRefundRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
self.generated()
|
||||
.admin_billing_refund(user_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refund_policy_cancel_now(
|
||||
&self,
|
||||
user_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<RefundCancelResponse> {
|
||||
let body = generated_types::AdminBillingRefundLatestInvoiceCancelRequest {
|
||||
reason: reason
|
||||
.map(generated_types::AdminBillingRefundLatestInvoiceCancelRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_billing_refund_policy_cancel_now(user_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn cancel_subscription(&self, user_id: &str) -> ApiResult<()> {
|
||||
self.generated()
|
||||
.admin_billing_cancel_subscription(user_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cancel_subscription_immediately(
|
||||
&self,
|
||||
user_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::AdminBillingCancelImmediatelyRequest {
|
||||
reason: reason
|
||||
.map(generated_types::AdminBillingCancelImmediatelyRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
self.generated()
|
||||
.admin_billing_cancel_subscription_now(user_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reactivate_subscription(&self, user_id: &str) -> ApiResult<()> {
|
||||
self.generated()
|
||||
.admin_billing_reactivate_subscription(user_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn end_premium_grace_period(&self, user_id: &str) -> ApiResult<()> {
|
||||
self.generated()
|
||||
.admin_billing_end_premium_grace_period(user_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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::types::BulkJobResponse;
|
||||
@@ -13,12 +13,12 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateUserFlagsRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateUserFlags {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/update-user-flags", &body, audit_log_reason)
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -29,17 +29,13 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateSuspiciousActivityFlagsRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateSuspiciousActivityFlags {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason(
|
||||
"/admin/bulk/update-suspicious-activity-flags",
|
||||
&body,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_update_guild_features(
|
||||
@@ -49,12 +45,12 @@ impl AdminApiClient {
|
||||
remove_features: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateGuildFeaturesRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::UpdateGuildFeatures {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/update-guild-features", &body, audit_log_reason)
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -64,11 +60,23 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkAddGuildMembersRequest {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::AddGuildMembers {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/add-guild-members", &body, audit_log_reason)
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_delete_user_messages(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -80,34 +88,28 @@ impl AdminApiClient {
|
||||
public_reason: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkScheduleUserDeletionRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
"reason_code",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason(
|
||||
"/admin/bulk/schedule-user-deletion",
|
||||
&body,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect()
|
||||
values.iter().map(|value| snowflake(value)).collect()
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
|
||||
+198
-41
@@ -120,7 +120,7 @@ impl AdminApiClient {
|
||||
query_params: Option<&[(&str, &str)]>,
|
||||
) -> ApiResult<T> {
|
||||
let response = Self::send_request(self.request(Method::GET, path, query_params)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned>(
|
||||
@@ -152,7 +152,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_with_reason<T: DeserializeOwned>(
|
||||
@@ -164,7 +164,7 @@ impl AdminApiClient {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::POST, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).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<()> {
|
||||
@@ -188,30 +188,118 @@ impl AdminApiClient {
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
let builder = Self::with_json_body(self.request(Method::PATCH, path, None), body);
|
||||
let response = Self::send_request(builder).await?;
|
||||
self.parse_response(response).await
|
||||
self.patch_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
let builder = Self::with_json_body(self.request(Method::DELETE, path, None), body);
|
||||
let response = Self::send_request(builder).await?;
|
||||
pub async fn patch_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn delete_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||||
Self::check_response_status(response).await.map(drop)
|
||||
}
|
||||
|
||||
async fn check_response_status(response: reqwest::Response) -> ApiResult<reqwest::Response> {
|
||||
if response.status().is_success() {
|
||||
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,
|
||||
})
|
||||
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) -> &crate::api::generated::GeneratedClient {
|
||||
@@ -240,28 +328,16 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_response<T: DeserializeOwned>(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> ApiResult<T> {
|
||||
let status = response.status();
|
||||
if status.as_u16() == 204 {
|
||||
return serde_json::from_value(serde_json::Value::Null)
|
||||
.map_err(|e| ApiError::Parse(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()))?;
|
||||
async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> ApiResult<T> {
|
||||
let response = Self::check_response_status(response).await?;
|
||||
let text = if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
String::new()
|
||||
} else {
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::Network(e.to_string()))?
|
||||
};
|
||||
if text.is_empty() {
|
||||
return serde_json::from_value(serde_json::Value::Null)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()));
|
||||
@@ -330,3 +406,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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,20 +13,21 @@ impl AdminApiClient {
|
||||
duration_quantity: u32,
|
||||
) -> ApiResult<CodesResponse> {
|
||||
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,
|
||||
"duration_quantity",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
duration_type: generated_types::GenerateGiftCodesRequestDurationType::try_from(
|
||||
duration_type,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
duration_type: generated_types::GiftCodeDurationTypeSchema::try_from(duration_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.generate_gift_codes(&body)
|
||||
.create_admin_gift_codes(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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::types::{
|
||||
@@ -13,11 +13,10 @@ impl AdminApiClient {
|
||||
) -> ApiResult<Vec<DiscoveryPendingApplication>> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_pending_discovery_applications()
|
||||
.list_admin_discovery_applications()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
.into_inner()
|
||||
Vec::from(response.into_inner())
|
||||
.into_iter()
|
||||
.map(pending_discovery_application)
|
||||
.collect()
|
||||
@@ -26,11 +25,10 @@ impl AdminApiClient {
|
||||
pub async fn list_discovery_listed_guilds(&self) -> ApiResult<Vec<DiscoveryListedGuild>> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_discovery_listed_guilds()
|
||||
.list_admin_discovery_listings()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
.into_inner()
|
||||
Vec::from(response.into_inner())
|
||||
.into_iter()
|
||||
.map(listed_guild)
|
||||
.collect()
|
||||
@@ -41,16 +39,16 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminReviewRequest {
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Approved {
|
||||
reason: reason
|
||||
.map(generated_types::DiscoveryAdminReviewRequestReason::try_from)
|
||||
.map(generated_types::DiscoveryReviewReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.approve_discovery_application(&guild_id, &body)
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -61,14 +59,14 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: &str,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminRejectRequest {
|
||||
reason: generated_types::DiscoveryAdminRejectRequestReason::try_from(reason)
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::Rejected {
|
||||
reason: generated_types::DiscoveryRejectionReason::try_from(reason)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.reject_discovery_application(&guild_id, &body)
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -79,14 +77,14 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
reason: &str,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let body = generated_types::DiscoveryAdminRemoveRequest {
|
||||
reason: generated_types::DiscoveryAdminRemoveRequestReason::try_from(reason)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.remove_from_discovery(&guild_id, &body)
|
||||
.delete_admin_discovery_listing(&guild_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -16,6 +16,10 @@ pub use inner::types;
|
||||
|
||||
pub use inner::Client as GeneratedClient;
|
||||
|
||||
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> {
|
||||
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 {
|
||||
@@ -32,8 +36,12 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
|
||||
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
|
||||
}
|
||||
|
||||
pub(crate) fn nonzero_u64(value: u64, field: &str) -> Result<std::num::NonZeroU64, String> {
|
||||
std::num::NonZeroU64::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
|
||||
pub(crate) fn deletion_reason_code(
|
||||
value: i32,
|
||||
field: &str,
|
||||
) -> Result<types::DeletionReasonCode, String> {
|
||||
types::DeletionReasonCode::try_from(value)
|
||||
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -122,10 +130,10 @@ mod tests {
|
||||
let response: 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[0].name, "Test Guild");
|
||||
assert_eq!(response.guilds[0].member_count, 42);
|
||||
assert_eq!(*response.guilds[0].member_count, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// 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::types::{ListGuildEmojisResponse, ListGuildStickersResponse};
|
||||
|
||||
impl AdminApiClient {
|
||||
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
|
||||
.generated()
|
||||
.admin_list_guild_emojis(&guild_id)
|
||||
.list_admin_guild_emojis(&guild_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -20,10 +20,10 @@ impl AdminApiClient {
|
||||
&self,
|
||||
guild_id: &str,
|
||||
) -> ApiResult<ListGuildStickersResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let guild_id = snowflake(guild_id);
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_list_guild_stickers(&guild_id)
|
||||
.list_admin_guild_stickers(&guild_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
+81
-157
@@ -1,9 +1,10 @@
|
||||
// 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 super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::reports::SearchReportsParams;
|
||||
use super::types::{
|
||||
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
|
||||
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
|
||||
@@ -17,32 +18,19 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchGuildsResponse> {
|
||||
let body = generated_types::SearchGuildsRequest {
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: Some(query.to_owned()),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.search_guilds(&body)
|
||||
.list_admin_guilds(Some(limit.as_str()), Some(offset.as_str()), Some(query))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
search_guilds_response(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
|
||||
let body = generated_types::LookupGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_guild(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
||||
resp.guild
|
||||
self.lookup_guild(guild_id)
|
||||
.await?
|
||||
.map(GuildInfo::from)
|
||||
.ok_or_else(|| super::client::ApiError::Http {
|
||||
status: 404,
|
||||
@@ -51,12 +39,9 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn lookup_guild(&self, guild_id: &str) -> ApiResult<Option<GuildDetailInfo>> {
|
||||
let body = generated_types::LookupGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_guild(&body)
|
||||
.get_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -69,26 +54,23 @@ impl AdminApiClient {
|
||||
add_features: &[String],
|
||||
remove_features: &[String],
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::UpdateGuildFeaturesRequest {
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
add_features: guild_features(add_features),
|
||||
guild_id: snowflake(guild_id),
|
||||
remove_features: guild_features(remove_features),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_features(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::DeleteGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_delete_guild(&body)
|
||||
.delete_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -99,13 +81,13 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
new_owner_id: &str,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::TransferGuildOwnershipRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
new_owner_id: snowflake(new_owner_id),
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
new_owner_id: Some(snowflake(new_owner_id)),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_transfer_guild_ownership(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -117,44 +99,32 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<ListGuildMembersResponse> {
|
||||
let body = generated_types::ListGuildMembersRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_list_guild_members(&body)
|
||||
.list_admin_guild_members(
|
||||
&snowflake(guild_id),
|
||||
Some(limit.as_str()),
|
||||
Some(offset.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanGuildMemberRequest {
|
||||
ban_duration_seconds: None,
|
||||
delete_message_days: None,
|
||||
delete_message_seconds: None,
|
||||
guild_id: snowflake(guild_id),
|
||||
reason: None,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::BanGuildMemberBody::default();
|
||||
self.generated()
|
||||
.admin_ban_guild_member(&body)
|
||||
.ban_admin_guild_member(&snowflake(guild_id), &snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kick_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::KickGuildMemberRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.kick_guild_member(&body)
|
||||
.kick_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -166,21 +136,21 @@ impl AdminApiClient {
|
||||
limit: Option<u32>,
|
||||
before: Option<&str>,
|
||||
) -> ApiResult<GuildAuditLogResponse> {
|
||||
let body = generated_types::ListGuildAuditLogsRequest {
|
||||
action_type: None,
|
||||
after: None,
|
||||
before: before.map(snowflake),
|
||||
guild_id: snowflake(guild_id),
|
||||
limit: limit
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.map(generated_types::Int32Type::from),
|
||||
user_id: None,
|
||||
};
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.list_guild_audit_logs_admin(&body)
|
||||
.list_admin_guild_audit_logs(
|
||||
&snowflake(guild_id),
|
||||
None,
|
||||
None,
|
||||
before.as_ref(),
|
||||
limit,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -189,15 +159,15 @@ impl AdminApiClient {
|
||||
pub async fn clear_guild_fields(&self, guild_id: &str, fields: &[String]) -> ApiResult<()> {
|
||||
let fields = fields
|
||||
.iter()
|
||||
.map(generated_types::ClearGuildFieldsRequestFieldsItem::try_from)
|
||||
.map(|field| generated_types::UpdateGuildRequestFieldsItem::try_from(field.as_str()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::ClearGuildFieldsRequest {
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
fields,
|
||||
guild_id: snowflake(guild_id),
|
||||
..Default::default()
|
||||
};
|
||||
self.generated()
|
||||
.clear_guild_fields(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -208,10 +178,10 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
settings: &serde_json::Value,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = guild_settings_request(guild_id, settings)?;
|
||||
let body = guild_settings_request(settings)?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_settings(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
guild_update_response(response.into_inner())
|
||||
@@ -222,13 +192,13 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
name: &str,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::UpdateGuildNameRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
name: name.to_owned(),
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
name: Some(name.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_name(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -239,37 +209,27 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
vanity: Option<&str>,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::UpdateGuildVanityRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
vanity_url_code: vanity.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_vanity(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let body = serde_json::json!({"vanity_url_code": vanity});
|
||||
self.patch(
|
||||
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
|
||||
Some(&body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reload_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::ReloadGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.reload_guild(&body)
|
||||
.create_admin_guild_reload(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn shutdown_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::ShutdownGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.shutdown_guild(&body)
|
||||
.create_admin_guild_shutdown(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -280,13 +240,9 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
guild_id: &str,
|
||||
) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::ForceAddUserToGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.force_add_user_to_guild(&body)
|
||||
.add_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -298,53 +254,27 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
let body = generated_types::SearchReportsRequest {
|
||||
category: None,
|
||||
guild_context_id: None,
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: None,
|
||||
report_type: None,
|
||||
reported_channel_id: None,
|
||||
reported_guild_id: Some(snowflake(guild_id)),
|
||||
reported_user_id: None,
|
||||
reporter_id: None,
|
||||
resolved_by_admin_id: None,
|
||||
sort_by: None,
|
||||
sort_order: None,
|
||||
status: None,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.search_reports(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let response = response.into_inner();
|
||||
Ok(SearchReportsResponse {
|
||||
reports: self.generated_value(response.reports)?,
|
||||
total: crate::api::generated::number_to_u64(response.total, "total")
|
||||
.map_err(ApiError::Parse)?,
|
||||
offset: crate::api::generated::number_to_u64(response.offset, "offset")
|
||||
.map_err(ApiError::Parse)?,
|
||||
limit: crate::api::generated::number_to_u64(response.limit, "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reported_guild_id: Some(guild_id),
|
||||
limit,
|
||||
offset,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuildSettingsPatch {
|
||||
content_warning_level: Option<generated_types::ContentWarningLevel>,
|
||||
content_warning_level: Option<generated_types::ContentWarningLevelInput>,
|
||||
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>,
|
||||
explicit_content_filter: Option<generated_types::GuildExplicitContentFilter>,
|
||||
mfa_level: Option<generated_types::GuildMfaLevel>,
|
||||
explicit_content_filter: Option<generated_types::GuildExplicitContentFilterInput>,
|
||||
mfa_level: Option<generated_types::GuildMfaLevelInput>,
|
||||
nsfw: Option<bool>,
|
||||
nsfw_level: Option<generated_types::NsfwLevel>,
|
||||
verification_level: Option<generated_types::GuildVerificationLevel>,
|
||||
nsfw_level: Option<generated_types::NsfwLevelInput>,
|
||||
verification_level: Option<generated_types::GuildVerificationLevelInput>,
|
||||
}
|
||||
|
||||
fn search_guilds_response(
|
||||
@@ -372,7 +302,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
||||
owner_global_name: response.owner_global_name,
|
||||
owner_discriminator: response.owner_discriminator,
|
||||
member_count: crate::api::generated::i64_to_u64(
|
||||
i64::from(response.member_count),
|
||||
i64::from(i32::from(response.member_count)),
|
||||
"member_count",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
@@ -380,7 +310,7 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
||||
nsfw_level: response.nsfw_level.map(i32::from),
|
||||
nsfw: response.nsfw,
|
||||
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,
|
||||
vanity_url_code: None,
|
||||
})
|
||||
@@ -393,9 +323,9 @@ fn guild_update_response(
|
||||
Ok(GuildUpdateResponse {
|
||||
guild: GuildInfo {
|
||||
id: String::from(guild.id),
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
banner: guild.banner,
|
||||
name: String::from(guild.name),
|
||||
icon: guild.icon.map(String::from),
|
||||
banner: guild.banner.map(String::from),
|
||||
owner_id: String::from(guild.owner_id),
|
||||
owner_username: None,
|
||||
owner_global_name: None,
|
||||
@@ -405,11 +335,11 @@ fn guild_update_response(
|
||||
"member_count",
|
||||
)
|
||||
.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: guild.nsfw,
|
||||
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,
|
||||
vanity_url_code: None,
|
||||
},
|
||||
@@ -417,29 +347,24 @@ fn guild_update_response(
|
||||
}
|
||||
|
||||
fn guild_settings_request(
|
||||
guild_id: &str,
|
||||
settings: &serde_json::Value,
|
||||
) -> ApiResult<generated_types::UpdateGuildSettingsRequest> {
|
||||
) -> ApiResult<generated_types::UpdateGuildRequest> {
|
||||
let patch = serde_json::from_value::<GuildSettingsPatch>(settings.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
Ok(generated_types::UpdateGuildSettingsRequest {
|
||||
Ok(generated_types::UpdateGuildRequest {
|
||||
content_warning_level: patch.content_warning_level,
|
||||
content_warning_text: patch.content_warning_text,
|
||||
default_message_notifications: patch.default_message_notifications,
|
||||
disabled_operations: patch.disabled_operations,
|
||||
explicit_content_filter: patch.explicit_content_filter,
|
||||
guild_id: snowflake(guild_id),
|
||||
mfa_level: patch.mfa_level,
|
||||
nsfw: patch.nsfw,
|
||||
nsfw_level: patch.nsfw_level,
|
||||
verification_level: patch.verification_level,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
@@ -459,9 +384,8 @@ mod tests {
|
||||
"nsfw": true,
|
||||
"verification_level": 2,
|
||||
});
|
||||
let request = guild_settings_request("123", &settings).unwrap();
|
||||
let request = guild_settings_request(&settings).unwrap();
|
||||
let json = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(json["guild_id"], "123");
|
||||
assert_eq!(json["disabled_operations"], 5);
|
||||
assert_eq!(json["nsfw"], true);
|
||||
assert_eq!(json["verification_level"], 2);
|
||||
|
||||
@@ -4,19 +4,18 @@ use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
|
||||
PendingRegistrationActionRequest, RegistrationUrlActionRequest,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn get_instance_config(&self) -> ApiResult<InstanceConfigResponse> {
|
||||
self.post("/admin/instance-config/get", None).await
|
||||
self.get("/admin/instance/config", None).await
|
||||
}
|
||||
|
||||
pub async fn update_instance_config(
|
||||
&self,
|
||||
update: &InstanceConfigUpdateRequest,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
self.post_typed("/admin/instance-config/update", update)
|
||||
self.patch_typed_with_reason("/admin/instance/config", update, None)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -24,7 +23,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
request: &InstanceEmailSmtpTestRequest,
|
||||
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
||||
self.post_typed("/admin/instance-config/integrations/smtp/test", request)
|
||||
self.post_typed("/admin/instance/config/smtp-tests", request)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -32,40 +31,49 @@ impl AdminApiClient {
|
||||
&self,
|
||||
request: &CreateRegistrationUrlRequest,
|
||||
) -> ApiResult<CreateRegistrationUrlResponse> {
|
||||
self.post_typed("/admin/instance-config/registration-urls/create", request)
|
||||
self.post_typed("/admin/instance/registration-urls", request)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn revoke_registration_url(&self, id: &str) -> ApiResult<InstanceConfigResponse> {
|
||||
let request = RegistrationUrlActionRequest { id: id.to_owned() };
|
||||
self.post_typed("/admin/instance-config/registration-urls/revoke", &request)
|
||||
.await
|
||||
self.delete_with_reason(
|
||||
&format!(
|
||||
"/admin/instance/registration-urls/{}",
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn approve_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let request = PendingRegistrationActionRequest {
|
||||
user_id: user_id.to_owned(),
|
||||
};
|
||||
self.post_typed(
|
||||
"/admin/instance-config/pending-registrations/approve",
|
||||
&request,
|
||||
)
|
||||
.await
|
||||
self.decide_pending_registration(user_id, "approved").await
|
||||
}
|
||||
|
||||
pub async fn reject_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let request = PendingRegistrationActionRequest {
|
||||
user_id: user_id.to_owned(),
|
||||
};
|
||||
self.post_typed(
|
||||
"/admin/instance-config/pending-registrations/reject",
|
||||
&request,
|
||||
self.decide_pending_registration(user_id, "rejected").await
|
||||
}
|
||||
|
||||
async fn decide_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
status: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let body = serde_json::json!({"status": status});
|
||||
self.patch_with_reason(
|
||||
&format!(
|
||||
"/admin/instance/pending-registrations/{}",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 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, ApiError, ApiResult};
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
|
||||
pub struct ListJobsParams {
|
||||
@@ -16,51 +16,32 @@ pub struct ListJobsParams {
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_jobs(&self, params: &ListJobsParams) -> ApiResult<ListJobsResponse> {
|
||||
let cursor = params
|
||||
.cursor
|
||||
.clone()
|
||||
.map(serde_json::from_value::<generated_types::ListJobsRequestCursor>)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let status = params
|
||||
.status
|
||||
.as_deref()
|
||||
.map(generated_types::ListJobsRequestStatus::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::ListJobsRequest {
|
||||
cursor,
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(params.limit, "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
let cursor = params.cursor.as_ref();
|
||||
let cursor_bucket_day = cursor_field(cursor, "bucket_day");
|
||||
let cursor_created_at = cursor_field(cursor, "created_at");
|
||||
let cursor_job_id = cursor_field(cursor, "job_id");
|
||||
let limit = params.limit.to_string();
|
||||
let max_lookback_days = params.max_lookback_days.to_string();
|
||||
let query_params = [
|
||||
("limit", limit.as_str()),
|
||||
("cursor_bucket_day", cursor_bucket_day.as_str()),
|
||||
("cursor_created_at", cursor_created_at.as_str()),
|
||||
("cursor_job_id", cursor_job_id.as_str()),
|
||||
("max_lookback_days", max_lookback_days.as_str()),
|
||||
("status", params.status.as_deref().unwrap_or_default()),
|
||||
("task_type", params.task_type.as_deref().unwrap_or_default()),
|
||||
(
|
||||
"requested_by_user_id",
|
||||
params.requested_by_user_id.as_deref().unwrap_or_default(),
|
||||
),
|
||||
max_lookback_days: Some(
|
||||
crate::api::generated::nonzero_u32(params.max_lookback_days, "max_lookback_days")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
requested_by_user_id: params
|
||||
.requested_by_user_id
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from),
|
||||
status,
|
||||
task_type: params.task_type.clone(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_jobs(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
];
|
||||
self.get("/admin/jobs", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
|
||||
let body = generated_types::GetJobRequest {
|
||||
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_job(&body)
|
||||
.get_admin_job(&snowflake(job_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -71,19 +52,28 @@ impl AdminApiClient {
|
||||
job_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<CancelJobResponse> {
|
||||
let body = generated_types::CancelJobRequest {
|
||||
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/jobs/cancel", &body, audit_log_reason)
|
||||
.await
|
||||
self.put_with_reason(
|
||||
&format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_active_jobs(&self) -> ApiResult<ActiveJobsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_active_jobs()
|
||||
.list_admin_active_jobs()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String {
|
||||
cursor
|
||||
.and_then(|cursor| cursor.get(field))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ use super::types::{LimitConfigResponse, LimitConfigUpdateRequest};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn get_limit_config(&self) -> ApiResult<LimitConfigResponse> {
|
||||
self.post("/admin/limit-config/get", Some(&serde_json::json!({})))
|
||||
.await
|
||||
self.get("/admin/limit-config", None).await
|
||||
}
|
||||
|
||||
pub async fn update_limit_config(
|
||||
&self,
|
||||
request: &LimitConfigUpdateRequest,
|
||||
) -> ApiResult<LimitConfigResponse> {
|
||||
self.post_typed("/admin/limit-config/update", request).await
|
||||
self.put_typed_with_reason("/admin/limit-config", request, None)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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::types::{
|
||||
@@ -16,12 +16,16 @@ impl AdminApiClient {
|
||||
message_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::DeleteMessageRequest {
|
||||
channel_id: snowflake(channel_id),
|
||||
message_id: snowflake(message_id),
|
||||
};
|
||||
let _: serde_json::Value = self
|
||||
.post_typed_with_reason("/admin/messages/delete", &body, audit_log_reason)
|
||||
.delete_with_reason(
|
||||
&format!(
|
||||
"/admin/channels/{}/messages/{}",
|
||||
urlencoding::encode(channel_id),
|
||||
urlencoding::encode(message_id)
|
||||
),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,7 +43,8 @@ impl AdminApiClient {
|
||||
attachment_id: snowflake(attachment_id),
|
||||
channel_id: snowflake(channel_id),
|
||||
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),
|
||||
reporter_full_name:
|
||||
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
|
||||
@@ -50,7 +55,7 @@ impl AdminApiClient {
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.report_message_attachment_to_ncmec(&body)
|
||||
.create_admin_ncmec_report(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -62,17 +67,14 @@ impl AdminApiClient {
|
||||
message_id: &str,
|
||||
context_limit: u32,
|
||||
) -> ApiResult<LookupMessageResponse> {
|
||||
let body = generated_types::LookupMessageRequest {
|
||||
channel_id: snowflake(channel_id),
|
||||
context_limit: Some(
|
||||
crate::api::generated::nonzero_u32(context_limit, "context_limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
message_id: snowflake(message_id),
|
||||
};
|
||||
let context_limit = context_limit.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_message(&body)
|
||||
.get_admin_message(
|
||||
&snowflake(channel_id),
|
||||
&snowflake(message_id),
|
||||
Some(context_limit.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -86,16 +88,13 @@ impl AdminApiClient {
|
||||
let entries = entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<generated_types::MessageShredRequestEntriesItem>)
|
||||
.map(serde_json::from_value::<generated_types::AdminUserMessageShredRequestEntriesItem>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::MessageShredRequest {
|
||||
entries,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserMessageShredRequest { entries };
|
||||
let response = self
|
||||
.generated()
|
||||
.queue_message_shred(&body)
|
||||
.shred_admin_user_messages(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -106,13 +105,10 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
dry_run: bool,
|
||||
) -> ApiResult<DeleteAllUserMessagesResponse> {
|
||||
let body = generated_types::DeleteAllUserMessagesRequest {
|
||||
dry_run: Some(dry_run),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let dry_run = if dry_run { "true" } else { "false" };
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_all_user_messages(&body)
|
||||
.delete_admin_user_messages(&snowflake(user_id), Some(dry_run))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -122,12 +118,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
job_id: &str,
|
||||
) -> ApiResult<MessageShredStatusResponse> {
|
||||
let body = generated_types::MessageShredStatusRequest {
|
||||
job_id: job_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_message_shred_status(&body)
|
||||
.get_admin_message_shred(&snowflake(job_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -140,18 +133,20 @@ impl AdminApiClient {
|
||||
filename: &str,
|
||||
context_limit: u32,
|
||||
) -> ApiResult<LookupMessageResponse> {
|
||||
let body = generated_types::LookupMessageByAttachmentRequest {
|
||||
attachment_id: snowflake(attachment_id),
|
||||
channel_id: snowflake(channel_id),
|
||||
context_limit: Some(
|
||||
crate::api::generated::nonzero_u32(context_limit, "context_limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
filename: filename.to_owned(),
|
||||
};
|
||||
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
|
||||
.generated()
|
||||
.lookup_message_by_attachment(&body)
|
||||
.search_admin_messages(
|
||||
Some(&snowflake(attachment_id)),
|
||||
&snowflake(channel_id),
|
||||
Some(context_limit.as_str()),
|
||||
Some(&filename),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -164,18 +159,17 @@ impl AdminApiClient {
|
||||
after: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<BrowseChannelResponse> {
|
||||
let body = generated_types::BrowseChannelRequest {
|
||||
after: after.map(snowflake),
|
||||
before: before.map(snowflake),
|
||||
channel_id: snowflake(channel_id),
|
||||
limit: limit
|
||||
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
|
||||
.transpose()
|
||||
.map_err(ApiError::Parse)?,
|
||||
};
|
||||
let after = after.map(snowflake);
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit.map(|value| value.to_string());
|
||||
let response = self
|
||||
.generated()
|
||||
.browse_channel_messages(&body)
|
||||
.list_admin_channel_messages(
|
||||
&snowflake(channel_id),
|
||||
after.as_ref(),
|
||||
before.as_ref(),
|
||||
limit.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -187,24 +181,20 @@ impl AdminApiClient {
|
||||
query: &str,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<SearchChannelMessagesResponse> {
|
||||
let body = generated_types::SearchChannelMessagesRequest {
|
||||
channel_id: snowflake(channel_id),
|
||||
limit: limit
|
||||
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
|
||||
.transpose()
|
||||
.map_err(ApiError::Parse)?,
|
||||
query: generated_types::SearchChannelMessagesRequestQuery::try_from(query)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let limit = limit.map(|value| value.to_string());
|
||||
let response = self
|
||||
.generated()
|
||||
.search_channel_messages(&body)
|
||||
.search_admin_messages(
|
||||
None,
|
||||
&snowflake(channel_id),
|
||||
None,
|
||||
None,
|
||||
limit.as_deref(),
|
||||
None,
|
||||
Some(query),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod archives;
|
||||
pub mod assets;
|
||||
pub mod audit;
|
||||
pub mod bans;
|
||||
pub mod billing;
|
||||
pub mod bulk;
|
||||
pub mod client;
|
||||
pub mod codes;
|
||||
|
||||
+178
-114
@@ -1,12 +1,30 @@
|
||||
// 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, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
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: u32,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_reports(
|
||||
&self,
|
||||
@@ -14,28 +32,21 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: Option<u32>,
|
||||
) -> ApiResult<ListReportsResponse> {
|
||||
let body = generated_types::ListReportsRequest {
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: offset.map(i64::from),
|
||||
status: status
|
||||
.map(generated_types::ReportStatus::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_reports(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
|
||||
let query_params = [
|
||||
("status", status),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_report(report_id)
|
||||
.get_admin_report(&snowflake(report_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -47,74 +58,71 @@ impl AdminApiClient {
|
||||
public_comment: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<ResolveReportResponse> {
|
||||
let body = generated_types::ResolveReportRequest {
|
||||
public_comment: public_comment.map(std::borrow::ToOwned::to_owned),
|
||||
report_id: generated_types::SnowflakeType::from(report_id.to_owned()),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/reports/resolve", &body, audit_log_reason)
|
||||
.await
|
||||
let mut body = serde_json::json!({"status": "resolved"});
|
||||
if let Some(public_comment) = public_comment {
|
||||
body["public_comment"] = serde_json::Value::from(public_comment);
|
||||
}
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
query: Option<&str>,
|
||||
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,
|
||||
params: &SearchReportsParams<'_>,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
let body = generated_types::SearchReportsRequest {
|
||||
category: nonempty_string(category),
|
||||
guild_context_id: nonempty_snowflake(guild_context_id),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
let status = params
|
||||
.status
|
||||
.map(report_status)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let report_type = params
|
||||
.report_type
|
||||
.map(report_type_name)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let sort_by = params
|
||||
.sort_by
|
||||
.map(report_sort_by)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let limit = params.limit.to_string();
|
||||
let offset = params.offset.to_string();
|
||||
let query_params = [
|
||||
("q", params.query.unwrap_or_default()),
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", params.category.unwrap_or_default()),
|
||||
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_user_id",
|
||||
params.reported_user_id.unwrap_or_default(),
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: nonempty_string(query),
|
||||
report_type: report_type
|
||||
.map(generated_types::ReportType::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
reported_channel_id: nonempty_snowflake(reported_channel_id),
|
||||
reported_guild_id: nonempty_snowflake(reported_guild_id),
|
||||
reported_user_id: nonempty_snowflake(reported_user_id),
|
||||
reporter_id: nonempty_snowflake(reporter_id),
|
||||
resolved_by_admin_id: nonempty_snowflake(resolved_by_admin_id),
|
||||
sort_by: sort_by
|
||||
.map(generated_types::SearchReportsRequestSortBy::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
sort_order: sort_order
|
||||
.map(generated_types::SearchReportsRequestSortOrder::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status: status
|
||||
.map(generated_types::ReportStatus::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.search_reports(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let response = response.into_inner();
|
||||
Ok(SearchReportsResponse {
|
||||
reports: self.generated_value(response.reports)?,
|
||||
total: response.total as u64,
|
||||
offset: response.offset as u64,
|
||||
limit: response.limit as u64,
|
||||
})
|
||||
(
|
||||
"reported_guild_id",
|
||||
params.reported_guild_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_channel_id",
|
||||
params.reported_channel_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"guild_context_id",
|
||||
params.guild_context_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"resolved_by_admin_id",
|
||||
params.resolved_by_admin_id.unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by),
|
||||
("sort_order", params.sort_order.unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn search_reports_by_reporter(
|
||||
@@ -123,22 +131,12 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(reporter_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reporter_id: Some(reporter_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -148,32 +146,98 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(reported_user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
self.search_reports(&SearchReportsParams {
|
||||
reported_user_id: Some(reported_user_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
fn report_status(value: i32) -> ApiResult<&'static str> {
|
||||
match value {
|
||||
0 => Ok("pending"),
|
||||
1 => Ok("resolved"),
|
||||
other => Err(ApiError::Parse(format!("unknown report status: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty_snowflake(value: Option<&str>) -> Option<generated_types::SnowflakeType> {
|
||||
nonempty_string(value).map(generated_types::SnowflakeType::from)
|
||||
fn report_type_name(value: i32) -> ApiResult<&'static str> {
|
||||
match value {
|
||||
0 => Ok("message"),
|
||||
1 => Ok("user"),
|
||||
2 => Ok("guild"),
|
||||
other => Err(ApiError::Parse(format!("unknown report type: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn report_sort_by(value: &str) -> ApiResult<&'static str> {
|
||||
match value {
|
||||
"created_at" | "createdAt" => Ok("created_at"),
|
||||
"reported_at" | "reportedAt" => Ok("reported_at"),
|
||||
"resolved_at" | "resolvedAt" => Ok("resolved_at"),
|
||||
other => Err(ApiError::Parse(format!(
|
||||
"unknown report sort field: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
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::types::{IndexRefreshStatusResponse, RefreshSearchIndexResponse};
|
||||
@@ -11,15 +11,16 @@ impl AdminApiClient {
|
||||
index_type: &str,
|
||||
guild_id: Option<&str>,
|
||||
) -> 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 {
|
||||
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
index_type: generated_types::RefreshSearchIndexRequestIndexType::try_from(index_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
guild_id: guild_id.map(snowflake),
|
||||
user_id: None,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.refresh_search_index(&body)
|
||||
.create_admin_search_index_refresh(index_type, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -29,14 +30,90 @@ impl AdminApiClient {
|
||||
&self,
|
||||
job_id: &str,
|
||||
) -> ApiResult<IndexRefreshStatusResponse> {
|
||||
let body = generated_types::GetIndexRefreshStatusRequest {
|
||||
job_id: job_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_search_index_refresh_status(&body)
|
||||
.get_admin_search_index_refresh(job_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
index_refresh_status(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn index_refresh_status(
|
||||
response: generated_types::IndexRefreshStatusResponse,
|
||||
) -> ApiResult<IndexRefreshStatusResponse> {
|
||||
match response {
|
||||
generated_types::IndexRefreshStatusResponse::Variant0 { status } => {
|
||||
Ok(IndexRefreshStatusResponse::NotFound {
|
||||
status: status.to_string(),
|
||||
})
|
||||
}
|
||||
generated_types::IndexRefreshStatusResponse::Variant1 {
|
||||
status,
|
||||
index_type,
|
||||
total,
|
||||
indexed,
|
||||
started_at,
|
||||
completed_at,
|
||||
failed_at,
|
||||
error,
|
||||
} => Ok(IndexRefreshStatusResponse::Progress {
|
||||
status: status.to_string(),
|
||||
index_type: Some(index_type),
|
||||
total: total
|
||||
.map(|value| float_to_u64(value, "total"))
|
||||
.transpose()?,
|
||||
indexed: indexed
|
||||
.map(|value| float_to_u64(value, "indexed"))
|
||||
.transpose()?,
|
||||
started_at,
|
||||
completed_at,
|
||||
failed_at,
|
||||
error,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
|
||||
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_a_running_refresh_to_progress() {
|
||||
let json = r#"{"status":"in_progress","index_type":"users","total":50000,"indexed":1200,"started_at":"2026-09-06T00:00:00Z"}"#;
|
||||
let response: generated_types::IndexRefreshStatusResponse =
|
||||
serde_json::from_str(json).unwrap();
|
||||
match index_refresh_status(response).unwrap() {
|
||||
IndexRefreshStatusResponse::Progress {
|
||||
status,
|
||||
index_type,
|
||||
total,
|
||||
indexed,
|
||||
started_at,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(status, "in_progress");
|
||||
assert_eq!(index_type.as_deref(), Some("users"));
|
||||
assert_eq!(total, Some(50_000));
|
||||
assert_eq!(indexed, Some(1_200));
|
||||
assert_eq!(started_at.as_deref(), Some("2026-09-06T00:00:00Z"));
|
||||
}
|
||||
other => panic!("expected a progress status, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_a_missing_refresh_to_not_found() {
|
||||
let json = r#"{"status":"not_found"}"#;
|
||||
let response: generated_types::IndexRefreshStatusResponse =
|
||||
serde_json::from_str(json).unwrap();
|
||||
match index_refresh_status(response).unwrap() {
|
||||
IndexRefreshStatusResponse::NotFound { status } => assert_eq!(status, "not_found"),
|
||||
other => panic!("expected a not found status, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// 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, ApiResult};
|
||||
use super::types::{
|
||||
GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse,
|
||||
ReloadAllGuildsResponse,
|
||||
@@ -10,12 +10,10 @@ use super::types::{
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn get_guild_memory_stats(&self, limit: u32) -> ApiResult<GuildMemoryStatsResponse> {
|
||||
let body = generated_types::GetProcessMemoryStatsRequest {
|
||||
limit: Some(i32::try_from(limit).map_err(|e| ApiError::Parse(e.to_string()))?),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.get_guild_memory_statistics(&body)
|
||||
.get_admin_gateway_memory_stats(Some(limit.as_str()))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -26,15 +24,11 @@ impl AdminApiClient {
|
||||
guild_ids: &[String],
|
||||
) -> ApiResult<ReloadAllGuildsResponse> {
|
||||
let body = generated_types::ReloadGuildsRequest {
|
||||
guild_ids: guild_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
guild_ids: guild_ids.iter().map(|id| snowflake(id)).collect(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.reload_all_specified_guilds(&body)
|
||||
.create_admin_gateway_reload(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -43,7 +37,7 @@ impl AdminApiClient {
|
||||
pub async fn get_node_stats(&self) -> ApiResult<NodeStatsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_gateway_node_statistics()
|
||||
.get_admin_gateway_stats()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -54,7 +48,7 @@ impl AdminApiClient {
|
||||
) -> ApiResult<GatewayVoiceStateCountsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_gateway_voice_state_counts()
|
||||
.get_admin_gateway_voice_state_counts()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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::types::SendSystemDmResponse;
|
||||
@@ -14,15 +14,11 @@ impl AdminApiClient {
|
||||
let body = generated_types::SendSystemDmRequest {
|
||||
content: generated_types::SendSystemDmRequestContent::try_from(content)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_ids: user_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
user_ids: user_ids.iter().map(|id| snowflake(id)).collect(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.send_system_dm(&body)
|
||||
.create_admin_system_dm(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BillingOverview {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PaymentListResponse {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SubscriptionResponse {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PaymentMethodListResponse {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InvoiceListResponse {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct RefundCancelResponse {
|
||||
#[serde(flatten)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::api::generated::types::VoiceNoiseSuppressionBackendSchema as NoiseSuppressionBackend;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceConfigResponse {
|
||||
pub sso: SsoConfigResponse,
|
||||
@@ -18,6 +20,10 @@ pub struct InstanceConfigResponse {
|
||||
pub integrations: InstanceIntegrationsResponse,
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -436,6 +442,125 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
}
|
||||
|
||||
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
|
||||
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 excluded_user_ids: Vec<String>,
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
pub stereo_enabled: bool,
|
||||
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(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
stereo_enabled: false,
|
||||
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 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 stereo_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct 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)]
|
||||
pub struct InstanceRegistrationResponse {
|
||||
pub mode: RegistrationMode,
|
||||
@@ -525,6 +650,10 @@ pub struct InstanceConfigUpdateRequest {
|
||||
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -827,12 +956,88 @@ pub struct CreateRegistrationUrlResponse {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RegistrationUrlActionRequest {
|
||||
pub id: String,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::generated::types as generated_types;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PendingRegistrationActionRequest {
|
||||
pub user_id: String,
|
||||
#[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 delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let noise = serde_json::to_value(noise).expect("serializable noise 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_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_delivery)
|
||||
.expect("serializable generated delivery config"),
|
||||
delivery
|
||||
);
|
||||
for (name, value) in [
|
||||
("VoiceNoiseSuppressionConfigResponse", noise),
|
||||
("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!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ mod admin_api_keys;
|
||||
mod applications;
|
||||
mod archives;
|
||||
mod audit;
|
||||
mod billing;
|
||||
mod bulk;
|
||||
mod codes;
|
||||
mod common;
|
||||
@@ -24,7 +23,6 @@ pub use admin_api_keys::*;
|
||||
pub use applications::*;
|
||||
pub use archives::*;
|
||||
pub use audit::*;
|
||||
pub use billing::*;
|
||||
pub use bulk::*;
|
||||
pub use codes::*;
|
||||
pub use common::*;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct VoiceServer {
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub is_active: Option<bool>,
|
||||
pub soft_connection_limit: Option<i64>,
|
||||
pub vip_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub required_guild_features: Vec<String>,
|
||||
|
||||
+120
-196
@@ -1,6 +1,6 @@
|
||||
// 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::types::{
|
||||
@@ -17,18 +17,19 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchUsersResponse> {
|
||||
let body = generated_types::SearchUsersRequest {
|
||||
email: nonempty_string(email),
|
||||
last_active_ip: nonempty_string(last_active_ip),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: nonempty_string(query),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.search_users(&body)
|
||||
.list_admin_users(
|
||||
nonempty(email),
|
||||
nonempty(last_active_ip),
|
||||
Some(limit.as_str()),
|
||||
Some(offset.as_str()),
|
||||
nonempty(query),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let response = response.into_inner();
|
||||
@@ -39,10 +40,9 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
|
||||
let body = generated_types::LookupUserRequest::Query(query.to_owned());
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_user(&body)
|
||||
.list_admin_users(None, None, None, None, None, Some(query), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -53,27 +53,18 @@ impl AdminApiClient {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let body = generated_types::LookupUserRequest::UserIds(
|
||||
user_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
);
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_user(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
let query_params: Vec<(&str, &str)> = user_ids
|
||||
.iter()
|
||||
.map(|user_id| ("user_id", user_id.as_str()))
|
||||
.collect();
|
||||
let resp: LookupUserResponse = self.get("/admin/users", Some(&query_params)).await?;
|
||||
Ok(resp.users)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::LookupUserRequest::Query(user_id.to_owned());
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_user(&body)
|
||||
.get_admin_user(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -89,7 +80,7 @@ impl AdminApiClient {
|
||||
pub async fn get_current_admin(&self) -> ApiResult<AdminUser> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_authenticated_admin_user()
|
||||
.get_current_admin_user()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: AdminUserMeResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -102,14 +93,13 @@ impl AdminApiClient {
|
||||
add_flags: &[String],
|
||||
remove_flags: &[String],
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdateUserFlagsRequest {
|
||||
let body = generated_types::AdminUserFlagsUpdateRequest {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_user_flags(&body)
|
||||
.update_admin_user_flags(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -124,19 +114,19 @@ impl AdminApiClient {
|
||||
after: Option<&str>,
|
||||
with_counts: Option<bool>,
|
||||
) -> ApiResult<Vec<GuildInfo>> {
|
||||
let body = generated_types::ListUserGuildsRequest {
|
||||
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit.unwrap_or(200), "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
with_counts: Some(with_counts.unwrap_or(true)),
|
||||
};
|
||||
let after = after.map(snowflake);
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit.unwrap_or(200).to_string();
|
||||
let with_counts = bool_param(with_counts.unwrap_or(true));
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_guilds(&body)
|
||||
.list_admin_user_guilds(
|
||||
&snowflake(user_id),
|
||||
after.as_ref(),
|
||||
before.as_ref(),
|
||||
Some(limit.as_str()),
|
||||
Some(with_counts),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: ListUserGuildsResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -147,12 +137,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::ListUserSessionsResponse> {
|
||||
let body = generated_types::ListUserSessionsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_sessions(&body)
|
||||
.list_admin_user_sessions(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -162,12 +149,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<TerminateSessionsResponse> {
|
||||
let body = generated_types::TerminateSessionsRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.terminate_user_sessions(&body)
|
||||
.terminate_admin_user_sessions(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -177,12 +161,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::ListUserRelationshipsResponse> {
|
||||
let body = generated_types::ListUserRelationshipsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_list_user_relationships(&body)
|
||||
.list_admin_user_relationships(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -195,18 +176,18 @@ impl AdminApiClient {
|
||||
after: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<super::types::ListUserDmChannelsResponse> {
|
||||
let body = generated_types::ListUserDmChannelsRequest {
|
||||
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let after = after.map(snowflake);
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit.unwrap_or(50).to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_dm_channels(&body)
|
||||
.list_admin_user_dm_channels(
|
||||
&snowflake(user_id),
|
||||
after.as_ref(),
|
||||
before.as_ref(),
|
||||
Some(limit.as_str()),
|
||||
Some(generated_types::AdminUserDmChannelType::Dm),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -216,12 +197,15 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::ListUserGroupDmChannelsResponse> {
|
||||
let body = generated_types::ListUserGroupDmChannelsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_group_dm_channels(&body)
|
||||
.list_admin_user_dm_channels(
|
||||
&snowflake(user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(generated_types::AdminUserDmChannelType::GroupDm),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -233,14 +217,13 @@ impl AdminApiClient {
|
||||
add_flags: &[i32],
|
||||
remove_flags: &[i32],
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdatePremiumFlagsRequest {
|
||||
let body = generated_types::AdminUserPremiumFlagsUpdateRequest {
|
||||
add_flags: premium_flags(add_flags),
|
||||
remove_flags: premium_flags(remove_flags),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_user_premium_flags(&body)
|
||||
.update_admin_user_premium_flags(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -248,13 +231,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn update_suspicious_flags(&self, user_id: &str, flags: i32) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdateSuspiciousActivityFlagsRequest {
|
||||
let body = generated_types::AdminUserSuspiciousActivityFlagsRequest {
|
||||
flags: generated_types::SuspiciousActivityFlags::from(flags),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_suspicious_activity_flags(&body)
|
||||
.update_admin_user_suspicious_activity_flags(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -262,13 +244,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_user_acls(&self, user_id: &str, acls: &[String]) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserAclsRequest {
|
||||
acls: acls.to_vec(),
|
||||
user_id: snowflake(user_id),
|
||||
let body = generated_types::AdminUserAclsRequest {
|
||||
acls: super::admin_api_keys::parse_acls(acls)?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_acls(&body)
|
||||
.set_admin_user_acls(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -276,13 +257,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_user_traits(&self, user_id: &str, traits: &[String]) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserTraitsRequest {
|
||||
let body = generated_types::AdminUserTraitsRequest {
|
||||
traits: traits.to_vec(),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_traits(&body)
|
||||
.set_admin_user_traits(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -290,34 +270,25 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn disable_mfa(&self, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::DisableMfaRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.disable_user_mfa(&body)
|
||||
.disable_admin_user_mfa(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resend_verification_email(&self, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::ResendVerificationEmailRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.admin_resend_verification_email(&body)
|
||||
.resend_admin_user_verification_email(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn verify_email(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::VerifyUserEmailRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.verify_user_email(&body)
|
||||
.verify_admin_user_email(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -329,13 +300,10 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
has_verified_phone: bool,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdateHasVerifiedPhoneRequest {
|
||||
has_verified_phone,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserPhoneVerificationRequest { has_verified_phone };
|
||||
let response = self
|
||||
.generated()
|
||||
.update_user_has_verified_phone(&body)
|
||||
.update_admin_user_phone_verification(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -349,16 +317,15 @@ impl AdminApiClient {
|
||||
) -> ApiResult<AdminUser> {
|
||||
let fields = fields
|
||||
.iter()
|
||||
.map(generated_types::ClearUserFieldsRequestFieldsItem::try_from)
|
||||
.map(|field| {
|
||||
generated_types::AdminUserClearFieldsRequestFieldsItem::try_from(field.as_str())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::ClearUserFieldsRequest {
|
||||
fields,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserClearFieldsRequest { fields };
|
||||
let response = self
|
||||
.generated()
|
||||
.clear_user_fields(&body)
|
||||
.clear_admin_user_profile_fields(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -366,13 +333,10 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserBotStatusRequest {
|
||||
bot: is_bot,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_bot_status(&body)
|
||||
.set_admin_user_bot_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -380,13 +344,10 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserSystemStatusRequest {
|
||||
system: is_system,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_system_status(&body)
|
||||
.set_admin_user_system_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -399,18 +360,14 @@ impl AdminApiClient {
|
||||
username: &str,
|
||||
discriminator: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ChangeUsernameRequest {
|
||||
let body = generated_types::AdminUserUsernameUpdateRequest {
|
||||
discriminator: discriminator
|
||||
.map(generated_types::DiscriminatorType::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_id: snowflake(user_id),
|
||||
username: generated_types::UsernameType::try_from(username)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
.map(|value| generated_types::DiscriminatorType::String(value.to_owned())),
|
||||
username: generated_types::UsernameType::from(username.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.change_user_username(&body)
|
||||
.update_admin_user_username(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -418,13 +375,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn change_email(&self, user_id: &str, email: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ChangeEmailRequest {
|
||||
let body = generated_types::AdminUserEmailUpdateRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.change_user_email(&body)
|
||||
.update_admin_user_email(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -438,25 +394,26 @@ impl AdminApiClient {
|
||||
reason: Option<&str>,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::TempBanUserRequest {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
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),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
.post_typed_with_reason("/admin/users/temp-ban", &body, private_reason)
|
||||
.put_typed_with_reason(
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
&body,
|
||||
private_reason,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn unban_user(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::DisableMfaRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.unban_user(&body)
|
||||
.unban_admin_user(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -470,18 +427,20 @@ impl AdminApiClient {
|
||||
public_reason: Option<&str>,
|
||||
days_until_deletion: u32,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ScheduleAccountDeletionRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
days_until_deletion: crate::api::generated::nonzero_u32(
|
||||
days_until_deletion,
|
||||
"days_until_deletion",
|
||||
)
|
||||
.map_err(ApiError::Parse)?
|
||||
.into(),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code,
|
||||
user_id: snowflake(user_id),
|
||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||
.map_err(ApiError::Parse)?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.schedule_account_deletion(&body)
|
||||
.schedule_admin_user_deletion(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -489,12 +448,9 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::DisableMfaRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.cancel_account_deletion(&body)
|
||||
.cancel_admin_user_deletion(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -502,13 +458,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ChangeDobRequest {
|
||||
let body = generated_types::AdminUserDobUpdateRequest {
|
||||
date_of_birth: dob.to_owned(),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.change_user_dob(&body)
|
||||
.update_admin_user_date_of_birth(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -516,11 +471,8 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn send_password_reset(&self, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::SendPasswordResetRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.send_password_reset(&body)
|
||||
.send_admin_user_password_reset(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -532,14 +484,10 @@ impl AdminApiClient {
|
||||
target_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::RemoveUserRelationshipRequest {
|
||||
category: generated_types::RemoveUserRelationshipRequestCategory::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
target_user_id: snowflake(target_id),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
self.generated()
|
||||
.remove_user_relationship(&body)
|
||||
.remove_admin_user_relationship(&snowflake(user_id), &snowflake(target_id), category)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -550,16 +498,11 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
|
||||
let body = generated_types::RemoveUserRelationshipsByCategoryRequest {
|
||||
category: generated_types::RemoveUserRelationshipsByCategoryRequestCategory::try_from(
|
||||
category,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let category = generated_types::RelationshipCategoryEnum::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.remove_user_relationships_by_category(&body)
|
||||
.clear_admin_user_relationships(&snowflake(user_id), category)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -570,12 +513,8 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
credential_id: &str,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::DeleteWebAuthnCredentialRequest {
|
||||
credential_id: credential_id.to_owned(),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.delete_user_webauthn_credential(&body)
|
||||
.delete_admin_user_webauthn_credential(&snowflake(user_id), credential_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -586,17 +525,10 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<super::types::ListUserChangeLogResponse> {
|
||||
let body = generated_types::ListUserChangeLogRequest {
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
page_token: None,
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let limit = limit.unwrap_or(50).to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.get_user_change_log(&body)
|
||||
.list_admin_user_change_log(&snowflake(user_id), Some(limit.as_str()), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -606,24 +538,18 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::WebAuthnCredentialListResponse> {
|
||||
let body = generated_types::ListWebAuthnCredentialsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_webauthn_credentials(&body)
|
||||
.list_admin_user_webauthn_credentials(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn cancel_bulk_message_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::CancelBulkMessageDeletionRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_cancel_bulk_message_deletion(&body)
|
||||
.cancel_admin_user_message_deletion(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -631,14 +557,12 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
generated_types::SnowflakeType::from(value.to_owned())
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> Vec<generated_types::UserFlags> {
|
||||
|
||||
+145
-48
@@ -14,12 +14,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
include_servers: bool,
|
||||
) -> ApiResult<ListVoiceRegionsResponse> {
|
||||
let body = generated_types::ListVoiceRegionsRequest {
|
||||
include_servers: Some(include_servers),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_voice_regions(&body)
|
||||
.list_admin_voice_regions(Some(bool_param(include_servers)))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -30,13 +27,9 @@ impl AdminApiClient {
|
||||
id: &str,
|
||||
include_servers: bool,
|
||||
) -> ApiResult<GetVoiceRegionResponse> {
|
||||
let body = generated_types::GetVoiceRegionRequest {
|
||||
id: id.to_owned(),
|
||||
include_servers: Some(include_servers),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_voice_region(&body)
|
||||
.get_admin_voice_region(id, Some(bool_param(include_servers)))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -51,7 +44,7 @@ impl AdminApiClient {
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.create_voice_region(&body)
|
||||
.create_admin_voice_region(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -61,34 +54,30 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_voice_region(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let region_id = required_field(params, "id")?;
|
||||
let body = voice_request_body(params, &["id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
|
||||
let body = generated_types::DeleteVoiceRegionRequest { id: id.to_owned() };
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_voice_region(&body)
|
||||
.delete_admin_voice_region(id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn list_voice_servers(&self, region_id: &str) -> ApiResult<ListVoiceServersResponse> {
|
||||
let body = generated_types::ListVoiceServersRequest {
|
||||
region_id: region_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_voice_servers(&body)
|
||||
.list_admin_voice_servers(region_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -99,13 +88,9 @@ impl AdminApiClient {
|
||||
region_id: &str,
|
||||
server_id: &str,
|
||||
) -> ApiResult<GetVoiceServerResponse> {
|
||||
let body = generated_types::GetVoiceServerRequest {
|
||||
region_id: region_id.to_owned(),
|
||||
server_id: server_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_voice_server(&body)
|
||||
.get_admin_voice_server(region_id, server_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -115,12 +100,15 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<CreateVoiceServerResponse> {
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body = serde_json::from_value::<generated_types::CreateVoiceServerRequestBody>(
|
||||
voice_request_body(params, &["region_id"])?,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.create_voice_server(&body)
|
||||
.create_admin_voice_server(®ion_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -130,15 +118,21 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceServerResponse> {
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_voice_server(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
let server_id = required_field(params, "server_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body = voice_request_body(params, &["region_id", "server_id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
&format!(
|
||||
"/admin/voice/regions/{}/servers/{}",
|
||||
urlencoding::encode(®ion_id),
|
||||
urlencoding::encode(&server_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_server(
|
||||
@@ -146,15 +140,118 @@ impl AdminApiClient {
|
||||
region_id: &str,
|
||||
server_id: &str,
|
||||
) -> ApiResult<DeleteVoiceResponse> {
|
||||
let body = generated_types::DeleteVoiceServerRequest {
|
||||
region_id: region_id.to_owned(),
|
||||
server_id: server_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_voice_server(&body)
|
||||
.delete_admin_voice_server(region_id, server_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
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<()> {
|
||||
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
||||
if has_coordinate("latitude") == has_coordinate("longitude") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Parse(
|
||||
"latitude and longitude must both be set or both be left empty".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String> {
|
||||
params
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+137
-23
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_common::config::normalize_public_endpoint_from_env;
|
||||
use std::env;
|
||||
|
||||
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
|
||||
@@ -40,40 +41,47 @@ pub enum RuntimeEnv {
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
pub fn from_env() -> Self {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
|
||||
let admin_endpoint = trim_trailing_slash(&read_env(
|
||||
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
"https://admin.fluxer.app",
|
||||
));
|
||||
let oauth_redirect_uri = read_env_preferred(
|
||||
)));
|
||||
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred(
|
||||
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
|
||||
&format!("{admin_endpoint}/oauth2_callback"),
|
||||
));
|
||||
let secret_key_base = read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "");
|
||||
anyhow::ensure!(
|
||||
!secret_key_base.trim().is_empty(),
|
||||
"FLUXER_ADMIN_SECRET_KEY_BASE is required"
|
||||
);
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
|
||||
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
|
||||
port: read_env("FLUXER_ADMIN_PORT", "3020")
|
||||
.parse()
|
||||
.unwrap_or(3020),
|
||||
secret_key_base: read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "development-admin-secret"),
|
||||
secret_key_base,
|
||||
base_path,
|
||||
api_endpoint: trim_trailing_slash(&read_env(
|
||||
"FLUXER_API_ENDPOINT",
|
||||
"https://api.fluxer.app",
|
||||
)),
|
||||
media_endpoint: trim_trailing_slash(&read_env(
|
||||
media_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
|
||||
"FLUXER_MEDIA_ENDPOINT",
|
||||
"https://media.fluxer.app",
|
||||
))),
|
||||
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
|
||||
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
|
||||
)),
|
||||
static_cdn_endpoint: trim_trailing_slash(&read_env("FLUXER_STATIC_CDN_ENDPOINT", "")),
|
||||
|
||||
admin_endpoint,
|
||||
web_app_endpoint: trim_trailing_slash(&read_env(
|
||||
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
|
||||
"FLUXER_APP_ENDPOINT",
|
||||
"https://app.fluxer.app",
|
||||
)),
|
||||
))),
|
||||
kv_url: read_env("FLUXER_KV_URL", ""),
|
||||
oauth_client_id: read_env(
|
||||
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
|
||||
@@ -107,7 +115,7 @@ impl AdminConfig {
|
||||
.trim()
|
||||
.to_ascii_lowercase(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_dev(&self) -> bool {
|
||||
@@ -117,6 +125,15 @@ impl AdminConfig {
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.env == RuntimeEnv::Production
|
||||
}
|
||||
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
|
||||
pub fn admin_origin(&self) -> Option<String> {
|
||||
let origin = url::Url::parse(&self.admin_endpoint).ok()?.origin();
|
||||
origin.is_tuple().then(|| origin.ascii_serialization())
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
@@ -166,6 +183,41 @@ pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const MANAGED_ENV: [&str; 11] = [
|
||||
"FLUXER_ENV",
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
|
||||
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
|
||||
"FLUXER_MASTER_CONFIG",
|
||||
"FLUXER_APP_ENDPOINT",
|
||||
"FLUXER_MEDIA_ENDPOINT",
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
"FLUXER_BASE_DOMAIN",
|
||||
];
|
||||
|
||||
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
for name in MANAGED_ENV {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_ORIGIN") };
|
||||
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
|
||||
for (name, value) in vars {
|
||||
unsafe { env::set_var(name, value) };
|
||||
}
|
||||
let config = AdminConfig::from_env().expect("config loads with a secret");
|
||||
for (name, _) in vars {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_strips_trailing_slashes() {
|
||||
@@ -287,18 +339,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn from_env_uses_defaults() {
|
||||
for var in &[
|
||||
"FLUXER_ENV",
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
|
||||
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
|
||||
"FLUXER_MASTER_CONFIG",
|
||||
] {
|
||||
unsafe { env::remove_var(var) };
|
||||
}
|
||||
let config = AdminConfig::from_env();
|
||||
let config = config_from_env(&[]);
|
||||
assert_eq!(config.env, RuntimeEnv::Development);
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 3020);
|
||||
@@ -308,4 +349,77 @@ mod tests {
|
||||
"https://admin.fluxer.app/oauth2_callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_default_public_port_reaches_the_public_endpoints() {
|
||||
let config = config_from_env(&[
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "19080"),
|
||||
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
|
||||
("FLUXER_APP_ENDPOINT", "http://fluxer.example"),
|
||||
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
|
||||
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
|
||||
(
|
||||
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
|
||||
"http://fluxer.example/admin/oauth2_callback",
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(config.admin_endpoint, "http://fluxer.example:19080/admin");
|
||||
assert_eq!(config.media_endpoint, "http://fluxer.example:19080/media");
|
||||
assert_eq!(config.web_app_endpoint, "http://fluxer.example:19080");
|
||||
assert_eq!(config.static_cdn_endpoint, "https://cdn.example.net");
|
||||
assert_eq!(
|
||||
config.oauth_redirect_uri,
|
||||
format!("{}/oauth2_callback", config.admin_endpoint)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_default_public_port_leaves_the_public_endpoints_alone() {
|
||||
let config = config_from_env(&[
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "443"),
|
||||
("FLUXER_ADMIN_ENDPOINT", "https://fluxer.example/admin"),
|
||||
("FLUXER_APP_ENDPOINT", "https://fluxer.example"),
|
||||
("FLUXER_MEDIA_ENDPOINT", "https://fluxer.example/media"),
|
||||
("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"),
|
||||
(
|
||||
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
|
||||
"https://fluxer.example/admin/oauth2_callback",
|
||||
),
|
||||
]);
|
||||
|
||||
assert_eq!(config.admin_endpoint, "https://fluxer.example/admin");
|
||||
assert_eq!(config.media_endpoint, "https://fluxer.example/media");
|
||||
assert_eq!(config.web_app_endpoint, "https://fluxer.example");
|
||||
assert_eq!(config.static_cdn_endpoint, "https://fluxer.example");
|
||||
assert_eq!(
|
||||
config.oauth_redirect_uri,
|
||||
"https://fluxer.example/admin/oauth2_callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_oauth_redirect_uri_matches_the_api_derived_admin_endpoint() {
|
||||
let config = config_from_env(&[
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "19080"),
|
||||
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
|
||||
(
|
||||
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
|
||||
"http://fluxer.example/admin/oauth2_callback",
|
||||
),
|
||||
]);
|
||||
|
||||
let api_admin_endpoint = fluxer_common::config::normalize_public_endpoint(
|
||||
"http://fluxer.example/admin",
|
||||
"fluxer.example",
|
||||
Some(19080),
|
||||
);
|
||||
assert_eq!(
|
||||
config.oauth_redirect_uri,
|
||||
format!("{api_admin_endpoint}/oauth2_callback")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = AdminConfig::from_env();
|
||||
let config = AdminConfig::from_env()?;
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let router = build_router(config);
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ async fn fetch_admin_user(
|
||||
config: &crate::config::AdminConfig,
|
||||
session: &Session,
|
||||
) -> AdminFetchResult {
|
||||
let url = format!("{}/admin/users/me", config.api_endpoint);
|
||||
let url = format!("{}/admin/users/@me", config.api_endpoint);
|
||||
let response =
|
||||
match crate::api::client::with_proxy_client_ip_header(http_client.get(&url), config)
|
||||
.header("Authorization", format!("Bearer {}", session.access_token))
|
||||
|
||||
@@ -27,8 +27,7 @@ pub async fn csrf_protection(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let is_production = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
|
||||
let user_id = request
|
||||
.extensions()
|
||||
@@ -50,7 +49,7 @@ pub async fn csrf_protection(
|
||||
.iter()
|
||||
.any(|suffix| path.ends_with(suffix));
|
||||
if !is_ignored {
|
||||
if !is_same_site_request(&request, &admin_endpoint) {
|
||||
if !is_same_site_request(&request, config.admin_origin().as_deref()) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let header_token = extract_csrf_header(&request);
|
||||
@@ -76,35 +75,47 @@ pub async fn csrf_protection(
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
let cookie_name = if is_production {
|
||||
let cookie_name = if secure_cookies {
|
||||
HOST_CSRF_COOKIE_NAME
|
||||
} else {
|
||||
CSRF_COOKIE_NAME
|
||||
};
|
||||
let secure = if is_production { "; Secure" } else { "" };
|
||||
let secure = if secure_cookies { "; Secure" } else { "" };
|
||||
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
if secure_cookies
|
||||
&& let Ok(value) = HeaderValue::from_str(&format!(
|
||||
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
|
||||
))
|
||||
{
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
fn extract_csrf_cookie(request: &Request) -> Option<String> {
|
||||
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
|
||||
let mut legacy = None;
|
||||
for pair in cookie_header.split(';') {
|
||||
let pair = pair.trim();
|
||||
let value = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="));
|
||||
if let Some(value) = value {
|
||||
if let Some(value) = pair.strip_prefix("__Host-csrf_token=") {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_owned());
|
||||
}
|
||||
} else if let Some(value) = pair.strip_prefix("csrf_token=")
|
||||
&& legacy.is_none()
|
||||
{
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
legacy = Some(trimmed.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
legacy
|
||||
}
|
||||
|
||||
fn extract_csrf_header(request: &Request) -> Option<String> {
|
||||
@@ -155,7 +166,7 @@ async fn extract_csrf_from_form_body(
|
||||
Ok((request, token))
|
||||
}
|
||||
|
||||
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
fn is_same_site_request(request: &Request, admin_origin: Option<&str>) -> bool {
|
||||
if let Some(site) = request
|
||||
.headers()
|
||||
.get("sec-fetch-site")
|
||||
@@ -168,7 +179,7 @@ fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some(origin) => origin == admin_endpoint,
|
||||
Some(origin) => admin_origin.is_some_and(|expected| origin == expected),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
@@ -187,6 +198,173 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
|
||||
use crate::state::AppState;
|
||||
use axum::{Router, middleware::from_fn_with_state, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
|
||||
AppState::new(AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: String::new(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn csrf_cookies(admin_endpoint: &str) -> Vec<String> {
|
||||
let state = state_with_admin_endpoint(admin_endpoint);
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "ok" }))
|
||||
.layer(from_fn_with_state(state, csrf_protection));
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.expect("router responds");
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.map(|value| value.to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_admin_endpoint_sets_a_host_prefixed_secure_cookie() {
|
||||
let cookies = csrf_cookies("https://example.com/admin").await;
|
||||
assert!(
|
||||
cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
|
||||
&& cookie.contains("; Secure")),
|
||||
"expected a secure __Host- cookie, got {cookies:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_admin_endpoint_sets_a_plain_cookie_without_secure() {
|
||||
let cookies = csrf_cookies("http://example.com/admin").await;
|
||||
assert!(
|
||||
cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("Secure")),
|
||||
"expected a plain csrf_token cookie, got {cookies:?}"
|
||||
);
|
||||
assert!(
|
||||
!cookies.iter().any(|cookie| cookie.contains("__Host-")),
|
||||
"expected no __Host- cookie, got {cookies:?}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn action_status(admin_endpoint: &str, origin: &str) -> StatusCode {
|
||||
let state = state_with_admin_endpoint(admin_endpoint);
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "ok" }).post(|| async { "ok" }))
|
||||
.layer(from_fn_with_state(state, csrf_protection));
|
||||
let issued = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.expect("router responds");
|
||||
let cookie = issued
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.filter_map(|value| value.split(';').next())
|
||||
.find(|pair| pair.contains("csrf_token=") && !pair.ends_with('='))
|
||||
.expect("a csrf cookie is issued")
|
||||
.to_owned();
|
||||
let token = cookie.split_once('=').expect("a cookie value").1.to_owned();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/")
|
||||
.header(header::COOKIE, cookie.as_str())
|
||||
.header(header::ORIGIN, origin)
|
||||
.header(CSRF_HEADER_NAME, token.as_str())
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("router responds");
|
||||
response.status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_on_a_non_default_port_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_foreign_origin_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://evil.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_port_on_the_admin_host_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unparseable_admin_endpoint_fails_closed() {
|
||||
let status = action_status("not-an-endpoint", "https://admin.example.test").await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_explicit_default_port_matches_a_portless_origin() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:443/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
|
||||
@@ -92,9 +92,9 @@ pub fn clear_flash_cookie(response: &mut Response) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, is_production: bool) -> Response {
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
|
||||
let encoded = serialize_flash(&flash);
|
||||
let secure_flag = if is_production { "; Secure" } else { "" };
|
||||
let secure_flag = if secure { "; Secure" } else { "" };
|
||||
let cookie_value = format!(
|
||||
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn admin_api_keys_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async fn admin_api_keys_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
FlashData::success(format!("API key action '{action}' completed.")),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ fn admin_api_key_flash_response(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{}/admin-api-keys", config.base_path),
|
||||
flash_data,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ async fn application_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/applications/{application_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -178,6 +178,6 @@ async fn application_detail_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/applications/{application_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn oauth2_callback_finish(
|
||||
|
||||
let session_cookie_value =
|
||||
session::create_session(&user.id, &token.access_token, &config.secret_key_base);
|
||||
let secure = if config.is_production() {
|
||||
let secure = if config.secure_cookies() {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn gift_codes_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -99,14 +99,14 @@ async fn gift_codes_post(
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let is_prod = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
match client.generate_gift_codes(count, dur_type, dur_qty).await {
|
||||
Ok(result) => {
|
||||
let codes = result.codes.join(",");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes?codes={codes}"),
|
||||
FlashData::success(format!("{} gift code(s) generated", result.codes.len())),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -114,7 +114,7 @@ async fn gift_codes_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes"),
|
||||
FlashData::error("Failed to generate gift codes"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ async fn discovery_approve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -115,7 +115,7 @@ async fn discovery_approve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -131,7 +131,7 @@ async fn discovery_approve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ async fn discovery_reject(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -159,7 +159,7 @@ async fn discovery_reject(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -173,7 +173,7 @@ async fn discovery_reject(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn discovery_remove(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -201,7 +201,7 @@ async fn discovery_remove(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -215,6 +215,6 @@ async fn discovery_remove(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=listed"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,22 +150,6 @@ pub async fn render(
|
||||
},
|
||||
))
|
||||
}
|
||||
"billing" => {
|
||||
if config.self_hosted || !acl::has_permission(admin_acls, acl::BILLING_VIEW) {
|
||||
return None;
|
||||
}
|
||||
let billing = client
|
||||
.get_billing_overview(guild_id)
|
||||
.await
|
||||
.log_error("load guild billing overview")
|
||||
.map(|b| b.data);
|
||||
Some(tabs::billing::billing_tab(
|
||||
config,
|
||||
guild_id,
|
||||
billing.as_ref(),
|
||||
csrf_token,
|
||||
))
|
||||
}
|
||||
"applications" => {
|
||||
if !acl::has_any_permission(
|
||||
admin_acls,
|
||||
@@ -174,7 +158,7 @@ pub async fn render(
|
||||
return None;
|
||||
}
|
||||
let apps = client
|
||||
.list_user_applications(guild_id)
|
||||
.list_guild_applications(guild_id)
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications"))
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn guild_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/guilds/{guild_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -203,7 +203,7 @@ async fn guild_detail_post(
|
||||
} else {
|
||||
format!("{base}/guilds/{guild_id}?tab={tab}")
|
||||
};
|
||||
flash::redirect_with_flash(&redirect, flash, config.is_production())
|
||||
flash::redirect_with_flash(&redirect, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn dispatch_guild_action(
|
||||
@@ -415,7 +415,7 @@ async fn dispatch_guild_action(
|
||||
return FlashData::error("Emoji ID is required");
|
||||
};
|
||||
action_result(
|
||||
client.purge_assets(&[emoji_id]).await,
|
||||
client.purge_assets(guild_id, &[emoji_id]).await,
|
||||
"Emoji deleted",
|
||||
"Failed to delete emoji",
|
||||
)
|
||||
@@ -425,7 +425,7 @@ async fn dispatch_guild_action(
|
||||
return FlashData::error("Sticker ID is required");
|
||||
};
|
||||
action_result(
|
||||
client.purge_assets(&[sticker_id]).await,
|
||||
client.purge_assets(guild_id, &[sticker_id]).await,
|
||||
"Sticker deleted",
|
||||
"Failed to delete sticker",
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn job_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/jobs/{job_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -214,7 +214,7 @@ async fn job_detail_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/jobs/{job_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ pub(crate) async fn messages_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/messages"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -164,7 +164,7 @@ pub(crate) async fn system_dms_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/system-dms"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -184,7 +184,11 @@ pub(crate) async fn system_dms_post(
|
||||
} else {
|
||||
FlashData::error("Recipients and content are required")
|
||||
};
|
||||
flash::redirect_with_flash(&format!("{base}/system-dms"), flash, config.is_production())
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/system-dms"),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn bulk_actions_post(
|
||||
@@ -201,7 +205,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -247,7 +251,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
.bulk_add_guild_members(&guild_id, &user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
"bulk-schedule-user-deletion" => {
|
||||
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
let reason_code = form.parse_u32("reason_code").unwrap_or(2);
|
||||
let days = form.parse_u32("days_until_deletion").unwrap_or(14);
|
||||
@@ -262,17 +266,17 @@ pub(crate) async fn bulk_actions_post(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"bulk_delete_users" => {
|
||||
"bulk-delete-user-messages" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
client
|
||||
.bulk_schedule_user_deletion(&user_ids, 0, 30, None, audit_log_reason.as_deref())
|
||||
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Unknown bulk action"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -284,7 +288,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::success("Bulk action submitted"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -292,8 +296,8 @@ pub(crate) async fn bulk_actions_post(
|
||||
tracing::warn!(%error, action, "admin API request failed: submit bulk action");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Failed to submit bulk action"),
|
||||
config.is_production(),
|
||||
FlashData::error(format!("Failed to submit bulk action: {error}")),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -399,14 +403,14 @@ pub(crate) async fn archives_download(
|
||||
Ok(_) => flash::redirect_with_flash(
|
||||
&format!("{base}/archives"),
|
||||
FlashData::error("Archive download URL was empty"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: get archive download URL");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/archives"),
|
||||
FlashData::error("Failed to create archive download URL"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
reports::SearchReportsParams,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf,
|
||||
@@ -22,6 +26,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const MAX_REPORT_OFFSET: u32 = 10_000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReportsQuery {
|
||||
q: Option<String>,
|
||||
@@ -70,6 +76,14 @@ async fn reports_list(
|
||||
let page = query.page.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(25).clamp(1, 200);
|
||||
let offset = page.saturating_mul(limit);
|
||||
if offset > MAX_REPORT_OFFSET {
|
||||
return reports_error_page(
|
||||
config,
|
||||
&auth.0,
|
||||
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
|
||||
);
|
||||
}
|
||||
let search_query = query.q.as_deref().and_then(clean_string);
|
||||
let (sort_by, sort_order) = decode_sort(query.sort.as_deref());
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let status = query.status.as_deref().and_then(|s| s.parse::<i32>().ok());
|
||||
@@ -78,22 +92,22 @@ async fn reports_list(
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(
|
||||
query.q.as_deref(),
|
||||
.search_reports(&SearchReportsParams {
|
||||
query: search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
query.category.as_deref(),
|
||||
query.reporter_id.as_deref(),
|
||||
query.reported_user_id.as_deref(),
|
||||
query.reported_guild_id.as_deref(),
|
||||
query.reported_channel_id.as_deref(),
|
||||
query.guild_context_id.as_deref(),
|
||||
query.resolved_by_admin_id.as_deref(),
|
||||
Some(sort_by),
|
||||
Some(sort_order),
|
||||
category: query.category.as_deref(),
|
||||
reporter_id: query.reporter_id.as_deref(),
|
||||
reported_user_id: query.reported_user_id.as_deref(),
|
||||
reported_guild_id: query.reported_guild_id.as_deref(),
|
||||
reported_channel_id: query.reported_channel_id.as_deref(),
|
||||
guild_context_id: query.guild_context_id.as_deref(),
|
||||
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
|
||||
sort_by: Some(sort_by),
|
||||
sort_order: Some(sort_order),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.log_error("search reports");
|
||||
|
||||
@@ -102,7 +116,7 @@ async fn reports_list(
|
||||
&auth.0,
|
||||
reports.as_ref(),
|
||||
&templates::pages::reports_list::ReportFilters {
|
||||
query: query.q.as_deref(),
|
||||
query: search_query.as_deref(),
|
||||
status: query.status.as_deref(),
|
||||
report_type: query.report_type.as_deref(),
|
||||
category: query.category.as_deref(),
|
||||
@@ -120,6 +134,18 @@ async fn reports_list(
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
fn reports_error_page(config: &AdminConfig, auth: &AuthContext, message: &str) -> Response {
|
||||
let markup = templates::layout::admin_layout(
|
||||
config,
|
||||
auth,
|
||||
"Reports",
|
||||
"reports",
|
||||
None,
|
||||
templates::components::error_display::error_alert(message),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn report_detail(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -195,7 +221,7 @@ async fn report_resolve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -212,7 +238,7 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::success("Report resolved"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -223,7 +249,7 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::error("Failed to resolve report"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -399,55 +399,6 @@ pub async fn dispatch(
|
||||
"Bulk message deletion cancelled successfully",
|
||||
"Failed to cancel bulk message deletion",
|
||||
),
|
||||
"refund_payment" => {
|
||||
let Some(pi) = form.clean("payment_intent_id") else {
|
||||
return DispatchOutcome::error("Payment intent ID is required");
|
||||
};
|
||||
let amt = form.parse_u64("amount_cents");
|
||||
let reason = get("reason");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.issue_refund(user_id, &pi, amt, reason.as_deref())
|
||||
.await,
|
||||
"Refund issued successfully",
|
||||
"Failed to issue refund",
|
||||
)
|
||||
}
|
||||
"refund_policy_cancel_now" => {
|
||||
let reason = get("reason");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.refund_policy_cancel_now(user_id, reason.as_deref())
|
||||
.await,
|
||||
"Refund policy cancellation completed successfully",
|
||||
"Failed to apply refund policy cancellation",
|
||||
)
|
||||
}
|
||||
"cancel_subscription" => DispatchOutcome::from_result(
|
||||
client.cancel_subscription(user_id).await,
|
||||
"Subscription cancelled successfully",
|
||||
"Failed to cancel subscription",
|
||||
),
|
||||
"cancel_subscription_now" => {
|
||||
let reason = get("reason");
|
||||
DispatchOutcome::from_result(
|
||||
client
|
||||
.cancel_subscription_immediately(user_id, reason.as_deref())
|
||||
.await,
|
||||
"Subscription cancelled immediately",
|
||||
"Failed to cancel subscription immediately",
|
||||
)
|
||||
}
|
||||
"reactivate_subscription" => DispatchOutcome::from_result(
|
||||
client.reactivate_subscription(user_id).await,
|
||||
"Subscription reactivated successfully",
|
||||
"Failed to reactivate subscription",
|
||||
),
|
||||
"end_premium_grace_period" => DispatchOutcome::from_result(
|
||||
client.end_premium_grace_period(user_id).await,
|
||||
"Premium grace period ended successfully",
|
||||
"Failed to end premium grace period",
|
||||
),
|
||||
"message_shred" => {
|
||||
let csv = form.first("csv_data").unwrap_or_default();
|
||||
match parse_message_shred_csv(csv) {
|
||||
|
||||
@@ -155,44 +155,6 @@ pub async fn render(
|
||||
csrf_token,
|
||||
))
|
||||
}
|
||||
"billing" => {
|
||||
if config.self_hosted
|
||||
|| !acl::has_any_permission(
|
||||
admin_acls,
|
||||
&[
|
||||
acl::BILLING_VIEW,
|
||||
acl::BILLING_REFUND,
|
||||
acl::BILLING_MANAGE_SUBSCRIPTION,
|
||||
],
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let can_view_billing = acl::has_permission(admin_acls, acl::BILLING_VIEW);
|
||||
let b = if can_view_billing {
|
||||
client
|
||||
.get_billing_overview(user_id)
|
||||
.await
|
||||
.log_error("load user billing overview")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let invoices = if can_view_billing {
|
||||
client
|
||||
.get_user_invoices(user_id, 25, None)
|
||||
.await
|
||||
.log_error("load user invoices")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(tabs::billing::billing_tab(
|
||||
config,
|
||||
user_id,
|
||||
b.as_ref().map(|v| &v.data),
|
||||
invoices.as_ref().map(|v| &v.data),
|
||||
csrf_token,
|
||||
))
|
||||
}
|
||||
"guilds" => {
|
||||
let g = client
|
||||
.get_user_guilds(user_id, Some(200), None, None, Some(true))
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResult, ApiResultExt},
|
||||
types::AdminUser,
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
|
||||
routes::user_tabs,
|
||||
state::AppState,
|
||||
@@ -18,6 +21,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const USER_ID_LOOKUP_BATCH: usize = 100;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserListQuery {
|
||||
q: Option<String>,
|
||||
@@ -55,7 +60,6 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/users", get(users_list))
|
||||
.route("/users/{user_id}", get(user_detail).post(user_detail_post))
|
||||
.route("/users/{user_id}/tabs/{tab}", get(user_tab))
|
||||
.route("/users/{user_id}/peek", get(user_peek))
|
||||
.route("/users/{user_id}/fragment", get(user_peek))
|
||||
}
|
||||
|
||||
@@ -84,14 +88,10 @@ async fn users_list(
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let results = if params.has_id_lookup() {
|
||||
let users = client
|
||||
.lookup_users_by_ids(¶ms.requested_ids)
|
||||
lookup_users_in_batches(&client, ¶ms.requested_ids)
|
||||
.await
|
||||
.map_err(
|
||||
|error| tracing::warn!(%error, "admin API request failed: lookup users by ids"),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
Some((users, false))
|
||||
.log_error("lookup users by ids")
|
||||
.map(|users| (users, false))
|
||||
} else if params.has_search() {
|
||||
let offset = params.page.saturating_mul(params.limit);
|
||||
client
|
||||
@@ -125,6 +125,17 @@ async fn users_list(
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn lookup_users_in_batches(
|
||||
client: &AdminApiClient,
|
||||
user_ids: &[String],
|
||||
) -> ApiResult<Vec<AdminUser>> {
|
||||
let mut users = Vec::new();
|
||||
for batch in user_ids.chunks(USER_ID_LOOKUP_BATCH) {
|
||||
users.extend(client.lookup_users_by_ids(batch).await?);
|
||||
}
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
async fn user_detail(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -189,7 +200,7 @@ async fn user_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/users/{user_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -208,7 +219,7 @@ async fn user_detail_post(
|
||||
{
|
||||
return htmx::toast_response(&outcome.flash);
|
||||
}
|
||||
flash::redirect_with_flash(&redirect, outcome.flash, config.is_production())
|
||||
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn user_tab(
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
api::client::AdminApiClient,
|
||||
middleware::{auth::AuthContext, csrf},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, pages::voice_servers::VoiceServersPageParams},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -13,12 +13,34 @@ use axum::{
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VoiceServersQuery {
|
||||
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> {
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -79,17 +101,21 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: None,
|
||||
region_name: None,
|
||||
servers: None,
|
||||
connection_counts: &HashMap::new(),
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
return Html(markup.into_string()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
Ok(resp) => resp
|
||||
@@ -107,11 +133,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
Some(&resp.servers),
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: Some(&resp.servers),
|
||||
connection_counts: &connection_counts,
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -120,11 +149,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
None,
|
||||
Some(&msg),
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: None,
|
||||
connection_counts: &connection_counts,
|
||||
error: Some(&msg),
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
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("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
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(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
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 {
|
||||
let mut body = serde_json::Map::new();
|
||||
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("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(
|
||||
"required_guild_features".into(),
|
||||
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(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
serde_json::Value::Object(body)
|
||||
}
|
||||
|
||||
@@ -160,7 +169,7 @@ pub(crate) async fn voice_regions_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/voice-regions"),
|
||||
flash_from_level(level, &msg),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +241,7 @@ pub(crate) async fn voice_servers_post(
|
||||
flash::redirect_with_flash(
|
||||
&redirect_url,
|
||||
flash_from_level(level, &msg),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,6 +264,86 @@ mod tests {
|
||||
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]
|
||||
fn build_server_body_preserves_single_and_repeated_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
|
||||
@@ -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::routes::auth::json_string;
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
use super::message_data::{Attachment, Message};
|
||||
|
||||
fn is_image(att: &Attachment) -> bool {
|
||||
att.content_type
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod error_display;
|
||||
pub mod form;
|
||||
pub mod icons;
|
||||
pub mod media;
|
||||
pub mod message_data;
|
||||
pub mod message_list;
|
||||
pub mod nsfw_indicators;
|
||||
pub mod page_container;
|
||||
|
||||
@@ -30,12 +30,6 @@ pub fn user_profile_badges(
|
||||
tooltip: "Fluxer Staff".into(),
|
||||
});
|
||||
}
|
||||
if !is_self_hosted && flags & user_flag_bits::CTP_MEMBER != 0 {
|
||||
badges.push(BadgeDef {
|
||||
icon_url: format!("{cdn}/badges/ctp.svg"),
|
||||
tooltip: "Fluxer Community Team".into(),
|
||||
});
|
||||
}
|
||||
if !is_self_hosted && flags & user_flag_bits::PARTNER != 0 {
|
||||
badges.push(BadgeDef {
|
||||
icon_url: format!("{cdn}/badges/partner.svg"),
|
||||
|
||||
@@ -57,6 +57,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
acl::BULK_UPDATE_GUILD_FEATURES,
|
||||
acl::BULK_ADD_GUILD_MEMBERS,
|
||||
acl::BULK_DELETE_USERS,
|
||||
acl::BULK_DELETE_USER_MESSAGES,
|
||||
]
|
||||
),
|
||||
],
|
||||
|
||||
@@ -51,10 +51,6 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
|
||||
name: "STAFF",
|
||||
value: 1 << 0,
|
||||
},
|
||||
UserFlag {
|
||||
name: "CTP_MEMBER",
|
||||
value: 1 << 1,
|
||||
},
|
||||
UserFlag {
|
||||
name: "PARTNER",
|
||||
value: 1 << 2,
|
||||
@@ -151,9 +147,14 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_EMOJI_ENABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
"CLONE_STICKER_ENABLED",
|
||||
"DETACHED_BANNER",
|
||||
"INVITE_SPLASH",
|
||||
"INVITES_DISABLED",
|
||||
@@ -179,6 +180,16 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
"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 {
|
||||
let base = &config.base_path;
|
||||
let admin_acls = auth
|
||||
@@ -205,6 +216,9 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
|
||||
(bulk_schedule_deletion_section(base, csrf_token))
|
||||
}
|
||||
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
|
||||
(bulk_delete_user_messages_section(base, csrf_token))
|
||||
}
|
||||
}
|
||||
};
|
||||
admin_layout(config, auth, "Bulk Actions", "bulk-actions", None, content)
|
||||
@@ -220,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 {
|
||||
html! {
|
||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||
@@ -302,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" {
|
||||
"Features to Add"
|
||||
}
|
||||
(flag_checkbox_grid("add_features[]", GUILD_FEATURES))
|
||||
(guild_feature_checkbox_grid("add_features[]", false))
|
||||
}
|
||||
div {
|
||||
p class="font-semibold text-neutral-500 text-xs uppercase tracking-wide mb-2" {
|
||||
"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,
|
||||
Some("Comma-separated list of custom features not in the standard set."),
|
||||
@@ -388,3 +414,47 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn bulk_delete_user_messages_section(base: &str, csrf_token: &str) -> Markup {
|
||||
section_card_simple(
|
||||
"Bulk Delete User Messages",
|
||||
html! {
|
||||
form method="post" action={(base) "/bulk-actions?action=bulk-delete-user-messages"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
p class="text-neutral-500 text-sm" {
|
||||
"Deletes every message authored by each user across all channels. This cannot be undone."
|
||||
}
|
||||
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
|
||||
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
|
||||
(form_actions(html! {
|
||||
(danger_button("Delete All Messages"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[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 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""#));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
form::{csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::{card_with_header, detail_row},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn billing_tab(
|
||||
config: &AdminConfig,
|
||||
guild_id: &str,
|
||||
billing: Option<&serde_json::Value>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
@if let Some(data) = billing {
|
||||
(render_billing_summary(data))
|
||||
} @else {
|
||||
(card_with_header("Billing", html! {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"No billing information available for this guild."
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
(card_with_header("Billing Actions", html! {
|
||||
div class="space-y-4" {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild_id) "?tab=billing&action=refresh_billing"}
|
||||
class="block" {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(submit_button("Refresh Billing Data"))
|
||||
}))
|
||||
}
|
||||
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild_id) "?tab=billing&action=cancel_subscription"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
input type="text" name="reason" placeholder="Reason (optional)"
|
||||
class="block w-full rounded-md border border-neutral-300 px-3 \
|
||||
py-2 text-sm shadow-sm focus:border-brand-primary \
|
||||
focus:outline-none focus:ring-1 focus:ring-brand-primary";
|
||||
(form_actions(html! {
|
||||
(danger_button("Cancel Subscription"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_billing_summary(data: &serde_json::Value) -> Markup {
|
||||
let customer_id = data
|
||||
.get("stripe_customer_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("\u{2014}");
|
||||
let sub_status = data
|
||||
.get("subscription")
|
||||
.and_then(|s| s.get("status"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("none");
|
||||
let period_end = data
|
||||
.get("subscription")
|
||||
.and_then(|s| s.get("current_period_end"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
html! {
|
||||
(card_with_header("Summary", html! {
|
||||
dl class="divide-y divide-neutral-100" {
|
||||
(detail_row("Stripe Customer", html! {
|
||||
span class="text-xs" { (customer_id) }
|
||||
}))
|
||||
(detail_row("Subscription Status", html! {
|
||||
span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs \
|
||||
font-medium bg-neutral-100 text-neutral-700" {
|
||||
(sub_status)
|
||||
}
|
||||
}))
|
||||
@if let Some(end) = period_end {
|
||||
(detail_row("Current Period Ends", html! { (end) }))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,14 @@ use maud::{Markup, html};
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_EMOJI_ENABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
"CLONE_STICKER_ENABLED",
|
||||
"DETACHED_BANNER",
|
||||
"INVITE_SPLASH",
|
||||
"INVITES_DISABLED",
|
||||
@@ -44,6 +49,16 @@ const GUILD_FEATURES: &[&str] = &[
|
||||
|
||||
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(
|
||||
config: &AdminConfig,
|
||||
guild: &GuildInfo,
|
||||
@@ -85,7 +100,7 @@ pub fn features_tab(
|
||||
(checkbox(
|
||||
"features[]",
|
||||
feature,
|
||||
feature,
|
||||
&feature_label(feature),
|
||||
guild.features.iter().any(|f| f == feature),
|
||||
true,
|
||||
))
|
||||
@@ -139,7 +154,7 @@ fn features_tab_readonly(guild: &GuildInfo, features_list: &[&str]) -> Markup {
|
||||
@for feature in &enabled {
|
||||
span class="inline-flex items-center rounded-full bg-green-100 \
|
||||
px-2.5 py-0.5 text-xs font-medium text-green-800" {
|
||||
(feature)
|
||||
(feature_label(feature))
|
||||
}
|
||||
}
|
||||
@for feature in &custom {
|
||||
@@ -166,3 +181,56 @@ fn filtered_features() -> Vec<&'static str> {
|
||||
.copied()
|
||||
.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
pub mod applications;
|
||||
pub mod archives;
|
||||
pub mod audit_log;
|
||||
pub mod billing;
|
||||
pub mod emojis;
|
||||
pub mod features;
|
||||
pub mod members;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -42,6 +44,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(
|
||||
name: &str,
|
||||
label: &str,
|
||||
@@ -134,6 +147,8 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
} @else {
|
||||
@@ -953,6 +968,254 @@ 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(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
||||
digits. Invalid entries prevent the save; blank entries and duplicate \
|
||||
IDs are ignored."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"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(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
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" }
|
||||
(checkbox(
|
||||
"voice_ns_stereo_enabled",
|
||||
"true",
|
||||
"Process stereo input instead of downmixing to mono",
|
||||
voice_noise_suppression.stereo_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Costs more CPU on the client. Leave off unless you are testing stereo \
|
||||
capture."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"voice_ns_suppression_strength",
|
||||
"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 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 one 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(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
@@ -1522,3 +1785,49 @@ 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 voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: (0..VOICE_NS_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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
|
||||
(select_input("status", "Status", &[
|
||||
("", "Any"), ("queued", "Queued"), ("running", "Running"),
|
||||
("succeeded", "Succeeded"), ("failed", "Failed"),
|
||||
("cancelled", "Cancelled"), ("deadletter", "Dead-letter"),
|
||||
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
|
||||
("deadletter", "Dead-letter"),
|
||||
], p.status_filter))
|
||||
div class="flex flex-col gap-2" {
|
||||
label for="task_type" class=(FORM_LABEL_CLASS) { "Task type" }
|
||||
|
||||
@@ -10,7 +10,8 @@ use crate::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, form_field_group,
|
||||
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},
|
||||
},
|
||||
layout::LayoutOptions,
|
||||
@@ -19,7 +20,6 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use serde_json::Value;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const MESSAGE_BROWSE_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
@@ -263,7 +263,7 @@ pub fn browse_messages_fragment(
|
||||
show_delete: bool,
|
||||
highlight_message_id: Option<&str>,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let has_more = result
|
||||
.get("has_more")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -295,7 +295,7 @@ fn browse_result_card(
|
||||
csrf_token: &str,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let has_more = result
|
||||
.get("has_more")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -345,7 +345,7 @@ fn search_result_card(
|
||||
query_text: &str,
|
||||
show_delete: bool,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let total = result
|
||||
.get("total")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -382,7 +382,7 @@ fn lookup_result_card(
|
||||
show_delete: bool,
|
||||
context_limit: u32,
|
||||
) -> Markup {
|
||||
let messages = ordered_messages(result);
|
||||
let messages = response_messages(result);
|
||||
let channel_id = messages
|
||||
.first()
|
||||
.map(|m| m.channel_id.as_str())
|
||||
@@ -508,130 +508,12 @@ fn empty_state(text: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn ordered_messages(result: &Value) -> Vec<Message> {
|
||||
let mut messages: Vec<Message> = result
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.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 response_messages(result: &Value) -> Vec<Message> {
|
||||
let values = result["messages"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
ordered_messages(values)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::types::ReportEntry,
|
||||
@@ -13,7 +11,8 @@ use crate::{
|
||||
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
|
||||
form::csrf_input,
|
||||
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::{
|
||||
adult_content_badge, channel_nsfw_state_badge, content_warning_badge,
|
||||
},
|
||||
@@ -26,7 +25,6 @@ use crate::{
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use serde_json::Value;
|
||||
|
||||
fn status_badge(status: i32) -> Markup {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ use maud::{Markup, html};
|
||||
pub const USER_TABS: &[(&str, &str)] = &[
|
||||
("overview", "Overview"),
|
||||
("account", "Account"),
|
||||
("billing", "Billing"),
|
||||
("guilds", "Guilds"),
|
||||
("dm_history", "DM History"),
|
||||
("group_dms", "Group DMs"),
|
||||
@@ -164,21 +163,10 @@ fn render_user_detail(
|
||||
}
|
||||
}
|
||||
|
||||
fn user_tab_visible(config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
|
||||
fn user_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
|
||||
match tab_id {
|
||||
"overview" | "account" | "guilds" | "dm_history" | "group_dms" | "reports"
|
||||
| "moderation" => true,
|
||||
"billing" => {
|
||||
!config.self_hosted
|
||||
&& acl::has_any_permission(
|
||||
admin_acls,
|
||||
&[
|
||||
acl::BILLING_VIEW,
|
||||
acl::BILLING_REFUND,
|
||||
acl::BILLING_MANAGE_SUBSCRIPTION,
|
||||
],
|
||||
)
|
||||
}
|
||||
"relationships" => acl::has_permission(admin_acls, acl::USER_LIST_RELATIONSHIPS),
|
||||
"applications" => acl::has_permission(admin_acls, acl::APPLICATION_LIST_BY_OWNER),
|
||||
"archives" => acl::has_any_permission(
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::{card_with_header, detail_row},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const INPUT_CLS: &str = "block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
|
||||
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
|
||||
focus:ring-brand-primary";
|
||||
|
||||
pub fn billing_tab(
|
||||
config: &AdminConfig,
|
||||
user_id: &str,
|
||||
billing: Option<&serde_json::Value>,
|
||||
invoices: Option<&serde_json::Value>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
div class="space-y-6" {
|
||||
@if let Some(data) = billing {
|
||||
(render_billing_summary(data))
|
||||
(render_subscription(data))
|
||||
(render_payment_methods(data))
|
||||
(render_payments(data))
|
||||
} @else {
|
||||
(card_with_header("Billing", html! {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"No billing information available for this user."
|
||||
}
|
||||
}))
|
||||
}
|
||||
@if let Some(data) = invoices {
|
||||
(render_invoices(data))
|
||||
}
|
||||
|
||||
(render_actions(base, user_id, csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription_badge_variant(status: &str) -> BadgeVariant {
|
||||
match status {
|
||||
"active" | "trialing" => BadgeVariant::Success,
|
||||
"past_due" | "unpaid" | "incomplete" => BadgeVariant::Warning,
|
||||
"canceled" | "incomplete_expired" => BadgeVariant::Danger,
|
||||
_ => BadgeVariant::Default,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_billing_summary(data: &serde_json::Value) -> Markup {
|
||||
let customer_id = data
|
||||
.get("stripe_customer_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("\u{2014}");
|
||||
let sub_status = data
|
||||
.get("subscription")
|
||||
.and_then(|s| s.get("status"))
|
||||
.and_then(|v| v.as_str());
|
||||
let period_end = data
|
||||
.get("subscription")
|
||||
.and_then(|s| s.get("current_period_end"))
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
html! {
|
||||
(card_with_header("Summary", html! {
|
||||
dl class="divide-y divide-neutral-100" {
|
||||
(detail_row("Stripe Customer", html! {
|
||||
span class="text-xs" { (customer_id) }
|
||||
}))
|
||||
(detail_row("Subscription", html! {
|
||||
@if let Some(status) = sub_status {
|
||||
(badge(status, subscription_badge_variant(status)))
|
||||
} @else {
|
||||
span class="text-sm text-neutral-900" { "none" }
|
||||
}
|
||||
}))
|
||||
@if let Some(end) = period_end {
|
||||
(detail_row("Current Period Ends", html! { (end) }))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_subscription(data: &serde_json::Value) -> Markup {
|
||||
let sub = match data.get("subscription") {
|
||||
Some(s) if !s.is_null() => s,
|
||||
_ => return html! {},
|
||||
};
|
||||
let status = sub
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let sub_id = sub.get("id").and_then(|v| v.as_str());
|
||||
let plan_interval = sub.get("plan_interval").and_then(|v| v.as_str());
|
||||
let period_start = sub.get("current_period_start").and_then(|v| v.as_str());
|
||||
let period_end = sub.get("current_period_end").and_then(|v| v.as_str());
|
||||
let cancel_at_period_end = sub
|
||||
.get("cancel_at_period_end")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
html! {
|
||||
(card_with_header("Subscription", html! {
|
||||
dl class="divide-y divide-neutral-100" {
|
||||
(detail_row("Status", html! {
|
||||
(badge(status, subscription_badge_variant(status)))
|
||||
}))
|
||||
@if let Some(id) = sub_id {
|
||||
(detail_row("ID", html! {
|
||||
span class="text-xs" { (id) }
|
||||
}))
|
||||
}
|
||||
@if let Some(interval) = plan_interval {
|
||||
(detail_row("Plan Interval", html! { (interval) }))
|
||||
}
|
||||
@if let Some(start) = period_start {
|
||||
(detail_row("Period Start", html! { (start) }))
|
||||
}
|
||||
@if let Some(end) = period_end {
|
||||
(detail_row("Period End", html! { (end) }))
|
||||
}
|
||||
(detail_row("Cancel at Period End", html! {
|
||||
@if cancel_at_period_end { "yes" } @else { "no" }
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_payment_methods(data: &serde_json::Value) -> Markup {
|
||||
let methods = data.get("payment_methods").and_then(|v| v.as_array());
|
||||
let empty = methods.is_none() || methods.is_some_and(|m| m.is_empty());
|
||||
html! {
|
||||
(card_with_header("Payment Methods", html! {
|
||||
@if empty {
|
||||
p class="text-sm text-neutral-500" { "No payment methods on file." }
|
||||
} @else if let Some(pms) = methods {
|
||||
div class="space-y-3" {
|
||||
@for pm in pms {
|
||||
@let pm_type = pm.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
@let brand = pm.get("card_brand").and_then(|v| v.as_str());
|
||||
@let last4 = pm.get("card_last4").and_then(|v| v.as_str());
|
||||
@let pm_id = pm.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
@let display = match (brand, last4) {
|
||||
(Some(b), Some(l)) => format!("{b} **** {l}"),
|
||||
_ => pm_type.to_string(),
|
||||
};
|
||||
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3" {
|
||||
p class="text-sm text-neutral-900" { (display) }
|
||||
p class="text-xs text-neutral-500" { (pm_id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_payments(data: &serde_json::Value) -> Markup {
|
||||
let payments = data.get("payments").and_then(|v| v.as_array());
|
||||
let empty = payments.is_none() || payments.is_some_and(|p| p.is_empty());
|
||||
html! {
|
||||
(card_with_header("Payments", html! {
|
||||
@if empty {
|
||||
p class="text-sm text-neutral-500" { "No payments recorded." }
|
||||
} @else if let Some(ps) = payments {
|
||||
div class="space-y-3" {
|
||||
@for p in ps { (payment_row(p)) }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn payment_row(p: &serde_json::Value) -> Markup {
|
||||
let amount = p.get("amount_cents").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let currency = p.get("currency").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = p
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let created = p.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let display_amount = format!("{:.2} {}", amount as f64 / 100.0, currency.to_uppercase());
|
||||
|
||||
let variant = match status {
|
||||
"completed" | "succeeded" => BadgeVariant::Success,
|
||||
"pending" | "processing" => BadgeVariant::Info,
|
||||
"failed" | "canceled" => BadgeVariant::Danger,
|
||||
"refunded" | "partially_refunded" => BadgeVariant::Warning,
|
||||
_ => BadgeVariant::Default,
|
||||
};
|
||||
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
|
||||
div class="flex items-center justify-between" {
|
||||
div class="flex items-center gap-2" {
|
||||
span class="text-sm font-medium text-neutral-900" {
|
||||
(display_amount)
|
||||
}
|
||||
(badge(status, variant))
|
||||
}
|
||||
span class="text-xs text-neutral-500" { (created) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn invoice_badge_variant(status: Option<&str>) -> BadgeVariant {
|
||||
match status {
|
||||
Some("paid") => BadgeVariant::Success,
|
||||
Some("open" | "draft") => BadgeVariant::Info,
|
||||
Some("uncollectible" | "void") => BadgeVariant::Danger,
|
||||
_ => BadgeVariant::Default,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_invoices(data: &serde_json::Value) -> Markup {
|
||||
let invoices = data.get("invoices").and_then(|v| v.as_array());
|
||||
let empty = invoices.is_none() || invoices.is_some_and(|i| i.is_empty());
|
||||
let has_more = data
|
||||
.get("has_more")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
html! {
|
||||
(card_with_header("Invoices", html! {
|
||||
@if empty {
|
||||
p class="text-sm text-neutral-500" { "No invoices on file." }
|
||||
} @else if let Some(items) = invoices {
|
||||
div class="space-y-3" {
|
||||
@for invoice in items {
|
||||
(invoice_row(invoice))
|
||||
}
|
||||
@if has_more {
|
||||
p class="text-xs text-neutral-500" {
|
||||
"More invoices exist beyond this list."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn invoice_row(invoice: &serde_json::Value) -> Markup {
|
||||
let amount = invoice
|
||||
.get("amount_paid")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let currency = invoice
|
||||
.get("currency")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let status = invoice.get("status").and_then(|v| v.as_str());
|
||||
let created = invoice
|
||||
.get("created")
|
||||
.and_then(|v| v.as_i64())
|
||||
.map(format_unix_timestamp)
|
||||
.unwrap_or_default();
|
||||
let display_amount = format_amount(amount, currency);
|
||||
let status_label = status.unwrap_or("unknown");
|
||||
let billing_reason = invoice.get("billing_reason").and_then(|v| v.as_str());
|
||||
let invoice_id = invoice.get("id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let subscription_id = invoice.get("subscription_id").and_then(|v| v.as_str());
|
||||
let payment_intent_id = invoice.get("payment_intent_id").and_then(|v| v.as_str());
|
||||
let charge_id = invoice.get("charge_id").and_then(|v| v.as_str());
|
||||
let hosted_invoice_url = invoice.get("hosted_invoice_url").and_then(|v| v.as_str());
|
||||
let invoice_pdf = invoice.get("invoice_pdf").and_then(|v| v.as_str());
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
|
||||
div class="space-y-3" {
|
||||
div class="flex items-center justify-between gap-3" {
|
||||
div class="flex items-center gap-2" {
|
||||
span class="text-sm font-medium text-neutral-900" {
|
||||
(display_amount)
|
||||
}
|
||||
(badge(status_label, invoice_badge_variant(status)))
|
||||
}
|
||||
span class="text-xs text-neutral-500" { (created) }
|
||||
}
|
||||
@if let Some(reason) = billing_reason {
|
||||
p class="text-sm text-neutral-500" { (reason) }
|
||||
}
|
||||
dl class="space-y-1" {
|
||||
(compact_detail_row("id", invoice_id))
|
||||
@if let Some(id) = subscription_id {
|
||||
(compact_detail_row("subscription", id))
|
||||
}
|
||||
@if let Some(id) = payment_intent_id {
|
||||
(compact_detail_row("payment_intent", id))
|
||||
}
|
||||
@if let Some(id) = charge_id {
|
||||
(compact_detail_row("charge", id))
|
||||
}
|
||||
}
|
||||
@if hosted_invoice_url.is_some() || invoice_pdf.is_some() {
|
||||
div class="flex items-center gap-3 text-sm" {
|
||||
@if let Some(url) = hosted_invoice_url {
|
||||
a href=(url) target="_blank" rel="noreferrer noopener"
|
||||
class="text-blue-600 hover:text-blue-800 hover:underline" {
|
||||
"View"
|
||||
}
|
||||
}
|
||||
@if let Some(url) = invoice_pdf {
|
||||
a href=(url) target="_blank" rel="noreferrer noopener"
|
||||
class="text-blue-600 hover:text-blue-800 hover:underline" {
|
||||
"PDF"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_detail_row(label: &str, value: &str) -> Markup {
|
||||
html! {
|
||||
div class="grid grid-cols-1 gap-1 text-xs sm:grid-cols-3" {
|
||||
dt class="text-neutral-500" { (label) }
|
||||
dd class="break-all text-neutral-700 sm:col-span-2" { (value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_amount(amount_minor: i64, currency: &str) -> String {
|
||||
let code = currency.trim().to_uppercase();
|
||||
if code.is_empty() {
|
||||
format!("{:.2}", amount_minor as f64 / 100.0)
|
||||
} else {
|
||||
format!("{:.2} {code}", amount_minor as f64 / 100.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_unix_timestamp(value: i64) -> String {
|
||||
time::OffsetDateTime::from_unix_timestamp(value)
|
||||
.ok()
|
||||
.and_then(|ts| {
|
||||
ts.format(&time::format_description::well_known::Rfc3339)
|
||||
.ok()
|
||||
})
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
fn render_actions(base: &str, user_id: &str, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Billing Actions", html! {
|
||||
div class="space-y-4" {
|
||||
form method="post"
|
||||
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription_now"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
p class="text-sm text-neutral-700" {
|
||||
"Cancel subscription immediately, no refund."
|
||||
}
|
||||
input type="text" name="reason" placeholder="Reason (optional)"
|
||||
class=(INPUT_CLS);
|
||||
(form_actions(html! {
|
||||
(danger_button("Cancel Now"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
form method="post"
|
||||
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
p class="text-sm text-neutral-700" {
|
||||
"Cancel at renewal (access until period end)."
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Cancel at Renewal"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
form method="post"
|
||||
action={(base) "/users/" (user_id) "?tab=billing&action=refund_payment"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-3" {
|
||||
p class="text-sm font-medium text-neutral-700" {
|
||||
"Manual Refund"
|
||||
}
|
||||
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
|
||||
input type="text" name="payment_intent_id"
|
||||
placeholder="pi_..." required
|
||||
class=(INPUT_CLS);
|
||||
input type="number" name="amount_cents" min="1"
|
||||
placeholder="Amount cents (blank = full)"
|
||||
class=(INPUT_CLS);
|
||||
}
|
||||
input type="text" name="reason"
|
||||
placeholder="Reason (optional)"
|
||||
class=(INPUT_CLS);
|
||||
(form_actions(html! {
|
||||
(danger_button("Refund"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
|
||||
pub mod account;
|
||||
pub mod applications;
|
||||
pub mod archives;
|
||||
pub mod billing;
|
||||
pub mod dm_history;
|
||||
pub mod group_dm;
|
||||
pub mod guilds;
|
||||
|
||||
@@ -560,6 +560,8 @@ fn traits_form(
|
||||
}
|
||||
}
|
||||
|
||||
const DERIVED_TRAITS: [&str; 1] = ["premium"];
|
||||
|
||||
fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> {
|
||||
limit_config
|
||||
.map(|response| {
|
||||
@@ -569,6 +571,7 @@ fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&s
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| !DERIVED_TRAITS.contains(value))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
@@ -579,5 +582,6 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|trait_name| !trait_definitions.contains(trait_name))
|
||||
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -18,19 +18,33 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::voice_servers_forms::{create_server_form, edit_server_form};
|
||||
|
||||
pub struct VoiceServersPageParams<'a> {
|
||||
pub region_id: Option<&'a str>,
|
||||
pub region_name: Option<&'a str>,
|
||||
pub servers: Option<&'a [VoiceServer]>,
|
||||
pub connection_counts: &'a HashMap<String, i64>,
|
||||
pub error: Option<&'a str>,
|
||||
pub csrf_token: &'a str,
|
||||
}
|
||||
|
||||
pub fn voice_servers_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
region_id: Option<&str>,
|
||||
region_name: Option<&str>,
|
||||
servers: Option<&[VoiceServer]>,
|
||||
error: Option<&str>,
|
||||
csrf_token: &str,
|
||||
p: &VoiceServersPageParams<'_>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let VoiceServersPageParams {
|
||||
region_id,
|
||||
region_name,
|
||||
servers,
|
||||
connection_counts,
|
||||
error,
|
||||
csrf_token,
|
||||
} = *p;
|
||||
let options = LayoutOptions {
|
||||
csrf_token,
|
||||
inspected_voice_region_id: region_id,
|
||||
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
|
||||
html! {},
|
||||
))
|
||||
@if let Some(servers) = servers {
|
||||
(servers_list(config, rid, servers, csrf_token))
|
||||
(servers_list(config, rid, servers, connection_counts, csrf_token))
|
||||
}
|
||||
div id="create" class="mt-8" {
|
||||
(create_server_form(config, rid, csrf_token))
|
||||
@@ -109,6 +123,7 @@ fn servers_list(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
servers: &[VoiceServer],
|
||||
connection_counts: &HashMap<String, i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
if servers.is_empty() {
|
||||
@@ -121,7 +136,7 @@ fn servers_list(
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
@for server in servers {
|
||||
(server_card(config, region_id, server, csrf_token))
|
||||
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +146,7 @@ fn server_card(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
server: &VoiceServer,
|
||||
connection_count: Option<i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
@@ -145,6 +161,15 @@ fn server_card(
|
||||
let lng_str = server
|
||||
.longitude
|
||||
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
|
||||
let soft_limit_str = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
|
||||
let connections_str =
|
||||
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
|
||||
let at_soft_limit = matches!(
|
||||
(server.soft_connection_limit, connection_count),
|
||||
(Some(limit), Some(count)) if limit > 0 && count >= limit
|
||||
);
|
||||
|
||||
card(html! {
|
||||
div class="mb-4 flex flex-col gap-1" {
|
||||
@@ -155,6 +180,9 @@ fn server_card(
|
||||
} @else {
|
||||
(badge("INACTIVE", BadgeVariant::Default))
|
||||
}
|
||||
@if at_soft_limit {
|
||||
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
|
||||
}
|
||||
(voice_status_badges(vip_only, has_features, has_guild_ids))
|
||||
}
|
||||
p class="text-sm text-neutral-500" { (endpoint) }
|
||||
@@ -164,6 +192,8 @@ fn server_card(
|
||||
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
|
||||
(data_field_text("Latitude", &lat_str))
|
||||
(data_field_text("Longitude", &lng_str))
|
||||
(data_field_text("Soft connection limit", &soft_limit_str))
|
||||
(data_field_text("Live connections", &connections_str))
|
||||
}
|
||||
(voice_features_list(&server.required_guild_features))
|
||||
(voice_guild_ids_list(&server.allowed_guild_ids))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user