mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
391
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -8,7 +8,7 @@ ARG USER_GID=1000
|
||||
ARG NODE_MAJOR=24
|
||||
ARG ELP_VERSION=2026-02-27
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
ARG WASM_BINDGEN_VERSION=0.2.122
|
||||
ARG WASM_BINDGEN_VERSION=0.2.123
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -32,6 +32,7 @@ RUN apt-get update \
|
||||
jq \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libaom-dev \
|
||||
libavcodec-dev \
|
||||
libavfilter-dev \
|
||||
libavformat-dev \
|
||||
@@ -51,9 +52,15 @@ RUN apt-get update \
|
||||
libcurl4-openssl-dev \
|
||||
libswresample-dev \
|
||||
libswscale-dev \
|
||||
libdav1d-dev \
|
||||
libde265-dev \
|
||||
liblcms2-dev \
|
||||
libvips-dev \
|
||||
libyuv-dev \
|
||||
libwayland-dev \
|
||||
libwebp-dev \
|
||||
nasm \
|
||||
yasm \
|
||||
libssl-dev \
|
||||
libx11-xcb1 \
|
||||
libxcb-dri3-0 \
|
||||
@@ -78,6 +85,7 @@ RUN apt-get update \
|
||||
unzip \
|
||||
xz-utils \
|
||||
xdg-utils \
|
||||
zlib1g-dev \
|
||||
zstd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour
|
||||
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
|
||||
|
||||
COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh
|
||||
RUN /tmp/fluxer-install-native-deps.sh /usr/local \
|
||||
&& rm /tmp/fluxer-install-native-deps.sh
|
||||
|
||||
ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig
|
||||
ENV LD_LIBRARY_PATH=/usr/local/lib
|
||||
|
||||
RUN printf '%s\n' \
|
||||
'#include <libheif/heif.h>' \
|
||||
'#include <string.h>' \
|
||||
'#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \
|
||||
'#error the source-built libheif headers must win the include search' \
|
||||
'#endif' \
|
||||
'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \
|
||||
>/tmp/fluxer-heif-probe.c \
|
||||
&& cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \
|
||||
&& /tmp/fluxer-heif-probe \
|
||||
&& [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \
|
||||
&& rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe
|
||||
|
||||
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
|
||||
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
|
||||
&& rm /tmp/fluxer-fonts-requirements.txt \
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: fluxer-dev
|
||||
|
||||
services:
|
||||
workspace:
|
||||
build:
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
-1
@@ -20,7 +20,7 @@
|
||||
"bracketSpacing": false,
|
||||
"bracketSameLine": false
|
||||
},
|
||||
"globals": ["React"]
|
||||
"globals": ["React", "__webpack_base_uri__"]
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
|
||||
Vendored
-1
@@ -43,7 +43,6 @@ FLUXER_SVC_NATS_URL=nats://nats:4222
|
||||
FLUXER_SVC_SHARD_COUNT=1
|
||||
FLUXER_SVC_CACHE_TTL_MS=30000
|
||||
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS=64
|
||||
|
||||
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
|
||||
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
|
||||
|
||||
@@ -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,8 +1,41 @@
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}
|
||||
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
|
||||
|
||||
# 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_CADDY_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}
|
||||
FLUXER_IMAGE_TAG=v1
|
||||
@@ -30,6 +63,7 @@ [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
|
||||
@@ -49,6 +83,20 @@ [email protected]
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# Ports LiveKit publishes on the host for voice and video media. They take the
|
||||
# same optional bind address as the Caddy ports above. This media does not pass
|
||||
# through Caddy or through a tunnel, so it needs these ports reachable from
|
||||
# clients. LiveKit advertises the port numbers from livekit.yaml, so publishing
|
||||
# them on different host ports means changing that file too.
|
||||
#FLUXER_LIVEKIT_TCP_PORT=7881
|
||||
#FLUXER_LIVEKIT_UDP_PORT=7882
|
||||
|
||||
# The voice server URL clients connect to. It defaults to FLUXER_PUBLIC_ORIGIN
|
||||
# plus /livekit, which the bundled Caddy proxies to the LiveKit container. Set
|
||||
# it only when LiveKit lives on its own host, and add that origin to
|
||||
# FLUXER_CSP_EXTRA_CONNECT_SRC when you do.
|
||||
#FLUXER_LIVEKIT_URL=wss://voice.example.com
|
||||
|
||||
FLUXER_KLIPY_API_KEY=
|
||||
|
||||
FLUXER_EMAIL_ENABLED=false
|
||||
@@ -65,3 +113,68 @@ FLUXER_EMAIL_SMTP_SECURE=true
|
||||
FLUXER_CAPTCHA_ENABLED=false
|
||||
FLUXER_CAPTCHA_PROVIDER=none
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. Every service limit and reservation below has a default that
|
||||
# assumes a host with at least 16 GB of RAM. Limits are per-container ceilings, so
|
||||
# their sum may exceed host RAM; the reservations are what protect the services
|
||||
# whose death takes the whole instance down. Lower these on a smaller host.
|
||||
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
#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_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
|
||||
# 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
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache: the bulk message
|
||||
# deletion queue, the account deletion queue and every distributed lock, none of
|
||||
# which carry an expiry. 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. 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
|
||||
|
||||
# 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,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
|
||||
@@ -50,6 +55,7 @@ x-fluxer-env: &fluxer-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:-}
|
||||
|
||||
@@ -80,7 +86,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
|
||||
@@ -90,36 +96,89 @@ 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:
|
||||
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}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
depends_on: [api, gateway, media-proxy, static-proxy, admin]
|
||||
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=256MB
|
||||
-c autovacuum_work_mem=128MB
|
||||
-c random_page_cost=1.1
|
||||
-c effective_io_concurrency=200
|
||||
-c default_statistics_target=200
|
||||
-c jit=off
|
||||
-c min_wal_size=512MB
|
||||
-c max_wal_size=2GB
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_buffers=16MB
|
||||
-c wal_compression=zstd
|
||||
-c bgwriter_delay=50ms
|
||||
-c bgwriter_lru_maxpages=1000
|
||||
-c autovacuum_vacuum_scale_factor=0.05
|
||||
-c autovacuum_analyze_scale_factor=0.02
|
||||
-c autovacuum_vacuum_cost_limit=2000
|
||||
-c track_io_timing=on
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
shm_size: 256mb
|
||||
environment:
|
||||
POSTGRES_DB: fluxer
|
||||
POSTGRES_USER: fluxer
|
||||
@@ -134,9 +193,17 @@ services:
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:8.1-alpine
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_VALKEY_MEMORY_LIMIT:-256mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: ["valkey-server", "--save", "", "--appendonly", "no"]
|
||||
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data",
|
||||
"--maxmemory", "${FLUXER_VALKEY_MAXMEMORY:-192mb}",
|
||||
"--maxmemory-policy", "${FLUXER_VALKEY_MAXMEMORY_POLICY:-noeviction}"]
|
||||
volumes:
|
||||
- valkey-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
@@ -145,35 +212,68 @@ 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: 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/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
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"
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -205,6 +305,10 @@ 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"]
|
||||
@@ -215,95 +319,152 @@ services:
|
||||
ports:
|
||||
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:7881"
|
||||
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}: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: ["node", "dist/WorkerEntrypoint.js"]
|
||||
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
|
||||
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
|
||||
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
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
|
||||
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
|
||||
@@ -317,124 +478,198 @@ services:
|
||||
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
caddy: {condition: service_started}
|
||||
postgres: {condition: service_healthy}
|
||||
caddy: {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_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_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
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: "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_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_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}
|
||||
|
||||
@@ -446,6 +681,7 @@ volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
postgres-data:
|
||||
valkey-data:
|
||||
nats-data:
|
||||
meilisearch-data:
|
||||
seaweedfs-data:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
services:
|
||||
caddy:
|
||||
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
|
||||
|
||||
|
||||
+83
-1377
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";
|
||||
@@ -155,11 +153,9 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
BAN_PROFILE_SUBSTRING_ADD,
|
||||
BAN_PROFILE_SUBSTRING_CHECK,
|
||||
BAN_PROFILE_SUBSTRING_REMOVE,
|
||||
BILLING_MANAGE_SUBSCRIPTION,
|
||||
BILLING_REFUND,
|
||||
BILLING_VIEW,
|
||||
BULK_ADD_GUILD_MEMBERS,
|
||||
BULK_DELETE_USERS,
|
||||
BULK_DELETE_USER_MESSAGES,
|
||||
BULK_UPDATE_GUILD_FEATURES,
|
||||
BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
BULK_UPDATE_USER_FLAGS,
|
||||
|
||||
@@ -12,7 +12,6 @@ pub struct I32Flag {
|
||||
|
||||
pub mod user_flag_bits {
|
||||
pub const STAFF: u64 = 1 << 0;
|
||||
pub const CTP_MEMBER: u64 = 1 << 1;
|
||||
pub const PARTNER: u64 = 1 << 2;
|
||||
pub const BUG_HUNTER: u64 = 1 << 3;
|
||||
pub const FRIENDLY_BOT: u64 = 1 << 4;
|
||||
@@ -40,10 +39,6 @@ pub const USER_FLAGS: &[U64Flag] = &[
|
||||
name: "STAFF",
|
||||
value: user_flag_bits::STAFF,
|
||||
},
|
||||
U64Flag {
|
||||
name: "CTP_MEMBER",
|
||||
value: user_flag_bits::CTP_MEMBER,
|
||||
},
|
||||
U64Flag {
|
||||
name: "PARTNER",
|
||||
value: user_flag_bits::PARTNER,
|
||||
|
||||
@@ -1,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(())
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,18 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_delete_user_messages(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkDeleteUserMessagesRequest {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/delete-user-messages", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_schedule_user_deletion(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
|
||||
@@ -32,10 +32,6 @@ 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"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{number_to_u64, types::*};
|
||||
|
||||
@@ -136,6 +136,7 @@ impl AdminApiClient {
|
||||
let body = generated_types::BanGuildMemberRequest {
|
||||
ban_duration_seconds: None,
|
||||
delete_message_days: None,
|
||||
delete_message_seconds: None,
|
||||
guild_id: snowflake(guild_id),
|
||||
reason: None,
|
||||
user_id: snowflake(user_id),
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
+118
-20
@@ -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";
|
||||
@@ -42,14 +43,14 @@ pub enum RuntimeEnv {
|
||||
impl AdminConfig {
|
||||
pub fn from_env() -> 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"),
|
||||
);
|
||||
));
|
||||
|
||||
Self {
|
||||
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
|
||||
@@ -63,17 +64,19 @@ impl AdminConfig {
|
||||
"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",
|
||||
@@ -166,6 +169,39 @@ 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") };
|
||||
for (name, value) in vars {
|
||||
unsafe { env::set_var(name, value) };
|
||||
}
|
||||
let config = AdminConfig::from_env();
|
||||
for (name, _) in vars {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_strips_trailing_slashes() {
|
||||
@@ -287,18 +323,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 +333,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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 is_production = config.is_production();
|
||||
|
||||
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 is_production {
|
||||
HOST_CSRF_COOKIE_NAME
|
||||
} else {
|
||||
CSRF_COOKIE_NAME
|
||||
};
|
||||
let secure = if is_production { "; 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 is_production
|
||||
&& 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)]
|
||||
@@ -156,40 +200,6 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_csrf_token_is_valid_hex() {
|
||||
let token = generate_csrf_token();
|
||||
assert!(
|
||||
token.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"token must contain only hex chars: {token}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
let exempt = IGNORED_PATH_SUFFIXES
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -262,6 +262,12 @@ pub(crate) async fn bulk_actions_post(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"bulk-delete-user-messages" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
client
|
||||
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
"bulk_delete_users" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
client
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function createRecordingProvider(): {
|
||||
client: IKVProvider;
|
||||
commands: Array<Array<string>>;
|
||||
} {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const client = {
|
||||
set: async (key: string) => {
|
||||
commands.push([key]);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string) => {
|
||||
commands.push([key]);
|
||||
},
|
||||
isClustered: () => true,
|
||||
pipeline: () => {
|
||||
const keys: Array<string> = [];
|
||||
commands.push(keys);
|
||||
const batch = {
|
||||
set: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
setex: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
exec: async () => [],
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
return {client, commands};
|
||||
}
|
||||
|
||||
describe('KVCacheProvider cluster hash slots', () => {
|
||||
it('keeps a multi entry write off batched commands that span hash slots', async () => {
|
||||
const {client, commands} = createRecordingProvider();
|
||||
const provider = new KVCacheProvider({client});
|
||||
|
||||
expect(computeHashSlot('cache:alpha')).not.toBe(computeHashSlot('cache:beta'));
|
||||
|
||||
await provider.mset([
|
||||
{key: 'cache:alpha', value: 1, ttlSeconds: 60},
|
||||
{key: 'cache:beta', value: 2},
|
||||
]);
|
||||
|
||||
expect(commands.flat().sort()).toEqual(['cache:alpha', 'cache:beta']);
|
||||
expect(commands.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const MAX_CONCURRENT_ROUND_TRIPS = 16;
|
||||
|
||||
interface RecordingProvider {
|
||||
client: IKVProvider;
|
||||
batches: Array<Array<string>>;
|
||||
peakInFlight: number;
|
||||
}
|
||||
|
||||
function createRecordingProvider(clustered: boolean): RecordingProvider {
|
||||
const recorder: RecordingProvider = {
|
||||
client: {} as IKVProvider,
|
||||
batches: [],
|
||||
peakInFlight: 0,
|
||||
};
|
||||
let inFlight = 0;
|
||||
const trackRoundTrip = async (keys: Array<string>): Promise<void> => {
|
||||
recorder.batches.push(keys);
|
||||
inFlight += 1;
|
||||
recorder.peakInFlight = Math.max(recorder.peakInFlight, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
inFlight -= 1;
|
||||
};
|
||||
recorder.client = {
|
||||
isClustered: () => clustered,
|
||||
set: async (key: string) => {
|
||||
await trackRoundTrip([key]);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string) => {
|
||||
await trackRoundTrip([key]);
|
||||
},
|
||||
pipeline: () => {
|
||||
const keys: Array<string> = [];
|
||||
const batch = {
|
||||
set: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
setex: (key: string) => {
|
||||
keys.push(key);
|
||||
return batch;
|
||||
},
|
||||
exec: async () => {
|
||||
await trackRoundTrip(keys);
|
||||
return [];
|
||||
},
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
return recorder;
|
||||
}
|
||||
|
||||
function createEntries(count: number): Array<{key: string; value: number; ttlSeconds: number}> {
|
||||
return Array.from({length: count}, (_unused, index) => ({
|
||||
key: `cache:entry:${index}`,
|
||||
value: index,
|
||||
ttlSeconds: 60,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('KVCacheProvider multi entry write fan out', () => {
|
||||
it('writes every entry in one round trip outside cluster mode', async () => {
|
||||
const recorder = createRecordingProvider(false);
|
||||
const provider = new KVCacheProvider({client: recorder.client});
|
||||
|
||||
await provider.mset(createEntries(1000));
|
||||
|
||||
expect(recorder.batches.map((keys) => keys.length)).toEqual([1000]);
|
||||
expect(recorder.peakInFlight).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces a failed command inside a batched write', async () => {
|
||||
const client = {
|
||||
isClustered: () => false,
|
||||
pipeline: () => {
|
||||
const batch = {
|
||||
set: () => batch,
|
||||
setex: () => batch,
|
||||
exec: async () => [[new Error('write rejected'), null]],
|
||||
} as unknown as IKVPipeline;
|
||||
return batch;
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
const provider = new KVCacheProvider({client});
|
||||
|
||||
await expect(provider.mset(createEntries(2))).rejects.toThrow('write rejected');
|
||||
});
|
||||
|
||||
it('bounds concurrent round trips when entries span hash slots', async () => {
|
||||
const recorder = createRecordingProvider(true);
|
||||
const provider = new KVCacheProvider({client: recorder.client});
|
||||
|
||||
await provider.mset(createEntries(1000));
|
||||
|
||||
expect(recorder.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
|
||||
expect(recorder.batches.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
|
||||
expect(recorder.batches.flat().length).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
|
||||
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function createKVCacheProvider(): {
|
||||
provider: KVCacheProvider;
|
||||
store: Map<string, string>;
|
||||
ttls: Array<[string, number]>;
|
||||
} {
|
||||
const store = new Map<string, string>();
|
||||
const ttls: Array<[string, number]> = [];
|
||||
const client = {
|
||||
get: async (key: string) => store.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
},
|
||||
setex: async (key: string, ttlSeconds: number, value: string) => {
|
||||
ttls.push([key, ttlSeconds]);
|
||||
store.set(key, value);
|
||||
},
|
||||
} as unknown as IKVProvider;
|
||||
return {provider: new KVCacheProvider({client}), store, ttls};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('ICacheService.getOrSet', () => {
|
||||
it('runs the factory once for concurrent callers on the same key', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const factory = vi.fn(async () => {
|
||||
await delay(10);
|
||||
return 7;
|
||||
});
|
||||
const results = await Promise.all([
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
]);
|
||||
expect(results).toEqual([7, 7, 7]);
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
await expect(cache.get('key')).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it('does not coalesce concurrent callers on different keys', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const factory = vi.fn(async () => {
|
||||
await delay(10);
|
||||
return 1;
|
||||
});
|
||||
await Promise.all([cache.getOrSet('a', factory), cache.getOrSet('b', factory)]);
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches a null factory result and serves it as a hit', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const factory = vi.fn(async () => null);
|
||||
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('serves a stored json null from the kv provider as a hit', async () => {
|
||||
const {provider, store} = createKVCacheProvider();
|
||||
const factory = vi.fn(async () => null);
|
||||
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
expect(store.get('key')).toBe('null');
|
||||
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats an unparseable stored value as a miss', async () => {
|
||||
const {provider, store} = createKVCacheProvider();
|
||||
store.set('key', '{not json');
|
||||
const factory = vi.fn(async () => 3);
|
||||
await expect(provider.getOrSet('key', factory, 60)).resolves.toBe(3);
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves the ttl from the produced value', async () => {
|
||||
const {provider, store, ttls} = createKVCacheProvider();
|
||||
const resolver = (value: number | null) => (value === null ? 5 : 30);
|
||||
await expect(provider.getOrSet<number | null>('present', async () => 1, resolver)).resolves.toBe(1);
|
||||
await expect(provider.getOrSet<number | null>('absent', async () => null, resolver)).resolves.toBeNull();
|
||||
expect(ttls).toEqual([
|
||||
['present', 30],
|
||||
['absent', 5],
|
||||
]);
|
||||
expect(store.get('absent')).toBe('null');
|
||||
});
|
||||
|
||||
it('rejects every waiter after a single coalesced retry when the factory keeps failing', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const failing = vi.fn(async () => {
|
||||
await delay(10);
|
||||
throw new Error('factory failed');
|
||||
});
|
||||
const settled = await Promise.allSettled([
|
||||
cache.getOrSet('key', failing),
|
||||
cache.getOrSet('key', failing),
|
||||
cache.getOrSet('key', failing),
|
||||
cache.getOrSet('key', failing),
|
||||
]);
|
||||
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected', 'rejected', 'rejected']);
|
||||
expect(failing).toHaveBeenCalledTimes(2);
|
||||
await expect(cache.exists('key')).resolves.toBe(false);
|
||||
const succeeding = vi.fn(async () => 11);
|
||||
await expect(cache.getOrSet('key', succeeding)).resolves.toBe(11);
|
||||
expect(succeeding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fan a transient producer failure out to the callers that joined it', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
let calls = 0;
|
||||
const factory = vi.fn(async () => {
|
||||
calls += 1;
|
||||
const attempt = calls;
|
||||
await delay(10);
|
||||
if (attempt === 1) {
|
||||
throw new Error('transient failure');
|
||||
}
|
||||
return 11;
|
||||
});
|
||||
const settled = await Promise.allSettled([
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
]);
|
||||
expect(settled.map((result) => result.status)).toEqual(['rejected', 'fulfilled', 'fulfilled', 'fulfilled']);
|
||||
expect(settled.filter((result) => result.status === 'fulfilled').map((result) => result.value)).toEqual([
|
||||
11, 11, 11,
|
||||
]);
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
await expect(cache.get('key')).resolves.toBe(11);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const INFLIGHT_OVERFLOW_ENTRIES = 10000;
|
||||
const PRODUCE_TIMEOUT_MS = 50;
|
||||
const PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
|
||||
|
||||
function deferred<T>(): {promise: Promise<T>; resolve: (value: T) => void; reject: (error: Error) => void} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return {promise, resolve, reject};
|
||||
}
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function settleWithin<T>(pending: Promise<T>, ms: number): Promise<T | 'pinned' | 'rejected'> {
|
||||
return Promise.race([
|
||||
pending.then(
|
||||
(value) => value,
|
||||
() => 'rejected' as const,
|
||||
),
|
||||
new Promise<'pinned'>((resolve) => setTimeout(() => resolve('pinned'), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
function trackedProduceKeys(cache: InMemoryProvider): Array<string> {
|
||||
return [...(cache as unknown as {produceInvalidations: Map<string, unknown>}).produceInvalidations.keys()];
|
||||
}
|
||||
|
||||
describe('cache invalidation during an in-flight produce', () => {
|
||||
it('does not resurrect a value deleted while the factory was running', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
|
||||
await cache.delete('session');
|
||||
gate.resolve('revoked-session');
|
||||
await expect(pending).resolves.toBe('revoked-session');
|
||||
expect(await cache.get('session')).toBeNull();
|
||||
});
|
||||
|
||||
it('still stores the value when no invalidation happens', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
|
||||
gate.resolve('live-session');
|
||||
await pending;
|
||||
expect(await cache.get('session')).toBe('live-session');
|
||||
});
|
||||
|
||||
it('does not resurrect a value deleted while a retried produce was running', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
gates.push(gate);
|
||||
return await gate.promise;
|
||||
};
|
||||
const producer = cache.getOrSet('session', factory, 30);
|
||||
const joiner = cache.getOrSet('session', factory, 30);
|
||||
await flush();
|
||||
gates[0].reject(new Error('produce failed'));
|
||||
await expect(producer).rejects.toThrow('produce failed');
|
||||
await flush();
|
||||
expect(gates).toHaveLength(2);
|
||||
await cache.delete('session');
|
||||
gates[1].resolve('fresh-after-delete');
|
||||
await expect(joiner).resolves.toBe('fresh-after-delete');
|
||||
expect(await cache.get('session')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores the value a retried produce built when no invalidation happens', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
gates.push(gate);
|
||||
return await gate.promise;
|
||||
};
|
||||
const producer = cache.getOrSet('session', factory, 30);
|
||||
const joiner = cache.getOrSet('session', factory, 30);
|
||||
await flush();
|
||||
gates[0].reject(new Error('produce failed'));
|
||||
await expect(producer).rejects.toThrow('produce failed');
|
||||
await flush();
|
||||
gates[1].resolve('retried-session');
|
||||
await expect(joiner).resolves.toBe('retried-session');
|
||||
expect(await cache.get('session')).toBe('retried-session');
|
||||
});
|
||||
|
||||
it('stores the value a retried produce built after the first produce was invalidated', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
gates.push(gate);
|
||||
return await gate.promise;
|
||||
};
|
||||
const producer = cache.getOrSet('session', factory, 30);
|
||||
const joiner = cache.getOrSet('session', factory, 30);
|
||||
await flush();
|
||||
await cache.delete('session');
|
||||
gates[0].reject(new Error('produce failed'));
|
||||
await expect(producer).rejects.toThrow('produce failed');
|
||||
await flush();
|
||||
expect(gates).toHaveLength(2);
|
||||
gates[1].resolve('retried-session');
|
||||
await expect(joiner).resolves.toBe('retried-session');
|
||||
expect(await cache.get('session')).toBe('retried-session');
|
||||
});
|
||||
|
||||
it('does not resurrect a value deleted after a concurrent caller released its produce', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
|
||||
await flush();
|
||||
await cache.set('session', 'served-from-cache', 30);
|
||||
await expect(cache.getOrSet('session', async () => 'unused', 30)).resolves.toBe('served-from-cache');
|
||||
await cache.delete('session');
|
||||
gate.resolve('stale-produce');
|
||||
await expect(pending).resolves.toBe('stale-produce');
|
||||
expect(await cache.get('session')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resurrect a value deleted while a second overflow produce was running', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const filling: Array<Promise<string>> = [];
|
||||
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
|
||||
const gate = deferred<string>();
|
||||
fillers.push(gate);
|
||||
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
|
||||
}
|
||||
await flush();
|
||||
const first = deferred<string>();
|
||||
const second = deferred<string>();
|
||||
const firstProduce = cache.getOrSet('session', async () => await first.promise, 30);
|
||||
const secondProduce = cache.getOrSet('session', async () => await second.promise, 30);
|
||||
await flush();
|
||||
first.resolve('first-produce');
|
||||
await expect(firstProduce).resolves.toBe('first-produce');
|
||||
await cache.delete('session');
|
||||
second.resolve('second-produce');
|
||||
await expect(secondProduce).resolves.toBe('second-produce');
|
||||
expect(await cache.get('session')).toBeNull();
|
||||
for (const gate of fillers) {
|
||||
gate.resolve('filler');
|
||||
}
|
||||
await Promise.all(filling);
|
||||
});
|
||||
|
||||
it('drops produce tracking once the last produce for a key settles', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
for (let index = 0; index < 50; index++) {
|
||||
const gate = deferred<string>();
|
||||
const pending = cache.getOrSet(`session:${index}`, async () => await gate.promise, 30);
|
||||
await cache.delete(`session:${index}`);
|
||||
gate.resolve('value');
|
||||
await pending;
|
||||
}
|
||||
const shared = deferred<string>();
|
||||
const producer = cache.getOrSet('shared', async () => await shared.promise, 30);
|
||||
const joiner = cache.getOrSet('shared', async () => 'unused', 30);
|
||||
await flush();
|
||||
await cache.delete('shared');
|
||||
shared.resolve('shared-value');
|
||||
await Promise.all([producer, joiner]);
|
||||
const failing = cache.getOrSet(
|
||||
'failing',
|
||||
async () => {
|
||||
throw new Error('produce failed');
|
||||
},
|
||||
30,
|
||||
);
|
||||
await expect(failing).rejects.toThrow('produce failed');
|
||||
expect(trackedProduceKeys(cache)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not pin a key forever when the factory never settles', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const stuck = deferred<string>();
|
||||
const pinned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(settleWithin(pinned, 500)).resolves.toBe('rejected');
|
||||
await expect(pinned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
const recovered = cache.getOrSet('session', async () => 'recovered', 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(settleWithin(recovered, 500)).resolves.toBe('recovered');
|
||||
stuck.resolve('never-settled');
|
||||
await flush();
|
||||
expect(await cache.get('session')).toBe('recovered');
|
||||
});
|
||||
|
||||
it('does not store a value produced by a factory that settled after the timeout', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const stuck = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
stuck.resolve('late-produce');
|
||||
await flush();
|
||||
expect(await cache.get('session')).toBeNull();
|
||||
expect(trackedProduceKeys(cache)).toEqual([]);
|
||||
});
|
||||
|
||||
it('retries once for the joiners when the producer times out', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
gates.push(gate);
|
||||
return await gate.promise;
|
||||
};
|
||||
const producer = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
|
||||
const joiner = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(producer).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
await flush();
|
||||
expect(gates).toHaveLength(2);
|
||||
gates[1].resolve('retried-session');
|
||||
await expect(joiner).resolves.toBe('retried-session');
|
||||
expect(await cache.get('session')).toBe('retried-session');
|
||||
gates[0].resolve('abandoned-produce');
|
||||
await flush();
|
||||
expect(await cache.get('session')).toBe('retried-session');
|
||||
});
|
||||
|
||||
it('releases produce tracking when the factory never settles', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const stuck = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
await flush();
|
||||
expect(trackedProduceKeys(cache)).toEqual([]);
|
||||
});
|
||||
|
||||
it('stores a sibling produce that succeeded after an overflow produce timed out', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const filling: Array<Promise<string>> = [];
|
||||
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
|
||||
const gate = deferred<string>();
|
||||
fillers.push(gate);
|
||||
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
|
||||
}
|
||||
await flush();
|
||||
const stuck = deferred<string>();
|
||||
const sibling = deferred<string>();
|
||||
const abandoned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
const succeeding = cache.getOrSet('session', async () => await sibling.promise, 30, PRODUCE_TIMEOUT_MS * 100);
|
||||
await expect(abandoned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
sibling.resolve('sibling-produce');
|
||||
await expect(succeeding).resolves.toBe('sibling-produce');
|
||||
expect(await cache.get('session')).toBe('sibling-produce');
|
||||
for (const gate of fillers) {
|
||||
gate.resolve('filler');
|
||||
}
|
||||
await Promise.all(filling);
|
||||
});
|
||||
|
||||
it('does not hold the event loop open while a produce is in flight', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const stuck = deferred<string>();
|
||||
const timers: Array<NodeJS.Timeout> = [];
|
||||
const scheduled = globalThis.setTimeout;
|
||||
const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number) => {
|
||||
const timer = scheduled(handler, ms);
|
||||
if (ms === PRODUCE_TIMEOUT_MS) {
|
||||
timers.push(timer);
|
||||
}
|
||||
return timer;
|
||||
}) as typeof globalThis.setTimeout);
|
||||
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
|
||||
await flush();
|
||||
spy.mockRestore();
|
||||
expect(timers).toHaveLength(1);
|
||||
expect(timers[0].hasRef()).toBe(false);
|
||||
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
|
||||
});
|
||||
|
||||
it('keeps a later produce cacheable after an earlier one was invalidated', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const first = deferred<string>();
|
||||
const pending = cache.getOrSet('session', async () => await first.promise, 30);
|
||||
await cache.delete('session');
|
||||
first.resolve('stale');
|
||||
await pending;
|
||||
expect(await cache.get('session')).toBeNull();
|
||||
await cache.getOrSet('session', async () => 'fresh', 30);
|
||||
expect(await cache.get('session')).toBe('fresh');
|
||||
});
|
||||
});
|
||||
+6
-6
@@ -6,7 +6,7 @@ import {
|
||||
validateLockKey,
|
||||
validateLockToken,
|
||||
} from '@pkgs/cache/src/CacheLockValidation';
|
||||
import {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
@@ -67,14 +67,14 @@ export class InMemoryProvider extends ICacheService {
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||
if (!entry) return null;
|
||||
if (!entry) return {hit: false};
|
||||
if (this.isExpired(entry)) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
return {hit: false};
|
||||
}
|
||||
return entry.value;
|
||||
return {hit: true, value: entry.value};
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
@@ -86,7 +86,7 @@ export class InMemoryProvider extends ICacheService {
|
||||
this.cache.set(key, entry);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
protected async deleteEntry(key: string): Promise<void> {
|
||||
this.cache.delete(key);
|
||||
}
|
||||
|
||||
|
||||
+29
-38
@@ -8,9 +8,10 @@ import {
|
||||
validateLockToken,
|
||||
} from '@pkgs/cache/src/CacheLockValidation';
|
||||
import type {CacheLogger, CacheTelemetry} from '@pkgs/cache/src/CacheProviderTypes';
|
||||
import {safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
|
||||
import {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {parseCachedValue, safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
|
||||
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
|
||||
interface KVCacheProviderConfig {
|
||||
client: IKVProvider;
|
||||
@@ -69,16 +70,16 @@ export class KVCacheProvider extends ICacheService {
|
||||
}
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
|
||||
return this.instrumented(
|
||||
'get',
|
||||
key,
|
||||
async () => {
|
||||
async (): Promise<CacheLookupResult<T>> => {
|
||||
const value = await this.client.get(key);
|
||||
if (value == null) return null;
|
||||
return safeJsonParse<T>(value, this.logger);
|
||||
if (value == null) return {hit: false};
|
||||
return parseCachedValue<T>(value, this.logger);
|
||||
},
|
||||
(result) => (result == null ? 'miss' : 'hit'),
|
||||
(result) => (result.hit ? 'hit' : 'miss'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ export class KVCacheProvider extends ICacheService {
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
protected async deleteEntry(key: string): Promise<void> {
|
||||
return this.instrumented('delete', key, async () => {
|
||||
await this.client.del(key);
|
||||
});
|
||||
@@ -137,37 +138,27 @@ export class KVCacheProvider extends ICacheService {
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
const withoutTtl: Array<{
|
||||
key: string;
|
||||
value: T;
|
||||
}> = [];
|
||||
const withTtl: Array<{
|
||||
key: string;
|
||||
value: T;
|
||||
ttlSeconds: number;
|
||||
}> = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.ttlSeconds) {
|
||||
withTtl.push({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
ttlSeconds: entry.ttlSeconds,
|
||||
});
|
||||
} else {
|
||||
withoutTtl.push({
|
||||
key: entry.key,
|
||||
value: entry.value,
|
||||
});
|
||||
const serialized = entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
value: serializeValue(entry.value),
|
||||
ttlSeconds: entry.ttlSeconds,
|
||||
}));
|
||||
const batches = splitIntoSlotBatches(serialized, (entry) => entry.key, this.client.isClustered());
|
||||
await runSlotBatches(batches, async (batch) => {
|
||||
const pipeline = this.client.pipeline();
|
||||
for (const entry of batch) {
|
||||
if (entry.ttlSeconds) {
|
||||
pipeline.setex(entry.key, entry.ttlSeconds, entry.value);
|
||||
} else {
|
||||
pipeline.set(entry.key, entry.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pipeline = this.client.pipeline();
|
||||
for (const entry of withoutTtl) {
|
||||
pipeline.set(entry.key, serializeValue(entry.value));
|
||||
}
|
||||
for (const entry of withTtl) {
|
||||
pipeline.setex(entry.key, entry.ttlSeconds, serializeValue(entry.value));
|
||||
}
|
||||
await pipeline.exec();
|
||||
for (const [error] of await pipeline.exec()) {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deletePattern(pattern: string): Promise<number> {
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,12 @@ import cassandra from 'cassandra-driver';
|
||||
|
||||
const distance = cassandra.types.distance;
|
||||
|
||||
const MAX_REQUESTS_PER_CONNECTION = 2048;
|
||||
const CONNECT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_READ_TIMEOUT_MS = 5000;
|
||||
|
||||
export const BACKGROUND_READ_TIMEOUT_MS = 12000;
|
||||
|
||||
interface CassandraConfig {
|
||||
hosts: Array<string>;
|
||||
port?: number | undefined;
|
||||
@@ -13,6 +19,7 @@ interface CassandraConfig {
|
||||
localDc: string;
|
||||
username?: string | undefined;
|
||||
password?: string | undefined;
|
||||
readTimeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
interface CassandraClientOptions {
|
||||
@@ -63,6 +70,7 @@ class CassandraClient implements ICassandraClient {
|
||||
localDc: config.localDc,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
readTimeoutMs: config.readTimeoutMs,
|
||||
};
|
||||
this.logger = options.logger ?? NoopLogger;
|
||||
this.client = null;
|
||||
@@ -83,12 +91,16 @@ class CassandraClient implements ICassandraClient {
|
||||
port: this.config.port ?? 9042,
|
||||
},
|
||||
pooling: {
|
||||
maxRequestsPerConnection: 32768,
|
||||
maxRequestsPerConnection: MAX_REQUESTS_PER_CONNECTION,
|
||||
coreConnectionsPerHost: {
|
||||
[distance.local]: 4,
|
||||
[distance.remote]: 2,
|
||||
},
|
||||
},
|
||||
socketOptions: {
|
||||
connectTimeout: CONNECT_TIMEOUT_MS,
|
||||
readTimeout: this.config.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS,
|
||||
},
|
||||
encoding: {
|
||||
map: Map,
|
||||
set: Set,
|
||||
|
||||
@@ -229,6 +229,19 @@ export class EmailService implements IEmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendMfaBackupCodesVerification(
|
||||
email: string,
|
||||
username: string,
|
||||
code: string,
|
||||
locale: string | null = null,
|
||||
): Promise<boolean> {
|
||||
return this.sendTemplatedEmail(email, 'mfa_backup_codes_view', locale, {
|
||||
username,
|
||||
code,
|
||||
expiresAt: new Date(Date.now() + ms('10 minutes')),
|
||||
});
|
||||
}
|
||||
|
||||
async sendEmailChangeOriginal(
|
||||
email: string,
|
||||
username: string,
|
||||
|
||||
@@ -92,6 +92,12 @@ export interface IEmailService {
|
||||
code: string,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendMfaBackupCodesVerification(
|
||||
email: string,
|
||||
username: string,
|
||||
code: string,
|
||||
locale?: string | null,
|
||||
): Promise<boolean>;
|
||||
sendDonationMagicLink(
|
||||
email: string,
|
||||
token: string,
|
||||
|
||||
@@ -223,6 +223,16 @@ export class TestEmailService implements ITestEmailService {
|
||||
return this.record(email, 'password_change_verification', {code});
|
||||
}
|
||||
|
||||
async sendMfaBackupCodesVerification(
|
||||
email: string,
|
||||
username: string,
|
||||
code: string,
|
||||
_locale?: string | null,
|
||||
): Promise<boolean> {
|
||||
this.logger.info(`MFA backup codes verification sent to ${email} for user ${username}`);
|
||||
return this.record(email, 'mfa_backup_codes_view', {code});
|
||||
}
|
||||
|
||||
async sendEmailChangeOriginal(
|
||||
email: string,
|
||||
username: string,
|
||||
|
||||
@@ -59,6 +59,10 @@ export const EMAIL_I18N_MESSAGES = {
|
||||
subject: 'Authorize login from a new IP address',
|
||||
body: "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorize this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team",
|
||||
},
|
||||
mfa_backup_codes_view: {
|
||||
subject: 'Confirm access to your {product_name} backup codes',
|
||||
body: "Hello {username},\n\nWe received a request to view the backup codes on your {product_name} account.\n\nTo confirm this request, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately.\n\n– {product_name} Team",
|
||||
},
|
||||
password_change_verification: {
|
||||
subject: 'Confirm your {product_name} password change',
|
||||
body: "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team",
|
||||
|
||||
@@ -15,6 +15,7 @@ export type EmailTemplateKey =
|
||||
| 'harvest_completed'
|
||||
| 'inactivity_warning'
|
||||
| 'ip_authorization'
|
||||
| 'mfa_backup_codes_view'
|
||||
| 'password_change_verification'
|
||||
| 'password_reset'
|
||||
| 'registration_approved'
|
||||
|
||||
@@ -76,6 +76,11 @@ export interface EmailTemplateVariables {
|
||||
ipAddress: string;
|
||||
location: string;
|
||||
};
|
||||
mfa_backup_codes_view: {
|
||||
username: string;
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
password_change_verification: {
|
||||
username: string;
|
||||
code: string;
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_AR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "السماح بتسجيل الدخول من عنوان IP جديد",
|
||||
"body": "مرحباً {username}،\n\nلقد اكتشفنا محاولة تسجيل دخول إلى حسابك في {product_name} من عنوان IP جديد:\n\nعنوان IP: {ipAddress}\nالموقع: {location}\n\nإذا كنت أنت من قام بذلك، يرجى تفويض عنوان IP هذا بالنقر على الرابط أدناه:\n\n{authUrl}\n\nإذا لم تحاول تسجيل الدخول، يرجى تغيير كلمة المرور الخاصة بك على الفور.\n\nهذا الرابط صالح لـ 30 دقيقة.\n\n– فريق {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "تأكيد الوصول إلى الرموز الاحتياطية في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nلقد تلقينا طلبًا لعرض الرموز الاحتياطية لحسابك في {product_name}.\n\nلتأكيد هذا الطلب، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt, date, full} الساعة {expiresAt, time, short}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قد وصل إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور.\n\n– فريق {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "تأكيد تغيير كلمة المرور في {product_name}",
|
||||
"body": "مرحباً {username}،\n\nلقد تلقينا طلبًا لتغيير كلمة المرور لحسابك في {product_name}.\n\nلتأكيد هذا التغيير، أدخل هذا الرمز في التطبيق:\n\n{code}\n\nينتهي هذا الرمز في {expiresAt}.\n\nإذا لم تطلب هذا، فقد يكون شخص ما قد وصل إلى حسابك. قم بتغيير كلمة المرور الخاصة بك على الفور وقم بتمكين المصادقة الثنائية.\n\n– فريق {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_BG_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Разреши влизане от нов IP адрес",
|
||||
"body": "Здравей, {username},\n\nОткрихме опит за влизане в акаунта ти във {product_name} от нов IP адрес:\n\nIP адрес: {ipAddress}\nМестоположение: {location}\n\nАко това си бил ти, разреши този IP адрес, като кликнеш върху линка по-долу:\n\n{authUrl}\n\nАко не си се опитвал да влезеш, смени паролата си незабавно.\n\nТози линк е валиден 30 минути.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Потвърди достъпа до кодовете си за възстановяване във {product_name}",
|
||||
"body": "Здравей, {username},\n\nПолучихме искане за преглед на кодовете за възстановяване на акаунта ти във {product_name}.\n\nЗа да потвърдиш това искане, въведи този код в приложението:\n\n{code}\n\nТози код изтича на {expiresAt, date, full} в {expiresAt, time, short}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно.\n\n– Екип на {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Потвърди промяната на паролата си във {product_name}",
|
||||
"body": "Здравей, {username},\n\nПолучихме искане за промяна на паролата на акаунта ти във {product_name}.\n\nЗа да потвърдиш тази промяна, въведи този код в приложението:\n\n{code}\n\nТози код изтича в {expiresAt}.\n\nАко не си го поискал, някой може да има достъп до акаунта ти. Смени паролата си незабавно и активирай двуфакторно удостоверяване.\n\n– Екип на {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_CS_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Povolit přihlášení z nové IP adresy",
|
||||
"body": "Ahoj {username},\n\nZaznamenali jsme pokus o přihlášení k tvému účtu {product_name} z nové IP adresy:\n\nIP adresa: {ipAddress}\nPoloha: {location}\n\nPokud jsi to byl ty, autorizuj tuto IP adresu kliknutím na odkaz níže:\n\n{authUrl}\n\nPokud jsi se nepřihlašoval, okamžitě si změň heslo.\n\nTento odkaz je platný 30 minut.\n\n– Tým {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Potvrď přístup k záložním kódům pro {product_name}",
|
||||
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na zobrazení záložních kódů tvého účtu {product_name}.\n\nPro potvrzení tohoto požadavku zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší dne {expiresAt, date, full} v {expiresAt, time, short}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo.\n\n– Tým {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Potvrď změnu hesla k {product_name}",
|
||||
"body": "Ahoj {username},\n\nObdrželi jsme požadavek na změnu hesla k tvému účtu pro {product_name}.\n\nPro potvrzení této změny zadej tento kód do aplikace:\n\n{code}\n\nTento kód vyprší v {expiresAt}.\n\nPokud jsi o to nežádal, někdo může mít přístup k tvému účtu. Okamžitě si změň heslo a povol dvoufaktorové ověřování.\n\n– Tým {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_DA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Godkend login fra en ny IP-adresse",
|
||||
"body": "Hej {username},\n\nVi har registreret et login-forsøg på din {product_name}-konto fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis dette var dig, skal du godkende denne IP-adresse ved at klikke på linket nedenfor:\n\n{authUrl}\n\nHvis du ikke har forsøgt at logge ind, skal du straks ændre din adgangskode.\n\nDette link er gyldigt i 30 minutter.\n\n– {product_name} Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Bekræft adgang til dine {product_name}-backupkoder",
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at se backupkoderne på din {product_name}-konto.\n\nFor at bekræfte denne anmodning skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber den {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bekræft din {product_name}-adgangskodeændring",
|
||||
"body": "Hej {username},\n\nVi har modtaget en anmodning om at ændre adgangskoden på din {product_name}-konto.\n\nFor at bekræfte denne ændring skal du indtaste denne kode i appen:\n\n{code}\n\nDenne kode udløber kl. {expiresAt}.\n\nHvis du ikke har anmodet om dette, har nogen muligvis adgang til din konto. Skift din adgangskode med det samme, og aktiver totrinsgodkendelse.\n\n– {product_name} Team"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Anmeldung von einer neuen IP-Adresse bestätigen",
|
||||
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für deinen {product_name}-Account von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du dich nicht anmelden wolltest, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name} Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Bestätige den Zugriff auf deine {product_name}-Backup-Codes",
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage erhalten, die Backup-Codes deines {product_name}-Accounts anzusehen.\n\nUm diese Anfrage zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bestätige deine {product_name}-Passwortänderung",
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft um {expiresAt} ab.\n\nWenn du diese Änderung nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name} Team"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_EL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Εξουσιοδότησε τη σύνδεση από μια νέα διεύθυνση IP",
|
||||
"body": "Γεια σου {username},\n\nΕντοπίσαμε μια προσπάθεια σύνδεσης στον λογαριασμό σου στο {product_name} από μια νέα διεύθυνση IP:\n\nΔιεύθυνση IP: {ipAddress}\nΤοποθεσία: {location}\n\nΕάν ήσουν εσύ, εξουσιοδότησε αυτήν τη διεύθυνση IP κάνοντας κλικ στον παρακάτω σύνδεσμο:\n\n{authUrl}\n\nΕάν δεν προσπάθησες να συνδεθείς, άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\nΑυτός ο σύνδεσμος ισχύει για 30 λεπτά.\n\n– Ομάδα {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Επιβεβαίωσε την πρόσβαση στους εφεδρικούς κωδικούς σου στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για προβολή των εφεδρικών κωδικών στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτό το αίτημα, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt, date, full} στις {expiresAt, time, short}.\n\nΕάν δεν το ζήτησες, κάποιος μπορεί να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου.\n\n– Ομάδα {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Επιβεβαίωσε την αλλαγή κωδικού πρόσβασης στο {product_name}",
|
||||
"body": "Γεια σου {username},\n\nΛάβαμε ένα αίτημα για αλλαγή του κωδικού πρόσβασης στον λογαριασμό σου στο {product_name}.\n\nΓια να επιβεβαιώσεις αυτήν την αλλαγή, εισήγαγε αυτόν τον κωδικό στην εφαρμογή:\n\n{code}\n\nΑυτός ο κωδικός λήγει στις {expiresAt}.\n\nΕάν δεν το ζήτησες, κάποιος μπορεί να έχει πρόσβαση στον λογαριασμό σου. Άλλαξε αμέσως τον κωδικό πρόσβασής σου και ενεργοποίησε τον έλεγχο ταυτότητας δύο παραγόντων.\n\n– Ομάδα {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_EN_GB_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Authorize login from a new IP address",
|
||||
"body": "Hello {username},\n\nWe detected a login attempt to your {product_name} account from a new IP address:\n\nIP address: {ipAddress}\nLocation: {location}\n\nIf this was you, please authorize this IP address by clicking the link below:\n\n{authUrl}\n\nIf you didn't attempt to log in, please change your password right away.\n\nThis link is valid for 30 minutes.\n\n– {product_name} Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirm access to your {product_name} backup codes",
|
||||
"body": "Hello {username},\n\nWe received a request to view the backup codes on your {product_name} account.\n\nTo confirm this request, enter this code in the app:\n\n{code}\n\nThis code expires on {expiresAt, date, full} at {expiresAt, time, short}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirm your {product_name} password change",
|
||||
"body": "Hello {username},\n\nWe received a request to change the password on your {product_name} account.\n\nTo confirm this change, enter this code in the app:\n\n{code}\n\nThis code expires at {expiresAt}.\n\nIf you didn't request this, someone may have access to your account. Change your password immediately and enable two-factor authentication.\n\n– {product_name} Team"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_ES_419_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autoriza el inicio de sesión desde una nueva dirección IP",
|
||||
"body": "Hola {username}:\n\nDetectamos un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva dirección IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, por favor, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, por favor, cambia tu contraseña de inmediato.\n\nEste enlace es válido por 30 minutos.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirma el acceso a tus códigos de respaldo de {product_name}",
|
||||
"body": "Hola {username}:\n\nRecibimos una solicitud para ver los códigos de respaldo de tu cuenta de {product_name}.\n\nPara confirmar esta solicitud, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirma tu cambio de contraseña de {product_name}",
|
||||
"body": "Hola {username}:\n\nRecibimos una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, ingresa este código en la aplicación:\n\n{code}\n\nEste código vence a las {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y habilita la autenticación de dos factores.\n\n– Equipo de {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_ES_ES_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autorizar inicio de sesión desde una nueva dirección IP",
|
||||
"body": "Hola {username}:\n\nHemos detectado un intento de inicio de sesión en tu cuenta de {product_name} desde una nueva IP:\n\nDirección IP: {ipAddress}\nUbicación: {location}\n\nSi fuiste tú, autoriza esta dirección IP haciendo clic en el siguiente enlace:\n\n{authUrl}\n\nSi no intentaste iniciar sesión, cambia tu contraseña de inmediato.\n\nEste enlace es válido durante 30 minutos.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirma el acceso a tus códigos de respaldo de {product_name}",
|
||||
"body": "Hola {username}:\n\nHemos recibido una solicitud para ver los códigos de respaldo de tu cuenta de {product_name}.\n\nPara confirmar esta solicitud, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt, date, full} a las {expiresAt, time, short}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente.\n\n– Equipo de {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirma tu cambio de contraseña en {product_name}",
|
||||
"body": "Hola {username}:\n\nHemos recibido una solicitud para cambiar la contraseña de tu cuenta de {product_name}.\n\nPara confirmar este cambio, introduce este código en la aplicación:\n\n{code}\n\nEste código caduca el {expiresAt}.\n\nSi no solicitaste esto, alguien podría tener acceso a tu cuenta. Cambia tu contraseña inmediatamente y activa la autenticación de dos factores.\n\n– Equipo de {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_FI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Hyväksy kirjautuminen uudesta IP-osoitteesta",
|
||||
"body": "Hei {username},\n\nHavaitsimme kirjautumisyrityksen {product_name}-tilillesi uudesta IP-osoitteesta:\n\nIP-osoite: {ipAddress}\nSijainti: {location}\n\nJos tämä olit sinä, valtuuta tämä IP-osoite napsauttamalla alla olevaa linkkiä:\n\n{authUrl}\n\nJos et yrittänyt kirjautua sisään, vaihda salasanasi välittömästi.\n\nTämä linkki on voimassa 30 minuuttia.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Vahvista pääsy {product_name}-tilisi varmuuskoodeihin",
|
||||
"body": "Hei {username},\n\nSaimme pyynnön tarkastella varmuuskoodeja {product_name}-tililläsi.\n\nVahvistaaksesi tämän pyynnön, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt, date, full} klo {expiresAt, time, short}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi.\n\n– {product_name}-tiimi"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Vahvista salasanan muutos {product_name}-tilillä",
|
||||
"body": "Hei {username},\n\nSaimme pyynnön muuttaa salasanaa {product_name}-tililläsi.\n\nVahvistaaksesi tämän muutoksen, syötä tämä koodi sovellukseen:\n\n{code}\n\nTämä koodi vanhenee {expiresAt}.\n\nJos et pyytänyt tätä, joku saattaa päästä tilillesi. Vaihda salasanasi välittömästi ja ota käyttöön kaksivaiheinen todennus.\n\n– {product_name}-tiimi"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_FR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autoriser la connexion depuis une nouvelle adresse IP",
|
||||
"body": "Bonjour {username},\n\nNous avons détecté une tentative de connexion à ton compte {product_name} depuis une nouvelle adresse IP :\n\nAdresse IP : {ipAddress}\nLocalisation : {location}\n\nSi c'était toi, autorise cette adresse IP en cliquant sur le lien ci-dessous :\n\n{authUrl}\n\nSi tu n'as pas tenté de te connecter, change ton mot de passe immédiatement.\n\nCe lien est valide pendant 30 minutes.\n\n– L'équipe {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirme l'accès à tes codes de secours {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de consultation des codes de secours de ton compte {product_name}.\n\nPour confirmer cette demande, saisis ce code dans l'application :\n\n{code}\n\nCe code expire le {expiresAt, date, full} à {expiresAt, time, short}.\n\nSi tu n'as pas demandé cela, quelqu'un a peut-être eu accès à ton compte. Change ton mot de passe immédiatement.\n\n– L'équipe {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirme la modification de ton mot de passe {product_name}",
|
||||
"body": "Bonjour {username},\n\nNous avons reçu une demande de modification du mot de passe de ton compte {product_name}.\n\nPour confirmer cette modification, saisis ce code dans l'application :\n\n{code}\n\nCe code expire à {expiresAt}.\n\nSi tu n'as pas demandé cela, quelqu'un a peut-être eu accès à ton compte. Change ton mot de passe immédiatement et active l'authentification à deux facteurs.\n\n– L'équipe {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_HE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "אישור כניסה מכתובת IP חדשה",
|
||||
"body": "שלום {username},\n\nזיהינו ניסיון התחברות לחשבון ה-{product_name} שלכם מכתובת IP חדשה:\n\nכתובת IP: {ipAddress}\nמיקום: {location}\n\nאם זה הייתם אתם, אנא אשרו כתובת IP זו על ידי לחיצה על הקישור למטה:\n\n{authUrl}\n\nאם לא ניסיתם להתחבר, אנא שנו את הסיסמה שלכם מיד.\n\nקישור זה תקף למשך 30 דקות.\n\nצוות {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "אישור גישה לקודי הגיבוי שלכם ב-{product_name}",
|
||||
"body": "שלום {username},\n\nקיבלנו בקשה לצפייה בקודי הגיבוי בחשבון ה-{product_name} שלכם.\n\nכדי לאשר בקשה זו, הזינו קוד זה באפליקציה:\n\n{code}\n\nקוד זה יפוג ב- {expiresAt, date, full} בשעה {expiresAt, time, short}.\n\nאם לא ביקשתם זאת, ייתכן שלמישהו יש גישה לחשבונכם. שנו את הסיסמה שלכם מיד.\n\nצוות {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "אישור שינוי הסיסמה שלכם ב-{product_name}",
|
||||
"body": "שלום {username},\n\nקיבלנו בקשה לשינוי הסיסמה בחשבון ה-{product_name} שלכם.\n\nכדי לאשר שינוי זה, הזינו קוד זה באפליקציה:\n\n{code}\n\nקוד זה יפוג ב- {expiresAt}.\n\nאם לא ביקשתם זאת, ייתכן שלמישהו יש גישה לחשבונכם. שנו את הסיסמה שלכם מיד ואפשרו אימות דו-שלבי.\n\nצוות {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_HI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "एक नए IP एड्रेस से लॉगिन को ऑथराइज़ करें",
|
||||
"body": "नमस्ते {username},\n\nहमें एक नए IP एड्रेस से आपके {product_name} अकाउंट में लॉगिन करने का प्रयास किया गया है:\n\nIP एड्रेस: {ipAddress}\nलोकेशन: {location}\n\nअगर यह आपने ही किया था, तो कृपया नीचे दिए गए लिंक पर क्लिक करके इस IP एड्रेस को ऑथराइज़ करें:\n\n{authUrl}\n\nअगर आपने लॉगिन करने का प्रयास नहीं किया था, तो कृपया तुरंत अपना पासवर्ड बदलें।\n\nयह लिंक 30 मिनट के लिए वैलिड है।\n\n– {product_name} टीम"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "अपने {product_name} बैकअप कोड के एक्सेस की पुष्टि करें",
|
||||
"body": "नमस्ते {username},\n\nहमें आपके {product_name} अकाउंट के बैकअप कोड देखने का अनुरोध मिला है।\n\nइस अनुरोध की पुष्टि करने के लिए, ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt, date, full} को {expiresAt, time, short} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो हो सकता है कि किसी और के पास आपके अकाउंट का एक्सेस हो। तुरंत अपना पासवर्ड बदलें।\n\n– {product_name} टीम"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "अपने {product_name} पासवर्ड चेंज की पुष्टि करें",
|
||||
"body": "नमस्ते {username},\n\nहमें आपके {product_name} अकाउंट पर पासवर्ड बदलने का अनुरोध मिला है।\n\nइस बदलाव की पुष्टि करने के लिए, ऐप में यह कोड एंटर करें:\n\n{code}\n\nयह कोड {expiresAt} पर एक्सपायर हो जाएगा।\n\nअगर आपने इसकी रिक्वेस्ट नहीं की थी, तो हो सकता है कि किसी और के पास आपके अकाउंट का एक्सेस हो। तुरंत अपना पासवर्ड बदलें और टू-फैक्टर ऑथेंटिकेशन इनेबल करें।\n\n– {product_name} टीम"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_HR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autoriziraj prijavu s nove IP adrese",
|
||||
"body": "Pozdrav {username},\n\nOtkrili smo pokušaj prijave na tvoj {product_name} račun s nove IP adrese:\n\nIP adresa: {ipAddress}\nLokacija: {location}\n\nAko si to bio ti, potvrdi ovu IP adresu klikom na poveznicu ispod:\n\n{authUrl}\n\nAko se nisi ti pokušao prijaviti, odmah promijeni lozinku.\n\nOva poveznica vrijedi 30 minuta.\n\n– {product_name} Tim"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Potvrdi pristup svojim {product_name} rezervnim kodovima",
|
||||
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za prikaz rezervnih kodova na tvom {product_name} računu.\n\nDa bi potvrdio ovaj zahtjev, unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod istječe {expiresAt, date, full} u {expiresAt, time, short}.\n\nAko ovo nisi zatražio, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku.\n\n– {product_name} Tim"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Potvrdi promjenu lozinke za {product_name}",
|
||||
"body": "Pozdrav {username},\n\nPrimili smo zahtjev za promjenu lozinke na tvom {product_name} računu.\n\nDa bi potvrdio ovu promjenu, unesi ovaj kod u aplikaciju:\n\n{code}\n\nOvaj kod vrijedi do {expiresAt}.\n\nAko ovo nisi zatražio, netko je možda dobio pristup tvom računu. Odmah promijeni lozinku i omogući dvofaktorsku autentifikaciju.\n\n– {product_name} Tim"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_HU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Új IP-címről való bejelentkezés engedélyezése",
|
||||
"body": "Szia {username},\n\nBejelentkezési kísérletet észleltünk a {product_name} fiókodba egy új IP-címről:\n\nIP-cím: {ipAddress}\nHelyszín: {location}\n\nHa te voltál, engedélyezd ezt az IP-címet az alábbi linkre kattintva:\n\n{authUrl}\n\nHa nem te próbáltál bejelentkezni, azonnal változtasd meg a jelszavadat.\n\nEz a link 30 percig érvényes.\n\n– {product_name} Csapat"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Erősítsd meg a {product_name} biztonsági kódjaidhoz való hozzáférést",
|
||||
"body": "Szia {username},\n\nKérést kaptunk a {product_name} fiókod biztonsági kódjainak megtekintésére.\n\nA kérés megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nEz a kód {expiresAt, date, full} {expiresAt, time, short} időpontban jár le.\n\nHa nem kérted ezt, valaki hozzáférhet a fiókodhoz. Azonnal változtasd meg a jelszavadat.\n\n– {product_name} Csapat"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Erősítsd meg a {product_name} jelszócserédet",
|
||||
"body": "Szia {username},\n\nKérést kaptunk a {product_name} fiókod jelszavának megváltoztatására.\n\nA változtatás megerősítéséhez add meg ezt a kódot az alkalmazásban:\n\n{code}\n\nEz a kód {expiresAt}-kor jár le.\n\nHa nem kérted ezt, valaki hozzáférhet a fiókodhoz. Azonnal változtasd meg a jelszavadat, és engedélyezd a kétfaktoros hitelesítést.\n\n– {product_name} Csapat"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_ID_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Otorisasi masuk dari alamat IP barumu",
|
||||
"body": "Halo {username},\n\nKami mendeteksi upaya masuk ke akun {product_name}mu dari alamat IP baru:\n\nAlamat IP: {ipAddress}\nLokasi: {location}\n\nKalau ini kamu, otorisasi alamat IP ini dengan mengeklik tautan di bawah ini:\n\n{authUrl}\n\nJika kamu tidak mencoba masuk, segera ubah kata sandimu.\n\nTautan ini berlaku selama 30 menit.\n\n– Tim {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Konfirmasi akses ke kode cadangan {product_name}mu",
|
||||
"body": "Halo {username},\n\nKami menerima permintaan untuk melihat kode cadangan di akun {product_name}mu.\n\nUntuk mengonfirmasi permintaan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt, date, full} pukul {expiresAt, time, short}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera.\n\n– Tim {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Konfirmasi perubahan kata sandi {product_name}mu",
|
||||
"body": "Halo {username},\n\nKami menerima permintaan untuk mengubah kata sandi di akun {product_name}mu.\n\nUntuk mengonfirmasi perubahan ini, masukkan kode ini di aplikasi:\n\n{code}\n\nKode ini kedaluwarsa pada {expiresAt}.\n\nJika kamu tidak meminta ini, seseorang mungkin punya akses ke akunmu. Ubah kata sandimu segera dan aktifkan autentikasi dua faktor.\n\n– Tim {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_IT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autorizza l'accesso da un nuovo indirizzo IP",
|
||||
"body": "Ciao {username},\n\nAbbiamo rilevato un tentativo di accesso al tuo account {product_name} da un nuovo indirizzo IP:\n\nIndirizzo IP: {ipAddress}\nPosizione: {location}\n\nSe sei stato tu, autorizza questo indirizzo IP cliccando sul link qui sotto:\n\n{authUrl}\n\nSe non hai tentato di accedere, cambia subito la password.\n\nQuesto link è valido per 30 minuti.\n\n– Team {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Conferma l'accesso ai tuoi codici di backup di {product_name}",
|
||||
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di visualizzazione dei codici di backup del tuo account {product_name}.\n\nPer confermare questa richiesta, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade il {expiresAt, date, full} alle {expiresAt, time, short}.\n\nSe non hai richiesto questa operazione, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password.\n\n– Team {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Conferma la modifica della password di {product_name}",
|
||||
"body": "Ciao {username},\n\nAbbiamo ricevuto una richiesta di modifica della password del tuo account {product_name}.\n\nPer confermare questa modifica, inserisci questo codice nell'app:\n\n{code}\n\nQuesto codice scade alle {expiresAt}.\n\nSe non hai richiesto questa modifica, qualcuno potrebbe avere accesso al tuo account. Cambia immediatamente la password e abilita l'autenticazione a due fattori.\n\n– Team {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "新しいIPアドレスからのログインを承認",
|
||||
"body": "こんにちは、{username}さん\n\n新しいIPアドレスから{product_name}アカウントへのログイン試行を検出しました。\n\nIPアドレス: {ipAddress}\n場所: {location}\n\nこれがご自身によるものである場合は、以下のリンクをクリックしてこのIPアドレスを承認してください。\n\n{authUrl}\n\nログインを試行していない場合は、すぐにパスワードを変更してください。\n\nこのリンクは30分間有効です。\n\n– {product_name}チーム"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "{product_name}バックアップコードへのアクセスを確認",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントのバックアップコードを表示するリクエストを受け付けました。\n\nこのリクエストを確認するには、アプリでこのコードを入力してください。\n\n{code}\n\nこのコードは{expiresAt, date, full} {expiresAt, time, short}に期限切れになります。\n\nご自身でリクエストしていない場合は、誰かがあなたのアカウントにアクセスしている可能性があります。すぐにパスワードを変更してください。\n\n– {product_name}チーム"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "{product_name}パスワードの変更を確認",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントのパスワード変更リクエストを受け付けました。\n\nこの変更を確認するには、アプリでこのコードを入力してください。\n\n{code}\n\nこのコードは{expiresAt}に期限切れになります。\n\nご自身でリクエストしていない場合は、誰かがあなたのアカウントにアクセスしている可能性があります。すぐにパスワードを変更し、二段階認証を有効にしてください。\n\n– {product_name}チーム"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_KO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "새 IP 주소에서 로그인을 승인해 주세요",
|
||||
"body": "안녕하세요, {username}님.\n\n새로운 IP 주소에서 {product_name} 계정으로 로그인 시도가 감지되었어요:\n\nIP 주소: {ipAddress}\n위치: {location}\n\n본인이 시도한 경우, 아래 링크를 클릭하여 이 IP 주소를 승인해 주세요:\n\n{authUrl}\n\n로그인을 시도하지 않으셨다면 즉시 비밀번호를 변경하세요.\n\n이 링크는 30분 동안 유효해요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "{product_name} 백업 코드 액세스를 확인해 주세요",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 백업 코드를 보려는 요청이 접수되었어요.\n\n이 요청을 확인하려면 앱에 이 코드를 입력해 주세요:\n\n{code}\n\n이 코드는 {expiresAt, date, full} {expiresAt, time, short}에 만료돼요.\n\n요청하지 않으셨다면 누군가 계정에 액세스했을 수 있어요. 즉시 비밀번호를 변경하세요.\n\n– {product_name} 팀"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "{product_name} 비밀번호 변경을 확인해 주세요",
|
||||
"body": "안녕하세요, {username}님.\n\n{product_name} 계정의 비밀번호 변경 요청이 접수되었어요.\n\n이 변경을 확인하려면 앱에 이 코드를 입력해 주세요:\n\n{code}\n\n이 코드는 {expiresAt}에 만료돼요.\n\n요청하지 않으셨다면 누군가 계정에 액세스했을 수 있어요. 즉시 비밀번호를 변경하고 2단계 인증을 활성화하세요.\n\n– {product_name} 팀"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_LT_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Leisti prisijungimą iš naujo IP adreso",
|
||||
"body": "Sveikas, {username},\n\nAptikome bandymą prisijungti prie tavo {product_name} paskyros iš naujo IP adreso:\n\nIP adresas: {ipAddress}\nVieta: {location}\n\nJei tai buvai tu, patvirtink šį IP adresą spustelėdamas žemiau esančią nuorodą:\n\n{authUrl}\n\nJei nebandei prisijungti, nedelsdamas pakeisk slaptažodį.\n\nŠi nuoroda galioja 30 minučių.\n\n– {product_name} komanda"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Patvirtink prieigą prie savo {product_name} atsarginių kodų",
|
||||
"body": "Sveikas, {username},\n\nGavome prašymą peržiūrėti tavo {product_name} paskyros atsarginius kodus.\n\nNorėdamas patvirtinti šį prašymą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt, date, full} {expiresAt, time, short}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsdamas pakeisk slaptažodį.\n\n– {product_name} komanda"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Patvirtink savo {product_name} slaptažodžio keitimą",
|
||||
"body": "Sveikas, {username},\n\nGavome prašymą pakeisti tavo {product_name} paskyros slaptažodį.\n\nNorėdamas patvirtinti šį pakeitimą, įvesk šį kodą programėlėje:\n\n{code}\n\nŠis kodas galioja iki {expiresAt}.\n\nJei to neprašei, kažkas gali turėti prieigą prie tavo paskyros. Nedelsdamas pakeisk slaptažodį ir įjunk dviejų veiksnių autentifikavimą.\n\n– {product_name} komanda"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_NL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autoriseer inlog vanaf een nieuw IP-adres",
|
||||
"body": "Hallo {username},\n\nWe hebben een inlogpoging op je {product_name}-account gedetecteerd vanaf een nieuw IP-adres:\n\nIP-adres: {ipAddress}\nLocatie: {location}\n\nAls jij dit was, autoriseer dan dit IP-adres door op de onderstaande link te klikken:\n\n{authUrl}\n\nAls je niet hebt geprobeerd in te loggen, wijzig dan onmiddellijk je wachtwoord.\n\nDeze link is 30 minuten geldig.\n\n– {product_name} Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Bevestig toegang tot je {product_name}-back-upcodes",
|
||||
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om de back-upcodes van je {product_name}-account te bekijken.\n\nOm dit verzoek te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt op {expiresAt, date, full} om {expiresAt, time, short}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bevestig je {product_name}-wachtwoordwijziging",
|
||||
"body": "Hallo {username},\n\nWe hebben een verzoek ontvangen om het wachtwoord van je {product_name}-account te wijzigen.\n\nOm deze wijziging te bevestigen, voer je deze code in de app in:\n\n{code}\n\nDeze code verloopt om {expiresAt}.\n\nAls je dit niet hebt aangevraagd, heeft iemand mogelijk toegang tot je account. Wijzig onmiddellijk je wachtwoord en schakel tweefactorauthenticatie in.\n\n– {product_name} Team"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_NO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Godkjenn pålogging fra en ny IP-adresse",
|
||||
"body": "Hei {username},\n\nVi oppdaget et påloggingsforsøk til {product_name}-kontoen din fra en ny IP-adresse:\n\nIP-adresse: {ipAddress}\nSted: {location}\n\nHvis dette var deg, godkjenn denne IP-adressen ved å klikke på lenken nedenfor:\n\n{authUrl}\n\nHvis du ikke forsøkte å logge inn, endre passordet ditt med en gang.\n\nDenne lenken er gyldig i 30 minutter.\n\n– {product_name} Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Bekreft tilgang til {product_name}-reservekodene dine",
|
||||
"body": "Hei {username},\n\nVi mottok en forespørsel om å se reservekodene på {product_name}-kontoen din.\n\nFor å bekrefte denne forespørselen, skriv inn denne koden i appen:\n\n{code}\n\nDenne koden utløper {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bekreft din {product_name}-passordendring",
|
||||
"body": "Hei {username},\n\nVi mottok en forespørsel om å endre passordet på {product_name}-kontoen din.\n\nFor å bekrefte denne endringen, skriv inn denne koden i appen:\n\n{code}\n\nDenne koden utløper {expiresAt}.\n\nHvis du ikke ba om dette, kan noen ha tilgang til kontoen din. Endre passordet ditt med en gang og aktiver tofaktorautentisering.\n\n– {product_name} Team"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_PL_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autoryzuj logowanie z nowego adresu IP",
|
||||
"body": "Witaj {username},\n\nWykryliśmy próbę logowania do Twojego konta {product_name} z nowego adresu IP:\n\nAdres IP: {ipAddress}\nLokalizacja: {location}\n\nJeśli to Ty, autoryzuj ten adres IP, klikając poniższy link:\n\n{authUrl}\n\nJeśli nie próbowałeś się zalogować, natychmiast zmień hasło.\n\nTen link jest ważny przez 30 minut.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Potwierdź dostęp do kodów zapasowych w {product_name}",
|
||||
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o wyświetlenie kodów zapasowych Twojego konta {product_name}.\n\nAby potwierdzić tę prośbę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa {expiresAt, date, full} o {expiresAt, time, short}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło.\n\n– Zespół {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Potwierdź zmianę hasła w {product_name}",
|
||||
"body": "Witaj {username},\n\nOtrzymaliśmy prośbę o zmianę hasła do Twojego konta {product_name}.\n\nAby potwierdzić tę zmianę, wprowadź ten kod w aplikacji:\n\n{code}\n\nTen kod wygasa o godzinie {expiresAt}.\n\nJeśli nie prosiłeś o to, ktoś może mieć dostęp do Twojego konta. Natychmiast zmień hasło i włącz uwierzytelnianie dwuskładnikowe.\n\n– Zespół {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_PT_BR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autorizar login de um novo endereço IP",
|
||||
"body": "Olá, {username},\n\nDetectamos uma tentativa de login em sua conta do {product_name} de um novo endereço IP:\n\nEndereço IP: {ipAddress}\nLocalização: {location}\n\nSe foi você, por favor, autorize este endereço IP clicando no link abaixo:\n\n{authUrl}\n\nSe você não tentou entrar, por favor, altere sua senha imediatamente.\n\nEste link é válido por 30 minutos.\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirme o acesso aos códigos de backup da sua conta do {product_name}",
|
||||
"body": "Olá, {username},\n\nRecebemos uma solicitação para visualizar os códigos de backup da sua conta do {product_name}.\n\nPara confirmar esta solicitação, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt, date, full} às {expiresAt, time, short}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente.\n\n– Equipe do {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirme a alteração da senha da sua conta do {product_name}",
|
||||
"body": "Olá, {username},\n\nRecebemos uma solicitação para alterar a senha da sua conta do {product_name}.\n\nPara confirmar esta alteração, insira este código no aplicativo:\n\n{code}\n\nEste código expira em {expiresAt}.\n\nSe você não solicitou isso, alguém pode ter acesso à sua conta. Altere sua senha imediatamente e ative a autenticação de dois fatores.\n\n– Equipe do {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_RO_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Autorizează conectarea de la o nouă adresă IP",
|
||||
"body": "Salut {username},\n\nAm detectat o tentativă de conectare la contul tău {product_name} de la o nouă adresă IP:\n\nAdresă IP: {ipAddress}\nLocație: {location}\n\nDacă ai fost tu, te rugăm să autorizezi această adresă IP apăsând pe linkul de mai jos:\n\n{authUrl}\n\nDacă nu ai încercat să te conectezi, te rugăm să-ți schimbi parola imediat.\n\nAcest link este valabil 30 de minute.\n\n– Echipa {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Confirmă accesul la codurile tale de rezervă pentru {product_name}",
|
||||
"body": "Salut {username},\n\nAm primit o solicitare de vizualizare a codurilor de rezervă ale contului tău {product_name}.\n\nPentru a confirma această solicitare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la data de {expiresAt, date, full} la ora {expiresAt, time, short}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat.\n\n– Echipa {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Confirmă schimbarea parolei pentru {product_name}",
|
||||
"body": "Salut {username},\n\nAm primit o solicitare de schimbare a parolei contului tău {product_name}.\n\nPentru a confirma această modificare, introdu acest cod în aplicație:\n\n{code}\n\nAcest cod expiră la {expiresAt}.\n\nDacă nu ai solicitat acest lucru, cineva ar putea avea acces la contul tău. Schimbă-ți parola imediat și activează autentificarea cu doi factori.\n\n– Echipa {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_RU_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Подтверди вход с нового IP-адреса",
|
||||
"body": "Привет, {username},\n\nМы обнаружили попытку входа в твой аккаунт {product_name} с нового IP-адреса:\n\nIP-адрес: {ipAddress}\nМестоположение: {location}\n\nЕсли это был ты, пожалуйста, подтверди этот IP-адрес, нажав на ссылку ниже:\n\n{authUrl}\n\nЕсли ты не пытался войти, пожалуйста, немедленно смени пароль.\n\nЭта ссылка действительна в течение 30 минут.\n\n– Команда {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Подтверди доступ к резервным кодам {product_name}",
|
||||
"body": "Привет, {username},\n\nМы получили запрос на просмотр резервных кодов твоего аккаунта {product_name}.\n\nЧтобы подтвердить этот запрос, введи этот код в приложении:\n\n{code}\n\nЭтот код истекает {expiresAt, date, full} в {expiresAt, time, short}.\n\nЕсли ты не запрашивал это, возможно, кто-то получил доступ к твоему аккаунту. Немедленно смени пароль.\n\n– Команда {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Подтверди смену пароля для {product_name}",
|
||||
"body": "Привет, {username},\n\nМы получили запрос на изменение пароля твоего аккаунта {product_name}.\n\nЧтобы подтвердить это изменение, введи этот код в приложении:\n\n{code}\n\nЭтот код истекает в {expiresAt}.\n\nЕсли ты не запрашивал это, возможно, кто-то получил доступ к твоему аккаунту. Немедленно смени пароль и включи двухфакторную аутентификацию.\n\n– Команда {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_SV_SE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Auktorisera inloggning från en ny IP-adress",
|
||||
"body": "Hej {username},\n\nVi upptäckte ett inloggningsförsök till ditt {product_name}-konto från en ny IP-adress:\n\nIP-adress: {ipAddress}\nPlats: {location}\n\nOm detta var du, godkänn denna IP-adress genom att klicka på länken nedan:\n\n{authUrl}\n\nOm du inte försökte logga in, ändra ditt lösenord omedelbart.\n\nDenna länk är giltig i 30 minuter.\n\n– {product_name} Team"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Bekräfta åtkomst till dina {product_name}-reservkoder",
|
||||
"body": "Hej {username},\n\nVi har mottagit en begäran om att visa reservkoderna för ditt {product_name}-konto.\n\nFör att bekräfta denna begäran, ange denna kod i appen:\n\n{code}\n\nDenna kod upphör att gälla {expiresAt, date, full} kl. {expiresAt, time, short}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bekräfta din {product_name}-lösenordsändring",
|
||||
"body": "Hej {username},\n\nVi har mottagit en begäran om att ändra lösenordet för ditt {product_name}-konto.\n\nFör att bekräfta denna ändring, ange denna kod i appen:\n\n{code}\n\nDenna kod upphör att gälla {expiresAt}.\n\nOm du inte begärde detta kan någon ha åtkomst till ditt konto. Ändra ditt lösenord omedelbart och aktivera tvåfaktorsautentisering.\n\n– {product_name} Team"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_TH_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "อนุมัติการเข้าสู่ระบบจาก IP ใหม่",
|
||||
"body": "สวัสดี {username},\n\nเราตรวจพบความพยายามในการเข้าสู่ระบบบัญชี {product_name} ของคุณจากที่อยู่ IP ใหม่:\n\nที่อยู่ IP: {ipAddress}\nตำแหน่ง: {location}\n\nหากเป็นคุณ โปรดอนุญาตที่อยู่ IP นี้โดยคลิกที่ลิงก์ด้านล่าง:\n\n{authUrl}\n\nหากคุณไม่ได้พยายามเข้าสู่ระบบ เปลี่ยนรหัสผ่านทันที\n\nลิงก์นี้ใช้ได้เป็นเวลา 30 นาที\n\n– ทีม {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "ยืนยันการเข้าถึงรหัสสำรองสำหรับ {product_name}",
|
||||
"body": "สวัสดี {username},\n\nเราได้รับคำขอให้ดูรหัสสำรองในบัญชี {product_name} ของคุณ\n\nหากต้องการยืนยันคำขอนี้ ให้ป้อนรหัสนี้ในแอป:\n\n{code}\n\nรหัสนี้จะหมดอายุในวันที่ {expiresAt, date, full} เวลา {expiresAt, time, short}\n\nหากคุณไม่ได้ร้องขอ อาจมีคนเข้าถึงบัญชีของคุณได้ เปลี่ยนรหัสผ่านทันที\n\n– ทีม {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "ยืนยันการเปลี่ยนรหัสผ่านสำหรับ {product_name}",
|
||||
"body": "สวัสดี {username},\n\nเราได้รับคำขอให้เปลี่ยนรหัสผ่านในบัญชี {product_name} ของคุณ\n\nหากต้องการยืนยันการเปลี่ยนแปลงนี้ ให้ป้อนรหัสนี้ในแอป:\n\n{code}\n\nรหัสนี้จะหมดอายุในเวลา {expiresAt}\n\nหากคุณไม่ได้ร้องขอ อาจมีคนเข้าถึงบัญชีของคุณได้ เปลี่ยนรหัสผ่านทันทีและเปิดใช้งานการยืนยันตัวตนสองชั้น\n\n– ทีม {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_TR_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Yeni bir IP adresinden girişi yetkilendir",
|
||||
"body": "Merhaba {username},\n\n{product_name} hesabına yeni bir IP adresinden giriş denemesi tespit ettik:\n\nIP adresi: {ipAddress}\nKonum: {location}\n\nBu sen isen, lütfen aşağıdaki bağlantıya tıklayarak bu IP adresini yetkilendir:\n\n{authUrl}\n\nGiriş yapmaya sen çalışmadıysan, lütfen hemen şifreni değiştir.\n\nBu bağlantı 30 dakika boyunca geçerlidir.\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "{product_name} yedek kodlarına erişimi onayla",
|
||||
"body": "Merhaba {username},\n\n{product_name} hesabındaki yedek kodları görüntüleme isteği aldık.\n\nBu isteği onaylamak için bu kodu uygulamaya gir:\n\n{code}\n\nBu kod {expiresAt, date, full} {expiresAt, time, short} tarihinde sona erecektir.\n\nBunu sen talep etmediysen, birisi hesabına erişmiş olabilir. Şifreni hemen değiştir.\n\n– {product_name} Ekibi"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "{product_name} şifre değişikliğini onayla",
|
||||
"body": "Merhaba {username},\n\n{product_name} hesabının şifresini değiştirme isteği aldık.\n\nBu değişikliği onaylamak için bu kodu uygulamaya gir:\n\n{code}\n\nBu kod {expiresAt} tarihinde sona erecektir.\n\nBunu sen talep etmediysen, birisi hesabına erişmiş olabilir. Şifreni hemen değiştir ve iki faktörlü kimlik doğrulamayı etkinleştir.\n\n– {product_name} Ekibi"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_UK_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Дозволь вхід з нової IP-адреси",
|
||||
"body": "Привіт, {username},\n\nМи виявили спробу входу в твій акаунт {product_name} з нової IP-адреси:\n\nIP-адреса: {ipAddress}\nМісцезнаходження: {location}\n\nЯкщо це був ти, будь ласка, дозволь цю IP-адресу, натиснувши посилання нижче:\n\n{authUrl}\n\nЯкщо ти не намагався увійти, будь ласка, негайно зміни свій пароль.\n\nЦе посилання діє протягом 30 хвилин.\n\n– Команда {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Підтвердь доступ до резервних кодів {product_name}",
|
||||
"body": "Привіт, {username},\n\nМи отримали запит на перегляд резервних кодів твого акаунту {product_name}.\n\nЩоб підтвердити цей запит, введи цей код у застосунку:\n\n{code}\n\nЦей код діє до {expiresAt, date, full} о {expiresAt, time, short}.\n\nЯкщо ти не запитував це, хтось може мати доступ до твого акаунту. Негайно зміни свій пароль.\n\n– Команда {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Підтвердь зміну пароля для {product_name}",
|
||||
"body": "Привіт, {username},\n\nМи отримали запит на зміну пароля для твого акаунту {product_name}.\n\nЩоб підтвердити цю зміну, введи цей код у застосунку:\n\n{code}\n\nЦей код діє до {expiresAt}.\n\nЯкщо ти не запитував це, хтось може мати доступ до твого акаунту. Негайно зміни свій пароль та увімкни двофакторну автентифікацію.\n\n– Команда {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_VI_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "Cho phép đăng nhập từ địa chỉ IP mới",
|
||||
"body": "Xin chào {username},\n\nChúng tôi đã phát hiện một nỗ lực đăng nhập vào tài khoản {product_name} của bạn từ một địa chỉ IP mới:\n\nĐịa chỉ IP: {ipAddress}\nVị trí: {location}\n\nNếu đây là bạn, vui lòng cho phép địa chỉ IP này bằng cách nhấp vào liên kết bên dưới:\n\n{authUrl}\n\nNếu bạn không thực hiện nỗ lực đăng nhập, vui lòng thay đổi mật khẩu ngay lập tức.\n\nLiên kết này có hiệu lực trong 30 phút.\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "Xác nhận quyền truy cập mã dự phòng {product_name} của bạn",
|
||||
"body": "Xin chào {username},\n\nChúng tôi đã nhận được yêu cầu xem mã dự phòng trên tài khoản {product_name} của bạn.\n\nĐể xác nhận yêu cầu này, hãy nhập mã này vào ứng dụng:\n\n{code}\n\nMã này hết hạn vào {expiresAt, date, full} lúc {expiresAt, time, short}.\n\nNếu bạn không yêu cầu điều này, ai đó có thể đã truy cập vào tài khoản của bạn. Thay đổi mật khẩu ngay lập tức.\n\n– Đội ngũ {product_name}"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Xác nhận thay đổi mật khẩu của bạn trên {product_name}",
|
||||
"body": "Xin chào {username},\n\nChúng tôi đã nhận được yêu cầu thay đổi mật khẩu trên tài khoản {product_name} của bạn.\n\nĐể xác nhận thay đổi này, hãy nhập mã này vào ứng dụng:\n\n{code}\n\nMã này hết hạn lúc {expiresAt}.\n\nNếu bạn không yêu cầu thay đổi này, ai đó có thể đã truy cập vào tài khoản của bạn. Thay đổi mật khẩu ngay lập tức và bật xác thực hai yếu tố.\n\n– Đội ngũ {product_name}"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_ZH_CN_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "授权新 IP 地址登录",
|
||||
"body": "你好 {username},\n\n我们检测到你的 {product_name} 账号有来自新 IP 地址的登录尝试:\n\nIP 地址:{ipAddress}\n位置:{location}\n\n如果你本人进行了此操作,请点击下方链接授权此 IP 地址:\n\n{authUrl}\n\n如果你没有进行此操作,请立即更改密码。\n\n此链接 30 分钟内有效。\n\n– {product_name} 团队"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "确认访问你的 {product_name} 备用码",
|
||||
"body": "你好 {username},\n\n我们收到了查看你的 {product_name} 账号备用码的请求。\n\n要确认此请求,请在应用中输入此验证码:\n\n{code}\n\n此验证码将于 {expiresAt, date, full} {expiresAt, time, short} 失效。\n\n如果你未请求此操作,可能有人访问了你的账号。请立即更改密码。\n\n– {product_name} 团队"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "确认你的 {product_name} 密码更改",
|
||||
"body": "你好 {username},\n\n我们收到了更改你的 {product_name} 账号密码的请求。\n\n要确认此更改,请在应用中输入此验证码:\n\n{code}\n\n此验证码将于 {expiresAt} 失效。\n\n如果你未请求此操作,可能有人访问了你的账号。请立即更改密码并启用双重身份验证。\n\n– {product_name} 团队"
|
||||
|
||||
@@ -59,6 +59,10 @@ const EMAIL_I18N_ZH_TW_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"subject": "授權從新的 IP 位址登入",
|
||||
"body": "哈囉 {username},\n\n我們偵測到您的 {product_name} 帳號有來自新 IP 位址的登入嘗試:\n\nIP 位址:{ipAddress}\n位置:{location}\n\n如果這是您本人操作,請點擊下方連結授權此 IP 位址:\n\n{authUrl}\n\n如果您沒有嘗試登入,請立即變更您的密碼。\n\n此連結在 30 分鐘內有效。\n\n– {product_name} 團隊"
|
||||
},
|
||||
"mfa_backup_codes_view": {
|
||||
"subject": "確認存取您的 {product_name} 備用碼",
|
||||
"body": "哈囉 {username},\n\n我們收到查看您 {product_name} 帳號備用碼的請求。\n\n若要確認此請求,請在應用程式中輸入此驗證碼:\n\n{code}\n\n此驗證碼將於 {expiresAt, date, full} {expiresAt, time, short} 失效。\n\n如果您沒有要求此操作,可能有人存取了您的帳號。請立即變更您的密碼。\n\n– {product_name} 團隊"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "確認您的 {product_name} 密碼變更",
|
||||
"body": "哈囉 {username},\n\n我們收到變更您 {product_name} 帳號密碼的請求。\n\n若要確認此變更,請在應用程式中輸入此驗證碼:\n\n{code}\n\n此驗證碼將於 {expiresAt} 失效。\n\n如果您沒有要求此驗證碼,可能有人存取了您的帳號。請立即變更您的密碼並啟用雙重驗證。\n\n– {product_name} 團隊"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user