mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 19:52:13 +09:00
Compare commits
343
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -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"
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
|
||||
rust:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -178,20 +178,40 @@ jobs:
|
||||
with:
|
||||
workspaces: |
|
||||
. -> target
|
||||
fluxer_desktop/native/rust -> target
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- 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: 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 \
|
||||
libavfilter-dev \
|
||||
libclang-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libfido2-dev \
|
||||
libheif-dev \
|
||||
libwebp-dev
|
||||
libpipewire-0.3-dev \
|
||||
libspa-0.2-dev \
|
||||
libssl-dev \
|
||||
libudev-dev \
|
||||
libvips-dev \
|
||||
libwebp-dev \
|
||||
meson \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
zlib1g-dev
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm --filter fluxer_admin install
|
||||
@@ -199,17 +219,20 @@ 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: Run tests
|
||||
run: cargo test --workspace
|
||||
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
+80
-367
@@ -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",
|
||||
@@ -1923,8 +1893,7 @@ dependencies = [
|
||||
"libc",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"rustls 0.23.40",
|
||||
"rustls-pemfile",
|
||||
"rustls",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1989,6 +1958,7 @@ dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
"cookie",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"maud",
|
||||
"openapiv3",
|
||||
@@ -2029,6 +1999,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
@@ -2100,16 +2071,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 +2243,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 +2341,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 +2348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever 0.39.0",
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2496,30 +2427,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 +2437,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.14",
|
||||
"h2",
|
||||
"http 1.4.2",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
@@ -2542,21 +2449,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 +2456,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 +2477,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 +2876,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 +2889,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 +3055,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 +3226,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 +3254,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 +3263,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 +3570,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 +3591,7 @@ dependencies = [
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.40",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
@@ -3797,7 +3609,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.3",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -4015,15 +3827,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 +3843,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4068,7 +3880,7 @@ dependencies = [
|
||||
"futures",
|
||||
"getrandom 0.2.17",
|
||||
"http 1.4.2",
|
||||
"hyper 1.10.1",
|
||||
"hyper",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"retry-policies",
|
||||
@@ -4155,19 +3967,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 +3981,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.13",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -4198,15 +3998,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 +4019,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 +4035,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 +4127,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 +4237,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 +4536,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 +4556,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 +4564,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 +4650,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 +4821,7 @@ dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf 0.13.1",
|
||||
"phf",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
@@ -5103,32 +4838,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 +4897,7 @@ dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
@@ -5713,28 +5438,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 +5518,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, app-proxy 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
|
||||
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-postgres-env
|
||||
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:-}
|
||||
@@ -315,126 +476,202 @@ services:
|
||||
FLUXER_CSP_EXTRA_WORKER_SRC: ${FLUXER_CSP_EXTRA_WORKER_SRC:-}
|
||||
FLUXER_CSP_EXTRA_MANIFEST_SRC: ${FLUXER_CSP_EXTRA_MANIFEST_SRC:-}
|
||||
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "5"
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
caddy: {condition: service_started}
|
||||
caddy: {condition: service_healthy}
|
||||
postgres: {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 +683,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
|
||||
|
||||
|
||||
+13
-1364
File diff suppressed because it is too large
Load Diff
@@ -41,9 +41,6 @@ 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_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
|
||||
@@ -155,9 +152,6 @@ 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_UPDATE_GUILD_FEATURES,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*"
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"undici": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
|
||||
@@ -83,6 +83,7 @@ function createFetchInit(
|
||||
headers: Record<string, string>,
|
||||
body: string | undefined,
|
||||
signal: AbortSignal,
|
||||
dispatcher: NonNullable<RequestInit['dispatcher']> | undefined,
|
||||
): RequestInit {
|
||||
return {
|
||||
method,
|
||||
@@ -90,9 +91,16 @@ function createFetchInit(
|
||||
body,
|
||||
signal,
|
||||
redirect: 'manual',
|
||||
...(dispatcher ? {dispatcher} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRequestUrlPolicyDispatcher(
|
||||
requestUrlPolicy: RequestUrlPolicy | undefined,
|
||||
): NonNullable<RequestInit['dispatcher']> | undefined {
|
||||
return (requestUrlPolicy as {dispatcher?: NonNullable<RequestInit['dispatcher']>} | undefined)?.dispatcher;
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return REDIRECT_STATUS_CODES.includes(status as (typeof REDIRECT_STATUS_CODES)[number]);
|
||||
}
|
||||
@@ -142,11 +150,15 @@ async function fetchWithRedirects(
|
||||
let currentMethod: HttpMethod = method;
|
||||
let currentBody = body;
|
||||
let currentHeaders = {...headers};
|
||||
const dispatcher = resolveRequestUrlPolicyDispatcher(requestUrlPolicy);
|
||||
await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, {
|
||||
phase: 'initial',
|
||||
redirectCount: 0,
|
||||
});
|
||||
let response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
|
||||
let response = await fetch(
|
||||
currentUrl.href,
|
||||
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
|
||||
);
|
||||
let redirectCount = 0;
|
||||
while (isRedirectStatus(response.status)) {
|
||||
if (redirectCount >= maxRedirects) {
|
||||
@@ -174,7 +186,10 @@ async function fetchWithRedirects(
|
||||
previousUrl: previousUrl.href,
|
||||
});
|
||||
currentUrl = nextUrl;
|
||||
response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
|
||||
response = await fetch(
|
||||
currentUrl.href,
|
||||
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
|
||||
);
|
||||
redirectCount = nextRedirectCount;
|
||||
}
|
||||
return response;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import dns from 'node:dns';
|
||||
import type {LookupFunction} from 'node:net';
|
||||
import {BlockList, isIP} from 'node:net';
|
||||
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {HttpError} from '@pkgs/http_client/src/HttpError';
|
||||
import {Agent} from 'undici';
|
||||
|
||||
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
@@ -89,29 +91,77 @@ function isFqdnHostname(hostname: string): boolean {
|
||||
return !/^\d+$/.test(topLevelDomain);
|
||||
}
|
||||
|
||||
function parseIpv4MappedIpv6Address(ipv6Address: string): string | null {
|
||||
function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null {
|
||||
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
|
||||
if (!normalized.startsWith('::ffff:')) {
|
||||
if (isIP(normalized) !== 6) {
|
||||
return null;
|
||||
}
|
||||
const suffix = normalized.slice('::ffff:'.length);
|
||||
if (isIP(suffix) === 4) {
|
||||
return suffix;
|
||||
let head = normalized;
|
||||
let embeddedIpv4Octets: Array<number> | null = null;
|
||||
const lastColonIndex = head.lastIndexOf(':');
|
||||
const trailing = head.slice(lastColonIndex + 1);
|
||||
if (trailing.includes('.')) {
|
||||
if (isIP(trailing) !== 4) {
|
||||
return null;
|
||||
}
|
||||
embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10));
|
||||
head = `${head.slice(0, lastColonIndex + 1)}0:0`;
|
||||
}
|
||||
const groups = suffix.split(':');
|
||||
if (groups.length !== 2) {
|
||||
let groups: Array<string>;
|
||||
if (head.indexOf('::') === -1) {
|
||||
groups = head.split(':');
|
||||
if (groups.length !== 8) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const [beforePart, afterPart] = head.split('::');
|
||||
const before = beforePart.length > 0 ? beforePart.split(':') : [];
|
||||
const after = afterPart.length > 0 ? afterPart.split(':') : [];
|
||||
const missing = 8 - before.length - after.length;
|
||||
if (missing < 1) {
|
||||
return null;
|
||||
}
|
||||
groups = [...before, ...new Array(missing).fill('0'), ...after];
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const value = parseHexGroup(groups[index]);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
bytes[index * 2] = (value >> 8) & 0xff;
|
||||
bytes[index * 2 + 1] = value & 0xff;
|
||||
}
|
||||
if (embeddedIpv4Octets) {
|
||||
bytes[12] = embeddedIpv4Octets[0];
|
||||
bytes[13] = embeddedIpv4Octets[1];
|
||||
bytes[14] = embeddedIpv4Octets[2];
|
||||
bytes[15] = embeddedIpv4Octets[3];
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
|
||||
const bytes = expandIpv6ToBytes(ipv6Address);
|
||||
if (!bytes) {
|
||||
return null;
|
||||
}
|
||||
const high = parseHexGroup(groups[0]);
|
||||
const low = parseHexGroup(groups[1]);
|
||||
if (high === null || low === null) {
|
||||
return null;
|
||||
const hasPrefix = (prefix: Array<number>): boolean => prefix.every((byte, index) => bytes[index] === byte);
|
||||
const dottedQuadAt = (start: number): string =>
|
||||
`${bytes[start]}.${bytes[start + 1]}.${bytes[start + 2]}.${bytes[start + 3]}`;
|
||||
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) {
|
||||
return dottedQuadAt(12);
|
||||
}
|
||||
const octet1 = (high >> 8) & 0xff;
|
||||
const octet2 = high & 0xff;
|
||||
const octet3 = (low >> 8) & 0xff;
|
||||
const octet4 = low & 0xff;
|
||||
return `${octet1}.${octet2}.${octet3}.${octet4}`;
|
||||
if (hasPrefix([0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) {
|
||||
return dottedQuadAt(12);
|
||||
}
|
||||
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) {
|
||||
return dottedQuadAt(12);
|
||||
}
|
||||
if (hasPrefix([0x20, 0x02])) {
|
||||
return dottedQuadAt(2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexGroup(value: string | undefined): number | null {
|
||||
@@ -128,15 +178,29 @@ function isBlockedIpAddress(address: string): boolean {
|
||||
return blockedIpv4List.check(normalizedAddress, 'ipv4');
|
||||
}
|
||||
if (family === 6) {
|
||||
const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress);
|
||||
if (mappedIpv4) {
|
||||
return blockedIpv4List.check(mappedIpv4, 'ipv4');
|
||||
const embeddedIpv4 = parseEmbeddedIpv4Address(normalizedAddress);
|
||||
if (embeddedIpv4) {
|
||||
return blockedIpv4List.check(embeddedIpv4, 'ipv4');
|
||||
}
|
||||
return blockedIpv6List.check(normalizedAddress, 'ipv6');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isPubliclyRoutableUrlShape(url: URL): boolean {
|
||||
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||
return false;
|
||||
}
|
||||
const normalizedHostname = normalizeHostname(url.hostname);
|
||||
if (!normalizedHostname) {
|
||||
return false;
|
||||
}
|
||||
if (isIP(normalizedHostname)) {
|
||||
return !isBlockedIpAddress(normalizedHostname);
|
||||
}
|
||||
return isFqdnHostname(normalizedHostname);
|
||||
}
|
||||
|
||||
function getPolicyErrorContext(context: RequestUrlValidationContext): string {
|
||||
if (context.phase === 'redirect') {
|
||||
const previous = context.previousUrl ?? 'unknown';
|
||||
@@ -168,9 +232,43 @@ function deduplicateAddresses(addresses: Array<string>): Array<string> {
|
||||
return deduplicated;
|
||||
}
|
||||
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
|
||||
const lookup: LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, {...options, all: true, verbatim: true}, (error, addresses) => {
|
||||
if (error) {
|
||||
callback(error, []);
|
||||
return;
|
||||
}
|
||||
if (!allowPrivateAddresses && addresses.some((entry) => isBlockedIpAddress(entry.address))) {
|
||||
callback(new Error(`Hostname ${hostname} resolved to a disallowed address`), []);
|
||||
return;
|
||||
}
|
||||
if (options.all) {
|
||||
callback(null, addresses);
|
||||
return;
|
||||
}
|
||||
const [primary] = addresses;
|
||||
if (!primary) {
|
||||
callback(new Error(`Hostname ${hostname} resolved to no IP addresses`), []);
|
||||
return;
|
||||
}
|
||||
callback(null, primary.address, primary.family);
|
||||
});
|
||||
};
|
||||
return new Agent({
|
||||
connect: {
|
||||
lookup,
|
||||
},
|
||||
}) as unknown as NonNullable<RequestInit['dispatcher']>;
|
||||
}
|
||||
|
||||
interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy {
|
||||
dispatcher: NonNullable<RequestInit['dispatcher']>;
|
||||
}
|
||||
|
||||
export function createPublicInternetRequestUrlPolicy(
|
||||
options?: PublicInternetRequestUrlPolicyOptions,
|
||||
): RequestUrlPolicy {
|
||||
): PublicInternetRequestUrlPolicy {
|
||||
const dnsCacheTtlMs =
|
||||
typeof options?.dnsCacheTtlMs === 'number' && options.dnsCacheTtlMs > 0
|
||||
? options.dnsCacheTtlMs
|
||||
@@ -221,5 +319,6 @@ export function createPublicInternetRequestUrlPolicy(
|
||||
}
|
||||
}
|
||||
}
|
||||
return {validate};
|
||||
const dispatcher = createBlocklistDispatcher(allowPrivateAddresses);
|
||||
return {validate, dispatcher};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,6 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,8 @@ export interface IKVProvider {
|
||||
refillIntervalMs: number,
|
||||
): Promise<number>;
|
||||
scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void>;
|
||||
removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean>;
|
||||
claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean>;
|
||||
removeBulkDeletion(queueKey: string, secondaryKey: string, member?: string): Promise<boolean>;
|
||||
scan(pattern: string, count: number): Promise<Array<string>>;
|
||||
dequeuePurgeBatch(
|
||||
queueKey: string,
|
||||
@@ -103,5 +104,6 @@ export interface IKVProvider {
|
||||
}>;
|
||||
pipeline(): IKVPipeline;
|
||||
multi(): IKVPipeline;
|
||||
isClustered(): boolean;
|
||||
health(): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {IKVPipeline, IKVProvider, IKVSubscription, KVRateLimitResult} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {
|
||||
type IKVLogger,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
parseRangeByScoreArguments,
|
||||
parseSetArguments,
|
||||
} from '@pkgs/kv_client/src/KVCommandArguments';
|
||||
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {KVPipeline} from '@pkgs/kv_client/src/KVPipeline';
|
||||
import {KVSubscription} from '@pkgs/kv_client/src/KVSubscription';
|
||||
import Redis, {Cluster} from 'ioredis';
|
||||
@@ -222,7 +224,23 @@ tokens = tokens - #urls
|
||||
redis.call('SET', bucketKey, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
|
||||
return cjson.encode({urls = urls, tokens = #urls})
|
||||
`;
|
||||
const CLAIM_BULK_DELETION_SCRIPT = `
|
||||
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
|
||||
if not score then
|
||||
return 0
|
||||
end
|
||||
if tonumber(score) > tonumber(ARGV[2]) then
|
||||
return 0
|
||||
end
|
||||
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[1])
|
||||
return 1
|
||||
`;
|
||||
const REMOVE_BULK_DELETION_SCRIPT = `
|
||||
local member = ARGV[1]
|
||||
if member ~= '' and redis.call('ZREM', KEYS[1], member) == 1 then
|
||||
redis.call('DEL', KEYS[2])
|
||||
return 1
|
||||
end
|
||||
local value = redis.call('GET', KEYS[2])
|
||||
if not value then
|
||||
return 0
|
||||
@@ -232,6 +250,8 @@ redis.call('DEL', KEYS[2])
|
||||
return 1
|
||||
`;
|
||||
|
||||
const SCRIPT_SHA_CACHE = new Map<string, string>();
|
||||
|
||||
interface ScriptPurgeBatchResult {
|
||||
urls: Array<string>;
|
||||
tokens: number;
|
||||
@@ -336,7 +356,23 @@ export class KVClient implements IKVProvider {
|
||||
}
|
||||
|
||||
async mget(...keys: Array<string>): Promise<Array<string | null>> {
|
||||
return await this.execute('mget', async () => this.client.mget(...keys));
|
||||
if (keys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await this.execute('mget', async () => {
|
||||
const values = new Array<string | null>(keys.length).fill(null);
|
||||
const batches = this.splitBySlot(
|
||||
keys.map((key, index) => ({key, index})),
|
||||
(entry) => entry.key,
|
||||
);
|
||||
await runSlotBatches(batches, async (batch) => {
|
||||
const batchValues = await this.client.mget(...batch.map((entry) => entry.key));
|
||||
for (const [position, entry] of batch.entries()) {
|
||||
values[entry.index] = batchValues[position] ?? null;
|
||||
}
|
||||
});
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
async mset(...args: Array<string>): Promise<void> {
|
||||
@@ -344,9 +380,11 @@ export class KVClient implements IKVProvider {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const pairs = entries.flatMap((entry) => [entry.key, entry.value]);
|
||||
await this.execute('mset', async () => {
|
||||
await this.client.mset(...pairs);
|
||||
const batches = this.splitBySlot(entries, (entry) => entry.key);
|
||||
await runSlotBatches(batches, async (batch) => {
|
||||
await this.client.mset(...batch.flatMap((entry) => [entry.key, entry.value]));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -354,7 +392,14 @@ export class KVClient implements IKVProvider {
|
||||
if (keys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await this.execute('del', async () => this.client.del(...keys));
|
||||
return await this.execute('del', async () => {
|
||||
const deleted: Array<number> = [];
|
||||
const batches = this.splitBySlot(keys, (key) => key);
|
||||
await runSlotBatches(batches, async (batch) => {
|
||||
deleted.push(await this.client.del(...batch));
|
||||
});
|
||||
return deleted.reduce((total, count) => total + count, 0);
|
||||
});
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<number> {
|
||||
@@ -605,13 +650,27 @@ export class KVClient implements IKVProvider {
|
||||
);
|
||||
}
|
||||
|
||||
async removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean> {
|
||||
async claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean> {
|
||||
const result = await this.executeScript(
|
||||
'claimBulkDeletion',
|
||||
CLAIM_BULK_DELETION_SCRIPT,
|
||||
1,
|
||||
queueKey,
|
||||
member,
|
||||
maxScore,
|
||||
leaseScore,
|
||||
);
|
||||
return Number(result) === 1;
|
||||
}
|
||||
|
||||
async removeBulkDeletion(queueKey: string, secondaryKey: string, member = ''): Promise<boolean> {
|
||||
const result = await this.executeScript(
|
||||
'removeBulkDeletion',
|
||||
REMOVE_BULK_DELETION_SCRIPT,
|
||||
2,
|
||||
queueKey,
|
||||
secondaryKey,
|
||||
member,
|
||||
);
|
||||
return Number(result) === 1;
|
||||
}
|
||||
@@ -669,6 +728,14 @@ export class KVClient implements IKVProvider {
|
||||
});
|
||||
}
|
||||
|
||||
isClustered(): boolean {
|
||||
return this.config.mode === 'cluster';
|
||||
}
|
||||
|
||||
private splitBySlot<T>(items: ReadonlyArray<T>, keyOf: (item: T) => string): Array<Array<T>> {
|
||||
return splitIntoSlotBatches(items, keyOf, this.isClustered());
|
||||
}
|
||||
|
||||
pipeline(): IKVPipeline {
|
||||
return new KVPipeline({
|
||||
createCommander: () => this.client.pipeline(),
|
||||
@@ -699,7 +766,18 @@ export class KVClient implements IKVProvider {
|
||||
keyCount: number,
|
||||
...args: Array<string | number>
|
||||
): Promise<unknown> {
|
||||
return await this.execute(command, async () => this.client.eval(script, keyCount, ...args));
|
||||
return await this.execute(command, async () => this.evalCachedScript(script, keyCount, args));
|
||||
}
|
||||
|
||||
private async evalCachedScript(script: string, keyCount: number, args: Array<string | number>): Promise<unknown> {
|
||||
try {
|
||||
return await this.client.evalsha(getScriptSha(script), keyCount, ...args);
|
||||
} catch (error) {
|
||||
if (!isNoScriptError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return await this.client.eval(script, keyCount, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeJsonScript<T>(
|
||||
@@ -795,6 +873,23 @@ function normalizeRateLimitResult(result: KVRateLimitResult): KVRateLimitResult
|
||||
};
|
||||
}
|
||||
|
||||
function getScriptSha(script: string): string {
|
||||
const cached = SCRIPT_SHA_CACHE.get(script);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
const sha = createHash('sha1').update(script).digest('hex');
|
||||
SCRIPT_SHA_CACHE.set(script, sha);
|
||||
return sha;
|
||||
}
|
||||
|
||||
function isNoScriptError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return error.message.includes('NOSCRIPT');
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const HASH_SLOT_COUNT = 16384;
|
||||
const MAX_CONCURRENT_SLOT_BATCHES = 16;
|
||||
|
||||
function extractHashTag(key: string): string {
|
||||
const start = key.indexOf('{');
|
||||
if (start === -1) {
|
||||
return key;
|
||||
}
|
||||
const end = key.indexOf('}', start + 1);
|
||||
if (end > start + 1) {
|
||||
return key.slice(start + 1, end);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function computeHashSlot(key: string): number {
|
||||
const hashed = extractHashTag(key);
|
||||
let crc = 0;
|
||||
for (let index = 0; index < hashed.length; index += 1) {
|
||||
crc ^= (hashed.charCodeAt(index) & 0xff) << 8;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 0x8000) === 0 ? (crc << 1) & 0xffff : ((crc << 1) ^ 0x1021) & 0xffff;
|
||||
}
|
||||
}
|
||||
return crc % HASH_SLOT_COUNT;
|
||||
}
|
||||
|
||||
export function splitIntoSlotBatches<T>(
|
||||
items: ReadonlyArray<T>,
|
||||
keyOf: (item: T) => string,
|
||||
clustered: boolean,
|
||||
): Array<Array<T>> {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!clustered) {
|
||||
return [[...items]];
|
||||
}
|
||||
const batches = new Map<number, Array<T>>();
|
||||
for (const item of items) {
|
||||
const slot = computeHashSlot(keyOf(item));
|
||||
const batch = batches.get(slot);
|
||||
if (batch) {
|
||||
batch.push(item);
|
||||
} else {
|
||||
batches.set(slot, [item]);
|
||||
}
|
||||
}
|
||||
return [...batches.values()];
|
||||
}
|
||||
|
||||
export async function runSlotBatches<T>(batches: ReadonlyArray<T>, run: (batch: T) => Promise<void>): Promise<void> {
|
||||
if (batches.length <= MAX_CONCURRENT_SLOT_BATCHES) {
|
||||
await Promise.all(batches.map(async (batch) => await run(batch)));
|
||||
return;
|
||||
}
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from({length: MAX_CONCURRENT_SLOT_BATCHES}, async () => {
|
||||
while (nextIndex < batches.length) {
|
||||
const batch = batches[nextIndex];
|
||||
nextIndex += 1;
|
||||
await run(batch);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {KVClient} from '@pkgs/kv_client/src/KVClient';
|
||||
import {KVClientErrorCode} from '@pkgs/kv_client/src/KVClientError';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {evalshaMock, evalMock} = vi.hoisted(() => ({
|
||||
evalshaMock: vi.fn(),
|
||||
evalMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('ioredis', () => {
|
||||
class MockRedis {
|
||||
evalsha = evalshaMock;
|
||||
eval = evalMock;
|
||||
}
|
||||
return {default: MockRedis, Cluster: MockRedis};
|
||||
});
|
||||
|
||||
const RATE_LIMIT_REPLY = JSON.stringify({
|
||||
allowed: true,
|
||||
limit: 5,
|
||||
remaining: 4,
|
||||
resetAfterMs: 200,
|
||||
resetAtMs: 1717171717,
|
||||
retryAfterMs: 0,
|
||||
});
|
||||
|
||||
const EXPECTED_RATE_LIMIT_RESULT = {
|
||||
allowed: true,
|
||||
limit: 5,
|
||||
remaining: 4,
|
||||
resetAfterMs: 200,
|
||||
resetAtMs: 1717171717,
|
||||
retryAfterMs: 0,
|
||||
};
|
||||
|
||||
function createClient(): KVClient {
|
||||
return new KVClient('redis://127.0.0.1:6379');
|
||||
}
|
||||
|
||||
function noScriptError(): Error {
|
||||
return new Error('NOSCRIPT No matching script. Please use EVAL.');
|
||||
}
|
||||
|
||||
function getCallArguments(mock: typeof evalshaMock, index: number): Array<unknown> {
|
||||
const call = mock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`Expected a call at index ${index}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
describe('KVClient script execution', () => {
|
||||
beforeEach(() => {
|
||||
evalshaMock.mockReset();
|
||||
evalMock.mockReset();
|
||||
});
|
||||
|
||||
it('sends EVALSHA instead of EVAL for the leaky bucket rate limit script', async () => {
|
||||
evalshaMock.mockResolvedValue(RATE_LIMIT_REPLY);
|
||||
const result = await createClient().checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
|
||||
expect(evalMock).not.toHaveBeenCalled();
|
||||
expect(evalshaMock).toHaveBeenCalledTimes(1);
|
||||
const [sha, keyCount, key, , limit, windowMs, cost] = getCallArguments(evalshaMock, 0);
|
||||
expect(sha).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(keyCount).toBe(1);
|
||||
expect(key).toBe('rate_limit:bucket');
|
||||
expect(limit).toBe(5);
|
||||
expect(windowMs).toBe(1000);
|
||||
expect(cost).toBe(1);
|
||||
expect(result).toEqual(EXPECTED_RATE_LIMIT_RESULT);
|
||||
});
|
||||
|
||||
it('falls back to EVAL with the original script and identical arguments on NOSCRIPT', async () => {
|
||||
evalshaMock.mockRejectedValue(noScriptError());
|
||||
evalMock.mockResolvedValue(RATE_LIMIT_REPLY);
|
||||
const result = await createClient().checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
|
||||
expect(evalshaMock).toHaveBeenCalledTimes(1);
|
||||
expect(evalMock).toHaveBeenCalledTimes(1);
|
||||
const [sha, ...evalshaRest] = getCallArguments(evalshaMock, 0);
|
||||
const [script, ...evalRest] = getCallArguments(evalMock, 0);
|
||||
expect(createHash('sha1').update(String(script)).digest('hex')).toBe(sha);
|
||||
expect(evalRest).toEqual(evalshaRest);
|
||||
expect(String(script)).toContain("local rawState = redis.call('GET', key)");
|
||||
expect(result).toEqual(EXPECTED_RATE_LIMIT_RESULT);
|
||||
});
|
||||
|
||||
it('keeps using EVALSHA with the same digest after a NOSCRIPT fallback', async () => {
|
||||
evalshaMock.mockRejectedValueOnce(noScriptError()).mockResolvedValue(RATE_LIMIT_REPLY);
|
||||
evalMock.mockResolvedValue(RATE_LIMIT_REPLY);
|
||||
const client = createClient();
|
||||
const first = await client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
|
||||
const second = await client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
|
||||
expect(evalshaMock).toHaveBeenCalledTimes(2);
|
||||
expect(evalMock).toHaveBeenCalledTimes(1);
|
||||
expect(getCallArguments(evalshaMock, 1)[0]).toBe(getCallArguments(evalshaMock, 0)[0]);
|
||||
expect(first).toEqual(EXPECTED_RATE_LIMIT_RESULT);
|
||||
expect(second).toEqual(EXPECTED_RATE_LIMIT_RESULT);
|
||||
});
|
||||
|
||||
it('sends EVALSHA for every scripted command', async () => {
|
||||
const cases: Array<{name: string; reply: unknown; keyCount: number; run: (client: KVClient) => Promise<unknown>}> =
|
||||
[
|
||||
{
|
||||
name: 'releaseLock',
|
||||
reply: 1,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.releaseLock('lock:key', 'token'),
|
||||
},
|
||||
{
|
||||
name: 'extendLock',
|
||||
reply: 1,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.extendLock('lock:key', 'token', 30),
|
||||
},
|
||||
{
|
||||
name: 'renewSnowflakeNode',
|
||||
reply: 1,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.renewSnowflakeNode('snowflake:1', 'instance', 30),
|
||||
},
|
||||
{
|
||||
name: 'checkLeakyBucketLimit',
|
||||
reply: RATE_LIMIT_REPLY,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1),
|
||||
},
|
||||
{
|
||||
name: 'tryConsumeTokens',
|
||||
reply: 2,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.tryConsumeTokens('tokens:key', 2, 10, 1, 1000),
|
||||
},
|
||||
{
|
||||
name: 'scheduleBulkDeletion',
|
||||
reply: 1,
|
||||
keyCount: 2,
|
||||
run: async (client) => client.scheduleBulkDeletion('queue:key', 'secondary:key', 1, 'value'),
|
||||
},
|
||||
{
|
||||
name: 'claimBulkDeletion',
|
||||
reply: 1,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.claimBulkDeletion('queue:key', 'member', 1, 2),
|
||||
},
|
||||
{
|
||||
name: 'removeBulkDeletion',
|
||||
reply: 1,
|
||||
keyCount: 2,
|
||||
run: async (client) => client.removeBulkDeletion('queue:key', 'secondary:key'),
|
||||
},
|
||||
{
|
||||
name: 'dequeuePurgeBatch',
|
||||
reply: JSON.stringify({urls: ['https://fluxer.test/a.png'], tokens: 1}),
|
||||
keyCount: 2,
|
||||
run: async (client) => client.dequeuePurgeBatch('queue:key', 'bucket:key', 10, 10, 1, 1000),
|
||||
},
|
||||
{
|
||||
name: 'evalScript',
|
||||
reply: 1,
|
||||
keyCount: 1,
|
||||
run: async (client) => client.evalScript('customScript', "return redis.call('GET', KEYS[1])", 1, 'key'),
|
||||
},
|
||||
];
|
||||
const digests = new Set<unknown>();
|
||||
for (const scriptCase of cases) {
|
||||
evalshaMock.mockReset();
|
||||
evalMock.mockReset();
|
||||
evalshaMock.mockResolvedValue(scriptCase.reply);
|
||||
await scriptCase.run(createClient());
|
||||
expect(evalMock, scriptCase.name).not.toHaveBeenCalled();
|
||||
expect(evalshaMock, scriptCase.name).toHaveBeenCalledTimes(1);
|
||||
const [sha, keyCount] = getCallArguments(evalshaMock, 0);
|
||||
expect(sha, scriptCase.name).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(keyCount, scriptCase.name).toBe(scriptCase.keyCount);
|
||||
digests.add(sha);
|
||||
}
|
||||
expect(digests.size).toBe(cases.length);
|
||||
});
|
||||
|
||||
it('does not retry with EVAL when the script fails for another reason', async () => {
|
||||
evalshaMock.mockRejectedValue(new Error('Connection is closed.'));
|
||||
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
|
||||
code: KVClientErrorCode.REQUEST_FAILED,
|
||||
message: 'KV request failed (releaseLock): Connection is closed.',
|
||||
});
|
||||
expect(evalMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes timeouts raised by the EVALSHA attempt', async () => {
|
||||
evalshaMock.mockRejectedValue(new Error('Command timed out'));
|
||||
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
|
||||
code: KVClientErrorCode.TIMEOUT,
|
||||
message: 'KV request timed out: releaseLock',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes failures raised by the EVAL fallback', async () => {
|
||||
evalshaMock.mockRejectedValue(noScriptError());
|
||||
evalMock.mockRejectedValue(new Error('Connection is closed.'));
|
||||
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
|
||||
code: KVClientErrorCode.REQUEST_FAILED,
|
||||
message: 'KV request failed (releaseLock): Connection is closed.',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports invalid JSON from a scripted command', async () => {
|
||||
evalshaMock.mockResolvedValue('not json');
|
||||
await expect(createClient().checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1)).rejects.toMatchObject({
|
||||
code: KVClientErrorCode.INVALID_RESPONSE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {KVClient} from '@pkgs/kv_client/src/KVClient';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {commands, store} = vi.hoisted(() => ({
|
||||
commands: [] as Array<{name: string; keys: Array<string>}>,
|
||||
store: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock('ioredis', () => {
|
||||
class MockRedis {
|
||||
async get(key: string): Promise<string | null> {
|
||||
commands.push({name: 'get', keys: [key]});
|
||||
return store.get(key) ?? null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<string> {
|
||||
commands.push({name: 'set', keys: [key]});
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async del(...keys: Array<string>): Promise<number> {
|
||||
commands.push({name: 'del', keys});
|
||||
return keys.filter((key) => store.delete(key)).length;
|
||||
}
|
||||
|
||||
async mget(...keys: Array<string>): Promise<Array<string | null>> {
|
||||
commands.push({name: 'mget', keys});
|
||||
return keys.map((key) => store.get(key) ?? null);
|
||||
}
|
||||
|
||||
async mset(...args: Array<string>): Promise<string> {
|
||||
const keys: Array<string> = [];
|
||||
for (let index = 0; index + 1 < args.length; index += 2) {
|
||||
keys.push(args[index]);
|
||||
store.set(args[index], args[index + 1]);
|
||||
}
|
||||
commands.push({name: 'mset', keys});
|
||||
return 'OK';
|
||||
}
|
||||
}
|
||||
return {default: MockRedis, Cluster: MockRedis};
|
||||
});
|
||||
|
||||
function crossSlotCommands(): Array<{name: string; keys: Array<string>}> {
|
||||
return commands.filter((command) => new Set(command.keys.map(computeHashSlot)).size > 1);
|
||||
}
|
||||
|
||||
function createClusteredClient(): KVClient {
|
||||
return new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
|
||||
}
|
||||
|
||||
function createStandaloneClient(): KVClient {
|
||||
return new KVClient({url: 'redis://127.0.0.1:6379', mode: 'standalone'});
|
||||
}
|
||||
|
||||
describe('KVClient cluster hash slots', () => {
|
||||
beforeEach(() => {
|
||||
commands.length = 0;
|
||||
store.clear();
|
||||
});
|
||||
|
||||
it('reads several keys without a command spanning hash slots', async () => {
|
||||
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
|
||||
const client = createClusteredClient();
|
||||
await client.set('slot:alpha', 'one');
|
||||
|
||||
await expect(client.mget('slot:alpha', 'slot:beta')).resolves.toEqual(['one', null]);
|
||||
expect(crossSlotCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes several keys without a command spanning hash slots', async () => {
|
||||
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
|
||||
const client = createClusteredClient();
|
||||
|
||||
await client.mset('slot:alpha', 'one', 'slot:beta', 'two');
|
||||
|
||||
expect(store.get('slot:alpha')).toBe('one');
|
||||
expect(store.get('slot:beta')).toBe('two');
|
||||
expect(crossSlotCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes several keys without a command spanning hash slots', async () => {
|
||||
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
|
||||
const client = createClusteredClient();
|
||||
await client.set('slot:alpha', 'one');
|
||||
await client.set('slot:beta', 'two');
|
||||
|
||||
await expect(client.del('slot:alpha', 'slot:beta', 'slot:gamma')).resolves.toBe(2);
|
||||
expect(store.size).toBe(0);
|
||||
expect(crossSlotCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps multi key commands whole outside cluster mode', async () => {
|
||||
const client = createStandaloneClient();
|
||||
|
||||
await client.mset('slot:alpha', 'one', 'slot:beta', 'two');
|
||||
await expect(client.mget('slot:alpha', 'slot:beta')).resolves.toEqual(['one', 'two']);
|
||||
await expect(client.del('slot:alpha', 'slot:beta')).resolves.toBe(2);
|
||||
|
||||
expect(commands).toEqual([
|
||||
{name: 'mset', keys: ['slot:alpha', 'slot:beta']},
|
||||
{name: 'mget', keys: ['slot:alpha', 'slot:beta']},
|
||||
{name: 'del', keys: ['slot:alpha', 'slot:beta']},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {KVClient} from '@pkgs/kv_client/src/KVClient';
|
||||
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const MAX_CONCURRENT_ROUND_TRIPS = 16;
|
||||
|
||||
const {commands, store, tracker} = vi.hoisted(() => ({
|
||||
commands: [] as Array<{name: string; keys: Array<string>}>,
|
||||
store: new Map<string, string>(),
|
||||
tracker: {inFlight: 0, peakInFlight: 0},
|
||||
}));
|
||||
|
||||
vi.mock('ioredis', () => {
|
||||
const trackRoundTrip = async (name: string, keys: Array<string>): Promise<void> => {
|
||||
commands.push({name, keys});
|
||||
tracker.inFlight += 1;
|
||||
tracker.peakInFlight = Math.max(tracker.peakInFlight, tracker.inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
tracker.inFlight -= 1;
|
||||
};
|
||||
class MockRedis {
|
||||
async mget(...keys: Array<string>): Promise<Array<string | null>> {
|
||||
await trackRoundTrip('mget', keys);
|
||||
return keys.map((key) => store.get(key) ?? null);
|
||||
}
|
||||
|
||||
async mset(...args: Array<string>): Promise<string> {
|
||||
const keys: Array<string> = [];
|
||||
for (let index = 0; index + 1 < args.length; index += 2) {
|
||||
keys.push(args[index]);
|
||||
store.set(args[index], args[index + 1]);
|
||||
}
|
||||
await trackRoundTrip('mset', keys);
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async del(...keys: Array<string>): Promise<number> {
|
||||
await trackRoundTrip('del', keys);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
await trackRoundTrip('get', [key]);
|
||||
return store.get(key) ?? null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<string> {
|
||||
await trackRoundTrip('set', [key]);
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
}
|
||||
}
|
||||
return {default: MockRedis, Cluster: MockRedis};
|
||||
});
|
||||
|
||||
function createKeys(count: number): Array<string> {
|
||||
return Array.from({length: count}, (_unused, index) => `fanout:key:${index}`);
|
||||
}
|
||||
|
||||
function crossSlotCommands(): Array<{name: string; keys: Array<string>}> {
|
||||
return commands.filter((command) => new Set(command.keys.map(computeHashSlot)).size > 1);
|
||||
}
|
||||
|
||||
describe('KVClient multi key fan out', () => {
|
||||
beforeEach(() => {
|
||||
commands.length = 0;
|
||||
store.clear();
|
||||
tracker.inFlight = 0;
|
||||
tracker.peakInFlight = 0;
|
||||
});
|
||||
|
||||
it('bounds concurrent round trips for a cluster read', async () => {
|
||||
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
|
||||
|
||||
const values = await client.mget(...createKeys(1000));
|
||||
|
||||
expect(values.length).toBe(1000);
|
||||
expect(tracker.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
|
||||
expect(crossSlotCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
it('bounds concurrent round trips for a cluster write', async () => {
|
||||
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
|
||||
|
||||
await client.mset(...createKeys(1000).flatMap((key) => [key, 'value']));
|
||||
|
||||
expect(tracker.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
|
||||
expect(crossSlotCommands()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads a thousand keys in one round trip outside cluster mode', async () => {
|
||||
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'standalone'});
|
||||
|
||||
await client.mget(...createKeys(1000));
|
||||
|
||||
expect(commands.map((command) => ({name: command.name, count: command.keys.length}))).toEqual([
|
||||
{name: 'mget', count: 1000},
|
||||
]);
|
||||
expect(tracker.peakInFlight).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps values ordered when a cluster read is split by slot', async () => {
|
||||
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
|
||||
await client.mset('fanout:a', 'one', 'fanout:b', 'two');
|
||||
|
||||
const values = await client.mget('fanout:a', 'fanout:missing', 'fanout:b');
|
||||
|
||||
expect(values).toEqual(['one', null, 'two']);
|
||||
});
|
||||
});
|
||||
@@ -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/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -14,10 +14,15 @@ interface PostgresConfig {
|
||||
sslCa?: string;
|
||||
maxConnections?: number;
|
||||
kvTable?: string;
|
||||
preparedStatements?: boolean;
|
||||
}
|
||||
|
||||
export interface PostgresQueryable {
|
||||
query<T extends QueryResultRow = QueryResultRow>(text: string, values?: Array<unknown>): Promise<QueryResult<T>>;
|
||||
query<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
values?: Array<unknown>,
|
||||
name?: string,
|
||||
): Promise<QueryResult<T>>;
|
||||
}
|
||||
|
||||
export interface IPostgresClient extends PostgresQueryable {
|
||||
@@ -92,15 +97,20 @@ class PostgresClient implements IPostgresClient {
|
||||
async query<T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
values: Array<unknown> = [],
|
||||
name?: string,
|
||||
): Promise<QueryResult<T>> {
|
||||
return this.getPool().query<T>(text, values);
|
||||
return this.getPool().query<T>({text, values, name: this.statementName(name)});
|
||||
}
|
||||
|
||||
private statementName(name: string | undefined): string | undefined {
|
||||
return this.config.preparedStatements === false ? undefined : name;
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T> {
|
||||
const client = await this.getPool().connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await fn(client);
|
||||
const result = await fn(poolClientQueryable(client, this.config.preparedStatements !== false));
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -123,6 +133,13 @@ class PostgresClient implements IPostgresClient {
|
||||
}
|
||||
}
|
||||
|
||||
function poolClientQueryable(client: PoolClient, preparedStatements: boolean): PostgresQueryable {
|
||||
return {
|
||||
query: <T extends QueryResultRow = QueryResultRow>(text: string, values: Array<unknown> = [], name?: string) =>
|
||||
client.query<T>({text, values, name: preparedStatements ? name : undefined}),
|
||||
};
|
||||
}
|
||||
|
||||
async function rollback(client: PoolClient): Promise<void> {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {existsSync, readFileSync, rmSync, statSync} from 'node:fs';
|
||||
import {isBuiltin} from 'node:module';
|
||||
import {dirname, join, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {build} from 'esbuild';
|
||||
|
||||
const API_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const REPO_ROOT = resolve(API_ROOT, '..');
|
||||
const OUT_DIR = join(API_ROOT, 'dist');
|
||||
const CANDIDATE_SUFFIXES = ['', '.ts', '.tsx', '.js', '.mjs', '/index.ts', '/index.tsx', '/index.js', '/src/index.ts'];
|
||||
|
||||
const WORKSPACE_ROOTS = {
|
||||
'@app/': join(API_ROOT, 'src'),
|
||||
'@pkgs/': join(API_ROOT, 'pkgs'),
|
||||
'@fluxer/': join(REPO_ROOT, 'packages'),
|
||||
};
|
||||
|
||||
function resolveWorkspacePath(specifier) {
|
||||
for (const [prefix, root] of Object.entries(WORKSPACE_ROOTS)) {
|
||||
if (!specifier.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
const base = join(root, specifier.slice(prefix.length));
|
||||
for (const suffix of CANDIDATE_SUFFIXES) {
|
||||
const candidate = `${base}${suffix}`;
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to resolve workspace import ${specifier}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function packageNameOf(specifier) {
|
||||
const segments = specifier.split('/');
|
||||
return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
|
||||
}
|
||||
|
||||
const declaredDependencies = new Set(
|
||||
Object.keys(JSON.parse(readFileSync(join(API_ROOT, 'package.json'), 'utf-8')).dependencies),
|
||||
);
|
||||
const undeclaredDependencies = new Set();
|
||||
|
||||
const workspacePlugin = {
|
||||
name: 'fluxer-workspace',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({filter: /^[^./]/}, (args) => {
|
||||
const workspacePath = resolveWorkspacePath(args.path);
|
||||
if (workspacePath) {
|
||||
return {path: workspacePath};
|
||||
}
|
||||
const packageName = packageNameOf(args.path);
|
||||
if (!isBuiltin(args.path) && !declaredDependencies.has(packageName)) {
|
||||
undeclaredDependencies.add(packageName);
|
||||
}
|
||||
return {path: args.path, external: true};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
rmSync(OUT_DIR, {recursive: true, force: true});
|
||||
|
||||
await build({
|
||||
entryPoints: [join(API_ROOT, 'src/AppEntrypoint.ts'), join(API_ROOT, 'src/WorkerEntrypoint.ts')],
|
||||
outdir: OUT_DIR,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node24',
|
||||
charset: 'utf8',
|
||||
sourcemap: true,
|
||||
sourcesContent: false,
|
||||
logLevel: 'info',
|
||||
banner: {js: '// SPDX-License-Identifier: AGPL-3.0-or-later'},
|
||||
plugins: [workspacePlugin],
|
||||
});
|
||||
|
||||
if (undeclaredDependencies.size > 0) {
|
||||
const names = [...undeclaredDependencies].sort().join(', ');
|
||||
throw new Error(
|
||||
`The bundle imports packages that fluxer_api does not declare as dependencies: ${names}. ` +
|
||||
'Workspace packages keep their own node_modules, so the bundle cannot reach them from fluxer_api.',
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAPIApp} from '@app/api/App';
|
||||
import {initializeConfig} from '@app/api/Config';
|
||||
import {buildAPIServerOptions, initializeConfig} from '@app/api/Config';
|
||||
import {initializeLogger} from '@app/api/Logger';
|
||||
import {Config} from '@app/Config';
|
||||
import {shutdownInstrumentation} from '@app/Instrument';
|
||||
@@ -34,7 +34,7 @@ async function main(): Promise<void> {
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
Logger.error({reason}, 'Unhandled rejection (suppressed)');
|
||||
});
|
||||
const server = createServer(app, {port: Config.port});
|
||||
const server = createServer(app, buildAPIServerOptions(Config));
|
||||
Logger.info({port: Config.port}, `Starting Fluxer API on port ${Config.port}`);
|
||||
setupGracefulShutdown(
|
||||
async () => {
|
||||
|
||||
@@ -48,6 +48,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
maxInflightRequests: config.maxInflightRequests,
|
||||
});
|
||||
routes.onError(AbuseAwareAppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import {createServer} from '@fluxer/hono/src/Server';
|
||||
import {Hono} from 'hono';
|
||||
import {afterAll, afterEach, describe, expect, test, vi} from 'vitest';
|
||||
import {buildAPIConfigFromMaster, buildAPIServerOptions} from './Config';
|
||||
|
||||
interface ListeningServer {
|
||||
close: (callback: () => void) => void;
|
||||
headersTimeout: number;
|
||||
requestTimeout: number;
|
||||
}
|
||||
|
||||
const servers: Array<ListeningServer> = [];
|
||||
|
||||
async function listenWithEnv(env: Record<string, string> = {}): Promise<ListeningServer> {
|
||||
for (const [key, value] of Object.entries({FLUXER_API_PORT: '0', ...env})) {
|
||||
vi.stubEnv(key, value);
|
||||
}
|
||||
resetConfig();
|
||||
const config = buildAPIConfigFromMaster(await loadConfig());
|
||||
const server = createServer(new Hono(), buildAPIServerOptions(config)) as unknown as ListeningServer;
|
||||
servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
vi.unstubAllEnvs();
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await loadConfig();
|
||||
});
|
||||
|
||||
describe('buildAPIServerOptions', () => {
|
||||
test('starts the api on the shipped header and request timeouts', async () => {
|
||||
const server = await listenWithEnv();
|
||||
expect(server.headersTimeout).toBe(30_000);
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator header timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator request timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
|
||||
expect(server.headersTimeout).toBe(30_000);
|
||||
expect(server.requestTimeout).toBe(600_000);
|
||||
});
|
||||
|
||||
test('clamps a header timeout set above the request timeout', async () => {
|
||||
const server = await listenWithEnv({
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: '90000',
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: '45000',
|
||||
});
|
||||
expect(server.requestTimeout).toBe(45_000);
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,13 @@ function isBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
function resolveValidateResponses(master: MasterConfig): boolean {
|
||||
if (isBoolean(master.dev.validate_responses)) {
|
||||
return master.dev.validate_responses;
|
||||
}
|
||||
return master.env !== 'production';
|
||||
}
|
||||
|
||||
function resolveTrustClientIpHeader(proxyConfig: object): boolean {
|
||||
const configuredValue = Reflect.get(proxyConfig, 'trust_client_ip_header');
|
||||
if (isBoolean(configuredValue)) {
|
||||
@@ -153,6 +160,9 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
return {
|
||||
nodeEnv: master.env === 'test' ? 'development' : master.env,
|
||||
port: master.services.api.port,
|
||||
headersTimeoutMs: master.services.api.headers_timeout_ms,
|
||||
requestTimeoutMs: master.services.api.request_timeout_ms,
|
||||
maxInflightRequests: master.services.api.max_inflight_requests,
|
||||
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
|
||||
desktopGitHubRedirectCountries: normalizeCountryCodes(
|
||||
master.services.api.desktop_github_redirect_countries,
|
||||
@@ -177,6 +187,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
sslCa: postgresSource?.ssl_ca ?? '',
|
||||
maxConnections: postgresSource?.max_connections ?? 20,
|
||||
kvTable: postgresSource?.kv_table ?? 'fluxer_kv',
|
||||
preparedStatements: postgresSource?.prepared_statements ?? true,
|
||||
},
|
||||
database: {
|
||||
backend: master.database.backend,
|
||||
@@ -467,6 +478,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
disableRateLimits: master.dev.disable_rate_limits,
|
||||
testModeEnabled: master.dev.test_mode_enabled,
|
||||
testHarnessToken: master.dev.test_harness_token,
|
||||
validateResponses: resolveValidateResponses(master),
|
||||
},
|
||||
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
|
||||
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
|
||||
@@ -520,6 +532,20 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
};
|
||||
}
|
||||
|
||||
interface APIServerOptions {
|
||||
port: number;
|
||||
headersTimeoutMs: number;
|
||||
requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
export function buildAPIServerOptions(config: APIConfig): APIServerOptions {
|
||||
return {
|
||||
port: config.port,
|
||||
headersTimeoutMs: config.headersTimeoutMs,
|
||||
requestTimeoutMs: config.requestTimeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
let _config: APIConfig | null = null;
|
||||
|
||||
export function initializeConfig(config: APIConfig): void {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {IntegrationRateLimitConfigs} from './rate_limit_configs/IntegrationRateL
|
||||
import {InviteRateLimitConfigs} from './rate_limit_configs/InviteRateLimitConfig';
|
||||
import {MiscRateLimitConfigs} from './rate_limit_configs/MiscRateLimitConfig';
|
||||
import {OAuthRateLimitConfigs} from './rate_limit_configs/OAuthRateLimitConfig';
|
||||
import {PackRateLimitConfigs} from './rate_limit_configs/PackRateLimitConfig';
|
||||
import type {RateLimitSection} from './rate_limit_configs/RateLimitHelpers';
|
||||
import {mergeRateLimitSections} from './rate_limit_configs/RateLimitHelpers';
|
||||
import {UserRateLimitConfigs} from './rate_limit_configs/UserRateLimitConfig';
|
||||
@@ -29,6 +28,5 @@ const rateLimitSections = [
|
||||
IntegrationRateLimitConfigs,
|
||||
AdminRateLimitConfigs,
|
||||
MiscRateLimitConfigs,
|
||||
PackRateLimitConfigs,
|
||||
] satisfies ReadonlyArray<RateLimitSection>;
|
||||
export const RateLimitConfigs = mergeRateLimitSections(...rateLimitSections);
|
||||
|
||||
@@ -290,13 +290,10 @@ import {
|
||||
type SuspiciousIpRow,
|
||||
} from './database/types/RiskTypes';
|
||||
import {
|
||||
EXPRESSION_PACK_COLUMNS,
|
||||
type ExpressionPackRow,
|
||||
FAVORITE_MEME_COLUMNS,
|
||||
type FavoriteMemeRow,
|
||||
NOTE_COLUMNS,
|
||||
type NoteRow,
|
||||
type PackInstallationRow,
|
||||
PUSH_SUBSCRIPTION_COLUMNS,
|
||||
type PushSubscriptionRow,
|
||||
RECENT_MENTION_COLUMNS,
|
||||
@@ -305,8 +302,6 @@ import {
|
||||
type RelationshipRow,
|
||||
SAVED_MESSAGE_COLUMNS,
|
||||
type SavedMessageRow,
|
||||
SCHEDULED_MESSAGE_COLUMNS,
|
||||
type ScheduledMessageRow,
|
||||
USER_BY_EMAIL_COLUMNS,
|
||||
USER_BY_LAST_ACTIVE_IP_COLUMNS,
|
||||
USER_BY_LAST_ACTIVE_IP_TRUST_KEY_COLUMNS,
|
||||
@@ -684,11 +679,6 @@ export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_i
|
||||
columns: SAVED_MESSAGE_COLUMNS,
|
||||
primaryKey: ['user_id', 'message_id'],
|
||||
});
|
||||
export const ScheduledMessages = defineTable<ScheduledMessageRow, 'user_id' | 'scheduled_message_id'>({
|
||||
name: 'scheduled_messages',
|
||||
columns: SCHEDULED_MESSAGE_COLUMNS,
|
||||
primaryKey: ['user_id', 'scheduled_message_id'],
|
||||
});
|
||||
export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 'subscription_id'>({
|
||||
name: 'push_subscriptions',
|
||||
columns: PUSH_SUBSCRIPTION_COLUMNS,
|
||||
@@ -1011,25 +1001,6 @@ export const FavoriteMemesByMemeId = defineTable<FavoriteMemesByMemeIdRow, 'meme
|
||||
columns: FAVORITE_MEMES_BY_MEME_ID_COLUMNS,
|
||||
primaryKey: ['meme_id', 'user_id'],
|
||||
});
|
||||
export const ExpressionPacks = defineTable<ExpressionPackRow, 'pack_id'>({
|
||||
name: 'expression_packs',
|
||||
columns: EXPRESSION_PACK_COLUMNS,
|
||||
primaryKey: ['pack_id'],
|
||||
});
|
||||
export const ExpressionPacksByCreator = defineTable<ExpressionPackRow, 'creator_id' | 'pack_id'>({
|
||||
name: 'expression_packs_by_creator',
|
||||
columns: EXPRESSION_PACK_COLUMNS,
|
||||
primaryKey: ['creator_id', 'pack_id'],
|
||||
partitionKey: ['creator_id'],
|
||||
});
|
||||
const PACK_INSTALLATION_COLUMNS = ['user_id', 'pack_id', 'pack_type', 'installed_at'] as const satisfies ReadonlyArray<
|
||||
keyof PackInstallationRow
|
||||
>;
|
||||
export const PackInstallations = defineTable<PackInstallationRow, 'user_id' | 'pack_id'>({
|
||||
name: 'pack_installations',
|
||||
columns: PACK_INSTALLATION_COLUMNS,
|
||||
primaryKey: ['user_id', 'pack_id'],
|
||||
});
|
||||
|
||||
interface InvitesByChannelRow {
|
||||
channel_id: ChannelID;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import type {ZodError, ZodTypeAny} from 'zod';
|
||||
import {parseJsonPreservingLargeIntegers} from './utils/LosslessJsonParser';
|
||||
import {readRequestJsonBody} from './utils/RequestJsonBody';
|
||||
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
||||
|
||||
initializeFluxerErrorMap();
|
||||
@@ -200,12 +200,7 @@ export const Validator = <
|
||||
let value: unknown;
|
||||
switch (target) {
|
||||
case 'json':
|
||||
try {
|
||||
const raw = await c.req.text();
|
||||
value = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
|
||||
} catch {
|
||||
value = {};
|
||||
}
|
||||
value = (await readRequestJsonBody(c.req)).value;
|
||||
break;
|
||||
case 'form': {
|
||||
const formData = await c.req.formData();
|
||||
|
||||
@@ -66,8 +66,6 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
|
||||
const origin = 'origin' in issue ? String(issue.origin) : undefined;
|
||||
if (origin === 'string') {
|
||||
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
|
||||
} else if (origin === 'date') {
|
||||
errorCode = ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE;
|
||||
} else {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -626,14 +626,14 @@ async function updatePendingRegistrationUser(
|
||||
return;
|
||||
}
|
||||
const traits = new Set(user.traits);
|
||||
traits.delete(REGISTRATION_PENDING_APPROVAL_TRAIT);
|
||||
const wasPendingApproval = traits.delete(REGISTRATION_PENDING_APPROVAL_TRAIT);
|
||||
if (decision === 'reject') {
|
||||
traits.add(REGISTRATION_REJECTED_TRAIT);
|
||||
} else {
|
||||
traits.delete(REGISTRATION_REJECTED_TRAIT);
|
||||
}
|
||||
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
|
||||
if (decision === 'approve') {
|
||||
if (decision === 'approve' && wasPendingApproval) {
|
||||
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
|
||||
}
|
||||
await ctx.get('adminService').auditService.createAuditLog({
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
VoiceDiagnosticsObjectListResponse,
|
||||
VoiceDiagnosticsQueryRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
|
||||
import {VOICE_DIAGNOSTICS_BUCKET, VoiceDiagnosticsService} from '../../channel/services/VoiceDiagnosticsService';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
export function VoiceDiagnosticsAdminController(app: HonoApp) {
|
||||
app.get(
|
||||
'/admin/voice/diagnostics/objects',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_DIAGNOSTICS_VIEW),
|
||||
Validator('query', VoiceDiagnosticsQueryRequest),
|
||||
OpenAPI({
|
||||
operationId: 'list_voice_diagnostics_objects',
|
||||
summary: 'List voice diagnostics objects',
|
||||
responseSchema: VoiceDiagnosticsObjectListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Lists raw voice diagnostics NDJSON S3 objects for a channel and time range. Requires VOICE_DIAGNOSTICS_VIEW permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const query = ctx.req.valid('query');
|
||||
const service = new VoiceDiagnosticsService(
|
||||
ctx.get('cacheService'),
|
||||
ctx.get('channelService'),
|
||||
ctx.get('gatewayService'),
|
||||
ctx.get('storageService'),
|
||||
);
|
||||
const objects = await service.listObjects({
|
||||
channelId: query.channel_id,
|
||||
startMs: query.start_ms,
|
||||
endMs: query.end_ms,
|
||||
sessionId: query.session_id,
|
||||
limitObjects: query.limit_objects,
|
||||
});
|
||||
return ctx.json({bucket: VOICE_DIAGNOSTICS_BUCKET, objects});
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/voice/diagnostics/raw',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_DIAGNOSTICS_VIEW),
|
||||
Validator('query', VoiceDiagnosticsQueryRequest),
|
||||
OpenAPI({
|
||||
operationId: 'stream_voice_diagnostics_raw',
|
||||
summary: 'Stream raw voice diagnostics',
|
||||
responseSchema: null,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Streams matching voice diagnostics NDJSON objects for local processing. Requires VOICE_DIAGNOSTICS_VIEW permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const query = ctx.req.valid('query');
|
||||
const service = new VoiceDiagnosticsService(
|
||||
ctx.get('cacheService'),
|
||||
ctx.get('channelService'),
|
||||
ctx.get('gatewayService'),
|
||||
ctx.get('storageService'),
|
||||
);
|
||||
const objects = await service.listObjects({
|
||||
channelId: query.channel_id,
|
||||
startMs: query.start_ms,
|
||||
endMs: query.end_ms,
|
||||
sessionId: query.session_id,
|
||||
limitObjects: query.limit_objects,
|
||||
});
|
||||
const stream = service.createRawObjectStream(objects);
|
||||
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-ndjson',
|
||||
'Cache-Control': 'no-store, private',
|
||||
'X-Fluxer-Diagnostics-Bucket': VOICE_DIAGNOSTICS_BUCKET,
|
||||
'X-Fluxer-Diagnostics-Object-Count': objects.length.toString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {ArchiveAdminController} from './ArchiveAdminController';
|
||||
import {AssetAdminController} from './AssetAdminController';
|
||||
import {AuditLogAdminController} from './AuditLogAdminController';
|
||||
import {BanAdminController} from './BanAdminController';
|
||||
import {BillingAdminController} from './BillingAdminController';
|
||||
import {BulkAdminController} from './BulkAdminController';
|
||||
import {CodesAdminController} from './CodesAdminController';
|
||||
import {DiscoveryAdminController} from './DiscoveryAdminController';
|
||||
@@ -23,7 +22,6 @@ import {SystemAdminController} from './SystemAdminController';
|
||||
import {SystemDmAdminController} from './SystemDmAdminController';
|
||||
import {UserAdminController} from './UserAdminController';
|
||||
import {VoiceAdminController} from './VoiceAdminController';
|
||||
import {VoiceDiagnosticsAdminController} from './VoiceDiagnosticsAdminController';
|
||||
|
||||
export function registerAdminControllers(app: HonoApp) {
|
||||
AdminApiKeyAdminController(app);
|
||||
@@ -40,9 +38,7 @@ export function registerAdminControllers(app: HonoApp) {
|
||||
AuditLogAdminController(app);
|
||||
ArchiveAdminController(app);
|
||||
ReportAdminController(app);
|
||||
BillingAdminController(app);
|
||||
VoiceAdminController(app);
|
||||
VoiceDiagnosticsAdminController(app);
|
||||
GatewayAdminController(app);
|
||||
SearchAdminController(app);
|
||||
DiscoveryAdminController(app);
|
||||
|
||||
@@ -133,6 +133,7 @@ export class AdminGuildMembershipService {
|
||||
guildId,
|
||||
targetId,
|
||||
deleteMessageDays: data.delete_message_days,
|
||||
deleteMessageSeconds: data.delete_message_seconds,
|
||||
reason: data.reason ?? undefined,
|
||||
banDurationSeconds: data.ban_duration_seconds ?? undefined,
|
||||
skipGuildAuditLog: true,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
|
||||
describe('Admin Billing Authorization', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
test('billing overview requires billing:view ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.get(`/admin/billing/users/${admin.userId}/overview`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('billing refund requires billing:refund ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${admin.userId}/refund`)
|
||||
.body({payment_intent_id: 'pi_test_refund'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('billing subscription management requires billing:manage_subscription ACL', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate']);
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${admin.userId}/cancel-subscription`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${admin.userId}/reactivate-subscription`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
test('refund policy immediate cancellation requires both refund and subscription management ACLs', async () => {
|
||||
const refundOnlyAdmin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, refundOnlyAdmin, ['admin:authenticate', 'billing:refund']);
|
||||
await createBuilder(harness, `${refundOnlyAdmin.token}`)
|
||||
.post(`/admin/billing/users/${refundOnlyAdmin.userId}/refund-policy-cancel-now`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
const subscriptionOnlyAdmin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, subscriptionOnlyAdmin, ['admin:authenticate', 'billing:manage_subscription']);
|
||||
await createBuilder(harness, `${subscriptionOnlyAdmin.token}`)
|
||||
.post(`/admin/billing/users/${subscriptionOnlyAdmin.userId}/refund-policy-cancel-now`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -1,924 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import type {
|
||||
AdminBillingOverviewResponse,
|
||||
AdminBillingRefundLatestInvoiceCancelResponse,
|
||||
AdminInvoiceListResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminBillingSchemas';
|
||||
import type Stripe from 'stripe';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {PaymentRepository} from '../../user/repositories/PaymentRepository';
|
||||
|
||||
const DAY_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function stripeFixture<T>(value: object): T {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
describe('Admin billing overview', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
async function setStripeCustomerId(userId: string, stripeCustomerId: string): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${userId}/premium`)
|
||||
.body({stripe_customer_id: stripeCustomerId})
|
||||
.execute();
|
||||
}
|
||||
async function mirrorCustomer(params: {stripeCustomerId: string; userId?: string}): Promise<void> {
|
||||
await getBillingRepository().customers.upsertFromStripe(
|
||||
{
|
||||
id: params.stripeCustomerId,
|
||||
object: 'customer',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
email: null,
|
||||
invoice_settings: {default_payment_method: null},
|
||||
livemode: false,
|
||||
metadata: params.userId ? {userId: params.userId} : {},
|
||||
} as Stripe.Customer,
|
||||
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
|
||||
);
|
||||
}
|
||||
async function mirrorSubscription(params: {
|
||||
currentPeriodEnd?: number;
|
||||
currentPeriodStart?: number;
|
||||
latestInvoiceId?: string;
|
||||
status?: Stripe.Subscription.Status;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId: string;
|
||||
userId?: string;
|
||||
}): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const currentPeriodStart = params.currentPeriodStart ?? now - DAY_SECONDS;
|
||||
const currentPeriodEnd = params.currentPeriodEnd ?? now + 29 * DAY_SECONDS;
|
||||
await getBillingRepository().subscriptions.upsertFromStripe(
|
||||
{
|
||||
id: params.stripeSubscriptionId,
|
||||
cancel_at: null,
|
||||
cancel_at_period_end: false,
|
||||
canceled_at: null,
|
||||
collection_method: 'charge_automatically',
|
||||
created: currentPeriodStart,
|
||||
currency: 'eur',
|
||||
customer: params.stripeCustomerId,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
id: `si_${params.stripeSubscriptionId}`,
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodEnd,
|
||||
price: {
|
||||
id: 'price_monthly_eur',
|
||||
product: 'prod_monthly',
|
||||
unit_amount: 499,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
latest_invoice: params.latestInvoiceId ?? null,
|
||||
livemode: false,
|
||||
metadata: params.userId ? {userId: params.userId} : {},
|
||||
status: params.status ?? 'active',
|
||||
},
|
||||
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
|
||||
);
|
||||
}
|
||||
async function mirrorInvoice(params: {
|
||||
amountPaidCents: number;
|
||||
chargeId?: string;
|
||||
created?: number;
|
||||
currency?: string;
|
||||
invoiceId: string;
|
||||
paymentIntentId?: string;
|
||||
paymentId?: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId?: string;
|
||||
userId?: string;
|
||||
}): Promise<void> {
|
||||
const created = params.created ?? Math.floor(Date.now() / 1000);
|
||||
await getBillingRepository().invoices.upsertFromStripe(
|
||||
stripeFixture<Stripe.Invoice>({
|
||||
id: params.invoiceId,
|
||||
object: 'invoice',
|
||||
amount_due: params.amountPaidCents,
|
||||
amount_paid: params.amountPaidCents,
|
||||
amount_remaining: 0,
|
||||
attempt_count: 1,
|
||||
attempted: true,
|
||||
billing_reason: 'subscription_cycle',
|
||||
collection_method: 'charge_automatically',
|
||||
created,
|
||||
currency: params.currency ?? 'eur',
|
||||
customer: params.stripeCustomerId,
|
||||
livemode: false,
|
||||
metadata: params.userId ? {userId: params.userId} : {},
|
||||
paid: true,
|
||||
payments:
|
||||
params.paymentIntentId || params.chargeId
|
||||
? {
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: params.paymentId ?? `inpay_${params.invoiceId}`,
|
||||
object: 'invoice_payment',
|
||||
amount_paid: params.amountPaidCents,
|
||||
amount_requested: params.amountPaidCents,
|
||||
created,
|
||||
currency: params.currency ?? 'eur',
|
||||
invoice: params.invoiceId,
|
||||
is_default: true,
|
||||
livemode: false,
|
||||
payment: {
|
||||
type: 'payment_intent',
|
||||
payment_intent: params.paymentIntentId ?? null,
|
||||
charge: params.chargeId ?? null,
|
||||
},
|
||||
status: 'paid',
|
||||
status_transitions: {canceled_at: null, paid_at: created + 20},
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
url: `/v1/invoices/${params.invoiceId}/payments`,
|
||||
}
|
||||
: {object: 'list', data: [], has_more: false, url: `/v1/invoices/${params.invoiceId}/payments`},
|
||||
status: 'paid',
|
||||
status_transitions: {finalized_at: created, paid_at: created + 20, voided_at: null},
|
||||
subscription: params.stripeSubscriptionId ?? null,
|
||||
subtotal: params.amountPaidCents,
|
||||
total: params.amountPaidCents,
|
||||
}),
|
||||
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
|
||||
);
|
||||
}
|
||||
async function mirrorPaymentIntent(params: {
|
||||
amountCents?: number;
|
||||
chargeId?: string;
|
||||
invoiceId?: string;
|
||||
paymentIntentId: string;
|
||||
stripeCustomerId: string;
|
||||
}): Promise<void> {
|
||||
await getBillingRepository().paymentIntents.upsertFromStripe(
|
||||
stripeFixture<Stripe.PaymentIntent>({
|
||||
id: params.paymentIntentId,
|
||||
object: 'payment_intent',
|
||||
amount: params.amountCents ?? 499,
|
||||
amount_capturable: 0,
|
||||
amount_received: params.amountCents ?? 499,
|
||||
capture_method: 'automatic',
|
||||
confirmation_method: 'automatic',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
currency: 'eur',
|
||||
customer: params.stripeCustomerId,
|
||||
invoice: params.invoiceId ?? null,
|
||||
latest_charge: params.chargeId ?? null,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
payment_method_types: ['card'],
|
||||
status: 'succeeded',
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function mirrorCharge(params: {
|
||||
amountCents?: number;
|
||||
chargeId: string;
|
||||
invoiceId?: string;
|
||||
paymentIntentId?: string;
|
||||
stripeCustomerId: string;
|
||||
}): Promise<void> {
|
||||
await getBillingRepository().charges.upsertFromStripe(
|
||||
stripeFixture<Stripe.Charge>({
|
||||
id: params.chargeId,
|
||||
object: 'charge',
|
||||
amount: params.amountCents ?? 499,
|
||||
amount_captured: params.amountCents ?? 499,
|
||||
amount_refunded: 0,
|
||||
billing_details: {address: {country: null}},
|
||||
captured: true,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
currency: 'eur',
|
||||
customer: params.stripeCustomerId,
|
||||
invoice: params.invoiceId ?? null,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
paid: true,
|
||||
payment_intent: params.paymentIntentId ?? null,
|
||||
payment_method_details: {type: 'card', card: {brand: 'visa', last4: '4242', country: null}},
|
||||
refunded: false,
|
||||
status: 'succeeded',
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function mirrorPaymentMethod(params: {paymentMethodId: string; stripeCustomerId: string}): Promise<void> {
|
||||
await getBillingRepository().paymentMethods.upsertFromStripe(
|
||||
{
|
||||
id: params.paymentMethodId,
|
||||
object: 'payment_method',
|
||||
billing_details: {address: {country: 'US'}, email: null, name: null, phone: null},
|
||||
card: {brand: 'visa', country: 'US', exp_month: 12, exp_year: 2031, funding: 'credit', last4: '4242'},
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
customer: params.stripeCustomerId,
|
||||
livemode: false,
|
||||
metadata: {},
|
||||
type: 'card',
|
||||
} as Stripe.PaymentMethod,
|
||||
{isDefault: true},
|
||||
);
|
||||
}
|
||||
async function setStripeSubscriptionState(params: {
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId: string;
|
||||
}): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${params.userId}/premium`)
|
||||
.body({
|
||||
stripe_customer_id: params.stripeCustomerId,
|
||||
stripe_subscription_id: params.stripeSubscriptionId,
|
||||
premium_type: UserPremiumTypes.SUBSCRIPTION,
|
||||
premium_billing_cycle: 'monthly',
|
||||
premium_will_cancel: false,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
function createRefundPolicyStripeHandlers(params: {
|
||||
amountPaidCents: number;
|
||||
currency?: string;
|
||||
elapsedDays: number;
|
||||
invoiceId: string;
|
||||
stripeCustomerId: string;
|
||||
stripeSubscriptionId: string;
|
||||
}) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const currentPeriodStart = now - params.elapsedDays * DAY_SECONDS;
|
||||
const currentPeriodEnd = currentPeriodStart + 30 * DAY_SECONDS;
|
||||
return createStripeApiHandlers({
|
||||
subscriptions: {
|
||||
[params.stripeSubscriptionId]: {
|
||||
customer: params.stripeCustomerId,
|
||||
current_period_start: currentPeriodStart,
|
||||
current_period_end: currentPeriodEnd,
|
||||
latest_invoice: params.invoiceId,
|
||||
status: 'active',
|
||||
},
|
||||
},
|
||||
invoices: {
|
||||
[params.invoiceId]: {
|
||||
customer: params.stripeCustomerId,
|
||||
subscriptionId: params.stripeSubscriptionId,
|
||||
amount_due: params.amountPaidCents,
|
||||
amount_paid: params.amountPaidCents,
|
||||
billing_reason: 'subscription_cycle',
|
||||
currency: params.currency ?? 'eur',
|
||||
created: now - 300,
|
||||
status: 'paid',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async function createPaymentRecord(params: {
|
||||
userId: string;
|
||||
checkoutSessionId: string;
|
||||
completedAt?: Date;
|
||||
euWithdrawalWaiverAccepted?: boolean;
|
||||
euWithdrawalWaiverAcceptedAt?: Date | null;
|
||||
euWithdrawalWaiverRequired?: boolean;
|
||||
euWithdrawalWaiverTextVersion?: string | null;
|
||||
invoiceId: string;
|
||||
purchaseClientCountryCode?: string | null;
|
||||
purchaseGeoipCountryCode?: string | null;
|
||||
subscriptionId: string;
|
||||
stripeCustomerId: string;
|
||||
}): Promise<void> {
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const createdAt = params.completedAt ?? new Date('2026-02-23T14:27:32.409Z');
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: params.checkoutSessionId,
|
||||
user_id: createUserID(BigInt(params.userId)),
|
||||
price_id: 'price_monthly_eur',
|
||||
product_type: 'monthly_subscription',
|
||||
status: 'completed',
|
||||
is_gift: false,
|
||||
created_at: createdAt,
|
||||
purchase_geoip_country_code: params.purchaseGeoipCountryCode ?? null,
|
||||
purchase_client_country_code: params.purchaseClientCountryCode ?? null,
|
||||
eu_withdrawal_waiver_required: params.euWithdrawalWaiverRequired ?? false,
|
||||
eu_withdrawal_waiver_accepted: params.euWithdrawalWaiverAccepted ?? false,
|
||||
eu_withdrawal_waiver_accepted_at: params.euWithdrawalWaiverAcceptedAt ?? null,
|
||||
eu_withdrawal_waiver_text_version: params.euWithdrawalWaiverTextVersion ?? null,
|
||||
});
|
||||
await paymentRepository.updatePayment({
|
||||
checkout_session_id: params.checkoutSessionId,
|
||||
stripe_customer_id: params.stripeCustomerId,
|
||||
payment_intent_id: null,
|
||||
subscription_id: params.subscriptionId,
|
||||
invoice_id: params.invoiceId,
|
||||
amount_cents: 499,
|
||||
currency: 'eur',
|
||||
status: 'completed',
|
||||
completed_at: createdAt,
|
||||
purchase_geoip_country_code: params.purchaseGeoipCountryCode ?? null,
|
||||
purchase_client_country_code: params.purchaseClientCountryCode ?? null,
|
||||
eu_withdrawal_waiver_required: params.euWithdrawalWaiverRequired ?? false,
|
||||
eu_withdrawal_waiver_accepted: params.euWithdrawalWaiverAccepted ?? false,
|
||||
eu_withdrawal_waiver_accepted_at: params.euWithdrawalWaiverAcceptedAt ?? null,
|
||||
eu_withdrawal_waiver_text_version: params.euWithdrawalWaiverTextVersion ?? null,
|
||||
});
|
||||
}
|
||||
test('resolves missing payment intents from Stripe invoice payments for overview and invoice listings', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'billing:view']);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const stripeCustomerId = 'cus_billing_target';
|
||||
await setStripeCustomerId(targetUser.userId, stripeCustomerId);
|
||||
await createPaymentRecord({
|
||||
userId: targetUser.userId,
|
||||
checkoutSessionId: 'cs_billing_overview_1',
|
||||
invoiceId: 'in_local_checkout_1',
|
||||
subscriptionId: 'sub_billing_target',
|
||||
stripeCustomerId,
|
||||
});
|
||||
const stripeHandlers = createStripeApiHandlers({
|
||||
invoices: {
|
||||
in_local_checkout_1: {
|
||||
customer: stripeCustomerId,
|
||||
subscriptionId: 'sub_billing_target',
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_create',
|
||||
currency: 'eur',
|
||||
created: 1771862851,
|
||||
payments: {
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'inpay_local_checkout_1',
|
||||
object: 'invoice_payment',
|
||||
amount_paid: 499,
|
||||
amount_requested: 499,
|
||||
created: 1771862851,
|
||||
currency: 'eur',
|
||||
invoice: 'in_local_checkout_1',
|
||||
is_default: true,
|
||||
livemode: false,
|
||||
payment: {
|
||||
type: 'payment_intent',
|
||||
payment_intent: 'pi_local_checkout_1',
|
||||
charge: 'ch_local_checkout_1',
|
||||
},
|
||||
status: 'paid',
|
||||
status_transitions: {
|
||||
canceled_at: null,
|
||||
paid_at: 1771862871,
|
||||
},
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
url: '/v1/invoices/in_local_checkout_1/payments',
|
||||
},
|
||||
},
|
||||
in_renewal_1: {
|
||||
customer: stripeCustomerId,
|
||||
subscriptionId: 'sub_billing_target',
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_cycle',
|
||||
currency: 'eur',
|
||||
created: 1776065330,
|
||||
payments: {
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'inpay_renewal_1',
|
||||
object: 'invoice_payment',
|
||||
amount_paid: 499,
|
||||
amount_requested: 499,
|
||||
created: 1776065330,
|
||||
currency: 'eur',
|
||||
invoice: 'in_renewal_1',
|
||||
is_default: true,
|
||||
livemode: false,
|
||||
payment: {
|
||||
type: 'payment_intent',
|
||||
payment_intent: 'pi_renewal_1',
|
||||
charge: 'ch_renewal_1',
|
||||
},
|
||||
status: 'paid',
|
||||
status_transitions: {
|
||||
canceled_at: null,
|
||||
paid_at: 1776065360,
|
||||
},
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
url: '/v1/invoices/in_renewal_1/payments',
|
||||
},
|
||||
},
|
||||
},
|
||||
paymentIntents: {
|
||||
pi_local_checkout_1: {
|
||||
customer: stripeCustomerId,
|
||||
currency: 'eur',
|
||||
latest_charge: 'ch_local_checkout_1',
|
||||
},
|
||||
pi_renewal_1: {
|
||||
customer: stripeCustomerId,
|
||||
currency: 'eur',
|
||||
latest_charge: 'ch_renewal_1',
|
||||
},
|
||||
},
|
||||
paymentMethods: {
|
||||
pm_billing_target_1: {
|
||||
customer: stripeCustomerId,
|
||||
type: 'card',
|
||||
card: {
|
||||
brand: 'visa',
|
||||
last4: '4242',
|
||||
exp_month: 12,
|
||||
exp_year: 2031,
|
||||
country: 'US',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
await mirrorInvoice({
|
||||
amountPaidCents: 499,
|
||||
chargeId: 'ch_local_checkout_1',
|
||||
created: 1771862851,
|
||||
invoiceId: 'in_local_checkout_1',
|
||||
paymentId: 'inpay_local_checkout_1',
|
||||
paymentIntentId: 'pi_local_checkout_1',
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId: 'sub_billing_target',
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorInvoice({
|
||||
amountPaidCents: 499,
|
||||
chargeId: 'ch_renewal_1',
|
||||
created: 1776065330,
|
||||
invoiceId: 'in_renewal_1',
|
||||
paymentId: 'inpay_renewal_1',
|
||||
paymentIntentId: 'pi_renewal_1',
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId: 'sub_billing_target',
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorPaymentIntent({
|
||||
chargeId: 'ch_local_checkout_1',
|
||||
invoiceId: 'in_local_checkout_1',
|
||||
paymentIntentId: 'pi_local_checkout_1',
|
||||
stripeCustomerId,
|
||||
});
|
||||
await mirrorPaymentIntent({
|
||||
chargeId: 'ch_renewal_1',
|
||||
invoiceId: 'in_renewal_1',
|
||||
paymentIntentId: 'pi_renewal_1',
|
||||
stripeCustomerId,
|
||||
});
|
||||
await mirrorCharge({
|
||||
chargeId: 'ch_local_checkout_1',
|
||||
invoiceId: 'in_local_checkout_1',
|
||||
paymentIntentId: 'pi_local_checkout_1',
|
||||
stripeCustomerId,
|
||||
});
|
||||
await mirrorCharge({
|
||||
chargeId: 'ch_renewal_1',
|
||||
invoiceId: 'in_renewal_1',
|
||||
paymentIntentId: 'pi_renewal_1',
|
||||
stripeCustomerId,
|
||||
});
|
||||
await mirrorPaymentMethod({paymentMethodId: 'pm_billing_target_1', stripeCustomerId});
|
||||
const overview = await createBuilder<AdminBillingOverviewResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/billing/users/${targetUser.userId}/overview`)
|
||||
.execute();
|
||||
expect(overview.payments).toHaveLength(2);
|
||||
const localCheckoutPayment = overview.payments.find((payment) => payment.invoice_id === 'in_local_checkout_1');
|
||||
expect(localCheckoutPayment?.payment_intent_id).toBe('pi_local_checkout_1');
|
||||
expect(localCheckoutPayment?.resolved_payment_intent_id).toBe('pi_local_checkout_1');
|
||||
expect(localCheckoutPayment?.charge_id).toBe('ch_local_checkout_1');
|
||||
expect(localCheckoutPayment?.refundable_via_payment_intent).toBe(true);
|
||||
expect(overview.payment_methods[0]?.id).toBe('pm_billing_target_1');
|
||||
const invoices = await createBuilder<AdminInvoiceListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/billing/users/${targetUser.userId}/invoices`)
|
||||
.execute();
|
||||
expect(invoices.invoices).toHaveLength(2);
|
||||
expect(invoices.invoices[0]?.id).toBe('in_renewal_1');
|
||||
expect(invoices.invoices[0]?.payment_intent_id).toBe('pi_renewal_1');
|
||||
expect(invoices.invoices[0]?.charge_id).toBe('ch_renewal_1');
|
||||
expect(invoices.invoices[0]?.billing_reason).toBe('subscription_cycle');
|
||||
expect(invoices.invoices[1]?.payment_intent_id).toBe('pi_local_checkout_1');
|
||||
});
|
||||
test('resolves billing overview from Stripe metadata even when local Stripe linkage is missing', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'billing:view']);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const stripeCustomerId = 'cus_billing_metadata_only';
|
||||
const stripeSubscriptionId = 'sub_billing_metadata_only';
|
||||
const invoiceId = 'in_billing_metadata_only';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const stripeHandlers = createStripeApiHandlers({
|
||||
customers: {
|
||||
[stripeCustomerId]: {
|
||||
email: '[email protected]',
|
||||
metadata: {
|
||||
userId: targetUser.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
invoices: {
|
||||
[invoiceId]: {
|
||||
customer: stripeCustomerId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_create',
|
||||
currency: 'eur',
|
||||
created: now - 300,
|
||||
status: 'paid',
|
||||
},
|
||||
},
|
||||
paymentMethods: {
|
||||
pm_billing_metadata_only: {
|
||||
customer: stripeCustomerId,
|
||||
type: 'card',
|
||||
card: {
|
||||
brand: 'visa',
|
||||
last4: '1111',
|
||||
exp_month: 8,
|
||||
exp_year: 2031,
|
||||
country: 'FR',
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
[stripeSubscriptionId]: {
|
||||
customer: stripeCustomerId,
|
||||
latest_invoice: invoiceId,
|
||||
status: 'active',
|
||||
current_period_start: now - DAY_SECONDS,
|
||||
current_period_end: now + 29 * DAY_SECONDS,
|
||||
},
|
||||
},
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
await mirrorCustomer({stripeCustomerId, userId: targetUser.userId});
|
||||
await mirrorSubscription({
|
||||
currentPeriodEnd: now + 29 * DAY_SECONDS,
|
||||
currentPeriodStart: now - DAY_SECONDS,
|
||||
latestInvoiceId: invoiceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorInvoice({
|
||||
amountPaidCents: 499,
|
||||
chargeId: `ch_${invoiceId}`,
|
||||
created: now - 300,
|
||||
invoiceId,
|
||||
paymentIntentId: `pi_${invoiceId}`,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorPaymentIntent({
|
||||
chargeId: `ch_${invoiceId}`,
|
||||
invoiceId,
|
||||
paymentIntentId: `pi_${invoiceId}`,
|
||||
stripeCustomerId,
|
||||
});
|
||||
await mirrorCharge({
|
||||
chargeId: `ch_${invoiceId}`,
|
||||
invoiceId,
|
||||
paymentIntentId: `pi_${invoiceId}`,
|
||||
stripeCustomerId,
|
||||
});
|
||||
await mirrorPaymentMethod({paymentMethodId: 'pm_billing_metadata_only', stripeCustomerId});
|
||||
const overview = await createBuilder<AdminBillingOverviewResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/billing/users/${targetUser.userId}/overview`)
|
||||
.execute();
|
||||
expect(overview.stripe_customer_id).toBe(stripeCustomerId);
|
||||
expect(overview.subscription?.id).toBe(stripeSubscriptionId);
|
||||
expect(overview.subscription?.status).toBe('active');
|
||||
expect(overview.payment_methods[0]?.id).toBe('pm_billing_metadata_only');
|
||||
expect(overview.payments[0]?.invoice_id).toBe(invoiceId);
|
||||
expect(overview.payments[0]?.resolved_payment_intent_id).toBe(`pi_${invoiceId}`);
|
||||
});
|
||||
test('cancels a Stripe subscription at period end after resolving missing local Stripe IDs from Stripe metadata', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'billing:manage_subscription',
|
||||
]);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const stripeCustomerId = 'cus_billing_cancel_metadata';
|
||||
const stripeSubscriptionId = 'sub_billing_cancel_metadata';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const stripeHandlers = createStripeApiHandlers({
|
||||
customers: {
|
||||
[stripeCustomerId]: {
|
||||
metadata: {
|
||||
userId: targetUser.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
[stripeSubscriptionId]: {
|
||||
customer: stripeCustomerId,
|
||||
status: 'active',
|
||||
current_period_start: now - DAY_SECONDS,
|
||||
current_period_end: now + 29 * DAY_SECONDS,
|
||||
},
|
||||
},
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
await mirrorCustomer({stripeCustomerId, userId: targetUser.userId});
|
||||
await mirrorSubscription({
|
||||
currentPeriodEnd: now + 29 * DAY_SECONDS,
|
||||
currentPeriodStart: now - DAY_SECONDS,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${targetUser.userId}/cancel-subscription`)
|
||||
.body({})
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toContainEqual({
|
||||
id: stripeSubscriptionId,
|
||||
params: {
|
||||
cancel_at_period_end: 'true',
|
||||
},
|
||||
});
|
||||
});
|
||||
test('allows admin refunds when the payment intent belongs to the target Stripe customer even without a local payment-intent index', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'billing:refund',
|
||||
]);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const stripeCustomerId = 'cus_refund_target';
|
||||
await setStripeCustomerId(targetUser.userId, stripeCustomerId);
|
||||
const stripeHandlers = createStripeApiHandlers({
|
||||
paymentIntents: {
|
||||
pi_remote_only_refund: {
|
||||
customer: stripeCustomerId,
|
||||
latest_charge: 'ch_remote_only_refund',
|
||||
},
|
||||
},
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
await mirrorPaymentIntent({
|
||||
chargeId: 'ch_remote_only_refund',
|
||||
paymentIntentId: 'pi_remote_only_refund',
|
||||
stripeCustomerId,
|
||||
});
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${targetUser.userId}/refund`)
|
||||
.body({
|
||||
payment_intent_id: 'pi_remote_only_refund',
|
||||
reason: 'Customer requested a refund',
|
||||
})
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdRefunds).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
|
||||
payment_intent: 'pi_remote_only_refund',
|
||||
reason: 'requested_by_customer',
|
||||
metadata: {
|
||||
admin_user_id: admin.userId,
|
||||
target_user_id: targetUser.userId,
|
||||
admin_reason: 'Customer requested a refund',
|
||||
},
|
||||
});
|
||||
});
|
||||
test('forces a full refund when the latest invoice is inside the EU withdrawal window without a waiver', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'billing:refund',
|
||||
'billing:manage_subscription',
|
||||
]);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const stripeCustomerId = 'cus_eu_missing_waiver';
|
||||
const stripeSubscriptionId = 'sub_eu_missing_waiver';
|
||||
const invoiceId = 'in_eu_missing_waiver';
|
||||
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
|
||||
await createPaymentRecord({
|
||||
userId: targetUser.userId,
|
||||
checkoutSessionId: 'cs_eu_missing_waiver',
|
||||
completedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
|
||||
euWithdrawalWaiverRequired: true,
|
||||
euWithdrawalWaiverAccepted: false,
|
||||
euWithdrawalWaiverTextVersion: '2026-04-23',
|
||||
invoiceId,
|
||||
purchaseClientCountryCode: 'DE',
|
||||
purchaseGeoipCountryCode: 'DE',
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
stripeCustomerId,
|
||||
});
|
||||
const stripeHandlers = createRefundPolicyStripeHandlers({
|
||||
amountPaidCents: 499,
|
||||
elapsedDays: 6,
|
||||
invoiceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await mirrorSubscription({
|
||||
currentPeriodEnd: now + 24 * DAY_SECONDS,
|
||||
currentPeriodStart: now - 6 * DAY_SECONDS,
|
||||
latestInvoiceId: invoiceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorInvoice({
|
||||
amountPaidCents: 499,
|
||||
chargeId: 'ch_eu_missing_waiver',
|
||||
invoiceId,
|
||||
paymentIntentId: 'pi_eu_missing_waiver',
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorCharge({
|
||||
chargeId: 'ch_eu_missing_waiver',
|
||||
invoiceId,
|
||||
paymentIntentId: 'pi_eu_missing_waiver',
|
||||
stripeCustomerId,
|
||||
});
|
||||
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
|
||||
.body({reason: 'Withdrawal waiver missing'})
|
||||
.execute();
|
||||
expect(result.refund_policy).toBe('full_refund');
|
||||
expect(result.refund_policy_basis).toBe('eu_eea_withdrawal_no_waiver');
|
||||
expect(result.refunded_amount_cents).toBe(499);
|
||||
expect(result.eu_withdrawal_waiver_required).toBe(true);
|
||||
expect(result.eu_withdrawal_waiver_accepted).toBe(false);
|
||||
expect(result.purchase_geoip_country_code).toBe('DE');
|
||||
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
|
||||
amount: '499',
|
||||
metadata: {
|
||||
refund_policy: 'full_refund',
|
||||
refund_policy_basis: 'eu_eea_withdrawal_no_waiver',
|
||||
eu_withdrawal_waiver_required: 'true',
|
||||
eu_withdrawal_waiver_accepted: 'false',
|
||||
},
|
||||
});
|
||||
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
|
||||
});
|
||||
test('uses the support prorate policy when an EU waiver was accepted', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'billing:refund',
|
||||
'billing:manage_subscription',
|
||||
]);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const stripeCustomerId = 'cus_eu_accepted_waiver';
|
||||
const stripeSubscriptionId = 'sub_eu_accepted_waiver';
|
||||
const invoiceId = 'in_eu_accepted_waiver';
|
||||
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
|
||||
await createPaymentRecord({
|
||||
userId: targetUser.userId,
|
||||
checkoutSessionId: 'cs_eu_accepted_waiver',
|
||||
completedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
|
||||
euWithdrawalWaiverRequired: true,
|
||||
euWithdrawalWaiverAccepted: true,
|
||||
euWithdrawalWaiverAcceptedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
|
||||
euWithdrawalWaiverTextVersion: '2026-04-23',
|
||||
invoiceId,
|
||||
purchaseClientCountryCode: 'DE',
|
||||
purchaseGeoipCountryCode: 'DE',
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
stripeCustomerId,
|
||||
});
|
||||
const stripeHandlers = createRefundPolicyStripeHandlers({
|
||||
amountPaidCents: 499,
|
||||
elapsedDays: 6,
|
||||
invoiceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await mirrorSubscription({
|
||||
currentPeriodEnd: now + 24 * DAY_SECONDS,
|
||||
currentPeriodStart: now - 6 * DAY_SECONDS,
|
||||
latestInvoiceId: invoiceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorInvoice({
|
||||
amountPaidCents: 499,
|
||||
chargeId: 'ch_eu_accepted_waiver',
|
||||
invoiceId,
|
||||
paymentIntentId: 'pi_eu_accepted_waiver',
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorCharge({
|
||||
chargeId: 'ch_eu_accepted_waiver',
|
||||
invoiceId,
|
||||
paymentIntentId: 'pi_eu_accepted_waiver',
|
||||
stripeCustomerId,
|
||||
});
|
||||
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
|
||||
.body({})
|
||||
.execute();
|
||||
expect(result.refund_policy).toBe('prorated_refund');
|
||||
expect(result.refund_policy_basis).toBe('support_policy');
|
||||
expect(result.refunded_amount_cents).toBe(400);
|
||||
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
|
||||
amount: '400',
|
||||
metadata: {
|
||||
refund_policy: 'prorated_refund',
|
||||
refund_policy_basis: 'support_policy',
|
||||
eu_withdrawal_waiver_required: 'true',
|
||||
eu_withdrawal_waiver_accepted: 'true',
|
||||
},
|
||||
});
|
||||
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
|
||||
});
|
||||
test('cancels without refund after the support refund window', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'billing:refund',
|
||||
'billing:manage_subscription',
|
||||
]);
|
||||
const targetUser = await createTestAccount(harness);
|
||||
const stripeCustomerId = 'cus_cancel_only';
|
||||
const stripeSubscriptionId = 'sub_cancel_only';
|
||||
const invoiceId = 'in_cancel_only';
|
||||
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
|
||||
await createPaymentRecord({
|
||||
userId: targetUser.userId,
|
||||
checkoutSessionId: 'cs_cancel_only',
|
||||
completedAt: new Date(Date.now() - 20 * DAY_SECONDS * 1000),
|
||||
invoiceId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
stripeCustomerId,
|
||||
});
|
||||
const stripeHandlers = createRefundPolicyStripeHandlers({
|
||||
amountPaidCents: 499,
|
||||
elapsedDays: 20,
|
||||
invoiceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await mirrorSubscription({
|
||||
currentPeriodEnd: now + 10 * DAY_SECONDS,
|
||||
currentPeriodStart: now - 20 * DAY_SECONDS,
|
||||
latestInvoiceId: invoiceId,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorInvoice({
|
||||
amountPaidCents: 499,
|
||||
chargeId: 'ch_cancel_only',
|
||||
invoiceId,
|
||||
paymentIntentId: 'pi_cancel_only',
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId,
|
||||
userId: targetUser.userId,
|
||||
});
|
||||
await mirrorCharge({
|
||||
chargeId: 'ch_cancel_only',
|
||||
invoiceId,
|
||||
paymentIntentId: 'pi_cancel_only',
|
||||
stripeCustomerId,
|
||||
});
|
||||
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
|
||||
.body({})
|
||||
.execute();
|
||||
expect(result.refund_policy).toBe('cancel_only');
|
||||
expect(result.refund_policy_basis).toBe('support_policy');
|
||||
expect(result.refunded_amount_cents).toBe(0);
|
||||
expect(stripeHandlers.spies.createdRefunds).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {getUserActivityBuffer} from '../../middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
@@ -19,6 +20,7 @@ async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: strin
|
||||
.header('x-forwarded-for', ip)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await getUserActivityBuffer().drainAndFlush();
|
||||
}
|
||||
|
||||
describe('Admin last active IP search', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createFriendship, createGuild} from '../../channel/tests/ChannelTestUtils';
|
||||
import {getUserActivityBuffer} from '../../middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
@@ -13,6 +14,7 @@ async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: strin
|
||||
.header('x-forwarded-for', ip)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await getUserActivityBuffer().drainAndFlush();
|
||||
}
|
||||
|
||||
describe('Admin Search Endpoints', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUser
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '../../channel/tests/ChannelTestUtils';
|
||||
import {getUserActivityBuffer} from '../../middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
@@ -40,6 +41,7 @@ async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: strin
|
||||
.header('x-forwarded-for', ip)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await getUserActivityBuffer().drainAndFlush();
|
||||
}
|
||||
|
||||
describe('Admin Search Field Coverage', () => {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
import {createTestAccount, createUniqueEmail, createUniqueUsername, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {setupTestGuildWithMembers} from '../../guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
|
||||
interface PendingRegistrationResponse {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
describe('pending registration approval and the stock community', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('keeps a banned user out of the stock community on approval', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
single_community_enabled: true,
|
||||
single_community_guild_id: guild.id,
|
||||
});
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body({
|
||||
email: createUniqueEmail('bannedapproval'),
|
||||
username: createUniqueUsername('bannedapproval'),
|
||||
global_name: 'The banned man',
|
||||
password: 'approving-since-1999',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${pending.user_id}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/instance-config/pending-registrations/approve')
|
||||
.body({user_id: pending.user_id})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('does not add a user who was never pending to the stock community', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const outsider = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
single_community_enabled: true,
|
||||
single_community_guild_id: guild.id,
|
||||
});
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/instance-config/pending-registrations/approve')
|
||||
.body({user_id: outsider.userId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${outsider.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import {VisionarySlotInitializer} from '../stripe/VisionarySlotInitializer';
|
||||
import {VoiceDataInitializer} from '../voice/VoiceDataInitializer';
|
||||
import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
|
||||
import {WorkerService} from '../worker/WorkerService';
|
||||
import {ensureDeletionQueueState} from './DeletionQueueStartup';
|
||||
|
||||
let jsConnectionManager: JetStreamConnectionManager | null = null;
|
||||
|
||||
@@ -133,18 +134,7 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
setInjectedWorkerService(new WorkerService(workerQueue, getSnowflakeService(), new JobLedgerRepository()));
|
||||
logger.info('JetStream worker service initialized');
|
||||
}
|
||||
try {
|
||||
const kvDeletionQueue = getKVAccountDeletionQueue();
|
||||
if (await kvDeletionQueue.needsRebuild()) {
|
||||
logger.info('KV deletion queue needs rebuild, rebuilding...');
|
||||
await kvDeletionQueue.rebuildState();
|
||||
} else {
|
||||
logger.info('KV deletion queue state is healthy');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Failed to verify KV deletion queue state');
|
||||
throw error;
|
||||
}
|
||||
await ensureDeletionQueueState(getKVAccountDeletionQueue(), logger);
|
||||
logger.info('Initializing search indexes...');
|
||||
let searchInitialized = false;
|
||||
try {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {getCacheService} from '../middleware/ServiceSingletons';
|
||||
import {OAuth2ApplicationsController} from '../oauth/OAuth2ApplicationsController';
|
||||
import {OAuth2Controller} from '../oauth/OAuth2Controller';
|
||||
import {OpenAPIController} from '../openapi/OpenAPIController';
|
||||
import {registerPackControllers} from '../pack/controllers/index';
|
||||
import {PremiumController} from '../premium/PremiumController';
|
||||
import {ReadStateController} from '../read_state/ReadStateController';
|
||||
import {ReportController} from '../report/ReportController';
|
||||
@@ -54,7 +53,6 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
FavoriteGifController(routes);
|
||||
FavoriteMemeController(routes);
|
||||
InviteController(routes);
|
||||
registerPackControllers(routes);
|
||||
ReadStateController(routes);
|
||||
ReportController(routes);
|
||||
GuildController(routes);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ILogger} from '../ILogger';
|
||||
import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
|
||||
|
||||
export async function ensureDeletionQueueState(
|
||||
deletionQueue: KVAccountDeletionQueueService,
|
||||
logger: ILogger,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!(await deletionQueue.needsRebuild())) {
|
||||
logger.info('KV deletion queue state is healthy');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Failed to read KV deletion queue state, aborting startup');
|
||||
throw error;
|
||||
}
|
||||
let lockToken: string | null;
|
||||
try {
|
||||
lockToken = await deletionQueue.acquireRebuildLock();
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Failed to acquire the KV deletion queue rebuild lock, aborting startup');
|
||||
throw error;
|
||||
}
|
||||
if (!lockToken) {
|
||||
logger.info('Another instance is rebuilding the KV deletion queue, skipping');
|
||||
return;
|
||||
}
|
||||
logger.info('KV deletion queue needs rebuild, rebuilding...');
|
||||
try {
|
||||
await deletionQueue.rebuildState(lockToken);
|
||||
} catch (error) {
|
||||
logger.error({error}, 'KV deletion queue rebuild failed, leaving the rebuild to the deletion worker');
|
||||
} finally {
|
||||
try {
|
||||
await deletionQueue.releaseRebuildLock(lockToken);
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Failed to release the KV deletion queue rebuild lock');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {resolveClientIpHeaderName} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import type {ILogger} from '../ILogger';
|
||||
import {ClientErrorAbuseSignalMiddleware} from '../middleware/AbusiveIpAutoBanner';
|
||||
import {AuditLogMiddleware} from '../middleware/AuditLogMiddleware';
|
||||
import {ConcurrencyLimitMiddleware} from '../middleware/ConcurrencyLimitMiddleware';
|
||||
import ContentFilterMiddleware from '../middleware/ContentFilterMiddleware';
|
||||
import {GuildAvailabilityMiddleware} from '../middleware/GuildAvailabilityMiddleware';
|
||||
import {IpBanMiddleware} from '../middleware/IpBanMiddleware';
|
||||
@@ -27,19 +28,35 @@ interface MiddlewarePipelineOptions {
|
||||
corsOrigins: Array<string>;
|
||||
trustClientIpHeader: boolean;
|
||||
clientIpHeaderName?: string;
|
||||
maxInflightRequests: number;
|
||||
}
|
||||
|
||||
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
|
||||
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName} = options;
|
||||
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
|
||||
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
|
||||
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
|
||||
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
|
||||
routes.use(
|
||||
'/.well-known/fluxer',
|
||||
cors({
|
||||
origins: '*',
|
||||
methods: ['GET', 'HEAD', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
HttpHeaders.ACCEPT,
|
||||
HttpHeaders.CONTENT_TYPE,
|
||||
HttpHeaders.IF_MODIFIED_SINCE,
|
||||
HttpHeaders.IF_NONE_MATCH,
|
||||
],
|
||||
exposedHeaders: [HttpHeaders.ETAG, HttpHeaders.LAST_MODIFIED],
|
||||
}),
|
||||
);
|
||||
applyMiddlewareStack(routes, {
|
||||
requestId: {},
|
||||
cors: {origins: corsOrigins, exposedHeaders: [HttpHeaders.X_FLUXER_VERSION]},
|
||||
skipLogger: true,
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
routes.use(ConcurrencyLimitMiddleware({maxInflightRequests}));
|
||||
routes.get('/_health', async (ctx) => ctx.text('OK'));
|
||||
routes.use(IpBanMiddleware);
|
||||
routes.use(
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {EMPTY_USER_ROW} from '../../database/types/UserTypes';
|
||||
import type {ILogger} from '../../ILogger';
|
||||
import {KVAccountDeletionQueueService} from '../../infrastructure/KVAccountDeletionQueueService';
|
||||
import {User} from '../../models/User';
|
||||
import {MockKVProvider} from '../../test/mocks/MockKVProvider';
|
||||
import {NoopLogger} from '../../test/mocks/NoopLogger';
|
||||
import type {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {ensureDeletionQueueState} from '../DeletionQueueStartup';
|
||||
|
||||
const WORKER_PAGE_COUNT = 2;
|
||||
|
||||
class RecordingLogger implements ILogger {
|
||||
readonly errors: Array<string> = [];
|
||||
|
||||
trace(): void {}
|
||||
debug(): void {}
|
||||
info(): void {}
|
||||
warn(): void {}
|
||||
fatal(): void {}
|
||||
|
||||
error(obj: object | string, msg?: string): void {
|
||||
this.errors.push(typeof obj === 'string' ? obj : (msg ?? ''));
|
||||
}
|
||||
|
||||
child(): ILogger {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class UnreadableStateKVProvider extends MockKVProvider {
|
||||
override async exists(): Promise<number> {
|
||||
throw new Error('kv unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
class UnlockableKVProvider extends MockKVProvider {
|
||||
override async acquireLock(): Promise<boolean> {
|
||||
throw new Error('kv lock unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
class UnreleasableKVProvider extends MockKVProvider {
|
||||
override async releaseLock(): Promise<boolean> {
|
||||
throw new Error('kv lock release failed');
|
||||
}
|
||||
}
|
||||
|
||||
function createFailingRepository(): UserRepository {
|
||||
return {
|
||||
async scanAllUsersPage() {
|
||||
throw new Error('paged user scan failed');
|
||||
},
|
||||
} as unknown as UserRepository;
|
||||
}
|
||||
|
||||
function createPendingUser(index: number): User {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(BigInt(9100 + index)),
|
||||
pending_deletion_at: new Date('2026-06-01T00:00:00.000Z'),
|
||||
deletion_reason_code: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function createWorkerRepository(onPageStart: (page: number) => Promise<void>): UserRepository {
|
||||
let page = 0;
|
||||
return {
|
||||
async scanAllUsersPage() {
|
||||
page += 1;
|
||||
await onPageStart(page);
|
||||
return {
|
||||
users: [createPendingUser(page)],
|
||||
pageState: page < WORKER_PAGE_COUNT ? `page-${page}` : null,
|
||||
};
|
||||
},
|
||||
} as unknown as UserRepository;
|
||||
}
|
||||
|
||||
describe('ensureDeletionQueueState', () => {
|
||||
it('leaves a rebuild owned by another instance alone', async () => {
|
||||
const kvClient = new MockKVProvider();
|
||||
let apiScans = 0;
|
||||
const apiRepository = {
|
||||
async scanAllUsersPage() {
|
||||
apiScans += 1;
|
||||
throw new Error('api pod scan failed');
|
||||
},
|
||||
} as unknown as UserRepository;
|
||||
const apiQueue = new KVAccountDeletionQueueService(kvClient, apiRepository);
|
||||
const apiFailures: Array<unknown> = [];
|
||||
const workerQueue = new KVAccountDeletionQueueService(
|
||||
kvClient,
|
||||
createWorkerRepository(async (page) => {
|
||||
if (page !== WORKER_PAGE_COUNT) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ensureDeletionQueueState(apiQueue, new NoopLogger());
|
||||
} catch (error) {
|
||||
apiFailures.push(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const workerToken = await workerQueue.acquireRebuildLock();
|
||||
expect(workerToken).not.toBeNull();
|
||||
await workerQueue.rebuildState(workerToken);
|
||||
expect(await workerQueue.releaseRebuildLock(workerToken!)).toBe(true);
|
||||
|
||||
const queued = await workerQueue.getReadyDeletions(Date.parse('2026-06-02T00:00:00.000Z'), 10);
|
||||
expect(queued.map((deletion) => deletion.userId).sort()).toEqual([9101n, 9102n]);
|
||||
expect(apiScans).toBe(0);
|
||||
expect(apiFailures).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not abort startup when the paged user scan fails', async () => {
|
||||
const kvClient = new MockKVProvider();
|
||||
let scans = 0;
|
||||
const repository = {
|
||||
async scanAllUsersPage() {
|
||||
scans += 1;
|
||||
throw new Error('paged user scan failed');
|
||||
},
|
||||
} as unknown as UserRepository;
|
||||
const queue = new KVAccountDeletionQueueService(kvClient, repository);
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
await expect(ensureDeletionQueueState(queue, logger)).resolves.toBeUndefined();
|
||||
|
||||
expect(scans).toBe(1);
|
||||
expect(logger.errors).toEqual(['KV deletion queue rebuild failed, leaving the rebuild to the deletion worker']);
|
||||
expect(await queue.acquireRebuildLock()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('aborts startup when the queue state cannot be read', async () => {
|
||||
const queue = new KVAccountDeletionQueueService(new UnreadableStateKVProvider(), createFailingRepository());
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
await expect(ensureDeletionQueueState(queue, logger)).rejects.toThrow('kv unavailable');
|
||||
|
||||
expect(logger.errors).toEqual(['Failed to read KV deletion queue state, aborting startup']);
|
||||
});
|
||||
|
||||
it('aborts startup when the rebuild lock cannot be acquired', async () => {
|
||||
const queue = new KVAccountDeletionQueueService(new UnlockableKVProvider(), createFailingRepository());
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
await expect(ensureDeletionQueueState(queue, logger)).rejects.toThrow('kv lock unavailable');
|
||||
|
||||
expect(logger.errors).toEqual(['Failed to acquire the KV deletion queue rebuild lock, aborting startup']);
|
||||
});
|
||||
|
||||
it('does not abort startup when releasing the rebuild lock fails', async () => {
|
||||
const queue = new KVAccountDeletionQueueService(
|
||||
new UnreleasableKVProvider(),
|
||||
createWorkerRepository(async () => {}),
|
||||
);
|
||||
const logger = new RecordingLogger();
|
||||
|
||||
await expect(ensureDeletionQueueState(queue, logger)).resolves.toBeUndefined();
|
||||
|
||||
const queued = await queue.getReadyDeletions(Date.parse('2026-06-02T00:00:00.000Z'), 10);
|
||||
expect(queued.map((deletion) => deletion.userId).sort()).toEqual([9101n, 9102n]);
|
||||
expect(logger.errors).toEqual(['Failed to release the KV deletion queue rebuild lock']);
|
||||
});
|
||||
|
||||
it('rebuilds under the lock when no other instance holds it', async () => {
|
||||
const kvClient = new MockKVProvider();
|
||||
const queue = new KVAccountDeletionQueueService(
|
||||
kvClient,
|
||||
createWorkerRepository(async () => {}),
|
||||
);
|
||||
|
||||
await ensureDeletionQueueState(queue, new NoopLogger());
|
||||
|
||||
const queued = await queue.getReadyDeletions(Date.parse('2026-06-02T00:00:00.000Z'), 10);
|
||||
expect(queued.map((deletion) => deletion.userId).sort()).toEqual([9101n, 9102n]);
|
||||
expect(await queue.acquireRebuildLock()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AttachmentID, ChannelID, MessageID} from '../BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchManyInChunks, fetchOne} from '../database/CassandraQueryExecution';
|
||||
import {
|
||||
BatchBuilder,
|
||||
deleteOneOrMany,
|
||||
fetchMany,
|
||||
fetchManyInChunks,
|
||||
fetchOne,
|
||||
} from '../database/CassandraQueryExecution';
|
||||
import {AttachmentDecayByExpiry, AttachmentDecayById} from '../Tables';
|
||||
import type {AttachmentDecayRow} from '../types/AttachmentDecayTypes';
|
||||
|
||||
@@ -73,6 +79,20 @@ export class AttachmentDecayRepository {
|
||||
return fetchMany(query.bind({expiry_bucket: bucket, current_time: currentTime}));
|
||||
}
|
||||
|
||||
async deleteExpiryRecord(params: {
|
||||
expiry_bucket: number;
|
||||
expires_at: Date;
|
||||
attachment_id: AttachmentID;
|
||||
}): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
AttachmentDecayByExpiry.deleteByPk({
|
||||
expiry_bucket: params.expiry_bucket,
|
||||
expires_at: params.expires_at,
|
||||
attachment_id: params.attachment_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRecords(params: {expiry_bucket: number; expires_at: Date; attachment_id: AttachmentID}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
AuthTokenWithUserIdResponse,
|
||||
EmailRevertRequest,
|
||||
ForgotPasswordRequest,
|
||||
HandoffCancelRequest,
|
||||
HandoffCodeParam,
|
||||
HandoffCompleteRequest,
|
||||
HandoffInfoResponse,
|
||||
HandoffInitiateResponse,
|
||||
HandoffStatusRequest,
|
||||
HandoffStatusResponse,
|
||||
IpAuthorizationPollQuery,
|
||||
IpAuthorizationPollResponse,
|
||||
@@ -632,10 +634,39 @@ export function AuthController(app: HonoApp) {
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/auth/handoff/:code/status',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
|
||||
Validator('param', HandoffCodeParam),
|
||||
Validator('json', HandoffStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_handoff_status_with_secret',
|
||||
summary: 'Get handoff status with secret',
|
||||
responseSchema: HandoffStatusResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Auth'],
|
||||
description:
|
||||
'Check the status of a handoff session using the poll secret from initiation. Returns the authentication token once the handoff is complete and the presented secret matches.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const clientIp = requireClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
const response = await ctx.get('authRequestService').getHandoffStatus({
|
||||
code: ctx.req.valid('param').code,
|
||||
clientIp,
|
||||
pollSecret: ctx.req.valid('json').poll_secret,
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/auth/handoff/:code',
|
||||
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_CANCEL),
|
||||
Validator('param', HandoffCodeParam),
|
||||
Validator('json', HandoffCancelRequest),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_handoff',
|
||||
summary: 'Cancel handoff',
|
||||
@@ -646,7 +677,10 @@ export function AuthController(app: HonoApp) {
|
||||
description: 'Cancel an ongoing handoff session. The handoff code will no longer be valid for authentication.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await ctx.get('authRequestService').cancelHandoff({code: ctx.req.valid('param').code});
|
||||
await ctx.get('authRequestService').cancelHandoff({
|
||||
code: ctx.req.valid('param').code,
|
||||
pollSecret: ctx.req.valid('json').poll_secret,
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDel
|
||||
import {REGISTRATION_PENDING_APPROVAL_TRAIT, REGISTRATION_REJECTED_TRAIT} from '../instance/InstanceConfigRepository';
|
||||
import type {InviteService} from '../invite/InviteService';
|
||||
import {Logger} from '../Logger';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import {createRequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {User} from '../models/User';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
@@ -38,18 +38,8 @@ import * as AuthPassword from './AuthPassword';
|
||||
import * as AuthSession from './AuthSession';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
function createRequestCache(): RequestCache {
|
||||
const userPartials = new Map();
|
||||
const messageMentionChannels = new Map();
|
||||
return {
|
||||
userPartials,
|
||||
messageMentionChannels,
|
||||
clear: () => {
|
||||
userPartials.clear();
|
||||
messageMentionChannels.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
const DUMMY_ARGON2_HASH =
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$fT6tGpAyxFiz+n1RbkRqWQ$v05UT17QGeqhsgRjcVjIWcGw6gUDYeCcAA8FiZ63MtA';
|
||||
|
||||
interface LoginParams {
|
||||
data: LoginRequest;
|
||||
@@ -207,7 +197,7 @@ export async function login(
|
||||
const {inviteService, kvDeletionQueue} = deps;
|
||||
const skipRateLimits = config.dev.testModeEnabled || config.dev.disableRateLimits;
|
||||
const emailRateLimit = await rateLimit.checkLimit({
|
||||
identifier: `login:email:${data.email}`,
|
||||
identifier: `login:email:${data.email.toLowerCase()}`,
|
||||
maxAttempts: 5,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
@@ -234,9 +224,16 @@ export async function login(
|
||||
]);
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
if (!user.passwordHash) {
|
||||
await AuthPassword.verifyPassword(ctx, {password: data.password, passwordHash: DUMMY_ARGON2_HASH});
|
||||
throw InputValidationError.fromCodes([
|
||||
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
||||
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
|
||||
]);
|
||||
}
|
||||
const isMatch = await AuthPassword.verifyPassword(ctx, {
|
||||
password: data.password,
|
||||
passwordHash: user.passwordHash!,
|
||||
passwordHash: user.passwordHash,
|
||||
});
|
||||
if (!isMatch) {
|
||||
throw InputValidationError.fromCodes([
|
||||
@@ -359,13 +356,36 @@ export async function login(
|
||||
|
||||
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
||||
const MFA_USER_MAX_ATTEMPTS = 10;
|
||||
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
|
||||
|
||||
async function consumeMfaAttempt(
|
||||
ctx: ApiContext,
|
||||
{userId, ticket, field}: {userId: string; ticket: string; field: string},
|
||||
): Promise<void> {
|
||||
const {cache, rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `mfa:user:${userId}`,
|
||||
maxAttempts: MFA_USER_MAX_ATTEMPTS,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!userLimit.allowed) {
|
||||
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
const ticketLimit = await rateLimit.checkLimit({
|
||||
identifier: `mfa:ticket:${ticket}`,
|
||||
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
|
||||
windowMs: ms('5 minutes'),
|
||||
});
|
||||
if (!ticketLimit.allowed) {
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginMfaTotp(
|
||||
ctx: ApiContext,
|
||||
{code, ticket, request}: LoginMfaTotpParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache} = ctx.services;
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -378,32 +398,19 @@ export async function loginMfaTotp(
|
||||
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
||||
}
|
||||
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
|
||||
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
|
||||
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
|
||||
const isValid = await AuthMfa.verifyMfaCode(ctx, {
|
||||
userId: user.id,
|
||||
mfaSecret: user.totpSecret,
|
||||
code,
|
||||
allowBackup: true,
|
||||
});
|
||||
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
|
||||
if (!isValid) {
|
||||
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
|
||||
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
|
||||
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await cache.delete(attemptsKey);
|
||||
} else {
|
||||
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
|
||||
}
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await cache.delete(attemptsKey);
|
||||
await cache.delete(userAttemptsKey);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
@@ -415,7 +422,7 @@ export async function loginMfaWebAuthn(
|
||||
ctx: ApiContext,
|
||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache} = ctx.services;
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -425,8 +432,11 @@ export async function loginMfaWebAuthn(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import {seconds} from 'itty-time';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
import {Logger} from '../Logger';
|
||||
@@ -79,7 +79,7 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
|
||||
return true;
|
||||
}
|
||||
const reuseKey = `mfa-totp:${userId}:${code}`;
|
||||
const lockToken = await cache.acquireLock(reuseKey, seconds('30 seconds'));
|
||||
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
|
||||
if (lockToken) {
|
||||
return true;
|
||||
}
|
||||
@@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us
|
||||
return options;
|
||||
}
|
||||
|
||||
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
|
||||
|
||||
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
|
||||
const {rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `sudo-mfa:user:${userId}`,
|
||||
maxAttempts: SUDO_MFA_USER_MAX_ATTEMPTS,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!userLimit.allowed) {
|
||||
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifySudoMfa(
|
||||
ctx: ApiContext,
|
||||
params: SudoMfaVerificationParams,
|
||||
@@ -427,7 +441,11 @@ export async function verifySudoMfa(
|
||||
case 'totp': {
|
||||
if (!code) return {success: false, error: 'TOTP code is required'};
|
||||
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
|
||||
await consumeSudoMfaAttempt(ctx, userId);
|
||||
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
|
||||
if (isValid) {
|
||||
await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`);
|
||||
}
|
||||
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
|
||||
}
|
||||
case 'webauthn': {
|
||||
|
||||
@@ -20,8 +20,11 @@ import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil}
|
||||
import * as AuthSession from './AuthSession';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
|
||||
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;
|
||||
|
||||
interface CacheEntry {
|
||||
result: boolean;
|
||||
pwnedSuffixes: ReadonlySet<string>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
@@ -30,34 +33,34 @@ class PwnedPasswordCache {
|
||||
private readonly maxSize: number;
|
||||
private readonly ttlMs: number;
|
||||
|
||||
constructor(maxSize = 1000, ttlMs = ms('1 hour')) {
|
||||
constructor(maxSize = PWNED_PASSWORD_CACHE_MAX_PREFIXES, ttlMs = ms('1 hour')) {
|
||||
this.maxSize = maxSize;
|
||||
this.ttlMs = ttlMs;
|
||||
}
|
||||
|
||||
get(key: string): boolean | undefined {
|
||||
const entry = this.cache.get(key);
|
||||
get(hashPrefix: string): ReadonlySet<string> | undefined {
|
||||
const entry = this.cache.get(hashPrefix);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
this.cache.delete(hashPrefix);
|
||||
return undefined;
|
||||
}
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, entry);
|
||||
return entry.result;
|
||||
this.cache.delete(hashPrefix);
|
||||
this.cache.set(hashPrefix, entry);
|
||||
return entry.pwnedSuffixes;
|
||||
}
|
||||
|
||||
set(key: string, result: boolean): void {
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
||||
set(hashPrefix: string, pwnedSuffixes: ReadonlySet<string>): void {
|
||||
if (this.cache.size >= this.maxSize && !this.cache.has(hashPrefix)) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, {
|
||||
result,
|
||||
this.cache.set(hashPrefix, {
|
||||
pwnedSuffixes,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
@@ -95,7 +98,11 @@ type ResetPasswordResult =
|
||||
webauthn: boolean;
|
||||
};
|
||||
|
||||
const pwnedPasswordCache = new PwnedPasswordCache(1000, ms('1 hour'));
|
||||
const pwnedPasswordCache = new PwnedPasswordCache(PWNED_PASSWORD_CACHE_MAX_PREFIXES, ms('1 hour'));
|
||||
|
||||
export function resetPwnedPasswordCacheForTesting(): void {
|
||||
pwnedPasswordCache.clear();
|
||||
}
|
||||
|
||||
export async function hashPassword(_ctx: ApiContext, password: string): Promise<string> {
|
||||
return hashPasswordUtil(password);
|
||||
@@ -112,9 +119,9 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
|
||||
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
const hashPrefix = hashed.slice(0, 5);
|
||||
const hashSuffix = hashed.slice(5);
|
||||
const cachedResult = pwnedPasswordCache.get(hashed);
|
||||
if (cachedResult !== undefined) {
|
||||
return cachedResult;
|
||||
const cachedSuffixes = pwnedPasswordCache.get(hashPrefix);
|
||||
if (cachedSuffixes !== undefined) {
|
||||
return cachedSuffixes.has(hashSuffix);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
|
||||
@@ -122,6 +129,7 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
|
||||
'User-Agent': FLUXER_USER_AGENT,
|
||||
'Add-Padding': 'true',
|
||||
},
|
||||
signal: AbortSignal.timeout(PWNED_PASSWORDS_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
Logger.warn(
|
||||
@@ -153,20 +161,16 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
|
||||
);
|
||||
}
|
||||
const limit = Math.min(lines.length, MAX_PWNED_LINES);
|
||||
const pwnedSuffixes = new Set<string>();
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const line = lines[i];
|
||||
const [hashSuffixLine, count] = line.split(':', 2);
|
||||
if (
|
||||
hashSuffixLine.length === hashSuffix.length &&
|
||||
crypto.timingSafeEqual(Buffer.from(hashSuffixLine), Buffer.from(hashSuffix)) &&
|
||||
Number.parseInt(count, 10) > 0
|
||||
) {
|
||||
pwnedPasswordCache.set(hashed, true);
|
||||
return true;
|
||||
if (hashSuffixLine.length === hashSuffix.length && Number.parseInt(count, 10) > 0) {
|
||||
pwnedSuffixes.add(hashSuffixLine);
|
||||
}
|
||||
}
|
||||
pwnedPasswordCache.set(hashed, false);
|
||||
return false;
|
||||
pwnedPasswordCache.set(hashPrefix, pwnedSuffixes);
|
||||
return pwnedSuffixes.has(hashSuffix);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Failed to check password against Pwned Passwords API');
|
||||
return false;
|
||||
@@ -267,7 +271,7 @@ export async function resetPassword(
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await users.deleteAllAuthSessions(user.id);
|
||||
await AuthSession.terminateAllUserSessions(ctx, user.id);
|
||||
await users.deletePasswordResetToken(data.token);
|
||||
const hasMfa =
|
||||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
|
||||
|
||||
@@ -133,6 +133,12 @@ interface AuthHandoffInfoRequest {
|
||||
interface AuthHandoffStatusRequest {
|
||||
code: string;
|
||||
clientIp: string;
|
||||
pollSecret?: string;
|
||||
}
|
||||
|
||||
interface AuthHandoffCancelRequest {
|
||||
code: string;
|
||||
pollSecret: string;
|
||||
}
|
||||
|
||||
export class AuthRequestService {
|
||||
@@ -182,7 +188,7 @@ export class AuthRequestService {
|
||||
}
|
||||
|
||||
async logout({authorizationHeader, authToken}: AuthLogoutRequest): Promise<void> {
|
||||
const token = authorizationHeader ?? authToken;
|
||||
const token = authToken ?? authorizationHeader;
|
||||
if (token) {
|
||||
await AuthSession.revokeToken(this.apiContext, token);
|
||||
}
|
||||
@@ -305,6 +311,7 @@ export class AuthRequestService {
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
poll_secret: result.pollSecret,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,8 +359,8 @@ export class AuthRequestService {
|
||||
);
|
||||
}
|
||||
|
||||
async getHandoffStatus({code, clientIp}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp);
|
||||
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
|
||||
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
|
||||
return {
|
||||
status: result.status,
|
||||
token: result.token,
|
||||
@@ -362,8 +369,8 @@ export class AuthRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async cancelHandoff({code}: {code: string}): Promise<void> {
|
||||
await this.desktopHandoffService.cancelHandoff(code);
|
||||
async cancelHandoff({code, pollSecret}: AuthHandoffCancelRequest): Promise<void> {
|
||||
await this.desktopHandoffService.cancelHandoff(code, pollSecret);
|
||||
}
|
||||
|
||||
private async getUserPartial(userId: string): Promise<UserPartialResponse> {
|
||||
|
||||
@@ -35,15 +35,6 @@ interface LogoutAuthSessionsParams {
|
||||
sessionIdHashes: Array<string>;
|
||||
}
|
||||
|
||||
interface UpdateUserActivityParams {
|
||||
userId: UserID;
|
||||
clientIp: string;
|
||||
user?: User;
|
||||
action?: 'session_authenticated' | 'bearer_fallback_session_authenticated' | 'unknown';
|
||||
tokenType?: 'session' | 'bearer';
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface DispatchAuthSessionChangeParams {
|
||||
userId: UserID;
|
||||
oldAuthSessionIdHash: string;
|
||||
@@ -91,6 +82,7 @@ export async function createAuthSession(
|
||||
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
|
||||
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
|
||||
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
|
||||
user = await AuthUtility.handleBanStatus(ctx, user);
|
||||
const now = new Date();
|
||||
const token = await AuthUtility.generateAuthToken(ctx);
|
||||
let clientCountry: string | null = null;
|
||||
@@ -152,11 +144,6 @@ export async function updateAuthSessionLastUsed(ctx: ApiContext, tokenHash: Uint
|
||||
await ctx.services.userActivityBuffer.recordAuthSessionActivity(Buffer.from(tokenHash), new Date());
|
||||
}
|
||||
|
||||
export async function updateUserActivity(ctx: ApiContext, {userId, clientIp}: UpdateUserActivityParams): Promise<void> {
|
||||
const {users} = ctx.services;
|
||||
await users.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
export async function revokeToken(ctx: ApiContext, token: string): Promise<void> {
|
||||
const {users, gateway} = ctx.services;
|
||||
const tokenHash = Buffer.from(AuthUtility.getTokenIdHash(ctx, token));
|
||||
|
||||
@@ -1,60 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
||||
import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCodeExpiredError';
|
||||
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
|
||||
import {
|
||||
DESKTOP_HANDOFF_CODE_ALPHABET,
|
||||
DESKTOP_HANDOFF_CODE_LENGTH,
|
||||
formatDesktopHandoffCode,
|
||||
parseDesktopHandoffCode,
|
||||
} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import type {SessionOrigin} from '../AuthSession';
|
||||
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
|
||||
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
|
||||
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 12;
|
||||
const NORMALIZED_CODE_REGEX = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{12}$/;
|
||||
const HANDOFF_ATTEMPT_PREFIX = 'desktop-handoff-attempts:';
|
||||
const HANDOFF_APPROVER_PREFIX = 'desktop-handoff-approver:';
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const ATTEMPT_TTL_SECONDS = 900;
|
||||
const MAX_INFO_LOOKUPS = 3;
|
||||
const POLL_SECRET_BYTES = 32;
|
||||
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
origin: SessionOrigin;
|
||||
infoLookupCount: number;
|
||||
pollSecretHash: string;
|
||||
}
|
||||
|
||||
interface HandoffTokenData {
|
||||
token: string;
|
||||
userId: string;
|
||||
pollSecretHash: string;
|
||||
}
|
||||
|
||||
interface HandoffApproverData {
|
||||
approvedAt: number;
|
||||
}
|
||||
|
||||
function generateHandoffCode(): string {
|
||||
const maxUnbiased = 256 - (256 % CODE_CHARACTERS.length);
|
||||
function generateNormalizedHandoffCode(): string {
|
||||
const maxUnbiased = 256 - (256 % DESKTOP_HANDOFF_CODE_ALPHABET.length);
|
||||
let code = '';
|
||||
while (code.length < CODE_LENGTH) {
|
||||
const bytes = randomBytes(CODE_LENGTH - code.length);
|
||||
for (let i = 0; i < bytes.length && code.length < CODE_LENGTH; i++) {
|
||||
while (code.length < DESKTOP_HANDOFF_CODE_LENGTH) {
|
||||
const bytes = randomBytes(DESKTOP_HANDOFF_CODE_LENGTH - code.length);
|
||||
for (let i = 0; i < bytes.length && code.length < DESKTOP_HANDOFF_CODE_LENGTH; i++) {
|
||||
if (bytes[i] < maxUnbiased) {
|
||||
code += CODE_CHARACTERS[bytes[i] % CODE_CHARACTERS.length];
|
||||
code += DESKTOP_HANDOFF_CODE_ALPHABET[bytes[i] % DESKTOP_HANDOFF_CODE_ALPHABET.length];
|
||||
}
|
||||
}
|
||||
}
|
||||
return `${code.slice(0, 6)}-${code.slice(6, 12)}`;
|
||||
return code;
|
||||
}
|
||||
|
||||
function normalizeHandoffCode(code: string): string {
|
||||
return code.replace(/[-\s]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function assertValidHandoffCode(code: string): void {
|
||||
if (!NORMALIZED_CODE_REGEX.test(code)) {
|
||||
function requireNormalizedHandoffCode(code: string): string {
|
||||
const normalized = parseDesktopHandoffCode(code);
|
||||
if (normalized == null) {
|
||||
throw new InvalidHandoffCodeError();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function generatePollSecret(): string {
|
||||
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
function hashPollSecret(secret: string): string {
|
||||
return createHash('sha256').update(secret).digest('hex');
|
||||
}
|
||||
|
||||
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
|
||||
if (!presented || !storedHash) {
|
||||
return false;
|
||||
}
|
||||
const presentedHash = Buffer.from(hashPollSecret(presented), 'hex');
|
||||
const stored = Buffer.from(storedHash, 'hex');
|
||||
if (presentedHash.length !== stored.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(presentedHash, stored);
|
||||
}
|
||||
|
||||
export class DesktopHandoffService {
|
||||
@@ -63,19 +87,21 @@ export class DesktopHandoffService {
|
||||
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
pollSecret: string;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const code = generateHandoffCode();
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
const normalizedCode = generateNormalizedHandoffCode();
|
||||
const pollSecret = generatePollSecret();
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
origin: args.origin,
|
||||
infoLookupCount: 0,
|
||||
pollSecretHash: hashPollSecret(pollSecret),
|
||||
};
|
||||
const expirySeconds = seconds('5 minutes');
|
||||
await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
|
||||
const expiresAt = new Date(Date.now() + ms('5 minutes'));
|
||||
return {code, expiresAt};
|
||||
return {code: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
|
||||
}
|
||||
|
||||
async completeHandoff(
|
||||
@@ -84,8 +110,7 @@ export class DesktopHandoffService {
|
||||
approverIp: string,
|
||||
): Promise<void> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
assertValidHandoffCode(normalizedCode);
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
const storedApprover = await cache.get<HandoffApproverData>(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
|
||||
if (!storedApprover) {
|
||||
@@ -108,6 +133,7 @@ export class DesktopHandoffService {
|
||||
const tokenData: HandoffTokenData = {
|
||||
token,
|
||||
userId,
|
||||
pollSecretHash: handoffData.pollSecretHash,
|
||||
};
|
||||
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
|
||||
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
@@ -122,8 +148,7 @@ export class DesktopHandoffService {
|
||||
origin?: SessionOrigin;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
assertValidHandoffCode(normalizedCode);
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(approverIp);
|
||||
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
|
||||
const handoffData = await cache.get<HandoffData>(codeKey);
|
||||
@@ -149,18 +174,23 @@ export class DesktopHandoffService {
|
||||
|
||||
async getHandoffStatus(
|
||||
code: string,
|
||||
_pollerIp: string,
|
||||
pollerIp: string,
|
||||
pollSecret: string | undefined,
|
||||
): Promise<{
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
userId?: string;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
assertValidHandoffCode(normalizedCode);
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
await this.checkAttemptLimit(pollerIp);
|
||||
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
|
||||
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
|
||||
if (tokenData) {
|
||||
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
|
||||
await this.recordFailedAttempt(pollerIp);
|
||||
return {status: 'pending'};
|
||||
}
|
||||
await cache.delete(tokenKey);
|
||||
return {
|
||||
status: 'completed',
|
||||
@@ -175,12 +205,19 @@ export class DesktopHandoffService {
|
||||
return {status: 'expired'};
|
||||
}
|
||||
|
||||
async cancelHandoff(code: string): Promise<void> {
|
||||
async cancelHandoff(code: string, pollSecret: string): Promise<void> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
assertValidHandoffCode(normalizedCode);
|
||||
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
|
||||
await cache.delete(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`);
|
||||
const normalizedCode = requireNormalizedHandoffCode(code);
|
||||
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
|
||||
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
|
||||
const handoffData = await cache.get<HandoffData>(codeKey);
|
||||
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
|
||||
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
|
||||
if (!pollSecretMatches(pollSecret, storedHash)) {
|
||||
throw new InvalidHandoffCodeError();
|
||||
}
|
||||
await cache.delete(codeKey);
|
||||
await cache.delete(tokenKey);
|
||||
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHmac, randomBytes, randomUUID} from 'node:crypto';
|
||||
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {Logger} from '../../Logger';
|
||||
@@ -19,7 +20,7 @@ interface PhoneAttemptRiskThresholds {
|
||||
userDistinctCountriesRequireInbound: number;
|
||||
ipDistinctPhonesHardBlock: number;
|
||||
ipDistinctUsersHardBlock: number;
|
||||
cidr24DistinctPhonesHardBlock: number;
|
||||
subnetDistinctPhonesHardBlock: number;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLDS: PhoneAttemptRiskThresholds = {
|
||||
@@ -28,7 +29,7 @@ const DEFAULT_THRESHOLDS: PhoneAttemptRiskThresholds = {
|
||||
userDistinctCountriesRequireInbound: 3,
|
||||
ipDistinctPhonesHardBlock: 10,
|
||||
ipDistinctUsersHardBlock: 3,
|
||||
cidr24DistinctPhonesHardBlock: 30,
|
||||
subnetDistinctPhonesHardBlock: 30,
|
||||
};
|
||||
|
||||
type PhoneAttemptRiskDecision = 'allow' | 'require_captcha' | 'require_inbound' | 'hard_block';
|
||||
@@ -38,10 +39,10 @@ type PhoneAttemptRiskReason =
|
||||
| 'user_country_sweep'
|
||||
| 'ip_distinct_phones'
|
||||
| 'ip_user_pool'
|
||||
| 'cidr24_distinct_phones'
|
||||
| 'subnet_distinct_phones'
|
||||
| 'user_already_hard_blocked'
|
||||
| 'ip_already_hard_blocked'
|
||||
| 'cidr24_already_hard_blocked';
|
||||
| 'subnet_already_hard_blocked';
|
||||
|
||||
interface PhoneAttemptRiskEvaluation {
|
||||
decision: PhoneAttemptRiskDecision;
|
||||
@@ -53,7 +54,7 @@ interface PhoneAttemptRiskEvaluation {
|
||||
userDistinctCountries: number;
|
||||
ipDistinctPhones: number;
|
||||
ipDistinctUsers: number;
|
||||
cidr24DistinctPhones: number;
|
||||
subnetDistinctPhones: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ const ZERO_COUNTERS: PhoneAttemptRiskEvaluation['counters'] = {
|
||||
userDistinctCountries: 0,
|
||||
ipDistinctPhones: 0,
|
||||
ipDistinctUsers: 0,
|
||||
cidr24DistinctPhones: 0,
|
||||
subnetDistinctPhones: 0,
|
||||
};
|
||||
|
||||
interface PhoneAttemptRiskInput {
|
||||
@@ -105,23 +106,16 @@ function prefix6(phone: string): string {
|
||||
return phone.slice(0, 6);
|
||||
}
|
||||
|
||||
function cidr24For(ip: string): string | null {
|
||||
if (!ip.includes('.')) return null;
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
|
||||
}
|
||||
|
||||
function userKey(userId: string | bigint, suffix: string): string {
|
||||
return `${KEY_PREFIX}user:${String(userId)}:${suffix}`;
|
||||
}
|
||||
|
||||
function ipKey(ip: string, suffix: string): string {
|
||||
return `${KEY_PREFIX}ip:${ip}:${suffix}`;
|
||||
return `${KEY_PREFIX}ip:${getSameIpDecisionKey(ip) ?? ip}:${suffix}`;
|
||||
}
|
||||
|
||||
function cidr24Key(cidr: string, suffix: string): string {
|
||||
return `${KEY_PREFIX}ip24:${cidr}:${suffix}`;
|
||||
function subnetKey(subnet: string, suffix: string): string {
|
||||
return `${KEY_PREFIX}ipnet:${subnet}:${suffix}`;
|
||||
}
|
||||
|
||||
type PhoneAttemptHardBlockHook = (params: {
|
||||
@@ -166,10 +160,10 @@ export class PhoneAttemptRiskService {
|
||||
evaluation.reason = 'ip_already_hard_blocked';
|
||||
return evaluation;
|
||||
}
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr && (await this.kv.exists(cidr24Key(cidr, 'hard_block'))) > 0) {
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
if ((await this.kv.exists(subnetKey(subnet, 'hard_block'))) > 0) {
|
||||
evaluation.decision = 'hard_block';
|
||||
evaluation.reason = 'cidr24_already_hard_blocked';
|
||||
evaluation.reason = 'subnet_already_hard_blocked';
|
||||
return evaluation;
|
||||
}
|
||||
}
|
||||
@@ -212,15 +206,13 @@ export class PhoneAttemptRiskService {
|
||||
evaluation.reason = 'ip_user_pool';
|
||||
return evaluation;
|
||||
}
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr) {
|
||||
const cidr24DistinctPhones = await this.kv.scard(cidr24Key(cidr, 'phones'));
|
||||
evaluation.counters.cidr24DistinctPhones = cidr24DistinctPhones;
|
||||
if (cidr24DistinctPhones >= this.thresholds.cidr24DistinctPhonesHardBlock) {
|
||||
evaluation.decision = 'hard_block';
|
||||
evaluation.reason = 'cidr24_distinct_phones';
|
||||
return evaluation;
|
||||
}
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
const subnetDistinctPhones = await this.kv.scard(subnetKey(subnet, 'phones'));
|
||||
evaluation.counters.subnetDistinctPhones = subnetDistinctPhones;
|
||||
if (subnetDistinctPhones >= this.thresholds.subnetDistinctPhonesHardBlock) {
|
||||
evaluation.decision = 'hard_block';
|
||||
evaluation.reason = 'subnet_distinct_phones';
|
||||
return evaluation;
|
||||
}
|
||||
}
|
||||
return evaluation;
|
||||
@@ -248,16 +240,14 @@ export class PhoneAttemptRiskService {
|
||||
}
|
||||
let ipDistinctPhones = 0;
|
||||
let ipDistinctUsers = 0;
|
||||
let cidr24DistinctPhones = 0;
|
||||
let subnetDistinctPhones = 0;
|
||||
if (input.clientIp) {
|
||||
ipDistinctPhones = await this.bumpSet(ipKey(input.clientIp, 'phones'), hmac(input.phone, key));
|
||||
if (input.userId !== null && input.userId !== undefined && input.userId !== '') {
|
||||
ipDistinctUsers = await this.bumpSet(ipKey(input.clientIp, 'users'), hmac(String(input.userId), key));
|
||||
}
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr) {
|
||||
cidr24DistinctPhones = await this.bumpSet(cidr24Key(cidr, 'phones'), hmac(input.phone, key));
|
||||
}
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
subnetDistinctPhones = await this.bumpSet(subnetKey(subnet, 'phones'), hmac(input.phone, key));
|
||||
}
|
||||
const counters = {
|
||||
userAttempts,
|
||||
@@ -266,7 +256,7 @@ export class PhoneAttemptRiskService {
|
||||
userDistinctCountries,
|
||||
ipDistinctPhones,
|
||||
ipDistinctUsers,
|
||||
cidr24DistinctPhones,
|
||||
subnetDistinctPhones,
|
||||
};
|
||||
const tripped = this.tripsAt(counters);
|
||||
if (tripped !== null) {
|
||||
@@ -290,8 +280,8 @@ export class PhoneAttemptRiskService {
|
||||
) {
|
||||
return 'ip_user_pool';
|
||||
}
|
||||
if (counters.cidr24DistinctPhones >= this.thresholds.cidr24DistinctPhonesHardBlock) {
|
||||
return 'cidr24_distinct_phones';
|
||||
if (counters.subnetDistinctPhones >= this.thresholds.subnetDistinctPhonesHardBlock) {
|
||||
return 'subnet_distinct_phones';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -306,11 +296,9 @@ export class PhoneAttemptRiskService {
|
||||
await this.kv.setex(userKey(input.userId, 'hard_block'), HARD_BLOCK_TTL_SECONDS, '1');
|
||||
} else if (reason === 'ip_user_pool' && input.clientIp) {
|
||||
await this.kv.setex(ipKey(input.clientIp, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
|
||||
} else if (reason === 'cidr24_distinct_phones' && input.clientIp) {
|
||||
const cidr = cidr24For(input.clientIp);
|
||||
if (cidr) {
|
||||
await this.kv.setex(cidr24Key(cidr, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
|
||||
}
|
||||
} else if (reason === 'subnet_distinct_phones' && input.clientIp) {
|
||||
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
|
||||
await this.kv.setex(subnetKey(subnet, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
|
||||
}
|
||||
Logger.warn(
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from 'jose';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import type {ILogger} from '../../ILogger';
|
||||
import type {IDiscriminatorService} from '../../infrastructure/DiscriminatorService';
|
||||
import type {KVActivityTracker} from '../../infrastructure/KVActivityTracker';
|
||||
import {
|
||||
@@ -100,6 +101,13 @@ const STATE_BYTE_LENGTH = 16;
|
||||
const NONCE_BYTE_LENGTH = 16;
|
||||
const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback';
|
||||
|
||||
let ssoLogger: ILogger | undefined;
|
||||
|
||||
function getLogger(): ILogger {
|
||||
ssoLogger ??= Logger.child({logger: 'SsoService'});
|
||||
return ssoLogger;
|
||||
}
|
||||
|
||||
function randomBase64UrlToken(byteLength: number): string {
|
||||
return randomBytes(byteLength).toString('base64url');
|
||||
}
|
||||
@@ -225,7 +233,7 @@ function resolveEmailVerified({
|
||||
if (values.includes(false)) {
|
||||
return false;
|
||||
}
|
||||
return values.length === 0 || values.includes(true);
|
||||
return values.length > 0 && values.includes(true);
|
||||
}
|
||||
|
||||
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
|
||||
@@ -235,7 +243,6 @@ function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
|
||||
}
|
||||
|
||||
export class SsoService {
|
||||
private readonly logger = Logger.child({logger: 'SsoService'});
|
||||
private static readonly STATE_TTL_SECONDS = seconds('10 minutes');
|
||||
private static readonly DISCOVERY_TTL_SECONDS = seconds('1 hour');
|
||||
private static readonly JWKS_CACHE_TTL_MS = ms('1 hour');
|
||||
@@ -344,12 +351,12 @@ export class SsoService {
|
||||
throw InputValidationError.fromCode('email_verified', ValidationErrorCodes.INVALID_SSO_TOKEN);
|
||||
}
|
||||
const emailLower = claims.email.toLowerCase();
|
||||
this.logger.info({email: emailLower, has_sub: true}, 'SSO login with sub claim');
|
||||
getLogger().info({email: emailLower, has_sub: true}, 'SSO login with sub claim');
|
||||
const identityUserId = await this.ssoIdentityRepository.findUserId(config.providerId, claims.sub);
|
||||
if (identityUserId) {
|
||||
const user = await this.apiContext.services.users.findUnique(identityUserId);
|
||||
if (!user) {
|
||||
this.logger.error(
|
||||
getLogger().error(
|
||||
{user_id: identityUserId.toString()},
|
||||
'SSO identity mapping points at a missing user; refusing reassignment',
|
||||
);
|
||||
@@ -401,7 +408,7 @@ export class SsoService {
|
||||
if (ownerId?.toString() === userId.toString()) {
|
||||
return;
|
||||
}
|
||||
this.logger.error(
|
||||
getLogger().error(
|
||||
{user_id: userId.toString(), owner_user_id: ownerId?.toString() ?? null},
|
||||
'SSO identity is already linked to another account',
|
||||
);
|
||||
@@ -413,7 +420,7 @@ export class SsoService {
|
||||
const traits = user.traits;
|
||||
const existingIdentities = Array.from(traits).filter((trait) => trait.startsWith('sso_identity:'));
|
||||
if (existingIdentities.length > 0 && !traits.has(identityTrait)) {
|
||||
this.logger.error({user_id: user.id.toString()}, 'SSO identity claim did not match linked account');
|
||||
getLogger().error({user_id: user.id.toString()}, 'SSO identity claim did not match linked account');
|
||||
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
|
||||
}
|
||||
await this.claimSsoIdentity(user.id, sub, config);
|
||||
@@ -534,13 +541,13 @@ export class SsoService {
|
||||
}),
|
||||
);
|
||||
void this.kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
|
||||
this.logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
|
||||
getLogger().warn({error, userId: user.id}, 'Failed to update real-time user activity');
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (!userCreated) {
|
||||
await this.ssoIdentityRepository.releaseIdentity(config.providerId, claims.sub).catch((releaseError) => {
|
||||
this.logger.error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
|
||||
getLogger().error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
@@ -567,7 +574,7 @@ export class SsoService {
|
||||
let claims: JWTPayload | null = null;
|
||||
if (tokenResponse.id_token) {
|
||||
if (!config.jwksUrl) {
|
||||
this.logger.warn('SSO id_token returned but no JWKS URL is configured; ignoring id_token claims');
|
||||
getLogger().warn('SSO id_token returned but no JWKS URL is configured; ignoring id_token claims');
|
||||
} else {
|
||||
claims = await this.verifyIdToken(tokenResponse.id_token, config, expectedNonce);
|
||||
}
|
||||
@@ -580,7 +587,7 @@ export class SsoService {
|
||||
const userInfoSub = userInfo ? readStringClaim(userInfo, 'sub') : undefined;
|
||||
if (claims && userInfo) {
|
||||
if (!idTokenSub || !userInfoSub || idTokenSub !== userInfoSub) {
|
||||
this.logger.error('SSO sub mismatch between id_token and userinfo');
|
||||
getLogger().error('SSO sub mismatch between id_token and userinfo');
|
||||
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
|
||||
}
|
||||
}
|
||||
@@ -597,7 +604,7 @@ export class SsoService {
|
||||
const normalizedIdTokenEmail = idTokenEmail ? normalizeSsoEmail(idTokenEmail) : undefined;
|
||||
const normalizedUserInfoEmail = userInfoEmail ? normalizeSsoEmail(userInfoEmail) : undefined;
|
||||
if (normalizedIdTokenEmail && normalizedUserInfoEmail && normalizedIdTokenEmail !== normalizedUserInfoEmail) {
|
||||
this.logger.error('SSO email mismatch between id_token and userinfo');
|
||||
getLogger().error('SSO email mismatch between id_token and userinfo');
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
|
||||
}
|
||||
const email =
|
||||
@@ -634,7 +641,7 @@ export class SsoService {
|
||||
});
|
||||
const nonce = payload['nonce'];
|
||||
if (nonce === undefined) {
|
||||
this.logger.warn('SSO id_token missing required nonce claim');
|
||||
getLogger().warn('SSO id_token missing required nonce claim');
|
||||
throw new Error('nonce missing');
|
||||
}
|
||||
if (typeof nonce !== 'string' || nonce.length === 0 || nonce !== expectedNonce) {
|
||||
@@ -644,7 +651,7 @@ export class SsoService {
|
||||
}
|
||||
return decodeJwt(idToken);
|
||||
} catch (error) {
|
||||
this.logger.error({error}, 'Failed to verify SSO id_token');
|
||||
getLogger().error({error}, 'Failed to verify SSO id_token');
|
||||
throw InputValidationError.fromCode('id_token', ValidationErrorCodes.INVALID_SSO_TOKEN);
|
||||
}
|
||||
}
|
||||
@@ -919,7 +926,7 @@ export class SsoService {
|
||||
try {
|
||||
return await this.assertPublicOutboundUrl(rawUrl, fieldName);
|
||||
} catch (error) {
|
||||
this.logger.warn({fieldName, rawUrl, error}, 'Ignoring SSO URL that failed outbound policy validation');
|
||||
getLogger().warn({fieldName, rawUrl, error}, 'Ignoring SSO URL that failed outbound policy validation');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user