mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
270
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20cdfd3009 | ||
|
|
9f739427c4 | ||
|
|
0201cafd7e | ||
|
|
37f57bb29f | ||
|
|
ebd723679b | ||
|
|
961fa1f007 | ||
|
|
7900a4da0c | ||
|
|
8a24730884 | ||
|
|
1688e7dc50 | ||
|
|
aa267b54ec | ||
|
|
bc40073a02 | ||
|
|
a93f9dd0af | ||
|
|
53a9fdc4b6 | ||
|
|
cef600277c | ||
|
|
bdac438329 | ||
|
|
2d77f36a0b | ||
|
|
90aa810ce4 | ||
|
|
cf3af50464 | ||
|
|
3b5b20c139 | ||
|
|
24cd163acd | ||
|
|
49f76e5b40 | ||
|
|
871788f0a9 | ||
|
|
24138b70f1 | ||
|
|
da3332e711 | ||
|
|
06e5cf2032 | ||
|
|
d4b1923c23 | ||
|
|
42df4f6731 | ||
|
|
f1e6e94041 | ||
|
|
0cd12b2f32 | ||
|
|
5da4d24d38 | ||
|
|
7806d2ac02 | ||
|
|
2c4d182d1f | ||
|
|
dcd5f88d65 | ||
|
|
662f4ac93b | ||
|
|
a2480c6a02 | ||
|
|
c49460a44f | ||
|
|
6786dfe7e3 | ||
|
|
7d710d881a | ||
|
|
2ea2e79f6f | ||
|
|
cd42dd8ca7 | ||
|
|
f2eddeae4d | ||
|
|
8e1a8fc7e3 | ||
|
|
87c08b051f | ||
|
|
9d95a80857 | ||
|
|
9371b6d5de | ||
|
|
bdcf4b25c0 | ||
|
|
3dc344be65 | ||
|
|
17ed0f70aa | ||
|
|
be3e12e60d | ||
|
|
4261cc2ea5 | ||
|
|
88dbc27019 | ||
|
|
f38fc80c31 | ||
|
|
803fdaf443 | ||
|
|
55d85db401 | ||
|
|
7ce3d71c44 | ||
|
|
04e150e4bf | ||
|
|
0f6b118921 | ||
|
|
44277e6aa2 | ||
|
|
bb7e8cc6f1 | ||
|
|
32a64fb097 | ||
|
|
c4594397e7 | ||
|
|
6a188a4cdf | ||
|
|
0ca0defd24 | ||
|
|
b0b84f9c98 | ||
|
|
ef8d1225b5 | ||
|
|
240b7e4388 | ||
|
|
bd205d2250 | ||
|
|
e5e5bcccee | ||
|
|
a5395b0109 | ||
|
|
09cea4394f | ||
|
|
afeaddea22 | ||
|
|
45f694310a | ||
|
|
995f5118b2 | ||
|
|
415888a615 | ||
|
|
542fb9176a | ||
|
|
b8f8d8d859 | ||
|
|
0eef611b6d | ||
|
|
f0612ee860 | ||
|
|
8c85cce75c | ||
|
|
5036ac3efa | ||
|
|
9025e03422 | ||
|
|
e82e8529bf | ||
|
|
eb1ed69489 | ||
|
|
e81f3f7eae | ||
|
|
4b9964bc89 | ||
|
|
b4a2af75d0 | ||
|
|
8c3e3285f7 | ||
|
|
0a4f6ff9fb | ||
|
|
bfa1367ca2 | ||
|
|
bb81a2f165 | ||
|
|
7f448b1cab | ||
|
|
2dd35c0d6e | ||
|
|
0e73346c5f | ||
|
|
8476595507 | ||
|
|
7b9284edb9 | ||
|
|
c982b33212 | ||
|
|
3c8466d714 | ||
|
|
eeea391b63 | ||
|
|
5c2dca1c51 | ||
|
|
e6e4c6f7b5 | ||
|
|
5f6f9428ac | ||
|
|
ba54b61dcf | ||
|
|
8dc2bad843 | ||
|
|
3594cbd5ca | ||
|
|
21b1e4e719 | ||
|
|
50a17b6263 | ||
|
|
0a920def2b | ||
|
|
c4b1471923 | ||
|
|
ab08ed0d7c | ||
|
|
34c13a747d | ||
|
|
d559d8853d | ||
|
|
a96d9cd075 | ||
|
|
b163888cf3 | ||
|
|
b07e2c397c | ||
|
|
3eeba1da2b | ||
|
|
e160b1bf07 | ||
|
|
1199b36d1a | ||
|
|
7a506478c7 | ||
|
|
6faa40e0c2 | ||
|
|
768657d7e5 | ||
|
|
7157cca22f | ||
|
|
7aec79d3ad | ||
|
|
4357d5ec5d | ||
|
|
7c1c8b2749 | ||
|
|
15656bd5c8 | ||
|
|
c4897a7026 | ||
|
|
e993a47720 | ||
|
|
0d4c65ad79 | ||
|
|
f09bdb2b00 | ||
|
|
f1400ae58e | ||
|
|
b5496097d2 | ||
|
|
d5fb495e19 | ||
|
|
00e716bc3f | ||
|
|
ea4edd668f | ||
|
|
a22db125a9 | ||
|
|
e7f68c2e20 | ||
|
|
933b13f3fa | ||
|
|
0be6c9c734 | ||
|
|
5aac331368 | ||
|
|
57ec484626 | ||
|
|
9cd832bfa9 | ||
|
|
299cc40ff5 | ||
|
|
6d305bacdf | ||
|
|
6fd3177844 | ||
|
|
5586d34293 | ||
|
|
d43d242b16 | ||
|
|
9f620e8c4b | ||
|
|
6c9afcc734 | ||
|
|
43d6c85f7e | ||
|
|
78056e0041 | ||
|
|
e83a2d6aec | ||
|
|
bac06fe182 | ||
|
|
8ff6518797 | ||
|
|
f0e7c25e4c | ||
|
|
0da94965dc | ||
|
|
d82eed16b7 | ||
|
|
b26748a2a7 | ||
|
|
a2d7f5e8cc | ||
|
|
72ffa3bd9a | ||
|
|
e2fccaee74 | ||
|
|
e41b209cb8 | ||
|
|
2517caf674 | ||
|
|
b9ec0d5f53 | ||
|
|
02e614632f | ||
|
|
3ca73901c9 | ||
|
|
c379eed266 | ||
|
|
5bac4fd719 | ||
|
|
dd610e4c0f | ||
|
|
bf080cb001 | ||
|
|
169088df26 | ||
|
|
4a2a29f154 | ||
|
|
1054962008 | ||
|
|
8bc8603460 | ||
|
|
6538b0bfeb | ||
|
|
9d2339bb3b | ||
|
|
096f38d365 | ||
|
|
1046edd903 | ||
|
|
cbf504dbb8 | ||
|
|
8491872908 | ||
|
|
c207918e90 | ||
|
|
12717f692b | ||
|
|
36d630b37b | ||
|
|
5333fe7c3a | ||
|
|
efae78056e | ||
|
|
6eca64a8f7 | ||
|
|
fcb629ca06 | ||
|
|
289f1af253 | ||
|
|
8adc3ecb0b | ||
|
|
e328c001a1 | ||
|
|
f796a31613 | ||
|
|
e1bab2e353 | ||
|
|
1c135176d2 | ||
|
|
723f0d6e6e | ||
|
|
e14d193b43 | ||
|
|
9d1733bdb3 | ||
|
|
e06436d6f5 | ||
|
|
4f67e2b362 | ||
|
|
8aa3415d73 | ||
|
|
74f22e89ce | ||
|
|
7d826d1602 | ||
|
|
8aa39bc7db | ||
|
|
36dcf51024 | ||
|
|
3e74180bdc | ||
|
|
45ed740575 | ||
|
|
8092ad8c4d | ||
|
|
b4d9cdc584 | ||
|
|
36b85512c6 | ||
|
|
14ae64f5f3 | ||
|
|
990176ac7c | ||
|
|
69ef46356b | ||
|
|
bd88c7b04b | ||
|
|
03641f622f | ||
|
|
3b1eb56713 | ||
|
|
059bcc6c53 | ||
|
|
82043ce2a8 | ||
|
|
470e752fba | ||
|
|
3cc7b9050c | ||
|
|
b1f7c78c7e | ||
|
|
8f20b29b16 | ||
|
|
19efbd3d61 | ||
|
|
f35c0effa2 | ||
|
|
8363cc0844 | ||
|
|
5a11cacbae | ||
|
|
27151a9487 | ||
|
|
c152b25deb | ||
|
|
04d3afaf7b | ||
|
|
dbc63e9ef7 | ||
|
|
ce91ff95ab | ||
|
|
d75a29f099 | ||
|
|
cb889b1160 | ||
|
|
8db4f5cb63 | ||
|
|
d297dc5805 | ||
|
|
9b8659a40b | ||
|
|
96c5db3f5d | ||
|
|
78e403819e | ||
|
|
805acf4e5e | ||
|
|
9f099a9127 | ||
|
|
4d15c39cd7 | ||
|
|
827451d12d | ||
|
|
03603662c6 | ||
|
|
34eb10cd88 | ||
|
|
82941c08c9 | ||
|
|
8d21c97d08 | ||
|
|
71a56f590d | ||
|
|
38297c4fe7 | ||
|
|
cf7ec06d85 | ||
|
|
f2ea10f951 | ||
|
|
bbd93df239 | ||
|
|
9a6ab93e01 | ||
|
|
38eed7cce6 | ||
|
|
79064c3399 | ||
|
|
0496b2f530 | ||
|
|
9d0be1ebd1 | ||
|
|
9ad026b8ce | ||
|
|
14de5971d5 | ||
|
|
5474be3efa | ||
|
|
9a54bbba2d | ||
|
|
5a0110ccc8 | ||
|
|
d032d577bf | ||
|
|
243954c9c5 | ||
|
|
ba1be73389 | ||
|
|
af3ad02962 | ||
|
|
53ddca725e | ||
|
|
094fb0d1c8 | ||
|
|
dc230926a4 | ||
|
|
026ace6747 | ||
|
|
374db9ed2b | ||
|
|
5ee59c4675 | ||
|
|
33605171a8 | ||
|
|
89fac5b088 |
@@ -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
|
||||
|
||||
@@ -524,6 +524,7 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -553,11 +554,29 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_release_assets
|
||||
|
||||
- name: Publish GitHub release descriptor
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_descriptor
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_payload
|
||||
|
||||
- name: Upload GitHub release asset handoff
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_release_assets
|
||||
|
||||
- name: Build summary
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
@@ -577,9 +596,17 @@ jobs:
|
||||
- upload
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -590,6 +617,12 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_release_assets
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
@@ -614,8 +647,16 @@ jobs:
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
--asset-dir release_assets
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
- name: Publish GitHub release readiness marker
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: release image set
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
from-tag:
|
||||
description: "Image tag every component is read from (v1 snapshots today's moving tags, a CalVer pins a coordinated build)"
|
||||
type: string
|
||||
required: false
|
||||
default: "v1"
|
||||
component-versions:
|
||||
description: "Per-component overrides, one <image>=<version> entry per line (for example fluxer-api=2026.830.191141)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: release-image-set
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve image set release
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Image set release approved."
|
||||
|
||||
manifest:
|
||||
name: resolve and publish the image set
|
||||
needs: approve
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: fluxer
|
||||
permission-contents: write
|
||||
permission-packages: read
|
||||
|
||||
- name: set variables
|
||||
id: vars
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: >-
|
||||
tools/ci/run.sh resolve-calver
|
||||
--github-output
|
||||
|
||||
- name: resolve release image set
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
VERSION: ${{ steps.vars.outputs.build_version }}
|
||||
FROM_TAG: ${{ inputs['from-tag'] }}
|
||||
COMPONENT_VERSIONS: ${{ inputs['component-versions'] }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(
|
||||
image-set resolve
|
||||
--version "${VERSION}"
|
||||
--registry "ghcr.io/${GHCR_OWNER}"
|
||||
--from-tag "${FROM_TAG}"
|
||||
--out-dir release-out
|
||||
--github-output
|
||||
)
|
||||
while IFS= read -r entry; do
|
||||
entry="$(echo "$entry" | xargs)"
|
||||
if [ -n "$entry" ]; then
|
||||
args+=( --component-version "$entry" )
|
||||
fi
|
||||
done <<< "${COMPONENT_VERSIONS}"
|
||||
tools/ci/run.sh "${args[@]}"
|
||||
|
||||
- name: verify release image set
|
||||
env:
|
||||
VERSION: ${{ steps.vars.outputs.build_version }}
|
||||
run: >-
|
||||
tools/ci/run.sh image-set verify
|
||||
--manifest "release-out/fluxer-release-${VERSION}.json"
|
||||
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
VERSION: ${{ steps.vars.outputs.build_version }}
|
||||
BUNDLE_COMMIT: ${{ steps.resolve.outputs.bundle_commit }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${BUNDLE_COMMIT}" ]; then
|
||||
echo "image-set resolve reported no bundle commit" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release create "fluxer-release@${VERSION}" \
|
||||
--repo fluxerapp/fluxer \
|
||||
--target "${BUNDLE_COMMIT}" \
|
||||
--title "fluxer-release ${VERSION}" \
|
||||
--latest=true \
|
||||
--notes "Immutable image set for ${VERSION}. Every image in the set contains ${BUNDLE_COMMIT}, the commit this tag points at, so the bundle here is never newer than the images. Pin with: docker compose -f docker-compose.yml -f fluxer-release-${VERSION}.yml up -d" \
|
||||
"release-out/fluxer-release-${VERSION}.json" \
|
||||
"release-out/fluxer-release-${VERSION}.yml"
|
||||
@@ -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"
|
||||
|
||||
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"]
|
||||
|
||||
+14
-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,42 @@
|
||||
"@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:",
|
||||
"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,
|
||||
|
||||
@@ -23,6 +23,7 @@ type CacheEntry = {
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_MAX_ENTRIES = 10_000;
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
@@ -85,6 +86,32 @@ function isAsciiUpperAlpha2(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function getCachedGeoipResult(cacheKey: string, normalizedIp: string): GeoipResult | null {
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() >= cached.expiresAt) {
|
||||
geoipCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
geoipCache.delete(cacheKey);
|
||||
geoipCache.set(cacheKey, cached);
|
||||
return {...cached.result, normalizedIp};
|
||||
}
|
||||
|
||||
function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
|
||||
geoipCache.delete(cacheKey);
|
||||
if (geoipCache.size >= CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = geoipCache.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
throw new Error('GeoIP cache reached capacity without an entry to evict');
|
||||
}
|
||||
geoipCache.delete(oldestKey);
|
||||
}
|
||||
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
try {
|
||||
const reader = await ensureReader(dbPath);
|
||||
@@ -108,14 +135,13 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
|
||||
}
|
||||
|
||||
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return {...cached.result, normalizedIp: clean};
|
||||
const cached = getCachedGeoipResult(cacheKey, clean);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const result = await lookupMaxmind(clean, dbPath);
|
||||
geoipCache.set(cacheKey, {result, expiresAt: now + CACHE_TTL_MS});
|
||||
setCachedGeoipResult(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -89,29 +89,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,9 +176,9 @@ 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');
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
@@ -85,6 +92,18 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
const countryCode = value.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/u.test(countryCode)) {
|
||||
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
|
||||
}
|
||||
normalized.add(countryCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapPushProviderApps(
|
||||
apps:
|
||||
| Array<{
|
||||
@@ -141,7 +160,14 @@ 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,
|
||||
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
|
||||
),
|
||||
cassandra: {
|
||||
hosts: cassandraSource?.hosts.join(',') ?? '',
|
||||
port: cassandraSource?.port ?? 9042,
|
||||
@@ -161,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,
|
||||
@@ -451,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,
|
||||
@@ -504,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,10 +28,11 @@ 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: '*'}));
|
||||
@@ -40,6 +42,7 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
|
||||
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(
|
||||
|
||||
@@ -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,19 +38,6 @@ 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginParams {
|
||||
data: LoginRequest;
|
||||
request: Request;
|
||||
@@ -359,13 +346,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 +388,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 +412,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 +422,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';
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createFakeAuthToken,
|
||||
createTestAccount,
|
||||
loginAccount,
|
||||
setUserACLs,
|
||||
type TestAccount,
|
||||
} from './AuthTestUtils';
|
||||
|
||||
function authSessionCacheKey(token: string): string {
|
||||
return `auth:session:${createHash('sha256').update(token).digest('base64url')}`;
|
||||
}
|
||||
|
||||
async function readCachedSession(harness: ApiTestHarness, token: string): Promise<string | null> {
|
||||
return harness.kvProvider.get(authSessionCacheKey(token));
|
||||
}
|
||||
|
||||
describe('Auth session cache invalidation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('caches the session on the token hash and never on the raw token', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
|
||||
const cached = await readCachedSession(harness, account.token);
|
||||
expect(cached).not.toBeNull();
|
||||
expect(cached).not.toContain(account.token);
|
||||
await expect(harness.kvProvider.get(`auth:session:${account.token}`)).resolves.toBeNull();
|
||||
});
|
||||
it('stops serving a session revoked by logout', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
|
||||
expect(await readCachedSession(harness, account.token)).not.toBeNull();
|
||||
await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute();
|
||||
expect(await readCachedSession(harness, account.token)).toBeNull();
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
|
||||
});
|
||||
it('stops serving a session revoked from another session list', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const second = await loginAccount(harness, account);
|
||||
await createBuilder(harness, second.token).get('/users/@me').expect(200).execute();
|
||||
expect(await readCachedSession(harness, second.token)).not.toBeNull();
|
||||
const sessions = await createBuilder<Array<AuthSessionResponse>>(harness, account.token)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
const secondSessionIdHash = createHash('sha256').update(second.token).digest('base64url');
|
||||
expect(sessions.some((session) => session.id_hash === secondSessionIdHash)).toBe(true);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/auth/sessions/logout')
|
||||
.body({session_id_hashes: [secondSessionIdHash], password: account.password})
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(await readCachedSession(harness, second.token)).toBeNull();
|
||||
await createBuilder(harness, second.token).get('/users/@me').expect(401).execute();
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
|
||||
});
|
||||
it('stops serving every session of a user after a password change', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const second = await loginAccount(harness, account);
|
||||
const third = await loginAccount(harness, account);
|
||||
for (const token of [account.token, second.token, third.token]) {
|
||||
await createBuilder(harness, token).get('/users/@me').expect(200).execute();
|
||||
expect(await readCachedSession(harness, token)).not.toBeNull();
|
||||
}
|
||||
await createBuilder(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({password: account.password, new_password: `cache-rotation-${Date.now()}`})
|
||||
.execute();
|
||||
for (const token of [account.token, second.token, third.token]) {
|
||||
expect(await readCachedSession(harness, token)).toBeNull();
|
||||
await createBuilder(harness, token).get('/users/@me').expect(401).execute();
|
||||
}
|
||||
});
|
||||
it('stops serving every session of a user after an admin temp ban', async () => {
|
||||
const target = await createTestAccount(harness);
|
||||
const targetSecond = await loginAccount(harness, target);
|
||||
let admin: TestAccount = await createTestAccount(harness);
|
||||
admin = await setUserACLs(harness, admin, ['admin:authenticate', 'user:temp_ban']);
|
||||
for (const token of [target.token, targetSecond.token]) {
|
||||
await createBuilder(harness, token).get('/users/@me').expect(200).execute();
|
||||
expect(await readCachedSession(harness, token)).not.toBeNull();
|
||||
}
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/users/temp-ban')
|
||||
.body({user_id: target.userId, duration_hours: 24, reason: 'cache invalidation coverage'})
|
||||
.execute();
|
||||
for (const token of [target.token, targetSecond.token]) {
|
||||
expect(await readCachedSession(harness, token)).toBeNull();
|
||||
await createBuilder(harness, token).get('/users/@me').expect(401).execute();
|
||||
}
|
||||
});
|
||||
it('stops serving sessions after the account disables itself', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
|
||||
expect(await readCachedSession(harness, account.token)).not.toBeNull();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/disable')
|
||||
.body({password: account.password})
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(await readCachedSession(harness, account.token)).toBeNull();
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(401).execute();
|
||||
});
|
||||
it('caches an unknown token hash without stranding sessions created afterwards', async () => {
|
||||
const unknownToken = createFakeAuthToken();
|
||||
await createBuilder(harness, unknownToken).get('/users/@me').expect(401).execute();
|
||||
await expect(readCachedSession(harness, unknownToken)).resolves.toBe('null');
|
||||
await createBuilder(harness, unknownToken).get('/users/@me').expect(401).execute();
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(200).execute();
|
||||
const rotated = await loginAccount(harness, account);
|
||||
await createBuilder(harness, rotated.token).get('/users/@me').expect(200).execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import {createAuthHarness, createUniqueEmail, createUniqueUsername, registerUser} from './AuthTestUtils';
|
||||
|
||||
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousEnabled = Config.captcha.enabled;
|
||||
const previousTestModeEnabled = Config.dev.testModeEnabled;
|
||||
Config.captcha.enabled = true;
|
||||
Config.dev.testModeEnabled = true;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Config.captcha.enabled = previousEnabled;
|
||||
Config.dev.testModeEnabled = previousTestModeEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerAndFlag(
|
||||
harness: ApiTestHarness,
|
||||
flags: Array<string>,
|
||||
): Promise<{email: string; password: string; userId: string}> {
|
||||
const email = createUniqueEmail('captcha-flags');
|
||||
const password = 'a-strong-password';
|
||||
const reg = await registerUser(harness, {
|
||||
email,
|
||||
username: createUniqueUsername('captchaflags'),
|
||||
global_name: 'Captcha Flags User',
|
||||
password,
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
if (flags.length > 0) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${reg.user_id}/security-flags`)
|
||||
.body({set_flags: flags})
|
||||
.execute();
|
||||
}
|
||||
return {email, password, userId: reg.user_id};
|
||||
}
|
||||
|
||||
describe('Auth Captcha Bypass Flags', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('lets APP_STORE_REVIEWER accounts log in without solving a captcha', async () => {
|
||||
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
|
||||
await withCaptchaEnabled(async () => {
|
||||
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.execute();
|
||||
expect(resp.token).toBeTruthy();
|
||||
expect(resp.user_id).toBe(account.userId);
|
||||
});
|
||||
});
|
||||
it('still requires a captcha for accounts without the APP_STORE_REVIEWER flag', async () => {
|
||||
const account = await registerAndFlag(harness, []);
|
||||
await withCaptchaEnabled(async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/login')
|
||||
.body({email: account.email, password: account.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {delay, HttpResponse, http} from 'msw';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {isPasswordPwned, resetPwnedPasswordCacheForTesting} from '../AuthPassword';
|
||||
|
||||
const PWNED_PASSWORD = 'fluxer-prefix-592';
|
||||
const SAFE_PASSWORD_SAME_PREFIX = 'fluxer-prefix-837';
|
||||
const HANGING_RESPONSE_MS = 8000;
|
||||
|
||||
const ctx = {} as unknown as ApiContext;
|
||||
|
||||
function sha1(password: string): string {
|
||||
return crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
function prefixOf(password: string): string {
|
||||
return sha1(password).slice(0, 5);
|
||||
}
|
||||
|
||||
function suffixOf(password: string): string {
|
||||
return sha1(password).slice(5);
|
||||
}
|
||||
|
||||
function rangeBody(pwnedSuffixes: Array<string>): string {
|
||||
const padded = ['0'.repeat(35), '1'.repeat(35)].map((suffix) => `${suffix}:0`);
|
||||
return [...pwnedSuffixes.map((suffix) => `${suffix}:42`), ...padded].join('\r\n');
|
||||
}
|
||||
|
||||
function rangeHandler(requestedPrefixes: Array<string>, pwnedSuffixes: Array<string>) {
|
||||
return http.get('https://api.pwnedpasswords.com/range/:prefix', ({params}) => {
|
||||
requestedPrefixes.push(String(params.prefix));
|
||||
return HttpResponse.text(rangeBody(pwnedSuffixes), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'text/plain; charset=utf-8'},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hangingRangeHandler() {
|
||||
return http.get('https://api.pwnedpasswords.com/range/:prefix', async () => {
|
||||
await delay(HANGING_RESPONSE_MS);
|
||||
return HttpResponse.text('');
|
||||
});
|
||||
}
|
||||
|
||||
describe('isPasswordPwned', () => {
|
||||
beforeEach(() => {
|
||||
resetPwnedPasswordCacheForTesting();
|
||||
});
|
||||
test('the fixture passwords are distinct but share a range prefix', () => {
|
||||
expect(PWNED_PASSWORD).not.toBe(SAFE_PASSWORD_SAME_PREFIX);
|
||||
expect(prefixOf(PWNED_PASSWORD)).toBe(prefixOf(SAFE_PASSWORD_SAME_PREFIX));
|
||||
});
|
||||
test('reports a breached password from the range response', async () => {
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(true);
|
||||
expect(requestedPrefixes).toEqual([prefixOf(PWNED_PASSWORD)]);
|
||||
});
|
||||
test('two passwords sharing a prefix trigger a single upstream call', async () => {
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(true);
|
||||
await expect(isPasswordPwned(ctx, SAFE_PASSWORD_SAME_PREFIX)).resolves.toBe(false);
|
||||
expect(requestedPrefixes).toHaveLength(1);
|
||||
});
|
||||
test('fails open on a non-OK response', async () => {
|
||||
server.use(http.get('https://api.pwnedpasswords.com/range/:prefix', () => HttpResponse.text('', {status: 503})));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
|
||||
});
|
||||
test('fails open the same way when the lookup times out, without caching the prefix', async () => {
|
||||
server.use(hangingRangeHandler());
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(true);
|
||||
expect(requestedPrefixes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {AttachmentID, ChannelID, EmojiID, GuildID, MessageID, UserID} from '../BrandedTypes';
|
||||
import type {ChannelRow} from '../database/types/ChannelTypes';
|
||||
import type {MessageRow} from '../database/types/MessageTypes';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../models/Channel';
|
||||
import type {Message} from '../models/Message';
|
||||
import type {MessageReaction} from '../models/MessageReaction';
|
||||
@@ -12,9 +13,9 @@ import {ChannelRepository as NewChannelRepository} from './repositories/ChannelR
|
||||
export class ChannelRepository extends IChannelRepository {
|
||||
private repository: NewChannelRepository;
|
||||
|
||||
constructor() {
|
||||
constructor(requestCache?: RequestCache) {
|
||||
super();
|
||||
this.repository = new NewChannelRepository();
|
||||
this.repository = new NewChannelRepository(requestCache);
|
||||
}
|
||||
|
||||
get channelData() {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
ScheduledMessageRequestSchema,
|
||||
ScheduledMessageResponseSchema,
|
||||
} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
|
||||
import {createChannelID} from '../../BrandedTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
import {parseScheduledMessageInput} from './ScheduledMessageParsing';
|
||||
|
||||
export function ScheduledMessageController(app: HonoApp) {
|
||||
app.post(
|
||||
'/channels/:channel_id/messages/schedule',
|
||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_MESSAGE_CREATE),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', ChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'schedule_message',
|
||||
summary: 'Schedule a message to send later',
|
||||
description:
|
||||
'Schedules a message to be sent at a specified time. Only available for regular user accounts. Requires permission to send messages in the target channel. Message is sent automatically at the scheduled time. Returns the scheduled message object with delivery time.',
|
||||
responseSchema: ScheduledMessageResponseSchema,
|
||||
requestSchema: ScheduledMessageRequestSchema,
|
||||
requestFormSchema: ScheduledMessageRequestSchema,
|
||||
statusCode: 201,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Channels',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const channelId = createChannelID(ctx.req.valid('param').channel_id);
|
||||
const scheduledMessageService = ctx.get('scheduledMessageService');
|
||||
const {message, scheduledLocalAt, timezone} = await parseScheduledMessageInput({
|
||||
ctx,
|
||||
user,
|
||||
channelId,
|
||||
});
|
||||
const scheduledMessage = await scheduledMessageService.createScheduledMessage({
|
||||
user,
|
||||
channelId,
|
||||
data: message,
|
||||
scheduledLocalAt,
|
||||
timezone,
|
||||
});
|
||||
return ctx.json(scheduledMessage.toResponse(), 201);
|
||||
},
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user