mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
508
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
14ae64f5f3 | ||
|
|
990176ac7c | ||
|
|
69ef46356b | ||
|
|
bd88c7b04b | ||
|
|
03641f622f | ||
|
|
3b1eb56713 | ||
|
|
059bcc6c53 | ||
|
|
82043ce2a8 | ||
|
|
470e752fba | ||
|
|
3cc7b9050c | ||
|
|
b1f7c78c7e | ||
|
|
8f20b29b16 | ||
|
|
19efbd3d61 | ||
|
|
f35c0effa2 | ||
|
|
8363cc0844 | ||
|
|
5a11cacbae | ||
|
|
27151a9487 | ||
|
|
c152b25deb | ||
|
|
04d3afaf7b | ||
|
|
dbc63e9ef7 | ||
|
|
ce91ff95ab | ||
|
|
d75a29f099 | ||
|
|
cb889b1160 | ||
|
|
8db4f5cb63 | ||
|
|
d297dc5805 | ||
|
|
9b8659a40b | ||
|
|
96c5db3f5d | ||
|
|
78e403819e | ||
|
|
805acf4e5e | ||
|
|
9f099a9127 | ||
|
|
4d15c39cd7 | ||
|
|
827451d12d | ||
|
|
03603662c6 | ||
|
|
34eb10cd88 | ||
|
|
82941c08c9 | ||
|
|
8d21c97d08 | ||
|
|
71a56f590d | ||
|
|
38297c4fe7 | ||
|
|
cf7ec06d85 | ||
|
|
f2ea10f951 | ||
|
|
bbd93df239 | ||
|
|
9a6ab93e01 | ||
|
|
38eed7cce6 | ||
|
|
79064c3399 | ||
|
|
0496b2f530 | ||
|
|
9d0be1ebd1 | ||
|
|
9ad026b8ce | ||
|
|
14de5971d5 | ||
|
|
5474be3efa | ||
|
|
9a54bbba2d | ||
|
|
5a0110ccc8 | ||
|
|
d032d577bf | ||
|
|
243954c9c5 | ||
|
|
ba1be73389 | ||
|
|
af3ad02962 | ||
|
|
53ddca725e | ||
|
|
094fb0d1c8 | ||
|
|
dc230926a4 | ||
|
|
026ace6747 | ||
|
|
374db9ed2b | ||
|
|
5ee59c4675 | ||
|
|
33605171a8 | ||
|
|
89fac5b088 | ||
|
|
4a34b942b7 | ||
|
|
fc3065ebe4 | ||
|
|
23493b4ac2 | ||
|
|
13344096b7 | ||
|
|
a800430997 | ||
|
|
509562e6da | ||
|
|
88d85919f1 | ||
|
|
154e223284 | ||
|
|
58732f7770 | ||
|
|
cb4c847d41 | ||
|
|
d3976e33f8 | ||
|
|
8e17970632 | ||
|
|
c0048504db | ||
|
|
5015452280 | ||
|
|
c504b68354 | ||
|
|
5d2e5932a4 | ||
|
|
cf1a7d7a8a | ||
|
|
14a935db81 | ||
|
|
f98a40062a | ||
|
|
f7ebc1492c | ||
|
|
da3922586a | ||
|
|
28184f8d4d | ||
|
|
a4e7522ca0 | ||
|
|
59b3d30323 | ||
|
|
53cac0b614 | ||
|
|
82c29398d3 | ||
|
|
6d289e31c7 | ||
|
|
5ec02c3089 | ||
|
|
9940273cd9 | ||
|
|
21c7b9872e | ||
|
|
fa99ec6f8f | ||
|
|
78f7db9250 | ||
|
|
c101610c46 | ||
|
|
6d383c7d0a | ||
|
|
2ca756d219 | ||
|
|
1153327c75 | ||
|
|
42e45a0e3a | ||
|
|
9f5a5b16d3 | ||
|
|
44ecd928f0 | ||
|
|
3f5c8d8d0a | ||
|
|
e32c5b73a7 |
+38
-32
@@ -7,16 +7,15 @@ ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
ARG NODE_MAJOR=24
|
||||
ARG ELP_VERSION=2026-02-27
|
||||
ARG HELM_VERSION=4.2.0
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
ARG WASM_BINDGEN_VERSION=0.2.122
|
||||
ARG WASM_BINDGEN_VERSION=0.2.123
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
acl \
|
||||
bash \
|
||||
brotli \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
clang \
|
||||
@@ -33,6 +32,7 @@ RUN apt-get update \
|
||||
jq \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libaom-dev \
|
||||
libavcodec-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
@@ -42,19 +42,25 @@ RUN apt-get update \
|
||||
libfido2-dev \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libimage-exiftool-perl \
|
||||
libnotify4 \
|
||||
libnss3 \
|
||||
libpipewire-0.3-dev \
|
||||
libpulse-dev \
|
||||
libsecret-1-0 \
|
||||
libudev-dev \
|
||||
libuv1-dev \
|
||||
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 \
|
||||
@@ -71,16 +77,15 @@ RUN apt-get update \
|
||||
ninja-build \
|
||||
openssh-client \
|
||||
pkg-config \
|
||||
protobuf-compiler \
|
||||
python3 \
|
||||
python3-pip \
|
||||
rsync \
|
||||
python3-venv \
|
||||
sudo \
|
||||
rpm \
|
||||
unzip \
|
||||
webp \
|
||||
xz-utils \
|
||||
xdg-utils \
|
||||
zlib1g-dev \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -90,6 +95,7 @@ RUN apt-get update \
|
||||
bat \
|
||||
btop \
|
||||
docker-cli \
|
||||
docker-compose \
|
||||
dnsutils \
|
||||
fd-find \
|
||||
gdb \
|
||||
@@ -122,24 +128,32 @@ RUN apt-get update \
|
||||
&& ln -sf /usr/bin/batcat /usr/local/bin/bat \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& case "$ARCH" in \
|
||||
amd64) HELM_ARCH="amd64" ;; \
|
||||
arm64) HELM_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture for Helm: $ARCH" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& curl -fsSL "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${HELM_ARCH}.tar.gz" -o /tmp/helm.tgz \
|
||||
&& tar -C /tmp -xzf /tmp/helm.tgz "linux-${HELM_ARCH}/helm" \
|
||||
&& mv "/tmp/linux-${HELM_ARCH}/helm" /usr/local/bin/helm \
|
||||
&& chmod +x /usr/local/bin/helm \
|
||||
&& rm -rf /tmp/helm.tgz "/tmp/linux-${HELM_ARCH}"
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli cqlsh
|
||||
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 \
|
||||
@@ -147,18 +161,13 @@ RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer
|
||||
&& pyftsubset --help >/dev/null \
|
||||
&& python3 -c "import fontTools, brotli"
|
||||
|
||||
RUN if ! command -v rebar3 >/dev/null 2>&1; then \
|
||||
curl -fsSL https://s3.amazonaws.com/rebar3/rebar3 -o /usr/local/bin/rebar3 \
|
||||
&& chmod +x /usr/local/bin/rebar3; \
|
||||
fi
|
||||
|
||||
RUN ARCH="$(dpkg --print-architecture)" \
|
||||
&& case "$ARCH" in \
|
||||
amd64) ELP_ARCH="x86_64" ;; \
|
||||
arm64) ELP_ARCH="aarch64" ;; \
|
||||
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& curl -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
|
||||
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
|
||||
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
|
||||
&& chmod +x /usr/local/bin/elp \
|
||||
&& rm /tmp/elp.tgz
|
||||
@@ -179,16 +188,13 @@ ENV DOCKER_HOST="unix:///var/run/docker.sock" \
|
||||
ENV CC_wasm32_unknown_unknown="clang" \
|
||||
AR_wasm32_unknown_unknown="llvm-ar"
|
||||
|
||||
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile default --component clippy,rustfmt \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy,rustfmt \
|
||||
&& rustup target add wasm32-unknown-unknown \
|
||||
&& cargo install cargo-watch --locked \
|
||||
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked
|
||||
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
|
||||
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
|
||||
|
||||
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
|
||||
&& pnpm --version
|
||||
|
||||
RUN sudo apt-get update \
|
||||
&& sudo apt-get install -y --no-install-recommends python3-venv \
|
||||
&& sudo rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspaces/fluxer
|
||||
|
||||
@@ -5,20 +5,17 @@
|
||||
"workspaceFolder": "/workspaces/fluxer",
|
||||
"shutdownAction": "stopCompose",
|
||||
"remoteUser": "vscode",
|
||||
"hostRequirements": {
|
||||
"cpus": 4,
|
||||
"memory": "8gb",
|
||||
"storage": "32gb"
|
||||
},
|
||||
"remoteEnv": {
|
||||
"DOCKER_HOST": "unix:///var/run/docker.sock"
|
||||
},
|
||||
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
|
||||
"forwardPorts": [
|
||||
3000, 8088, 8080, 8771, 8082, 3010, 3020, 8100, 8101, 8102, 8103, 8104, 8105, 8106, 8107, 8108, 8109, 8110, 8111,
|
||||
8112, 8113, 8114, 8115, 8116, 8117, 8118, 8119, 8120, 8121, 8122, 8123, 8124, 8125, 3900, 8888, 9333, 9340, 23646,
|
||||
4222, 7700, 7880, 7900, 9200, 8000
|
||||
],
|
||||
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
|
||||
"portsAttributes": {
|
||||
"8000": {
|
||||
"label": "Zensical docs",
|
||||
"onAutoForward": "openBrowserOnce"
|
||||
},
|
||||
"8088": {
|
||||
"label": "Fluxer dev proxy",
|
||||
"onAutoForward": "notify"
|
||||
@@ -29,36 +26,15 @@
|
||||
"3020": {
|
||||
"label": "Fluxer admin"
|
||||
},
|
||||
"8100": {
|
||||
"label": "Fluxer Rust service health"
|
||||
},
|
||||
"3900": {
|
||||
"8333": {
|
||||
"label": "SeaweedFS S3"
|
||||
},
|
||||
"8888": {
|
||||
"label": "SeaweedFS filer"
|
||||
},
|
||||
"9333": {
|
||||
"label": "SeaweedFS master"
|
||||
},
|
||||
"9340": {
|
||||
"label": "SeaweedFS volume"
|
||||
},
|
||||
"23646": {
|
||||
"label": "SeaweedFS admin"
|
||||
},
|
||||
"7880": {
|
||||
"label": "LiveKit"
|
||||
},
|
||||
"7700": {
|
||||
"label": "Meilisearch"
|
||||
},
|
||||
"9200": {
|
||||
"label": "Elasticsearch"
|
||||
"8773": {
|
||||
"label": "Fluxer app proxy"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "sudo chown -R vscode:vscode /workspaces/fluxer/target && find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -exec sudo chown -R vscode:vscode {} + && sudo chown -R vscode:vscode /home/vscode/.local/share/pnpm && cargo run -p fluxer-dev -- bootstrap",
|
||||
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && cargo run -p fluxer-dev -- post-start && bash /workspaces/fluxer/fluxer_docs/serve.sh --daemon",
|
||||
"postCreateCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- bootstrap",
|
||||
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- post-start",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
|
||||
@@ -7,15 +7,29 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
command: sleep infinity
|
||||
init: true
|
||||
env_file:
|
||||
- ../config/env/development.env
|
||||
environment:
|
||||
FLUXER_SEARCH_ENGINE: meilisearch
|
||||
FLUXER_SEARCH_URL: http://meilisearch:7700
|
||||
FLUXER_SEARCH_API_KEY: fluxer-dev-meilisearch
|
||||
FLUXER_POSTGRES_HOST: postgres
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
|
||||
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
FLUXER_API_CLIENT_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
FLUXER_APP_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_GATEWAY_ENDPOINT: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/gateway"
|
||||
FLUXER_MEDIA_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_ADMIN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin"
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_LIVEKIT_URL: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/livekit"
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: "http://livekit:7880"
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api/webhooks/livekit"
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
volumes:
|
||||
- ..:/workspaces/fluxer:cached
|
||||
- type: volume
|
||||
@@ -236,45 +250,31 @@ services:
|
||||
source: ${FLUXER_DOCKER_SOCKET:-/var/run/docker.sock}
|
||||
target: /var/run/docker.sock
|
||||
ports:
|
||||
- "${FLUXER_DEV_DOCS_PORT:-8000}:8000"
|
||||
- "${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
|
||||
- "${FLUXER_DEV_PROXY_PORT:-8088}:8088"
|
||||
- "${FLUXER_DEV_APP_PROXY_PORT:-8080}:8080"
|
||||
- "${FLUXER_DEV_API_PORT:-8771}:8771"
|
||||
- "${FLUXER_DEV_GATEWAY_PORT:-8082}:8082"
|
||||
- "${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
|
||||
- "${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
|
||||
- "${FLUXER_DEV_RUST_SERVICE_PORTS:-8100-8125}:8100-8125"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_FILER_PORT:-8888}:8888"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_MASTER_PORT:-9333}:9333"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_VOLUME_PORT:-9340}:9340"
|
||||
- "${FLUXER_DEV_SEAWEEDFS_ADMIN_PORT:-23646}:23646"
|
||||
- "127.0.0.1:${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
|
||||
- "127.0.0.1:${FLUXER_DEV_PROXY_PORT:-8088}:8088"
|
||||
- "127.0.0.1:${FLUXER_DEV_API_PORT:-8080}:8080"
|
||||
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
|
||||
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
|
||||
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
|
||||
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
|
||||
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
|
||||
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
|
||||
depends_on:
|
||||
- postgres
|
||||
- valkey
|
||||
- nats
|
||||
- livekit
|
||||
- meilisearch
|
||||
- mailpit
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_started
|
||||
nats:
|
||||
condition: service_started
|
||||
livekit:
|
||||
condition: service_started
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
cassandra:
|
||||
image: cassandra:5.0.8
|
||||
profiles:
|
||||
- full
|
||||
environment:
|
||||
CASSANDRA_CLUSTER_NAME: fluxer-dev
|
||||
CASSANDRA_DC: datacenter1
|
||||
CASSANDRA_ENDPOINT_SNITCH: GossipingPropertyFileSnitch
|
||||
HEAP_NEWSIZE: 128M
|
||||
MAX_HEAP_SIZE: 768M
|
||||
volumes:
|
||||
- cassandra-data:/var/lib/cassandra
|
||||
ports:
|
||||
- "${FLUXER_DEV_CASSANDRA_PORT:-9042}:9042"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
@@ -284,13 +284,19 @@ services:
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
|
||||
- "127.0.0.1:${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U fluxer -d fluxer"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8.1.7-alpine
|
||||
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
||||
ports:
|
||||
- "${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
|
||||
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
|
||||
|
||||
nats:
|
||||
image: nats:2.14.2-alpine
|
||||
@@ -298,33 +304,22 @@ services:
|
||||
volumes:
|
||||
- nats-data:/data
|
||||
ports:
|
||||
- "${FLUXER_DEV_NATS_PORT:-4222}:4222"
|
||||
- "${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
|
||||
- "127.0.0.1:${FLUXER_DEV_NATS_PORT:-4222}:4222"
|
||||
- "127.0.0.1:${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.12.0
|
||||
command: ["--config", "/etc/livekit.yaml", "--bind", "0.0.0.0"]
|
||||
environment:
|
||||
LIVEKIT_RTC_TCP_PORT: "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
|
||||
LIVEKIT_RTC_UDP_PORT_START: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
|
||||
LIVEKIT_RTC_UDP_PORT_END: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- "${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
|
||||
- "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:7881"
|
||||
- "${FLUXER_DEV_LIVEKIT_UDP_PORTS:-7882-7892}:7882-7892/udp"
|
||||
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:9.3.2
|
||||
profiles:
|
||||
- full
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
xpack.security.enabled: "true"
|
||||
xpack.security.http.ssl.enabled: "false"
|
||||
ELASTIC_PASSWORD: fluxer-dev-elasticsearch
|
||||
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
volumes:
|
||||
- elasticsearch-data:/usr/share/elasticsearch/data
|
||||
ports:
|
||||
- "${FLUXER_DEV_ELASTICSEARCH_PORT:-9200}:9200"
|
||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
|
||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
|
||||
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.12
|
||||
@@ -334,7 +329,13 @@ services:
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
ports:
|
||||
- "${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
|
||||
- "127.0.0.1:${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:7700/health"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 5s
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30
|
||||
@@ -394,9 +395,7 @@ volumes:
|
||||
cargo-registry:
|
||||
cargo-git:
|
||||
rust-target:
|
||||
cassandra-data:
|
||||
nats-data:
|
||||
elasticsearch-data:
|
||||
meilisearch-data:
|
||||
mailpit-data:
|
||||
postgres-data:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
set -uo pipefail
|
||||
set -euo pipefail
|
||||
|
||||
SOCKET="${DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
USER_NAME="${USER:-vscode}"
|
||||
USER_NAME="$(id -un)"
|
||||
|
||||
if [ ! -S "$SOCKET" ]; then
|
||||
echo "fix-docker-socket: no socket at $SOCKET; skipping (Docker-in-devcontainer will not work)"
|
||||
@@ -16,31 +16,11 @@ if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
socket_gid="$(stat -c '%g' "$SOCKET" 2>/dev/null || echo "")"
|
||||
if [ -z "$socket_gid" ]; then
|
||||
echo "fix-docker-socket: could not stat $SOCKET; skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sudo sh -c '
|
||||
set -e
|
||||
gid="$1"
|
||||
user="$2"
|
||||
socket="$3"
|
||||
if ! getent group "$gid" >/dev/null 2>&1; then
|
||||
groupadd --gid "$gid" docker-host
|
||||
fi
|
||||
group_name="$(getent group "$gid" | cut -d: -f1)"
|
||||
usermod --append --groups "$group_name" "$user"
|
||||
chgrp "$gid" "$socket"
|
||||
chmod g+rw "$socket"
|
||||
' sh "$socket_gid" "$USER_NAME" "$SOCKET" || {
|
||||
echo "fix-docker-socket: could not adjust $SOCKET; run docker with sudo" >&2
|
||||
exit 0
|
||||
}
|
||||
sudo setfacl --modify "user:${USER_NAME}:rw" "$SOCKET"
|
||||
|
||||
if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
|
||||
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME (gid $socket_gid)"
|
||||
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME"
|
||||
else
|
||||
echo "fix-docker-socket: $SOCKET still unreachable as $USER_NAME; run docker with sudo" >&2
|
||||
echo "fix-docker-socket: $SOCKET is still unreachable as $USER_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
owner="$(id -u):$(id -g)"
|
||||
|
||||
repair_tree() {
|
||||
local path="$1"
|
||||
local unwritable
|
||||
if [ ! -d "$path" ]; then
|
||||
echo "fix-workspace-permissions: expected mount is missing: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
|
||||
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
|
||||
sudo find "$path" -xdev \( -type d -o -type f \) -exec chown "$owner" {} +
|
||||
fi
|
||||
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
|
||||
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
|
||||
echo "fix-workspace-permissions: $path is not writable as $(id -un)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
for path in \
|
||||
/workspaces/fluxer/target \
|
||||
/home/vscode/.cargo/registry \
|
||||
/home/vscode/.cargo/git \
|
||||
/home/vscode/.local \
|
||||
/home/vscode/.local/share/pnpm/store; do
|
||||
repair_tree "$path"
|
||||
done
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
if mountpoint -q "$path"; then
|
||||
repair_tree "$path"
|
||||
fi
|
||||
done < <(find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -print0)
|
||||
@@ -1,11 +1,10 @@
|
||||
port: 7880
|
||||
|
||||
keys:
|
||||
devkey: secret
|
||||
devkey: fluxer-livekit-development-secret
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882-7892
|
||||
node_ip: 127.0.0.1
|
||||
use_mdns: true
|
||||
stun_servers:
|
||||
|
||||
+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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -524,6 +524,7 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -553,11 +554,29 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_release_assets
|
||||
|
||||
- name: Publish GitHub release descriptor
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_descriptor
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_payload
|
||||
|
||||
- name: Upload GitHub release asset handoff
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_release_assets
|
||||
|
||||
- name: Build summary
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
@@ -577,9 +596,17 @@ jobs:
|
||||
- upload
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -590,6 +617,12 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_release_assets
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
@@ -614,8 +647,16 @@ jobs:
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
--asset-dir release_assets
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
- name: Publish GitHub release readiness marker
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -405,6 +460,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
+81
-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",
|
||||
@@ -1989,6 +1959,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"cookie",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"maud",
|
||||
"openapiv3",
|
||||
@@ -2019,16 +1990,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 +2069,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 +2241,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 +2339,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 +2346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever 0.39.0",
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2496,30 +2425,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 +2435,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.14",
|
||||
"h2",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
@@ -2542,21 +2447,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 +2454,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 +2475,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 +2874,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 +2887,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 +3053,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 +3224,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 +3252,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 +3261,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 +3568,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 +3589,7 @@ dependencies = [
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
@@ -3797,7 +3607,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -4015,15 +3825,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 +3841,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4068,7 +3878,7 @@ dependencies = [
|
||||
"futures",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.4.2",
|
||||
"hyper 1.10.1",
|
||||
"hyper",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"retry-policies",
|
||||
@@ -4155,19 +3965,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 +3979,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.13",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -4198,15 +3996,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 +4017,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 +4033,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 +4125,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 +4235,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 +4534,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 +4554,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 +4562,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 +4648,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 +4819,7 @@ dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf 0.13.1",
|
||||
"phf",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
@@ -5103,32 +4836,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 +4895,7 @@ dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
@@ -5713,28 +5436,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 +5516,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"
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
> [!CAUTION]
|
||||
> As of this writing (15 June 2026), we are working to finalise the API and self-hosting documentation over the next few days.
|
||||
>
|
||||
> We apologise for the brief delay in open-source releases. We paused after spam waves created safety concerns while we built out Fluxer's trust and safety infrastructure. During that same stretch, we have been fixing hundreds of bugs, adding new features, and preparing a much improved audio and video system.
|
||||
>
|
||||
> You can already try that work in the Fluxer Canary client: [download Canary](https://canary.fluxer.app/download) or [open Canary on the web](https://web.canary.fluxer.app). The latest stable client remains out of date for now, but over the coming weeks we are finalising the remaining work needed to stabilise the current latest code out in the open.
|
||||
|
||||
> [!NOTE]
|
||||
> Learn about the developer behind Fluxer, the goals of the project, the tech stack, and what's coming next.
|
||||
>
|
||||
> [Read the launch blog post](https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/) | [View full roadmap](https://blog.fluxer.app/roadmap-2026/)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./fluxer_static/marketing/branding/logo-white.svg">
|
||||
|
||||
+12
-1
@@ -20,7 +20,7 @@
|
||||
"bracketSpacing": false,
|
||||
"bracketSameLine": false
|
||||
},
|
||||
"globals": ["React"]
|
||||
"globals": ["React", "__webpack_base_uri__"]
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
@@ -115,6 +115,17 @@
|
||||
}
|
||||
},
|
||||
"assist": {"actions": {"source": {"organizeImports": "on"}}},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
|
||||
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
"assist": {"actions": {"source": {"organizeImports": "off"}}}
|
||||
}
|
||||
],
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
Vendored
+4
-11
@@ -30,12 +30,6 @@ FLUXER_POSTGRES_PASSWORD=fluxer
|
||||
FLUXER_POSTGRES_SSL=false
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS=20
|
||||
FLUXER_POSTGRES_KV_TABLE=fluxer_kv
|
||||
FLUXER_CASSANDRA_HOSTS=cassandra
|
||||
FLUXER_CASSANDRA_PORT=9042
|
||||
FLUXER_CASSANDRA_KEYSPACE=fluxer
|
||||
FLUXER_CASSANDRA_LOCAL_DC=datacenter1
|
||||
FLUXER_CASSANDRA_USERNAME=fluxer
|
||||
FLUXER_CASSANDRA_PASSWORD=fluxer
|
||||
FLUXER_KV_URL=redis://valkey:6379/0
|
||||
FLUXER_NATS_URL=nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
|
||||
@@ -49,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
|
||||
@@ -66,8 +59,9 @@ FLUXER_S3_BUCKET_STATIC=fluxer-static
|
||||
|
||||
FLUXER_LIVEKIT_ENABLED=true
|
||||
FLUXER_LIVEKIT_URL=ws://localhost:8088/livekit
|
||||
FLUXER_LIVEKIT_INTERNAL_URL=http://localhost:7880
|
||||
FLUXER_LIVEKIT_API_KEY=devkey
|
||||
FLUXER_LIVEKIT_API_SECRET=secret
|
||||
FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
|
||||
|
||||
@@ -95,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
|
||||
@@ -105,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,86 @@
|
||||
# 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.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
|
||||
|
||||
# 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 public origin browsers use, without a trailing slash. Derived from the three
|
||||
# values above and correct for the usual https-on-443 setup, so leave it alone
|
||||
# unless you serve Fluxer on a non-default port, where the port must appear here.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
|
||||
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
|
||||
# file win, so a value here is discarded under the proxy overlay with no warning.
|
||||
# Set it only for an unusual default-mode layout, such as serving several
|
||||
# hostnames or binding a non-default TLS port.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=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=
|
||||
|
||||
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
|
||||
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
|
||||
# origin written with a default port never matches a browser Origin header.
|
||||
# Serving on any other port means setting all three, plus the published port
|
||||
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
|
||||
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
|
||||
# stay below the two values it reads. Above them it silently expands to a bare
|
||||
# host with a trailing colon.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
|
||||
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
|
||||
# the container, so change only these when something else already owns the
|
||||
# standard ports or another proxy sits in front. Both take an optional bind
|
||||
# address in front of the port, and 127.0.0.1 keeps the publish off every
|
||||
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
|
||||
# together, because HTTP/3 needs both on the same port.
|
||||
#FLUXER_HTTP_PORT=80
|
||||
#FLUXER_HTTPS_PORT=443
|
||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||
#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. 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}
|
||||
@@ -15,6 +94,7 @@ 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
|
||||
@@ -22,7 +102,10 @@ FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
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 +113,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,15 +142,38 @@ [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. Derived from FLUXER_PUBLIC_SCHEME,
|
||||
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
|
||||
# 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
|
||||
FLUXER_EMAIL_PROVIDER=none
|
||||
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
||||
FLUXER_EMAIL_FROM_NAME=Fluxer
|
||||
FLUXER_EMAIL_APP_BASE_URL=
|
||||
FLUXER_EMAIL_SMTP_HOST=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
FLUXER_EMAIL_SMTP_USERNAME=
|
||||
@@ -63,4 +182,108 @@ 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 16.75 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=512mb
|
||||
#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
|
||||
|
||||
# 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. The Rust built-in defaults are 192
|
||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
||||
|
||||
# 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,58 +1,85 @@
|
||||
{
|
||||
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
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle /gateway {
|
||||
rewrite * /
|
||||
reverse_proxy gateway:8080
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /gateway/* {
|
||||
reverse_proxy gateway:8080
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /media/* {
|
||||
reverse_proxy media-proxy:8080
|
||||
reverse_proxy media-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /livekit/* {
|
||||
reverse_proxy livekit:7880
|
||||
reverse_proxy livekit:7880 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle /admin {
|
||||
rewrite * /
|
||||
reverse_proxy admin:8080
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /admin/* {
|
||||
reverse_proxy admin:8080
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
|
||||
handle @staticAssets {
|
||||
reverse_proxy static-proxy:8080
|
||||
reverse_proxy static-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy app-proxy:8080
|
||||
reverse_proxy app-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:8088 {
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8080
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +1,17 @@
|
||||
name: fluxer
|
||||
|
||||
x-fluxer-postgres-env: &fluxer-postgres-env
|
||||
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_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
|
||||
|
||||
x-fluxer-env: &fluxer-env
|
||||
<<: *fluxer-postgres-env
|
||||
FLUXER_ENV: production
|
||||
NODE_ENV: production
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
@@ -9,14 +20,8 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
|
||||
|
||||
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_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
@@ -39,16 +44,18 @@ x-fluxer-env: &fluxer-env
|
||||
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_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: us-east-1
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
|
||||
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_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}}/livekit}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
|
||||
@@ -56,6 +63,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-none}
|
||||
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
|
||||
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-Fluxer}
|
||||
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
|
||||
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-587}
|
||||
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
|
||||
@@ -65,6 +73,10 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_SMS_ENABLED: "false"
|
||||
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
|
||||
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
|
||||
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: "false"
|
||||
FLUXER_NCMEC_ENABLED: "false"
|
||||
FLUXER_CLAMAV_ENABLED: "false"
|
||||
@@ -78,7 +90,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}
|
||||
@@ -88,36 +100,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_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
|
||||
@@ -132,9 +198,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
|
||||
@@ -143,36 +217,73 @@ 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:-512mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
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}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
@@ -190,6 +301,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;
|
||||
@@ -203,105 +318,182 @@ 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
|
||||
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:-}
|
||||
@@ -315,124 +507,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:-20}"
|
||||
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:-20}"
|
||||
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:-20}"
|
||||
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:-20}"
|
||||
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}
|
||||
|
||||
@@ -441,9 +711,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,4 @@
|
||||
services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
|
||||
@@ -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
|
||||
|
||||
|
||||
+7620
-9490
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,
|
||||
|
||||
@@ -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 = generated_types::SnowflakeType::from(key_id.to_owned());
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::TriggerUserArchiveRequest {
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
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),
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
};
|
||||
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(
|
||||
@@ -73,7 +80,7 @@ impl AdminApiClient {
|
||||
) -> ApiResult<ArchiveDownloadUrlResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_archive_download_url(subject_type, subject_id, archive_id)
|
||||
.get_admin_archive_download(subject_type, subject_id, archive_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -5,11 +5,18 @@ use crate::api::generated::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(
|
||||
&generated_types::SnowflakeType::from(guild_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -23,26 +23,47 @@ 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",
|
||||
nonempty_string(params.query.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
|
||||
(
|
||||
"admin_user_id",
|
||||
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_type",
|
||||
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_id",
|
||||
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by.unwrap_or_default()),
|
||||
("sort_order", sort_order.unwrap_or_default()),
|
||||
("limit", limit),
|
||||
("offset", offset),
|
||||
];
|
||||
let query_params: Vec<(&str, &str)> = query_params
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect();
|
||||
self.get("/admin/audit-logs", Some(&query_params)).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +79,7 @@ fn audit_logs_response(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem) -> AuditLogEntry {
|
||||
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
|
||||
AuditLogEntry {
|
||||
log_id: String::from(entry.log_id),
|
||||
admin_user_id: String::from(entry.admin_user_id),
|
||||
@@ -78,17 +99,17 @@ fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem)
|
||||
}
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
|
||||
+176
-216
@@ -7,209 +7,123 @@ 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::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.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::BanIpRequest { ip: ip.to_owned() }.into(),
|
||||
)
|
||||
.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,
|
||||
suspicious_email_domain_request(domain)?.into(),
|
||||
)
|
||||
.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::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.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::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.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::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.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 +131,22 @@ 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,
|
||||
};
|
||||
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/add", &body, audit_log_reason)
|
||||
.await
|
||||
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
},
|
||||
);
|
||||
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 +154,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,54 +175,45 @@ 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::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.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(
|
||||
.ban_admin_user_avatar(
|
||||
&generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
@@ -318,21 +223,16 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
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,
|
||||
profile_substring_request(scope, substring)?.into(),
|
||||
)
|
||||
.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 +240,76 @@ 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<()> {
|
||||
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 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 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_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(())
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,16 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateUserFlagsRequest {
|
||||
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)
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
task:
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -29,17 +33,16 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateSuspiciousActivityFlagsRequest {
|
||||
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
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_update_guild_features(
|
||||
@@ -49,12 +52,15 @@ impl AdminApiClient {
|
||||
remove_features: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateGuildFeaturesRequest {
|
||||
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)
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -64,11 +70,31 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkAddGuildMembersRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/add-guild-members", &body, audit_log_reason)
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
task:
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
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::from(
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
|
||||
task:
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -80,21 +106,24 @@ 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)?,
|
||||
),
|
||||
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()))?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason(
|
||||
"/admin/bulk/schedule-user-deletion",
|
||||
&body,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
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)?,
|
||||
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,17 +188,105 @@ 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.patch_with_reason(path, body, None).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 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_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<()> {
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
|
||||
@@ -26,7 +26,7 @@ impl AdminApiClient {
|
||||
};
|
||||
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())
|
||||
|
||||
@@ -13,7 +13,7 @@ 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
|
||||
@@ -26,7 +26,7 @@ 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
|
||||
@@ -42,15 +42,18 @@ impl AdminApiClient {
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminReviewRequest {
|
||||
reason: reason
|
||||
.map(generated_types::DiscoveryAdminReviewRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
|
||||
reason: reason
|
||||
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
|
||||
},
|
||||
);
|
||||
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())
|
||||
@@ -62,13 +65,20 @@ impl AdminApiClient {
|
||||
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)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
|
||||
reason:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
|
||||
reason,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
|
||||
},
|
||||
);
|
||||
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())
|
||||
@@ -86,7 +96,7 @@ impl AdminApiClient {
|
||||
};
|
||||
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())
|
||||
|
||||
@@ -32,8 +32,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)]
|
||||
|
||||
@@ -10,7 +10,7 @@ impl AdminApiClient {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
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())
|
||||
@@ -23,7 +23,7 @@ impl AdminApiClient {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
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())
|
||||
|
||||
+77
-133
@@ -17,28 +17,20 @@ 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)
|
||||
.get_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -51,12 +43,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 +58,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 +85,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,43 +103,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,
|
||||
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(())
|
||||
@@ -165,21 +140,22 @@ 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()))?
|
||||
.map(generated_types::Int32Type::from);
|
||||
let response = self
|
||||
.generated()
|
||||
.list_guild_audit_logs_admin(&body)
|
||||
.list_admin_guild_audit_logs(
|
||||
&snowflake(guild_id),
|
||||
None,
|
||||
None,
|
||||
before.as_ref(),
|
||||
limit.as_ref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -188,15 +164,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(())
|
||||
@@ -207,10 +183,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())
|
||||
@@ -221,13 +197,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())
|
||||
@@ -238,37 +214,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())
|
||||
@@ -279,13 +245,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())
|
||||
@@ -297,39 +259,23 @@ 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(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(guild_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,22 +362,21 @@ 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -458,9 +403,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,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
|
||||
pub struct ListJobsParams {
|
||||
@@ -16,51 +14,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(job_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -71,19 +50,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -50,7 +54,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 +66,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 +87,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 +104,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 +117,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(job_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -140,18 +132,18 @@ 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 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 +156,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,18 +178,18 @@ 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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// 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::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
@@ -14,28 +12,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(report_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -47,12 +38,16 @@ 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)]
|
||||
@@ -73,48 +68,37 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> 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 = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let report_type = report_type
|
||||
.map(report_type_name)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let query_params = [
|
||||
("q", query.unwrap_or_default()),
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", category.unwrap_or_default()),
|
||||
("reporter_id", reporter_id.unwrap_or_default()),
|
||||
("reported_user_id", reported_user_id.unwrap_or_default()),
|
||||
("reported_guild_id", reported_guild_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_channel_id",
|
||||
reported_channel_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,
|
||||
})
|
||||
("guild_context_id", guild_context_id.unwrap_or_default()),
|
||||
(
|
||||
"resolved_by_admin_id",
|
||||
resolved_by_admin_id.unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by),
|
||||
("sort_order", 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(
|
||||
@@ -168,12 +152,30 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
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}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,11 @@ impl AdminApiClient {
|
||||
) -> ApiResult<RefreshSearchIndexResponse> {
|
||||
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()))?,
|
||||
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 +27,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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::api::generated::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())
|
||||
@@ -34,7 +32,7 @@ impl AdminApiClient {
|
||||
};
|
||||
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 +41,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 +52,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())
|
||||
|
||||
@@ -22,7 +22,7 @@ impl AdminApiClient {
|
||||
};
|
||||
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,
|
||||
}
|
||||
@@ -826,13 +826,3 @@ pub struct CreateRegistrationUrlResponse {
|
||||
pub code: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RegistrationUrlActionRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PendingRegistrationActionRequest {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+111
-183
@@ -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,17 @@ 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()))?,
|
||||
};
|
||||
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 +378,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 +397,25 @@ 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()))?,
|
||||
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 +429,18 @@ impl AdminApiClient {
|
||||
public_reason: Option<&str>,
|
||||
days_until_deletion: u32,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ScheduleAccountDeletionRequest {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
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::RemoveAdminUserRelationshipCategory::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
self.generated()
|
||||
.remove_user_relationship(&body)
|
||||
.remove_admin_user_relationship(&snowflake(user_id), 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::ClearAdminUserRelationshipsCategory::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,10 +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 bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
|
||||
@@ -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,31 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||
let region_id = required_field(params, "id")?;
|
||||
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)
|
||||
.update_admin_voice_region(®ion_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
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 +89,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 +101,14 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<CreateVoiceServerResponse> {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
|
||||
.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,12 +118,15 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceServerResponse> {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
let server_id = required_field(params, "server_id")?;
|
||||
paired_coordinates(params)?;
|
||||
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)
|
||||
.update_admin_voice_server(®ion_id, &server_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -146,15 +137,34 @@ 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 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")))
|
||||
}
|
||||
|
||||
+131
-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,10 @@ impl AdminConfig {
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.env == RuntimeEnv::Production
|
||||
}
|
||||
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
@@ -166,6 +178,40 @@ 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::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 +333,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 +343,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:19080"),
|
||||
("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))
|
||||
|
||||
@@ -2,24 +2,43 @@
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
extract::Request,
|
||||
extract::{Request, State},
|
||||
http::{HeaderValue, Method, StatusCode, header},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rand::RngExt;
|
||||
|
||||
use crate::middleware::auth::AuthContext;
|
||||
use crate::session::{create_csrf_token, verify_csrf_token};
|
||||
use crate::state::AppState;
|
||||
|
||||
const CSRF_COOKIE_NAME: &str = "csrf_token";
|
||||
pub const CSRF_FORM_FIELD: &str = "_csrf";
|
||||
const CSRF_HEADER_NAME: &str = "x-csrf-token";
|
||||
const TOKEN_LENGTH: usize = 32;
|
||||
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
|
||||
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
|
||||
|
||||
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
|
||||
let existing_token = extract_csrf_cookie(&request);
|
||||
let token = existing_token.unwrap_or_else(generate_csrf_token);
|
||||
pub async fn csrf_protection(
|
||||
State(state): State<AppState>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
|
||||
let user_id = request
|
||||
.extensions()
|
||||
.get::<AuthContext>()
|
||||
.map(|ctx| ctx.session.user_id.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let token = extract_csrf_cookie(&request)
|
||||
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
|
||||
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
|
||||
request.extensions_mut().insert(CsrfToken(token.clone()));
|
||||
|
||||
if matches!(
|
||||
@@ -31,6 +50,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
|
||||
.iter()
|
||||
.any(|suffix| path.ends_with(suffix));
|
||||
if !is_ignored {
|
||||
if !is_same_site_request(&request, &admin_endpoint) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let header_token = extract_csrf_header(&request);
|
||||
let query_token = extract_csrf_from_query(&request);
|
||||
let mut submitted = query_token.or(header_token);
|
||||
@@ -43,40 +65,58 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
|
||||
request = restored_request;
|
||||
submitted = body_token;
|
||||
}
|
||||
match submitted {
|
||||
Some(ref submitted_token) if submitted_token == &token => {}
|
||||
_ => {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
|
||||
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
|
||||
});
|
||||
if !accepted {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
let cookie_value = format!(
|
||||
"{}={}; Path=/; SameSite=Lax; HttpOnly",
|
||||
CSRF_COOKIE_NAME, token
|
||||
);
|
||||
let cookie_name = if secure_cookies {
|
||||
HOST_CSRF_COOKIE_NAME
|
||||
} else {
|
||||
CSRF_COOKIE_NAME
|
||||
};
|
||||
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();
|
||||
if let Some(value) = pair.strip_prefix("csrf_token=") {
|
||||
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> {
|
||||
@@ -127,18 +167,22 @@ async fn extract_csrf_from_form_body(
|
||||
Ok((request, token))
|
||||
}
|
||||
|
||||
fn generate_csrf_token() -> String {
|
||||
let mut rng = rand::rng();
|
||||
let bytes: [u8; TOKEN_LENGTH] = rng.random();
|
||||
hex_encode(&bytes)
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
s.push_str(&format!("{byte:02x}"));
|
||||
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
if let Some(site) = request
|
||||
.headers()
|
||||
.get("sec-fetch-site")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
return matches!(site, "same-origin" | "same-site" | "none");
|
||||
}
|
||||
match request
|
||||
.headers()
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some(origin) => origin == admin_endpoint,
|
||||
None => true,
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -155,39 +199,80 @@ 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;
|
||||
|
||||
#[test]
|
||||
fn generate_csrf_token_correct_length() {
|
||||
let token = generate_csrf_token();
|
||||
assert_eq!(
|
||||
token.len(),
|
||||
TOKEN_LENGTH * 2,
|
||||
"token must be {} hex chars",
|
||||
TOKEN_LENGTH * 2
|
||||
);
|
||||
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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_csrf_token_is_valid_hex() {
|
||||
let token = generate_csrf_token();
|
||||
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!(
|
||||
token.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"token must contain only hex chars: {token}"
|
||||
cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
|
||||
&& cookie.contains("; Secure")),
|
||||
"expected a secure __Host- cookie, got {cookies:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_csrf_token_is_unique() {
|
||||
let a = generate_csrf_token();
|
||||
let b = generate_csrf_token();
|
||||
assert_ne!(a, b, "consecutive tokens must differ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_encode_produces_correct_output() {
|
||||
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
|
||||
assert_eq!(hex_encode(&[]), "");
|
||||
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
|
||||
#[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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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 {
|
||||
""
|
||||
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
|
||||
)
|
||||
}
|
||||
|
||||
fn json_string(value: &str) -> String {
|
||||
pub(crate) fn json_string(value: &str) -> String {
|
||||
serde_json::to_string(value)
|
||||
.expect("JSON string serialization cannot fail")
|
||||
.replace('<', "\\u003c")
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
|
||||
.route("/", get(dashboard))
|
||||
.route("/dashboard", get(dashboard))
|
||||
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
|
||||
.layer(from_fn(middleware::csrf::csrf_protection))
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::csrf::csrf_protection,
|
||||
))
|
||||
.layer(from_fn(middleware::self_hosted::self_hosted_override))
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf,
|
||||
@@ -22,6 +23,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const MAX_REPORT_OFFSET: u32 = 10_000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReportsQuery {
|
||||
q: Option<String>,
|
||||
@@ -70,6 +73,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());
|
||||
@@ -79,7 +90,7 @@ async fn reports_list(
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(
|
||||
query.q.as_deref(),
|
||||
search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
query.category.as_deref(),
|
||||
@@ -102,7 +113,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 +131,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 +218,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 +235,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 +246,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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ pub struct ActionQuery {
|
||||
pub rule: Option<String>,
|
||||
}
|
||||
|
||||
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, prod: bool) -> Response {
|
||||
flash::redirect_with_flash(&format!("{base}{path}"), fd, prod)
|
||||
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, secure: bool) -> Response {
|
||||
flash::redirect_with_flash(&format!("{base}{path}"), fd, secure)
|
||||
}
|
||||
|
||||
pub async fn gateway_post(
|
||||
@@ -63,7 +63,7 @@ pub async fn gateway_post(
|
||||
base,
|
||||
"/gateway",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -80,7 +80,7 @@ pub async fn gateway_post(
|
||||
} else {
|
||||
FlashData::error("Unknown gateway action")
|
||||
};
|
||||
redirect_back_with_flash(base, "/gateway", flash, config.is_production())
|
||||
redirect_back_with_flash(base, "/gateway", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
pub async fn search_index_post(
|
||||
@@ -97,7 +97,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -114,7 +114,7 @@ pub async fn search_index_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/search-index?job_id={job_id}"),
|
||||
FlashData::success("Search index refresh started"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -123,7 +123,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Failed to start search index refresh"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -132,7 +132,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Index type is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ pub async fn instance_config_post(
|
||||
base,
|
||||
"/instance-config",
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -320,7 +320,7 @@ pub async fn instance_config_post(
|
||||
if htmx::is_htmx_request(&headers) {
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.is_production())
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
fn render_registration_url_list_response(
|
||||
@@ -866,13 +866,13 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let action = aq.action.as_deref().unwrap_or("");
|
||||
let is_prod = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
let current = match client.get_limit_config().await {
|
||||
Ok(current) => current,
|
||||
Err(error) => {
|
||||
@@ -881,7 +881,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Failed to fetch current limit configuration"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -895,7 +895,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -908,7 +908,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
let fallback = current
|
||||
@@ -923,7 +923,7 @@ pub async fn limit_config_post(
|
||||
"Limit configuration updated",
|
||||
"Failed to update limit configuration",
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"delete" => {
|
||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||
@@ -933,7 +933,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -942,7 +942,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("The default rule cannot be deleted"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let old_len = limit_config.rules.len();
|
||||
@@ -952,14 +952,14 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash =
|
||||
limit_config_result(result, "Limit rule deleted", "Failed to delete limit rule");
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"create" => {
|
||||
let rule_id = match form.clean("rule_id") {
|
||||
@@ -969,7 +969,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule ID is required"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -978,7 +978,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("The default rule ID is reserved"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
if limit_config.rules.iter().any(|rule| rule.id == rule_id) {
|
||||
@@ -986,7 +986,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule ID already exists"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let limits = current.defaults.get("default").cloned().unwrap_or_default();
|
||||
@@ -1000,7 +1000,7 @@ pub async fn limit_config_post(
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash =
|
||||
limit_config_result(result, "Limit rule created", "Failed to create limit rule");
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1008,7 +1008,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::success("Limit config updated"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -160,7 +160,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 +232,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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use rand::RngExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -78,6 +79,36 @@ fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a st
|
||||
if diff == 0 { Some(data) } else { None }
|
||||
}
|
||||
|
||||
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
|
||||
let mut rng = rand::rng();
|
||||
let nonce: [u8; 16] = rng.random();
|
||||
let payload = URL_SAFE_NO_PAD.encode(format!(
|
||||
"{}:{}",
|
||||
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
|
||||
URL_SAFE_NO_PAD.encode(nonce)
|
||||
));
|
||||
sign_data(&payload, secret_key)
|
||||
}
|
||||
|
||||
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
|
||||
let Some(data) = verify_signature(token, secret_key) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(payload) = String::from_utf8(decoded) else {
|
||||
return false;
|
||||
};
|
||||
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
|
||||
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
|
||||
pub const SESSION_COOKIE_NAME: &str = "admin_session";
|
||||
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::media::user_avatar_url;
|
||||
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
|
||||
use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
|
||||
pub struct Attachment {
|
||||
pub id: String,
|
||||
@@ -309,7 +310,7 @@ pub fn message_list(
|
||||
}
|
||||
|
||||
pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
let csrf = serde_json::to_string(csrf_token).unwrap_or_else(|_| "\"\"".into());
|
||||
let csrf = json_string(csrf_token);
|
||||
let script = r#"(function() {
|
||||
var csrf = __CSRF__;
|
||||
function bp() {
|
||||
|
||||
@@ -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,
|
||||
@@ -205,6 +201,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)
|
||||
@@ -388,3 +387,24 @@ 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"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) }))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
|
||||
const ADMIN_ORIGIN: &str = "https://admin.example.test";
|
||||
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clean_browser_can_submit_an_action() {
|
||||
let app = setup().await;
|
||||
let cookie = app.session_cookie.clone();
|
||||
|
||||
let (cookie_token, page_token) = load_page(&app, &cookie).await;
|
||||
assert_eq!(cookie_token, page_token);
|
||||
|
||||
let with_csrf = format!("{cookie}; __Host-csrf_token={cookie_token}");
|
||||
let status = submit_action(&app, &with_csrf, &page_token).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_csrf_cookie_does_not_wedge_actions() {
|
||||
let app = setup().await;
|
||||
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
|
||||
|
||||
let (cookie_token, page_token) = load_page(&app, &stale).await;
|
||||
assert_eq!(cookie_token, page_token);
|
||||
|
||||
let both = format!("{stale}; __Host-csrf_token={cookie_token}");
|
||||
let status = submit_action(&app, &both, &page_token).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"a leftover unsigned csrf_token cookie must not block actions"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_responses_expire_the_legacy_csrf_cookie() {
|
||||
let app = setup().await;
|
||||
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
|
||||
let headers = page_headers(&app, &stale).await;
|
||||
|
||||
let expiry = headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find(|value| value.starts_with("csrf_token=;"))
|
||||
.unwrap_or_else(|| panic!("legacy cookie was not expired: {headers:?}"));
|
||||
assert!(expiry.contains("Max-Age=0"), "{expiry}");
|
||||
assert!(expiry.contains("Path=/"), "{expiry}");
|
||||
}
|
||||
|
||||
async fn setup() -> TestApp {
|
||||
let api_endpoint = spawn_mock_api().await;
|
||||
let router = build_router(production_config(api_endpoint));
|
||||
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
|
||||
TestApp {
|
||||
router,
|
||||
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
}
|
||||
}
|
||||
|
||||
fn production_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: ADMIN_ORIGIN.to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn page_headers(app: &TestApp, cookie: &str) -> HeaderMap {
|
||||
app.router
|
||||
.clone()
|
||||
.oneshot(page_request(cookie))
|
||||
.await
|
||||
.unwrap()
|
||||
.headers()
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(page_request(cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
let cookie_token = host_csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
|
||||
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
|
||||
(cookie_token, page_token)
|
||||
}
|
||||
|
||||
fn page_request(cookie: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/admin-api-keys")
|
||||
.header(header::COOKIE, cookie)
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusCode {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/admin-api-keys?action=create")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, cookie)
|
||||
.header(header::ORIGIN, ADMIN_ORIGIN)
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.header("HX-Request", "true")
|
||||
.header("HX-Boosted", "true")
|
||||
.header("HX-Target", "body")
|
||||
.body(Body::from(format!(
|
||||
"_csrf={form_token}&name=Legacy+Cookie+Key&acls=*"
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
}
|
||||
|
||||
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|pair| pair.trim().strip_prefix("__Host-csrf_token="))
|
||||
.map(str::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn form_csrf_value(body: &str) -> Option<String> {
|
||||
let marker = r#"name="_csrf" value=""#;
|
||||
body.match_indices(marker)
|
||||
.filter_map(|(index, _)| {
|
||||
let rest = &body[index + marker.len()..];
|
||||
let end = rest.find('"')?;
|
||||
Some(rest[..end].to_owned())
|
||||
})
|
||||
.find(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
async fn spawn_mock_api() -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, Router::new().fallback(mock_api))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
|
||||
(Method::POST, "/admin/api-keys") => Json(json!({
|
||||
"key_id": "1900000000000000001",
|
||||
"key": "fa_1900000000000000001_OneTimeSecretForTests",
|
||||
"name": "Legacy Cookie Key",
|
||||
"created_at": "2026-07-10T15:00:00.000Z",
|
||||
"expires_at": null,
|
||||
"acls": ["*"]
|
||||
}))
|
||||
.into_response(),
|
||||
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_user() -> Value {
|
||||
json!({
|
||||
"id": "1500000000000000000",
|
||||
"username": "AdminUser",
|
||||
"discriminator": 1,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"global_name": "AdminUser",
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"has_verified_phone": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
@@ -218,6 +218,16 @@ async fn user_fragment_alias_returns_drawer_fragment() {
|
||||
assert!(fragment.contains("SearchedUser"), "{fragment}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_peek_alias_is_gone() {
|
||||
let app = setup().await;
|
||||
|
||||
assert_eq!(
|
||||
get_status(&app, "/users/1500000000000000001/peek").await,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drawer_triggers_use_htmx_and_native_popover() {
|
||||
let app = setup().await;
|
||||
@@ -644,6 +654,23 @@ async fn get(app: &TestApp, uri: &str, headers: &[(&str, &str)]) -> String {
|
||||
get_with_headers(app, uri, headers).await.1
|
||||
}
|
||||
|
||||
async fn get_status(app: &TestApp, uri: &str) -> StatusCode {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
}
|
||||
|
||||
async fn get_with_headers(
|
||||
app: &TestApp,
|
||||
uri: &str,
|
||||
@@ -708,11 +735,11 @@ fn csrf_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|pair| pair.strip_prefix("csrf_token="))
|
||||
.map(str::to_owned)
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -752,8 +779,9 @@ async fn spawn_mock_api() -> String {
|
||||
}
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/me") => json_response(json!({ "user": admin_user() })),
|
||||
let path = uri.path().to_owned();
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
|
||||
(Method::GET, "/admin/api-keys") => json_response(json!([])),
|
||||
(Method::POST, "/admin/api-keys") => json_response(json!({
|
||||
"key_id": "1900000000000000001",
|
||||
@@ -763,60 +791,56 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
"expires_at": null,
|
||||
"acls": ["*"]
|
||||
})),
|
||||
(Method::POST, "/admin/users/search") => {
|
||||
(Method::GET, "/admin/users") => {
|
||||
json_response(json!({ "users": [searched_user()], "total": 1 }))
|
||||
}
|
||||
(Method::POST, "/admin/users/lookup") => {
|
||||
(Method::GET, "/admin/users/1500000000000000001") => {
|
||||
json_response(json!({ "users": [searched_user()] }))
|
||||
}
|
||||
(Method::POST, "/admin/users/update-has-verified-phone") => {
|
||||
(Method::PUT, "/admin/users/1500000000000000001/phone-verification") => {
|
||||
json_response(json!({ "user": searched_user() }))
|
||||
}
|
||||
(Method::POST, "/admin/guilds/search") => {
|
||||
(Method::GET, "/admin/guilds") => {
|
||||
json_response(json!({ "guilds": [searched_guild()], "total": 1 }))
|
||||
}
|
||||
(Method::POST, "/admin/guilds/lookup") => {
|
||||
(Method::GET, "/admin/guilds/1600000000000000001") => {
|
||||
json_response(json!({ "guild": searched_guild_detail() }))
|
||||
}
|
||||
(Method::POST, "/admin/applications/lookup") => {
|
||||
json_response(json!({ "application": searched_application() }))
|
||||
}
|
||||
(Method::POST, "/admin/applications/list-by-owner") => {
|
||||
(Method::GET, "/admin/applications") => {
|
||||
json_response(json!({ "applications": [searched_application()] }))
|
||||
}
|
||||
(Method::POST, "/admin/reports/search") => json_response(
|
||||
(Method::GET, "/admin/reports") => json_response(
|
||||
json!({ "reports": [searched_report()], "total": 1, "offset": 0, "limit": 25 }),
|
||||
),
|
||||
(Method::GET, "/admin/reports/1800000000000000001") => json_response(searched_report()),
|
||||
(Method::GET, "/admin/reports/1800000000000000002") => {
|
||||
json_response(searched_message_report())
|
||||
}
|
||||
(Method::POST, "/admin/reports/resolve") => json_response(json!({
|
||||
(Method::PATCH, "/admin/reports/1800000000000000001") => json_response(json!({
|
||||
"report_id": "1800000000000000001",
|
||||
"status": 1,
|
||||
"resolved_at": "2026-05-26T12:03:00.000Z",
|
||||
"public_comment": "done"
|
||||
})),
|
||||
(Method::POST, "/admin/jobs/list") => {
|
||||
(Method::GET, "/admin/jobs") => {
|
||||
json_response(json!({ "jobs": [searched_job()], "next_cursor": null, "cursor": null }))
|
||||
}
|
||||
(Method::POST, "/admin/jobs/get") => json_response(json!({ "job": searched_job() })),
|
||||
(Method::POST, "/admin/instance-config/get") => json_response(instance_config()),
|
||||
(Method::POST, "/admin/instance-config/registration-urls/create") => json_response(json!({
|
||||
(Method::GET, "/admin/jobs/1900000000000000001") => {
|
||||
json_response(json!({ "job": searched_job() }))
|
||||
}
|
||||
(Method::GET, "/admin/instance/config") => json_response(instance_config()),
|
||||
(Method::POST, "/admin/instance/registration-urls") => json_response(json!({
|
||||
"registration_url": registration_url_fixture(),
|
||||
"code": "11111111-1111-4111-8111-111111111111",
|
||||
"url": "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111"
|
||||
})),
|
||||
(Method::POST, "/admin/instance-config/registration-urls/revoke") => {
|
||||
(Method::DELETE, path) if path.starts_with("/admin/instance/registration-urls/") => {
|
||||
json_response(instance_config_without_registration_urls())
|
||||
}
|
||||
(Method::POST, "/admin/instance-config/pending-registrations/approve") => {
|
||||
(Method::PATCH, path) if path.starts_with("/admin/instance/pending-registrations/") => {
|
||||
json_response(instance_config_without_pending_registrations())
|
||||
}
|
||||
(Method::POST, "/admin/instance-config/pending-registrations/reject") => {
|
||||
json_response(instance_config_without_pending_registrations())
|
||||
}
|
||||
(Method::POST, "/admin/limit-config/get") => json_response(limit_config()),
|
||||
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
|
||||
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users/me",
|
||||
"path": "/admin/users/@me",
|
||||
"body_file": "admin_user_me.json"
|
||||
},
|
||||
{
|
||||
@@ -21,28 +21,28 @@
|
||||
"body": "{}"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/users/search",
|
||||
"method": "GET",
|
||||
"path": "/admin/users",
|
||||
"body_file": "search_users.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/users/lookup",
|
||||
"method": "GET",
|
||||
"path": "/admin/users/1508576042312688531",
|
||||
"body_file": "lookup_user.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/guilds/search",
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds",
|
||||
"body_file": "search_guilds.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/guilds/lookup",
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds/1600000000000000001",
|
||||
"body_file": "lookup_guild.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/reports/search",
|
||||
"method": "GET",
|
||||
"path": "/admin/reports",
|
||||
"body_file": "search_reports.json"
|
||||
},
|
||||
{
|
||||
|
||||
+19
-3
@@ -23,11 +23,26 @@ COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @fluxer/config run --if-present generate
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod /out
|
||||
RUN pnpm --filter fluxer_api run build
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
ARG BUILD_VERSION
|
||||
ARG SOURCE_SHA
|
||||
ARG SOURCE_DATE
|
||||
|
||||
LABEL org.opencontainers.image.title="fluxer-api"
|
||||
LABEL org.opencontainers.image.description="Fluxer HTTP API and background workers"
|
||||
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/src/app/fluxer_api
|
||||
|
||||
@@ -48,6 +63,7 @@ RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/s
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
|
||||
COPY --from=deploy /out ./
|
||||
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
|
||||
COPY --from=deploy /usr/src/app/tsconfigs /usr/src/app/tsconfigs
|
||||
|
||||
RUN rm -rf pkgs && \
|
||||
@@ -57,7 +73,7 @@ RUN rm -rf pkgs && \
|
||||
ENV HOME=/usr/src/app
|
||||
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||
ENV NODE_OPTIONS="--enable-source-maps"
|
||||
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
|
||||
ENV BUILD_VERSION=${BUILD_VERSION}
|
||||
|
||||
@@ -65,4 +81,4 @@ USER 65532:65532
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./node_modules/.bin/tsx", "src/AppEntrypoint.ts"]
|
||||
CMD ["node", "dist/AppEntrypoint.js"]
|
||||
|
||||
+15
-2
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts",
|
||||
@@ -17,6 +18,7 @@
|
||||
"@bluesky-social/jwk-jose": "catalog:",
|
||||
"@bluesky-social/oauth-client-node": "catalog:",
|
||||
"@bufbuild/protobuf": "^2.12.0",
|
||||
"@elastic/elasticsearch": "catalog:",
|
||||
"@fluxer/config": "workspace:*",
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"@fluxer/date_utils": "workspace:*",
|
||||
@@ -29,6 +31,9 @@
|
||||
"@fluxer/logger": "workspace:*",
|
||||
"@fluxer/schema": "workspace:*",
|
||||
"@fluxer/snowflake": "workspace:*",
|
||||
"@hono/node-server": "catalog:",
|
||||
"@messageformat/core": "catalog:",
|
||||
"@messageformat/parser": "catalog:",
|
||||
"@pkgs/cache": "workspace:*",
|
||||
"@pkgs/captcha": "workspace:*",
|
||||
"@pkgs/cassandra": "workspace:*",
|
||||
@@ -49,35 +54,43 @@
|
||||
"@pkgs/worker": "workspace:*",
|
||||
"@simplewebauthn/server": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vvo/tzdb": "catalog:",
|
||||
"archiver": "catalog:",
|
||||
"argon2": "catalog:",
|
||||
"bowser": "catalog:",
|
||||
"cassandra-driver": "catalog:",
|
||||
"emoji-regex": "catalog:",
|
||||
"fast-xml-parser": "catalog:",
|
||||
"hi-base32": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"html-entities": "catalog:",
|
||||
"idna-uts46-hx": "catalog:",
|
||||
"ioredis": "catalog:",
|
||||
"itty-time": "catalog:",
|
||||
"jose": "catalog:",
|
||||
"livekit-server-sdk": "catalog:",
|
||||
"lodash": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"maxmind": "catalog:",
|
||||
"mime": "catalog:",
|
||||
"nats": "catalog:",
|
||||
"nodemailer": "catalog:",
|
||||
"pg": "catalog:",
|
||||
"pino": "catalog:",
|
||||
"sharp": "catalog:",
|
||||
"stripe": "catalog:",
|
||||
"tempy": "catalog:",
|
||||
"transliteration": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"uint8array-extras": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"validator": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"msw": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
Vendored
+5
-1
@@ -7,6 +7,8 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,6 +16,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -1,20 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {CacheLogger} from '@pkgs/cache/src/CacheProviderTypes';
|
||||
import type {CacheLookupResult} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
|
||||
export function parseCachedValue<T>(value: string, logger?: CacheLogger): CacheLookupResult<T> {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
return {hit: true, value: JSON.parse(value)};
|
||||
} catch (error) {
|
||||
if (logger) {
|
||||
const truncatedValue = value.length > 200 ? `${value.substring(0, 200)}...` : value;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error({errorMessage, value: truncatedValue}, '[CacheProvider] JSON parse error');
|
||||
}
|
||||
return null;
|
||||
return {hit: false};
|
||||
}
|
||||
}
|
||||
|
||||
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
|
||||
const parsed = parseCachedValue<T>(value, logger);
|
||||
return parsed.hit ? parsed.value : null;
|
||||
}
|
||||
|
||||
export function serializeValue<T>(value: T): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
+160
-9
@@ -1,17 +1,48 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const CACHE_INFLIGHT_MAX_ENTRIES = 10000;
|
||||
const CACHE_INFLIGHT_JOIN_RETRIES = 1;
|
||||
const CACHE_PRODUCE_TIMEOUT_MS = 15000;
|
||||
const CACHE_PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
|
||||
|
||||
interface CacheMSetEntry<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
interface CacheProduceTracking {
|
||||
generation: number;
|
||||
produces: number;
|
||||
}
|
||||
|
||||
interface CacheProduceAbandonment {
|
||||
abandoned: boolean;
|
||||
}
|
||||
|
||||
export type CacheLookupResult<T> = {hit: true; value: T} | {hit: false};
|
||||
|
||||
type CacheTtlSeconds<T> = number | ((value: T) => number);
|
||||
|
||||
type CacheJoinResult<T> = {joined: true; value: T} | {joined: false; error: unknown};
|
||||
|
||||
export abstract class ICacheService {
|
||||
abstract get<T>(key: string): Promise<T | null>;
|
||||
private readonly inflightValues = new Map<string, Promise<unknown>>();
|
||||
private readonly produceInvalidations = new Map<string, CacheProduceTracking>();
|
||||
|
||||
abstract getEntry<T>(key: string): Promise<CacheLookupResult<T>>;
|
||||
|
||||
abstract set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
|
||||
abstract delete(key: string): Promise<void>;
|
||||
protected abstract deleteEntry(key: string): Promise<void>;
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const tracked = this.produceInvalidations.get(key);
|
||||
if (tracked) {
|
||||
tracked.generation += 1;
|
||||
}
|
||||
await this.deleteEntry(key);
|
||||
}
|
||||
|
||||
abstract getAndDelete<T>(key: string): Promise<T | null>;
|
||||
|
||||
@@ -45,13 +76,133 @@ export abstract class ICacheService {
|
||||
|
||||
abstract sismember(key: string, member: string): Promise<boolean>;
|
||||
|
||||
async getOrSet<T>(key: string, valueFactory: () => Promise<T>, ttlSeconds?: number): Promise<T> {
|
||||
const existingValue = await this.get<T>(key);
|
||||
if (existingValue !== null) {
|
||||
return existingValue;
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const entry = await this.getEntry<T>(key);
|
||||
return entry.hit ? entry.value : null;
|
||||
}
|
||||
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
valueFactory: () => Promise<T>,
|
||||
ttlSeconds?: CacheTtlSeconds<T>,
|
||||
produceTimeoutMs: number = CACHE_PRODUCE_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
let generation = this.trackProduce(key);
|
||||
try {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const existing = await this.getEntry<T>(key);
|
||||
if (existing.hit) {
|
||||
return existing.value;
|
||||
}
|
||||
const inflight = this.inflightValues.get(key);
|
||||
if (!inflight) {
|
||||
return await this.produceSingleFlight(key, valueFactory, ttlSeconds, generation, produceTimeoutMs);
|
||||
}
|
||||
const joined = await this.joinInflight<T>(inflight);
|
||||
if (joined.joined) {
|
||||
return joined.value;
|
||||
}
|
||||
if (attempt >= CACHE_INFLIGHT_JOIN_RETRIES) {
|
||||
throw joined.error;
|
||||
}
|
||||
generation = this.currentGeneration(key);
|
||||
}
|
||||
} finally {
|
||||
this.releaseProduce(key);
|
||||
}
|
||||
const newValue = await valueFactory();
|
||||
await this.set(key, newValue, ttlSeconds);
|
||||
return newValue;
|
||||
}
|
||||
|
||||
private trackProduce(key: string): number {
|
||||
const tracked = this.produceInvalidations.get(key);
|
||||
if (tracked) {
|
||||
tracked.produces += 1;
|
||||
return tracked.generation;
|
||||
}
|
||||
this.produceInvalidations.set(key, {generation: 0, produces: 1});
|
||||
return 0;
|
||||
}
|
||||
|
||||
private currentGeneration(key: string): number {
|
||||
return this.produceInvalidations.get(key)?.generation ?? 0;
|
||||
}
|
||||
|
||||
private releaseProduce(key: string): void {
|
||||
const tracked = this.produceInvalidations.get(key);
|
||||
if (!tracked) {
|
||||
return;
|
||||
}
|
||||
tracked.produces -= 1;
|
||||
if (tracked.produces <= 0) {
|
||||
this.produceInvalidations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async joinInflight<T>(inflight: Promise<unknown>): Promise<CacheJoinResult<T>> {
|
||||
try {
|
||||
return {joined: true, value: (await inflight) as T};
|
||||
} catch (error) {
|
||||
return {joined: false, error};
|
||||
}
|
||||
}
|
||||
|
||||
private async produceSingleFlight<T>(
|
||||
key: string,
|
||||
valueFactory: () => Promise<T>,
|
||||
ttlSeconds: CacheTtlSeconds<T> | undefined,
|
||||
generation: number,
|
||||
produceTimeoutMs: number,
|
||||
): Promise<T> {
|
||||
const abandonment: CacheProduceAbandonment = {abandoned: false};
|
||||
const produced = this.boundProduce(
|
||||
this.produceAndStore(key, valueFactory, ttlSeconds, generation, abandonment),
|
||||
abandonment,
|
||||
produceTimeoutMs,
|
||||
);
|
||||
if (this.inflightValues.size >= CACHE_INFLIGHT_MAX_ENTRIES) {
|
||||
return await produced;
|
||||
}
|
||||
const pending = produced.finally(() => {
|
||||
this.inflightValues.delete(key);
|
||||
});
|
||||
this.inflightValues.set(key, pending);
|
||||
return await pending;
|
||||
}
|
||||
|
||||
private boundProduce<T>(
|
||||
produced: Promise<T>,
|
||||
abandonment: CacheProduceAbandonment,
|
||||
produceTimeoutMs: number,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
abandonment.abandoned = true;
|
||||
reject(new Error(CACHE_PRODUCE_TIMEOUT_MESSAGE));
|
||||
}, produceTimeoutMs);
|
||||
timer.unref?.();
|
||||
produced.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async produceAndStore<T>(
|
||||
key: string,
|
||||
valueFactory: () => Promise<T>,
|
||||
ttlSeconds: CacheTtlSeconds<T> | undefined,
|
||||
generation: number,
|
||||
abandonment: CacheProduceAbandonment,
|
||||
): Promise<T> {
|
||||
const value = await valueFactory();
|
||||
if (!abandonment.abandoned && this.currentGeneration(key) === generation) {
|
||||
await this.set(key, value, typeof ttlSeconds === 'function' ? ttlSeconds(value) : ttlSeconds);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user