mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-11 05:02:24 +09:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d81dd407 | ||
|
|
6ef0f1fbe1 | ||
|
|
2106102fc5 | ||
|
|
e2d7460f14 | ||
|
|
e956e6fb4a | ||
|
|
4e9b8fa1a6 | ||
|
|
fca5f63b9e | ||
|
|
ea1fac588a | ||
|
|
fb4fd19d01 | ||
|
|
cb64c05129 | ||
|
|
72fd1728d1 | ||
|
|
6497e396c5 | ||
|
|
2943a8fe46 | ||
|
|
18cb423e95 | ||
|
|
6dcc137d0e | ||
|
|
8ff2eb0ca7 | ||
|
|
6e4c055ebd | ||
|
|
3588090f22 | ||
|
|
237b8ddfbf | ||
|
|
3dab64d040 | ||
|
|
0e4e03b879 | ||
|
|
b8218f906b | ||
|
|
dd6dd827b5 | ||
|
|
7922876b81 | ||
|
|
152f64aac7 | ||
|
|
08566fc244 | ||
|
|
beb906753f | ||
|
|
8a9b12e6a1 | ||
|
|
01432bc682 | ||
|
|
d56c1e5674 | ||
|
|
70509fb978 | ||
|
|
ab46d16d12 | ||
|
|
27f459e6bd | ||
|
|
5cc92469aa | ||
|
|
09ea748479 | ||
|
|
614ee3a54b | ||
|
|
7be5b0589d | ||
|
|
42cdc1f877 | ||
|
|
0c291a01da | ||
|
|
dba1ba1112 | ||
|
|
f7324ee73c | ||
|
|
10fc79ab37 | ||
|
|
f88b0f69b2 |
@@ -79,8 +79,6 @@ RUN apt-get update \
|
||||
rpm \
|
||||
unzip \
|
||||
webp \
|
||||
xauth \
|
||||
xvfb \
|
||||
xz-utils \
|
||||
xdg-utils \
|
||||
zstd \
|
||||
@@ -99,8 +97,6 @@ RUN apt-get update \
|
||||
hyperfine \
|
||||
iproute2 \
|
||||
iputils-ping \
|
||||
kind \
|
||||
kubernetes-client \
|
||||
lldb \
|
||||
lsof \
|
||||
ltrace \
|
||||
@@ -177,7 +173,6 @@ COPY --from=seaweedfs /usr/bin/weed /usr/local/bin/weed
|
||||
USER ${USERNAME}
|
||||
|
||||
ENV DOCKER_HOST="unix:///var/run/docker.sock" \
|
||||
KUBECONFIG="/workspaces/fluxer/.fluxer/k8s/local-kubeconfig" \
|
||||
PATH="/home/${USERNAME}/.cargo/bin:${PATH}" \
|
||||
PNPM_HOME="/home/${USERNAME}/.local/share/pnpm"
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
"shutdownAction": "stopCompose",
|
||||
"remoteUser": "vscode",
|
||||
"remoteEnv": {
|
||||
"DOCKER_HOST": "unix:///var/run/docker.sock",
|
||||
"KUBECONFIG": "/workspaces/fluxer/.fluxer/k8s/local-kubeconfig"
|
||||
"DOCKER_HOST": "unix:///var/run/docker.sock"
|
||||
},
|
||||
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
|
||||
"forwardPorts": [
|
||||
|
||||
@@ -16,7 +16,6 @@ services:
|
||||
FLUXER_POSTGRES_HOST: postgres
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
DOCKER_HOST: unix:///var/run/docker.sock
|
||||
KUBECONFIG: /workspaces/fluxer/.fluxer/k8s/local-kubeconfig
|
||||
volumes:
|
||||
- ..:/workspaces/fluxer:cached
|
||||
- type: volume
|
||||
@@ -39,11 +38,6 @@ services:
|
||||
target: /workspaces/fluxer/fluxer_desktop/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-marketing-node-modules
|
||||
target: /workspaces/fluxer/fluxer_marketing/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-admin-node-modules
|
||||
target: /workspaces/fluxer/fluxer_admin/node_modules
|
||||
@@ -359,7 +353,6 @@ volumes:
|
||||
fluxer-api-node-modules:
|
||||
fluxer-app-node-modules:
|
||||
fluxer-desktop-node-modules:
|
||||
fluxer-marketing-node-modules:
|
||||
fluxer-admin-node-modules:
|
||||
package-config-node-modules:
|
||||
package-constants-node-modules:
|
||||
|
||||
@@ -71,12 +71,10 @@ stage "rust: fmt" cargo fmt --all -- --check
|
||||
if [ "$QUICK" -eq 0 ]; then
|
||||
stage "rust: clippy (workspace)" cargo clippy --workspace --all-targets -- -D warnings
|
||||
else
|
||||
stage "rust: clippy (servers)" cargo clippy -p fluxer_app_proxy -p fluxer_admin -p fluxer_marketing --all-targets -- -D warnings
|
||||
stage "rust: clippy (servers)" cargo clippy -p fluxer_app_proxy -p fluxer_admin --all-targets -- -D warnings
|
||||
fi
|
||||
stage "rust: app proxy tests" cargo test -p fluxer_app_proxy
|
||||
|
||||
stage "integration: font serving" cargo run -q -p fluxer-dev -- font-serving-it
|
||||
|
||||
echo
|
||||
echo "---------------------------------------------"
|
||||
echo "${PASSED} stages passed, ${#FAILURES[@]} failed"
|
||||
|
||||
+3
-7
@@ -5,8 +5,11 @@
|
||||
/.direnv/
|
||||
/.fluxer/
|
||||
/.git/
|
||||
**/.git
|
||||
**/.git/**
|
||||
/.github/
|
||||
/.pnpm-store/
|
||||
/fluxer_marketing
|
||||
|
||||
**/.env
|
||||
**/.env.*.local
|
||||
@@ -27,11 +30,6 @@
|
||||
**/target/
|
||||
**/test-results.json
|
||||
/fluxer_docs/site/
|
||||
/tools/integration/integration-driver/Cargo.lock
|
||||
/tools/integration/integration-driver/target-*/
|
||||
/tools/integration/results/*.json
|
||||
/tools/integration/results/latest-summary.txt
|
||||
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
@@ -51,8 +49,6 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/release-input/
|
||||
/release-out/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/.github/CODEOWNERS @fluxerapp/developers
|
||||
/.github/workflows/ @fluxerapp/developers
|
||||
/fluxer_marketing @fluxerapp/developers
|
||||
/.gitmodules @fluxerapp/developers
|
||||
/.github/workflows/dispatch-private-marketing-build.yaml @fluxerapp/developers
|
||||
/packages/i18n/marketing/ @fluxerapp/developers
|
||||
/scripts/setup-private-marketing.sh @fluxerapp/developers
|
||||
@@ -89,3 +89,21 @@ Submit translations through [Weblate](https://weblate.fluxer.tools), not through
|
||||
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
|
||||
## Private marketing project
|
||||
|
||||
The marketing implementation is maintained in a private repository at the `fluxer_marketing` submodule path. The public workspace, bootstrap, checks, and development stack work without initializing it.
|
||||
|
||||
Authorized maintainers can initialize only that submodule and install its independent dependencies:
|
||||
|
||||
```sh
|
||||
./scripts/setup-private-marketing.sh
|
||||
pnpm --dir fluxer_marketing install --frozen-lockfile
|
||||
cargo metadata --locked --manifest-path fluxer_marketing/Cargo.toml
|
||||
```
|
||||
|
||||
To run the private marketing service in the local development stack and direct application links to it, add this override to the ignored `config/env/local.env` file:
|
||||
|
||||
```sh
|
||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
||||
```
|
||||
|
||||
@@ -24,7 +24,9 @@ f:gateway:
|
||||
- any-glob-to-any-file: fluxer_gateway/**/*
|
||||
f:marketing:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_marketing/**/*
|
||||
- any-glob-to-any-file:
|
||||
- fluxer_marketing
|
||||
- packages/i18n/marketing/**/*
|
||||
f:media_proxy:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_media_proxy/**/*
|
||||
|
||||
@@ -32,17 +32,15 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this image fragment is uploaded. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ inputs.image }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -55,6 +53,8 @@ jobs:
|
||||
name: resolve metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
@@ -79,6 +79,10 @@ jobs:
|
||||
needs: meta
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -96,7 +100,7 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ github.token }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
@@ -119,6 +123,9 @@ jobs:
|
||||
needs: [meta, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -132,71 +139,45 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ github.token }}
|
||||
- name: create and push multi-arch manifest
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
|
||||
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: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish
|
||||
--component "${{ inputs.image }}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
- 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=( "-t" "${IMAGE}:${VERSION}" )
|
||||
tag_args=()
|
||||
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
|
||||
for raw in "${moving[@]}"; do
|
||||
t="$(echo "$raw" | xargs)"
|
||||
[ -n "$t" ] && tag_args+=( "-t" "${IMAGE}:${t}" )
|
||||
tag="$(echo "$raw" | xargs)"
|
||||
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
|
||||
done
|
||||
docker buildx imagetools create "${tag_args[@]}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Write GitHub release image fragment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE_REF: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
MOVING_TAGS: ${{ inputs.moving-tags }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-image
|
||||
--build-version "${VERSION}"
|
||||
--image "${{ inputs.image }}"
|
||||
--image-ref "${IMAGE_REF}"
|
||||
--moving-tags "${MOVING_TAGS}"
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-${{ inputs.image }}
|
||||
path: release-out/fragments/fluxer-release-fragment-image-${{ inputs.image }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release
|
||||
if: ${{ inputs['finalise-release'] }}
|
||||
needs: [meta, merge]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
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: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: finalise GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${VERSION}"
|
||||
if (( ${#tag_args[@]} > 0 )); then
|
||||
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
|
||||
fi
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-admin
|
||||
dockerfile: fluxer_admin/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-api
|
||||
dockerfile: fluxer_api/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,12 +28,10 @@ jobs:
|
||||
|
||||
build:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-app-proxy-self-hosted
|
||||
dockerfile: fluxer_app_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
extra-build-args: |
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
|
||||
@@ -9,41 +9,23 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-fluxer-app-proxy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -54,9 +36,10 @@ jobs:
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
@@ -79,6 +62,10 @@ jobs:
|
||||
needs: meta
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -117,13 +104,6 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
|
||||
--step generate_asset_manifest
|
||||
|
||||
- name: Upload asset manifest handoff
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: app-proxy-assets-manifest
|
||||
path: app-dist-output/dist/assets-manifest.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -134,18 +114,15 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
|
||||
--step upload_assets
|
||||
|
||||
- name: verify bundled faces are CORS-usable from the app origin
|
||||
run: >-
|
||||
deploy/scripts/verify-asset-cors.sh
|
||||
--endpoint https://fluxerstatic.com
|
||||
--origin https://fluxer.app
|
||||
--manifest app-dist-output/dist/assets-manifest.txt
|
||||
|
||||
build-arm64:
|
||||
name: build app-proxy (arm64)
|
||||
needs: meta
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -179,6 +156,9 @@ jobs:
|
||||
needs: [meta, build, build-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -187,11 +167,6 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download app-proxy asset manifest
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
name: app-proxy-assets-manifest
|
||||
path: release-input/app-proxy
|
||||
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
|
||||
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
|
||||
with:
|
||||
@@ -208,58 +183,30 @@ jobs:
|
||||
echo "amd64 digest: ${amd64_digest}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:v1" \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${IMAGE}@${amd64_digest}" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Write GitHub release app-proxy fragment
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-app-proxy
|
||||
publish
|
||||
--component fluxer-app-proxy
|
||||
--build-version "${VERSION}"
|
||||
--image fluxer-app-proxy
|
||||
--image-ref "${IMAGE_REF}"
|
||||
--moving-tags "v1,latest"
|
||||
--asset-manifest release-input/app-proxy/assets-manifest.txt
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-fluxer-app-proxy
|
||||
path: release-out/fragments/fluxer-release-fragment-app-proxy.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release
|
||||
if: ${{ inputs['finalise-release'] != false }}
|
||||
needs: [meta, merge]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- name: Advance moving image tags
|
||||
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: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: finalise GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${VERSION}"
|
||||
docker buildx imagetools create
|
||||
-t "${IMAGE}:v1"
|
||||
-t "${IMAGE}:latest"
|
||||
"${IMAGE}:${VERSION}"
|
||||
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
skip_targets:
|
||||
description: Comma-separated platforms or targets to skip, such as windows, macos-arm64, linux-x64.
|
||||
description: Comma-separated platforms or targets to skip, such as windows, macos, linux-x64.
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -46,6 +46,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
@@ -81,6 +83,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
@@ -107,6 +111,10 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||
@@ -233,7 +241,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
targets: ${{ matrix.platform == 'macos' && (matrix.arch == 'arm64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin') || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||
|
||||
- name: Install MSVC ARM64 build tools
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
@@ -460,8 +468,8 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_artifacts_unix
|
||||
|
||||
- name: Normalize updater YAML (arm64)
|
||||
if: matrix.arch == 'arm64'
|
||||
- name: Normalize updater YAML (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step normalise_updater_yaml
|
||||
@@ -492,6 +500,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
DISPLAY_CHANNEL: ${{ needs.meta.outputs.channel }}
|
||||
@@ -544,34 +554,14 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_summary
|
||||
|
||||
- name: Write GitHub release desktop fragment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-desktop
|
||||
--build-version "${{ needs.meta.outputs.version }}"
|
||||
--channel "${{ needs.meta.outputs.build_channel }}"
|
||||
--test-build "${{ needs.meta.outputs.test_build }}"
|
||||
--s3-prefix "${{ needs.meta.outputs.s3_prefix }}"
|
||||
--payload-root s3_payload
|
||||
--source-sha "${{ needs.meta.outputs.source_sha }}"
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-desktop
|
||||
path: release-out/fragments/fluxer-release-fragment-desktop-${{ needs.meta.outputs.build_channel }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Cleanup S3 handoff
|
||||
if: ${{ success() }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step cleanup_handoff
|
||||
|
||||
finalise_release:
|
||||
name: Finalise GitHub desktop release
|
||||
publish_release:
|
||||
name: Publish GitHub desktop release
|
||||
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
|
||||
needs:
|
||||
- meta
|
||||
@@ -579,6 +569,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -589,18 +581,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
|
||||
- name: Finalise GitHub desktop release
|
||||
- name: Publish GitHub desktop release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ needs.meta.outputs.version }}"
|
||||
--source-sha "${{ needs.meta.outputs.source_sha }}"
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_args=(
|
||||
release publish
|
||||
--component "fluxer-desktop-${CHANNEL}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,12 +28,9 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-docs
|
||||
dockerfile: fluxer_docs/Dockerfile
|
||||
context: fluxer_docs
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-gateway
|
||||
dockerfile: fluxer_gateway/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-gifs
|
||||
dockerfile: fluxer_gifs/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: build marketing
|
||||
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Build release approved."
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-marketing
|
||||
dockerfile: fluxer_marketing/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-media-proxy
|
||||
dockerfile: fluxer_media_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-messages
|
||||
dockerfile: fluxer_messages/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-snowflakes
|
||||
dockerfile: fluxer_snowflakes/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-static
|
||||
dockerfile: fluxer_static/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-unfurl
|
||||
dockerfile: fluxer_unfurl/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
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: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-users
|
||||
dockerfile: fluxer_users/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,473 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: deploy service
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: "Helm chart name to deploy"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- api
|
||||
- app-proxy
|
||||
- admin
|
||||
- docs
|
||||
- marketing
|
||||
- media-proxy
|
||||
- gateway
|
||||
- messages
|
||||
- search
|
||||
- snowflakes
|
||||
- users
|
||||
- unfurl
|
||||
- uploads
|
||||
- worker
|
||||
channel:
|
||||
description: "Release channel (stable or canary)"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- stable
|
||||
- canary
|
||||
image-tag:
|
||||
description: "Docker image tag to deploy (Fluxer CalVer: YYYY.MDD.MICRO)"
|
||||
type: string
|
||||
required: true
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version to inject into runtime env vars"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow-rollback:
|
||||
description: "Allow deploying an older image tag than the newest GHCR tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
service:
|
||||
description: "Helm chart name to deploy"
|
||||
type: string
|
||||
required: true
|
||||
channel:
|
||||
description: "Release channel (stable or canary)"
|
||||
type: string
|
||||
required: true
|
||||
image-tag:
|
||||
description: "Docker image tag to deploy (Fluxer CalVer: YYYY.MDD.MICRO)"
|
||||
type: string
|
||||
required: true
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version to inject into runtime env vars"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow-rollback:
|
||||
description: "Allow deploying an older image tag than the newest GHCR tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
KUBE_CONFIG:
|
||||
required: true
|
||||
GHCR_USERNAME:
|
||||
required: false
|
||||
GHCR_TOKEN:
|
||||
required: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy ${{ inputs.service }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
environment: ${{ inputs.channel }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: install helm
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310
|
||||
|
||||
- name: configure kubectl
|
||||
shell: bash
|
||||
env:
|
||||
KUBE_CONFIG_B64: ${{ secrets.KUBE_CONFIG }}
|
||||
run: |
|
||||
mkdir -p "$HOME/.kube"
|
||||
printf '%s' "$KUBE_CONFIG_B64" | base64 -d > "$HOME/.kube/config"
|
||||
chmod 600 "$HOME/.kube/config"
|
||||
|
||||
- name: resolve helm args
|
||||
id: helm
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_SERVICE: ${{ inputs.service }}
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
INPUT_IMAGE_TAG: ${{ inputs['image-tag'] }}
|
||||
INPUT_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: |
|
||||
SERVICE="$INPUT_SERVICE"
|
||||
CHANNEL="$INPUT_CHANNEL"
|
||||
TAG="$INPUT_IMAGE_TAG"
|
||||
BUILD_VERSION="$INPUT_BUILD_VERSION"
|
||||
GHCR_REGISTRY="${GHCR_REGISTRY:?GHCR_REGISTRY is required}"
|
||||
if [[ -z "$BUILD_VERSION" ]]; then
|
||||
BUILD_VERSION="$TAG"
|
||||
fi
|
||||
CALVER_RE='^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.(0|[1-9][0-9]{0,5})$'
|
||||
if [[ ! "$TAG" =~ $CALVER_RE ]]; then
|
||||
echo "::error::image-tag must be a Fluxer CalVer tag (YYYY.MDD.MICRO). Channel tags, latest tags, and suffixed tags are not deployable."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$BUILD_VERSION" =~ $CALVER_RE ]]; then
|
||||
echo "::error::build-version must be a Fluxer CalVer value (YYYY.MDD.MICRO)."
|
||||
exit 1
|
||||
fi
|
||||
CHART_DIR="./deploy/helm/${SERVICE}"
|
||||
VALUES_ARGS="-f ${CHART_DIR}/values.yaml"
|
||||
SETS=""
|
||||
BUILD_PATHS=""
|
||||
DEPLOY_IMAGE=""
|
||||
SYNC_WORKER_RELEASE=""
|
||||
SYNC_WORKER_CHART_DIR=""
|
||||
SYNC_WORKER_VALUES_ARGS=""
|
||||
SYNC_WORKER_SETS=""
|
||||
case "$SERVICE" in
|
||||
uploads)
|
||||
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::uploads deployments are stable-only (single relay serves both channels)."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-uploads"
|
||||
DEPLOY_IMAGE="fluxer-media-proxy"
|
||||
SETS="--set-string app.name=uploads --set-string app.image=fluxer-media-proxy --set-string app.tag=${TAG} --set-string app.config=stable"
|
||||
SETS="${SETS} --set-string app.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string app.build.channel=stable"
|
||||
;;
|
||||
api|app-proxy|admin|docs|marketing)
|
||||
BASE_IMAGE="fluxer-${SERVICE}"
|
||||
if [[ "$SERVICE" == "docs" && "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::docs deployments are stable-only."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CHANNEL" == "canary" ]]; then
|
||||
NAME="${SERVICE}-canary"
|
||||
else
|
||||
NAME="${SERVICE}"
|
||||
fi
|
||||
DEPLOY_IMAGE="${BASE_IMAGE}"
|
||||
RELEASE="fluxer-${SERVICE}-${CHANNEL}"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.${CHANNEL}.prod.yaml"
|
||||
SETS="--set-string app.name=${NAME} --set-string app.image=${DEPLOY_IMAGE} --set-string app.tag=${TAG}"
|
||||
SETS="${SETS} --set-string app.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string app.build.channel=${CHANNEL}"
|
||||
;;
|
||||
media-proxy)
|
||||
if [[ "$CHANNEL" != "canary" ]]; then
|
||||
echo "::error::Media-proxy deployments are only supported on the canary lane."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-media-proxy"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string mediaProxy.image=fluxer-media-proxy --set-string staticProxy.image=fluxer-media-proxy --set-string mediaProxy.tag=${TAG} --set-string staticProxy.tag=${TAG} --set mediaProxy.replicas=16 --set staticProxy.replicas=4 --set-string mediaProxy.nsfwServiceEndpoint=http://int.flx-nyc-misc1.srv.fluxer.dev:8000"
|
||||
BUILD_PATHS="mediaProxy staticProxy"
|
||||
;;
|
||||
gateway)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::gateway deployments are stable-only."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-gateway"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string gateway.image=${DEPLOY_IMAGE} --set-string gateway.tag=${TAG}"
|
||||
BUILD_PATHS="gateway"
|
||||
;;
|
||||
worker)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::Worker deployments are only supported on the stable lane."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-api"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string workerRealtime.image=fluxer-api --set-string workerUnfurl.image=fluxer-api --set-string workerLifecycle.image=fluxer-api --set-string workerBatch.image=fluxer-api --set-string workerRealtime.tag=${TAG} --set-string workerUnfurl.tag=${TAG} --set-string workerLifecycle.tag=${TAG} --set-string workerBatch.tag=${TAG}"
|
||||
BUILD_PATHS="workerRealtime workerUnfurl workerLifecycle workerBatch"
|
||||
;;
|
||||
messages|search|snowflakes|users|unfurl)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::Shared microservice deployments are stable-only; canary traffic selection is done by the callers."
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_IMAGE="fluxer-${SERVICE}"
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string svc.image=${DEPLOY_IMAGE} --set-string svc.tag=${TAG}"
|
||||
SETS="${SETS} --set-string svc.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string svc.build.channel=stable"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown service chart: ${SERVICE}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
for BUILD_PATH in $BUILD_PATHS; do
|
||||
SETS="${SETS} --set-string ${BUILD_PATH}.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string ${BUILD_PATH}.build.channel=${CHANNEL}"
|
||||
done
|
||||
SETS="--set-string global.registry=${GHCR_REGISTRY} ${SETS}"
|
||||
if [[ "$SERVICE" == "api" && "$CHANNEL" == "canary" ]]; then
|
||||
SYNC_WORKER_RELEASE="fluxer-worker"
|
||||
SYNC_WORKER_CHART_DIR="./deploy/helm/worker"
|
||||
SYNC_WORKER_VALUES_ARGS="-f ${SYNC_WORKER_CHART_DIR}/values.yaml -f ${SYNC_WORKER_CHART_DIR}/values.prod.yaml"
|
||||
SYNC_WORKER_SETS="--set-string workerRealtime.image=fluxer-api --set-string workerUnfurl.image=fluxer-api --set-string workerLifecycle.image=fluxer-api --set-string workerBatch.image=fluxer-api"
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string workerRealtime.tag=${TAG} --set-string workerUnfurl.tag=${TAG} --set-string workerLifecycle.tag=${TAG} --set-string workerBatch.tag=${TAG}"
|
||||
for BUILD_PATH in workerRealtime workerUnfurl workerLifecycle workerBatch; do
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string ${BUILD_PATH}.build.version=${BUILD_VERSION}"
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string ${BUILD_PATH}.build.channel=${CHANNEL}"
|
||||
done
|
||||
SYNC_WORKER_SETS="--set-string global.registry=${GHCR_REGISTRY} ${SYNC_WORKER_SETS}"
|
||||
fi
|
||||
{
|
||||
echo "chart-dir=${CHART_DIR}"
|
||||
echo "release=${RELEASE}"
|
||||
echo "values-args=${VALUES_ARGS}"
|
||||
echo "sets=${SETS}"
|
||||
echo "deploy-image=${DEPLOY_IMAGE}"
|
||||
echo "deploy-tag=${TAG}"
|
||||
echo "sync-worker-release=${SYNC_WORKER_RELEASE}"
|
||||
echo "sync-worker-chart-dir=${SYNC_WORKER_CHART_DIR}"
|
||||
echo "sync-worker-values-args=${SYNC_WORKER_VALUES_ARGS}"
|
||||
echo "sync-worker-sets=${SYNC_WORKER_SETS}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: helm dependency update
|
||||
shell: bash
|
||||
run: |
|
||||
helm dependency update "${{ steps.helm.outputs.chart-dir }}"
|
||||
if [[ -n "${{ steps.helm.outputs.sync-worker-chart-dir }}" ]]; then
|
||||
helm dependency update "${{ steps.helm.outputs.sync-worker-chart-dir }}"
|
||||
fi
|
||||
|
||||
- name: prepare docker config
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
run: |
|
||||
echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config" >> "$GITHUB_ENV"
|
||||
mkdir -p "${RUNNER_TEMP}/docker-config"
|
||||
|
||||
- name: configure ghcr auth
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
auth="$(printf '%s:%s' "$GHCR_USERNAME" "$GHCR_TOKEN" | base64 | tr -d '\n')"
|
||||
printf '{"auths":{"ghcr.io":{"auth":"%s"}}}\n' "$auth" > "$DOCKER_CONFIG/config.json"
|
||||
|
||||
- name: verify deploy image exists
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
run: |
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
echo "Verifying ${IMAGE_REF}"
|
||||
docker manifest inspect "${IMAGE_REF}" > /dev/null
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
- name: verify api deploy uses latest image
|
||||
if: ${{ steps.helm.outputs.deploy-image == 'fluxer-api' && !inputs['allow-rollback'] }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GHCR_OWNER: ${{ env.GHCR_OWNER }}
|
||||
DEPLOY_TAG: ${{ steps.helm.outputs.deploy-tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CALVER_RE='^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.(0|[1-9][0-9]{0,5})$'
|
||||
OWNER_TYPE="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL:-https://api.github.com}/repos/${GITHUB_REPOSITORY}" \
|
||||
| jq -r '.owner.type'
|
||||
)"
|
||||
case "$OWNER_TYPE" in
|
||||
Organization) PACKAGE_OWNER_PATH="orgs/${GHCR_OWNER}" ;;
|
||||
User) PACKAGE_OWNER_PATH="users/${GHCR_OWNER}" ;;
|
||||
*)
|
||||
echo "::error::Unsupported GitHub owner type for package lookup: ${OWNER_TYPE}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
LATEST_TAG="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL:-https://api.github.com}/${PACKAGE_OWNER_PATH}/packages/container/fluxer-api/versions?per_page=100" \
|
||||
| jq -r --arg re "$CALVER_RE" '
|
||||
[.[].metadata.container.tags[]? |
|
||||
select(test($re)) |
|
||||
{tag: ., parts: (split(".") | map(tonumber))}
|
||||
] | max_by(.parts) | .tag // empty
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ -z "$LATEST_TAG" ]]; then
|
||||
echo "::error::Could not resolve the latest fluxer-api CalVer tag from GHCR."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DEPLOY_TAG" != "$LATEST_TAG" ]]; then
|
||||
echo "::error::Refusing to deploy fluxer-api:${DEPLOY_TAG}; latest GHCR tag is fluxer-api:${LATEST_TAG}. Re-run with allow-rollback=true only for an intentional rollback."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: approve api image for admission policy
|
||||
if: ${{ inputs.service == 'api' }}
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
DEPLOYMENT="api"
|
||||
if [[ "$INPUT_CHANNEL" == "canary" ]]; then
|
||||
DEPLOYMENT="api-canary"
|
||||
fi
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
PREVIOUS_IMAGE="$(kubectl -n fluxer get deployment "$DEPLOYMENT" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true)"
|
||||
PREVIOUS_TAG=""
|
||||
if [[ -n "$PREVIOUS_IMAGE" && "$PREVIOUS_IMAGE" != "$IMAGE_REF" && "$PREVIOUS_IMAGE" == *:* ]]; then
|
||||
PREVIOUS_TAG="${PREVIOUS_IMAGE##*:}"
|
||||
else
|
||||
PREVIOUS_IMAGE=""
|
||||
fi
|
||||
kubectl -n fluxer create configmap fluxer-api-approved-image \
|
||||
--from-literal=tag="${{ steps.helm.outputs.deploy-tag }}" \
|
||||
--from-literal=image="${IMAGE_REF}" \
|
||||
--from-literal=previousTag="${PREVIOUS_TAG}" \
|
||||
--from-literal=previousImage="${PREVIOUS_IMAGE}" \
|
||||
--dry-run=client -o yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
- name: ensure api admission policy
|
||||
if: ${{ inputs.service == 'api' }}
|
||||
shell: bash
|
||||
run: kubectl apply -f deploy/k8s/fluxer-api-approved-image-policy.yaml
|
||||
|
||||
- name: helm upgrade
|
||||
shell: bash
|
||||
run: |
|
||||
RELEASE="${{ steps.helm.outputs.release }}"
|
||||
CHART_DIR="${{ steps.helm.outputs.chart-dir }}"
|
||||
VALUES_ARGS="${{ steps.helm.outputs.values-args }}"
|
||||
SETS="${{ steps.helm.outputs.sets }}"
|
||||
wait_for_release_idle() {
|
||||
local release="$1"
|
||||
local max_checks="$2"
|
||||
local check=0
|
||||
local status="unknown"
|
||||
while (( check < max_checks )); do
|
||||
check=$((check + 1))
|
||||
status=$(helm status "$release" -n fluxer -o json 2>/dev/null | jq -r '.info.status // "unknown"' || echo "unknown")
|
||||
if [[ "$status" != pending-* ]]; then
|
||||
echo "Release ${release} is ${status}; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "Release ${release} is ${status}; waiting 10s (${check}/${max_checks})."
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::Release ${release} still ${status} after ${max_checks} checks; forcing rollback."
|
||||
if helm rollback "$release" -n fluxer --wait --timeout 5m 2>&1; then
|
||||
echo "Rollback succeeded; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "::error::Release ${release} is stuck in ${status} and rollback failed."
|
||||
return 1
|
||||
}
|
||||
helm_upgrade_with_retries() {
|
||||
local release="$1"
|
||||
local chart_dir="$2"
|
||||
local values_args="$3"
|
||||
local sets="$4"
|
||||
local values_args_array=()
|
||||
local sets_array=()
|
||||
read -r -a values_args_array <<< "$values_args"
|
||||
read -r -a sets_array <<< "$sets"
|
||||
wait_for_release_idle "$release" 18
|
||||
local max_attempts=4
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
echo "Running helm upgrade for ${release}, attempt ${attempt}/${max_attempts}."
|
||||
set +e
|
||||
upgrade_output=$(helm upgrade --install "$release" \
|
||||
"$chart_dir" \
|
||||
"${values_args_array[@]}" \
|
||||
-n fluxer \
|
||||
"${sets_array[@]}" \
|
||||
--wait --timeout 20m --atomic --history-max 10 2>&1)
|
||||
exit_code=$?
|
||||
set -e
|
||||
printf '%s\n' "$upgrade_output"
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! grep -q "another operation (install/upgrade/rollback) is in progress" <<< "$upgrade_output"; then
|
||||
return "$exit_code"
|
||||
fi
|
||||
if [[ $attempt -eq $max_attempts ]]; then
|
||||
echo "::error::Helm upgrade failed for ${release} after ${max_attempts} attempts because another operation remained in progress."
|
||||
return "$exit_code"
|
||||
fi
|
||||
wait_for_release_idle "$release" 18
|
||||
done
|
||||
}
|
||||
helm_upgrade_with_retries "$RELEASE" "$CHART_DIR" "$VALUES_ARGS" "$SETS"
|
||||
if [[ -n "${{ steps.helm.outputs.sync-worker-release }}" ]]; then
|
||||
helm_upgrade_with_retries \
|
||||
"${{ steps.helm.outputs.sync-worker-release }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-chart-dir }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-values-args }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-sets }}"
|
||||
fi
|
||||
|
||||
- name: seal api admission approved image
|
||||
if: ${{ success() && inputs.service == 'api' }}
|
||||
shell: bash
|
||||
run: |
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
kubectl -n fluxer create configmap fluxer-api-approved-image \
|
||||
--from-literal=tag="${{ steps.helm.outputs.deploy-tag }}" \
|
||||
--from-literal=image="${IMAGE_REF}" \
|
||||
--from-literal=previousTag="" \
|
||||
--from-literal=previousImage="" \
|
||||
--dry-run=client -o yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
- name: recover stuck release on failure
|
||||
if: failure() || cancelled()
|
||||
shell: bash
|
||||
run: |
|
||||
RELEASE="${{ steps.helm.outputs.release }}"
|
||||
for RELEASE in "$RELEASE" "${{ steps.helm.outputs.sync-worker-release }}"; do
|
||||
if [[ -z "$RELEASE" ]]; then
|
||||
continue
|
||||
fi
|
||||
STATUS=$(helm status "$RELEASE" -n fluxer -o json 2>/dev/null | jq -r '.info.status' 2>/dev/null || echo "unknown")
|
||||
if [[ "$STATUS" == "pending-upgrade" || "$STATUS" == "pending-install" || "$STATUS" == "pending-rollback" ]]; then
|
||||
echo "::warning::Release ${RELEASE} stuck in ${STATUS}, rolling back..."
|
||||
helm rollback "$RELEASE" -n fluxer --wait --timeout 5m || true
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,220 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: Dispatch private marketing build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_marketing
|
||||
- Cargo.toml
|
||||
- fluxer_common/**
|
||||
- packages/fonts/manifest.json
|
||||
- packages/fonts/NOTICE.md
|
||||
- packages/fonts/LICENSE-IBM-PLEX.txt
|
||||
- packages/fonts/css/locale-fallbacks.css
|
||||
- packages/fonts/files/FluxerSans/**
|
||||
- packages/fonts/files/FluxerMono/**
|
||||
- packages/fonts/marketing/**
|
||||
- packages/i18n/marketing/**
|
||||
- fluxer_static/marketing/branding/**
|
||||
- .github/workflows/dispatch-private-marketing-build.yaml
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: private-marketing-dispatch
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: resolve exact private build metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
parent_sha: ${{ steps.inputs.outputs.parent_sha }}
|
||||
gitlink_sha: ${{ steps.inputs.outputs.gitlink_sha }}
|
||||
build_version: ${{ steps.inputs.outputs.build_version }}
|
||||
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
|
||||
steps:
|
||||
- name: Resolve trusted build inputs
|
||||
id: inputs
|
||||
env:
|
||||
EVENT_AFTER: ${{ github.event.after }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARENT_SHA: ${{ github.sha }}
|
||||
PUBLIC_REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$PUBLIC_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$EVENT_AFTER" == "$PARENT_SHA" ]]
|
||||
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
|
||||
(( 10#$RUN_ATTEMPT <= 10 ))
|
||||
|
||||
main_sha="$(gh api "repos/$PUBLIC_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
|
||||
[[ "$main_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
main_comparison="$(gh api "repos/$PUBLIC_REPOSITORY/compare/$PARENT_SHA...$main_sha")"
|
||||
main_status="$(jq -r .status <<<"$main_comparison")"
|
||||
[[ "$main_status" == "identical" || "$main_status" == "ahead" ]]
|
||||
[[ "$(jq -r .merge_base_commit.sha <<<"$main_comparison")" == "$PARENT_SHA" ]]
|
||||
|
||||
commit="$(gh api "repos/$PUBLIC_REPOSITORY/git/commits/$PARENT_SHA")"
|
||||
[[ "$(jq -r .sha <<<"$commit")" == "$PARENT_SHA" ]]
|
||||
tree_sha="$(jq -r .tree.sha <<<"$commit")"
|
||||
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
entry="$(
|
||||
gh api "repos/$PUBLIC_REPOSITORY/git/trees/$tree_sha" |
|
||||
jq -cer '[.tree[] | select(.path == "fluxer_marketing")] | if length == 1 then .[0] else error("expected exactly one marketing gitlink") end'
|
||||
)"
|
||||
mode="$(jq -r .mode <<<"$entry")"
|
||||
type="$(jq -r .type <<<"$entry")"
|
||||
gitlink_sha="$(jq -r .sha <<<"$entry")"
|
||||
path="$(jq -r .path <<<"$entry")"
|
||||
if [[ "$mode" != "160000" || "$type" != "commit" || "$path" != "fluxer_marketing" || ! "$gitlink_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Public parent does not contain a valid fluxer_marketing gitlink."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run="$(gh api "repos/$PUBLIC_REPOSITORY/actions/runs/$RUN_ID")"
|
||||
[[ "$(jq -r .id <<<"$run")" == "$RUN_ID" ]]
|
||||
[[ "$(jq -r .run_attempt <<<"$run")" == "$RUN_ATTEMPT" ]]
|
||||
[[ "$(jq -r .event <<<"$run")" == "push" ]]
|
||||
[[ "$(jq -r .head_sha <<<"$run")" == "$PARENT_SHA" ]]
|
||||
run_created_at="$(jq -r .created_at <<<"$run")"
|
||||
[[ "$run_created_at" =~ ^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
||||
run_created_epoch="$(date -u -d "$run_created_at" +%s)"
|
||||
[[ "$run_created_epoch" =~ ^[1-9][0-9]*$ ]]
|
||||
build_epoch=$((run_created_epoch + 10#$RUN_ATTEMPT - 1))
|
||||
read -r year month day time_segment <<<"$(date -u -d "@$build_epoch" '+%Y %m %d %H%M%S')"
|
||||
month="$((10#$month))"
|
||||
micro="$((10#$time_segment))"
|
||||
build_version="$year.$month$day.$micro"
|
||||
[[ "$build_version" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
correlation_id="public-${RUN_ID}-${RUN_ATTEMPT}"
|
||||
[[ "$correlation_id" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
{
|
||||
echo "parent_sha=$PARENT_SHA"
|
||||
echo "gitlink_sha=$gitlink_sha"
|
||||
echo "build_version=$build_version"
|
||||
echo "correlation_id=$correlation_id"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
dispatch:
|
||||
name: dispatch exact private build
|
||||
needs: metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 65
|
||||
environment: private-marketing-dispatch
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Validate trusted build inputs
|
||||
env:
|
||||
DISPATCH_ENABLED: ${{ vars.MARKETING_DISPATCH_ENABLED }}
|
||||
EXPECTED_PARENT_SHA: ${{ github.sha }}
|
||||
EXPECTED_CORRELATION_ID: public-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$GITHUB_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" == "$EXPECTED_PARENT_SHA" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$GITLINK_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$BUILD_VERSION" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
[[ "$CORRELATION_ID" == "$EXPECTED_CORRELATION_ID" ]]
|
||||
[[ "$CORRELATION_ID" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
if [[ "$DISPATCH_ENABLED" != "true" ]]; then
|
||||
echo "::error::Private marketing dispatch is intentionally disabled until the package cutover guard completes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create private dispatch token
|
||||
id: private-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: marketing
|
||||
permission-actions: write
|
||||
|
||||
- name: Dispatch exact private build
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api --method POST repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/dispatches \
|
||||
--field ref=main \
|
||||
--field "inputs[parent_sha]=$PARENT_SHA" \
|
||||
--field "inputs[gitlink_sha]=$GITLINK_SHA" \
|
||||
--field "inputs[build_version]=$BUILD_VERSION" \
|
||||
--field "inputs[correlation_id]=$CORRELATION_ID"
|
||||
|
||||
- name: Wait for private build conclusion
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_title="marketing-build correlation=$CORRELATION_ID parent=$PARENT_SHA gitlink=$GITLINK_SHA version=$BUILD_VERSION"
|
||||
deadline=$((SECONDS + 3600))
|
||||
run_id=""
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
count="$(jq 'length' <<<"$matches")"
|
||||
if [[ "$count" == "1" ]]; then
|
||||
run_id="$(jq -r '.[0].id' <<<"$matches")"
|
||||
break
|
||||
fi
|
||||
if [[ "$count" != "0" ]]; then
|
||||
echo "::error::Private build correlation matched multiple workflow runs."
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::error::Timed out waiting for the private build dispatch to appear."
|
||||
exit 1
|
||||
fi
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
if [[ "$(jq 'length' <<<"$matches")" != "1" || "$(jq -r '.[0].id' <<<"$matches")" != "$run_id" ]]; then
|
||||
echo "::error::Private build correlation is missing or ambiguous."
|
||||
exit 1
|
||||
fi
|
||||
run="$(jq '.[0]' <<<"$matches")"
|
||||
status="$(jq -r '.status' <<<"$run")"
|
||||
conclusion="$(jq -r '.conclusion // empty' <<<"$run")"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
echo "::error::Private marketing build concluded with $conclusion."
|
||||
exit 1
|
||||
fi
|
||||
echo "Private marketing build completed successfully."
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "::error::Timed out waiting for the private marketing build."
|
||||
exit 1
|
||||
@@ -1,51 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: finalise release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes)"
|
||||
type: string
|
||||
required: true
|
||||
fragment-run-id:
|
||||
description: "Workflow run id that produced the release-fragment-* artifacts"
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
finalise:
|
||||
name: finalise GitHub release manifest
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 10
|
||||
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: Download GitHub release fragments
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
gh run download "${{ inputs.fragment-run-id }}"
|
||||
--pattern "release-fragment-*"
|
||||
--dir release-out/fragments
|
||||
- name: Finalise release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ inputs.build-version }}"
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales fluxer_marketing/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
echo "No source catalog changes."
|
||||
exit 0
|
||||
fi
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
git config user.name "fluxer-ci[bot]"
|
||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||
git switch -c "$SOURCE_BRANCH"
|
||||
git add fluxer_app/src/features/i18n/locales fluxer_marketing/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git commit -m "chore(i18n): refresh source catalogs"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git fetch origin "$SOURCE_BRANCH" || true
|
||||
|
||||
@@ -77,14 +77,14 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
echo "No generated catalog changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "fluxer-ci[bot]"
|
||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git add fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git commit -m "i18n: compile Weblate catalogs"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git push origin "HEAD:$WEBLATE_BRANCH"
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: release all builds
|
||||
|
||||
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: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: release-all-${{ inputs['build-version'] || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve release build
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Release build approved."
|
||||
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
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
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- resolve-calver
|
||||
--github-output
|
||||
|
||||
build_admin:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-admin.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_api:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-api.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_app_proxy:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-app-proxy.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_app_proxy_self_hosted:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-app-proxy-self-hosted.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_docs:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-docs.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_gateway:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-gateway.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_gifs:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-gifs.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_marketing:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-marketing.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_media_proxy:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-media-proxy.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_messages:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-messages.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_snowflakes:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-snowflakes.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_static:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-static.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_unfurl:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-unfurl.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_users:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-users.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
release_assets:
|
||||
name: package Helm/self-hosting
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
needs:
|
||||
- meta
|
||||
- build_admin
|
||||
- build_api
|
||||
- build_app_proxy
|
||||
- build_app_proxy_self_hosted
|
||||
- build_docs
|
||||
- build_gateway
|
||||
- build_gifs
|
||||
- build_marketing
|
||||
- build_media_proxy
|
||||
- build_messages
|
||||
- build_snowflakes
|
||||
- build_static
|
||||
- build_unfurl
|
||||
- build_users
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
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 up Helm
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310
|
||||
- name: Publish self-hosting bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-self-hosting
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
- name: Publish Helm chart bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-helm
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
- name: Upload release asset fragments
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-release-assets
|
||||
path: release-out/fragments/*.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release manifest
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
needs:
|
||||
- meta
|
||||
- build_admin
|
||||
- build_api
|
||||
- build_app_proxy
|
||||
- build_app_proxy_self_hosted
|
||||
- build_docs
|
||||
- build_gateway
|
||||
- build_gifs
|
||||
- build_marketing
|
||||
- build_media_proxy
|
||||
- build_messages
|
||||
- build_snowflakes
|
||||
- build_static
|
||||
- build_unfurl
|
||||
- build_users
|
||||
- release_assets
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
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: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: Finalise GitHub release manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
libwebp-dev
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm --filter fluxer_admin --filter fluxer_marketing install
|
||||
run: pnpm --filter fluxer_admin install
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
@@ -234,56 +234,6 @@ jobs:
|
||||
- name: Verify shipped fonts match the lockfile
|
||||
run: python3 tools/fonts/build_fonts.py --verify
|
||||
|
||||
font-serving:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: rust-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: |
|
||||
rust-${{ runner.os }}-
|
||||
|
||||
- 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 \
|
||||
libavfilter-dev \
|
||||
libheif-dev \
|
||||
libwebp-dev
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm --filter fluxer_admin --filter fluxer_marketing install
|
||||
|
||||
- name: Run the font serving integration suite
|
||||
run: cargo run --locked -q -p fluxer-dev -- font-serving-it
|
||||
|
||||
ci-scripts:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/release-input/
|
||||
/release-out/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "fluxer_marketing"]
|
||||
path = fluxer_marketing
|
||||
url = https://github.com/fluxerapp/marketing.git
|
||||
update = none
|
||||
Generated
-481
@@ -2,12 +2,6 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accept-language"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f27d075294830fcab6f66e320dab524bc6d048f4a151698e153205559113772"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -896,25 +890,6 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calendrical_calculations"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
"displaydoc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caseless"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8"
|
||||
dependencies = [
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -1103,29 +1078,6 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "comrak"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aac0b255932a9cd52fbfd664b67957f9f2e095ae4711cb0e41b4e291edef94c2"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"entities",
|
||||
"finl_unicode",
|
||||
"jetscii",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
"rustc-hash",
|
||||
"smallvec",
|
||||
"typed-arena",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concat-string"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7439becb5fafc780b6f4de382b1a7a3e70234afe783854a4702ee8adbb838609"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1173,15 +1125,6 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1681,79 +1624,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding"
|
||||
version = "0.2.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec"
|
||||
dependencies = [
|
||||
"encoding-index-japanese",
|
||||
"encoding-index-korean",
|
||||
"encoding-index-simpchinese",
|
||||
"encoding-index-singlebyte",
|
||||
"encoding-index-tradchinese",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-japanese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-korean"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-simpchinese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-singlebyte"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding-index-tradchinese"
|
||||
version = "1.20141219.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18"
|
||||
dependencies = [
|
||||
"encoding_index_tests",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_index_tests"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"
|
||||
|
||||
[[package]]
|
||||
name = "entities"
|
||||
version = "1.0.1"
|
||||
@@ -1834,39 +1704,12 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flagset"
|
||||
version = "0.4.7"
|
||||
@@ -1895,14 +1738,12 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"hex",
|
||||
"md-5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
@@ -1926,9 +1767,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"hmac 0.13.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
@@ -1942,9 +1781,7 @@ dependencies = [
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1979,17 +1816,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-marketing-update-gettext-catalogs"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"serde_json",
|
||||
"syn",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer-media-proxy"
|
||||
version = "0.1.0"
|
||||
@@ -2221,42 +2047,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_marketing"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"accept-language",
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64",
|
||||
"comrak",
|
||||
"cookie",
|
||||
"email_address",
|
||||
"fluxer_common",
|
||||
"gettext",
|
||||
"hmac 0.13.0",
|
||||
"http-body-util",
|
||||
"icu_datetime",
|
||||
"icu_locale",
|
||||
"maud",
|
||||
"mime_guess",
|
||||
"moka",
|
||||
"polib",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"syn",
|
||||
"time",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -2449,16 +2239,6 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gettext"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ebb594e753d5997e4be036e5a8cf048ab9414352870fb45c779557bbc9ba971"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
@@ -2820,29 +2600,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"displaydoc",
|
||||
"icu_calendar_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
"ixdtf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
@@ -2857,73 +2614,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_datetime"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989d56ea5bbc43ae2b4e0388874b002884eaf4ed3a76c84a6c8c5ad575e04d72"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_calendar",
|
||||
"icu_datetime_data",
|
||||
"icu_decimal",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"icu_time",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_datetime_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40d3cc1b690d9703202bc319692ac8a1f3a6390686f0930ff40542450fa34f0b"
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_decimal_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22"
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_locale_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
@@ -2932,18 +2622,11 @@ checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
@@ -2964,38 +2647,6 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c4c568054ffe735398a9f4c55aec37ad7c768844553cc0978f09cc9b933a1fb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_locale",
|
||||
"icu_plurals_data",
|
||||
"icu_provider",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
@@ -3024,8 +2675,6 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -3033,30 +2682,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_time"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec3af0c141da0a61d4f6970cd1d5f4b388b17ea22f8124f8f6049d3d5147586a"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"displaydoc",
|
||||
"icu_calendar",
|
||||
"icu_locale_core",
|
||||
"icu_provider",
|
||||
"icu_time_data",
|
||||
"ixdtf",
|
||||
"serde",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_time_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f2f8aeca682d874a5247084aa4fb7d1cef9ba45d889c21209a8818dcaaa0ec9"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
@@ -3181,18 +2806,6 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "ixdtf"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ceaf4c6c48465bead8cb6a0b7c4ee0c86ecbb31239032b9c66ab9a08d2f3ee1"
|
||||
|
||||
[[package]]
|
||||
name = "jetscii"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3282,12 +2895,6 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.17"
|
||||
@@ -3297,15 +2904,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linereader"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d921fea6860357575519aca014c6e22470585accdd543b370c404a8a72d0dd1d"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify"
|
||||
version = "0.11.0"
|
||||
@@ -3938,16 +3536,6 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polib"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee83e5a284d919e51b071969bbf2d12d6943857aab02d84c5cc449373c9f3b7b"
|
||||
dependencies = [
|
||||
"concat-string",
|
||||
"linereader",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -3991,8 +3579,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5288,17 +4874,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5400,7 +4975,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5549,22 +5123,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls 0.23.40",
|
||||
"rustls-native-certs",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -5742,36 +5300,12 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http 1.4.2",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.4",
|
||||
"rustls 0.23.40",
|
||||
"rustls-pki-types",
|
||||
"sha1 0.10.6",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
|
||||
[[package]]
|
||||
name = "typed-arena"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
@@ -6509,9 +6043,6 @@ name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wyhash"
|
||||
@@ -6534,16 +6065,6 @@ dependencies = [
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
@@ -6643,7 +6164,6 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6652,7 +6172,6 @@ version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ members = [
|
||||
"fluxer_admin",
|
||||
"fluxer_app_proxy",
|
||||
"fluxer_common",
|
||||
"fluxer_marketing",
|
||||
"fluxer_media_proxy",
|
||||
"fluxer_gifs",
|
||||
"fluxer_svc",
|
||||
@@ -13,12 +12,12 @@ members = [
|
||||
"tools/content/update-frozen-snapshot",
|
||||
"tools/dev",
|
||||
"tools/i18n_auto",
|
||||
"tools/marketing/update-gettext-catalogs",
|
||||
"fluxer_users",
|
||||
"fluxer_unfurl",
|
||||
"packages/markdown_parser/rust",
|
||||
]
|
||||
exclude = [
|
||||
"fluxer_marketing",
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.19.16",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.23.1",
|
||||
|
||||
@@ -145,7 +145,6 @@
|
||||
"!fluxer_static",
|
||||
"!packages/fonts",
|
||||
"!fluxer_admin/static/htmx.min.js",
|
||||
"!fluxer_marketing/static/htmx.min.js",
|
||||
"!fluxer_api/src/api/openapi/openapi.json"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
|
||||
Vendored
+1
-1
@@ -17,7 +17,7 @@ FLUXER_GATEWAY_ENDPOINT=ws://localhost:8088/gateway
|
||||
FLUXER_MEDIA_ENDPOINT=http://localhost:8088/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT=http://localhost:8088
|
||||
FLUXER_ADMIN_ENDPOINT=http://localhost:8088/admin
|
||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
||||
FLUXER_MARKETING_ENDPOINT=https://fluxer.app
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ENDPOINT="${ASSET_CORS_ENDPOINT:-https://fluxerstatic.com}"
|
||||
MANIFEST="${ASSET_MANIFEST:-app-dist-output/dist/assets-manifest.txt}"
|
||||
ORIGIN="${ASSET_CORS_ORIGIN:-https://fluxer.app}"
|
||||
SAMPLES="${ASSET_CORS_SAMPLES:-5}"
|
||||
EXPLICIT_KEYS=()
|
||||
|
||||
usage() {
|
||||
sed -n '3,22p' "$0"
|
||||
exit "${1:-2}"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--endpoint)
|
||||
ENDPOINT="${2:?--endpoint needs a URL}"
|
||||
shift 2
|
||||
;;
|
||||
--manifest)
|
||||
MANIFEST="${2:?--manifest needs a path}"
|
||||
shift 2
|
||||
;;
|
||||
--origin)
|
||||
ORIGIN="${2:?--origin needs an origin}"
|
||||
shift 2
|
||||
;;
|
||||
--samples)
|
||||
SAMPLES="${2:?--samples needs a count}"
|
||||
shift 2
|
||||
;;
|
||||
--key)
|
||||
EXPLICIT_KEYS+=("${2:?--key needs an S3 key}")
|
||||
shift 2
|
||||
;;
|
||||
-h | --help) usage 0 ;;
|
||||
*)
|
||||
echo "verify-asset-cors: unknown argument '$1'" >&2
|
||||
usage 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
ENDPOINT="${ENDPOINT%/}"
|
||||
|
||||
keys=()
|
||||
if [ "${#EXPLICIT_KEYS[@]}" -gt 0 ]; then
|
||||
keys=("${EXPLICIT_KEYS[@]}")
|
||||
elif [ -f "$MANIFEST" ]; then
|
||||
while IFS= read -r key; do
|
||||
keys+=("$key")
|
||||
done < <(grep -E '\.woff2$' "$MANIFEST" | awk -v n="$SAMPLES" '
|
||||
{ lines[NR] = $0 }
|
||||
END {
|
||||
if (NR == 0) exit
|
||||
if (n > NR) n = NR
|
||||
for (i = 0; i < n; i++) print lines[int(i * NR / n) + 1]
|
||||
}')
|
||||
else
|
||||
cat >&2 <<-EOF
|
||||
verify-asset-cors: no asset manifest at '$MANIFEST' and no --key given.
|
||||
|
||||
The manifest is produced by \`build-app-proxy --step generate_asset_manifest\` and uploaded
|
||||
by the release workflow as the 'app-proxy-assets-manifest' artifact. Pass --manifest to point
|
||||
at a downloaded copy, or --key to check a single face by hand.
|
||||
EOF
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "${#keys[@]}" -eq 0 ]; then
|
||||
echo "verify-asset-cors: no .woff2 keys to check (manifest '$MANIFEST' has none)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "verify-asset-cors: endpoint=$ENDPOINT origin=$ORIGIN keys=${#keys[@]}"
|
||||
|
||||
failures=0
|
||||
for key in "${keys[@]}"; do
|
||||
url="$ENDPOINT/$key"
|
||||
headers="$(curl --silent --show-error --location --max-time 20 \
|
||||
--header "Origin: $ORIGIN" --output /dev/null --dump-header - "$url" 2>&1 || true)"
|
||||
|
||||
status="$(printf '%s\n' "$headers" | awk '/^HTTP\//{code=$2} END{print code+0}')"
|
||||
header_value() {
|
||||
printf '%s\n' "$headers" |
|
||||
tr -d '\r' |
|
||||
awk -v want="$1" 'index(tolower($0), want ":") == 1 {sub(/^[^:]*:[ \t]*/, ""); v=$0} END{print v}'
|
||||
}
|
||||
acao="$(header_value 'access-control-allow-origin')"
|
||||
ctype="$(header_value 'content-type')"
|
||||
cache="$(header_value 'cache-control')"
|
||||
cdn_cache="$(header_value 'cdn-cache-control')"
|
||||
|
||||
problems=()
|
||||
[ "$status" = "200" ] || problems+=("status=$status (want 200)")
|
||||
if [ -z "$acao" ]; then
|
||||
problems+=("no Access-Control-Allow-Origin -- the browser will drop this face")
|
||||
elif [ "$acao" != "*" ] && [ "$acao" != "$ORIGIN" ]; then
|
||||
problems+=("Access-Control-Allow-Origin='$acao' (want '*' or '$ORIGIN')")
|
||||
fi
|
||||
case "$ctype" in
|
||||
font/woff2*) ;;
|
||||
*) problems+=("Content-Type='$ctype' (want font/woff2)") ;;
|
||||
esac
|
||||
caching_note=""
|
||||
case "$cache$cdn_cache" in
|
||||
*immutable*) ;;
|
||||
*) caching_note=" (note: no 'immutable' token; cache-control='$cache')" ;;
|
||||
esac
|
||||
|
||||
if [ "${#problems[@]}" -eq 0 ]; then
|
||||
echo " OK $key [$status $ctype acao=$acao]$caching_note"
|
||||
else
|
||||
failures=$((failures + 1))
|
||||
echo " FAIL $key"
|
||||
for problem in "${problems[@]}"; do
|
||||
echo " - $problem"
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$failures" -gt 0 ]; then
|
||||
cat >&2 <<-EOF
|
||||
|
||||
verify-asset-cors: $failures of ${#keys[@]} sampled faces are NOT usable from $ORIGIN.
|
||||
|
||||
Most likely cause: the bucket's CORS rule is scoped to the retired 'fonts/' prefix and does
|
||||
not cover 'assets/'. Every bundled face fails in production while JS and CSS keep working,
|
||||
and the CDN still answers 200, so no server-side alarm fires.
|
||||
|
||||
Fix the bucket rule so it allows GET on 'assets/*' from the app origin (or bucket-wide),
|
||||
then re-run this check.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "verify-asset-cors: all ${#keys[@]} sampled faces are usable from $ORIGIN"
|
||||
@@ -9813,7 +9813,6 @@
|
||||
"DISCRIMINATOR_REQUIRED",
|
||||
"EMAIL_SERVICE_NOT_TESTABLE",
|
||||
"EMAIL_VERIFICATION_REQUIRED",
|
||||
"CANARY_TESTER_EMAIL_VERIFICATION_REQUIRED",
|
||||
"DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED",
|
||||
"FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED",
|
||||
"GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED",
|
||||
|
||||
@@ -4,22 +4,11 @@
|
||||
mod parity_support;
|
||||
|
||||
use parity_support::{
|
||||
PARITY_RUN_ENV, PROTECTED_ROUTES_ENV, PUBLIC_ROUTES_ENV, TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET,
|
||||
TEST_ADMIN_USER_ID, api_fixtures, capture, env_flag, html_normalizer, reference_worktree,
|
||||
route_list_from_env, servers,
|
||||
TEST_ACCESS_TOKEN, TEST_ADMIN_SECRET, TEST_ADMIN_USER_ID, api_fixtures, capture,
|
||||
html_normalizer, rust_server,
|
||||
};
|
||||
use std::{error::Error, io};
|
||||
|
||||
const DEFAULT_PUBLIC_ROUTES: &[&str] = &["/_health", "/robots.txt", "/static/app.css", "/login"];
|
||||
const DEFAULT_PROTECTED_ROUTES: &[&str] = &[
|
||||
"/dashboard",
|
||||
"/users?q=Parity",
|
||||
"/guilds?q=Parity",
|
||||
"/guilds/1600000000000000001",
|
||||
"/reports",
|
||||
"/reports/1700000000000000001",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn html_normalizer_canonicalizes_attribute_order_and_csrf_values() {
|
||||
let left = r#"<form><input value="aaaaaaaa" name="_csrf" type="hidden"><svg><line x1="1" x2="2"></line></svg><a class="b" href="/static/app.css?v=123" id="x">Open</a></form>"#;
|
||||
@@ -64,7 +53,7 @@ async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<()
|
||||
let api_server = api_fixtures::ApiFixtureServer::start_default()
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let rust_admin = servers::start_rust_admin(api_server.base_url())
|
||||
let rust_admin = rust_server::start(api_server.base_url())
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
@@ -115,68 +104,6 @@ async fn rust_admin_fixture_routes_cover_default_protected_routes() -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "set FLUXER_ADMIN_PARITY_RUN=1 to create/use the TS worktree and run dual-server parity"]
|
||||
async fn dual_server_static_public_and_protected_routes() -> Result<(), Box<dyn Error>> {
|
||||
if !env_flag(PARITY_RUN_ENV) {
|
||||
eprintln!("skipping dual-server parity; set {PARITY_RUN_ENV}=1 to run it");
|
||||
return Ok(());
|
||||
}
|
||||
let repo_root = repo_root()?;
|
||||
let api_server = api_fixtures::ApiFixtureServer::start_default()
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let worktree = reference_worktree::ensure_reference_worktree(&repo_root).map_err(test_error)?;
|
||||
reference_worktree::prepare_reference_package(&worktree).map_err(test_error)?;
|
||||
let ts_port = servers::reserve_local_port().map_err(test_error)?;
|
||||
let ts_admin = servers::start_ts_admin(&worktree, ts_port, api_server.base_url())
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let rust_admin = servers::start_rust_admin(api_server.base_url())
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
let client = capture::capture_client().map_err(test_error)?;
|
||||
let public_routes = route_list_from_env(PUBLIC_ROUTES_ENV, DEFAULT_PUBLIC_ROUTES);
|
||||
for route in public_routes {
|
||||
capture::compare_route(
|
||||
&client,
|
||||
&route,
|
||||
ts_admin.base_url(),
|
||||
rust_admin.base_url(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
}
|
||||
let session = fluxer_admin::session::create_session(
|
||||
TEST_ADMIN_USER_ID,
|
||||
TEST_ACCESS_TOKEN,
|
||||
TEST_ADMIN_SECRET,
|
||||
);
|
||||
let session_cookie = format!("{}={session}", fluxer_admin::session::SESSION_COOKIE_NAME);
|
||||
let protected_routes = route_list_from_env(PROTECTED_ROUTES_ENV, DEFAULT_PROTECTED_ROUTES);
|
||||
for route in protected_routes {
|
||||
capture::compare_route(
|
||||
&client,
|
||||
&route,
|
||||
ts_admin.base_url(),
|
||||
rust_admin.base_url(),
|
||||
Some(&session_cookie),
|
||||
)
|
||||
.await
|
||||
.map_err(test_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn repo_root() -> Result<std::path::PathBuf, Box<dyn Error>> {
|
||||
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
manifest_dir
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.ok_or_else(|| test_error("fluxer_admin must have a repository parent".to_owned()))
|
||||
}
|
||||
|
||||
fn test_error(message: String) -> Box<dyn Error> {
|
||||
Box::new(io::Error::other(message))
|
||||
}
|
||||
|
||||
@@ -18,24 +18,6 @@ pub fn capture_client() -> Result<Client, String> {
|
||||
.map_err(|error| format!("failed to build capture client: {error}"))
|
||||
}
|
||||
|
||||
pub async fn compare_route(
|
||||
client: &Client,
|
||||
route: &str,
|
||||
ts_base_url: &str,
|
||||
rust_base_url: &str,
|
||||
cookie: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let ts = fetch_route(client, ts_base_url, route, cookie).await?;
|
||||
let rust = fetch_route(client, rust_base_url, route, cookie).await?;
|
||||
if ts == rust {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"parity mismatch for {route}\nTS: {ts:#?}\nRust: {rust:#?}\nfirst body diff: {}",
|
||||
first_body_diff(&ts.body, &rust.body)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn fetch_route(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
@@ -73,25 +55,3 @@ pub async fn fetch_route(
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn first_body_diff(left: &str, right: &str) -> String {
|
||||
let left_chars = left.chars().collect::<Vec<_>>();
|
||||
let right_chars = right.chars().collect::<Vec<_>>();
|
||||
let max_len = left_chars.len().max(right_chars.len());
|
||||
for index in 0..max_len {
|
||||
if left_chars.get(index) != right_chars.get(index) {
|
||||
let left_preview = preview_from(&left_chars, index);
|
||||
let right_preview = preview_from(&right_chars, index);
|
||||
return format!("at char {index}: left `{left_preview}`, right `{right_preview}`");
|
||||
}
|
||||
}
|
||||
"bodies differ but no character diff was found".to_owned()
|
||||
}
|
||||
|
||||
fn preview_from(chars: &[char], start: usize) -> String {
|
||||
chars
|
||||
.iter()
|
||||
.skip(start.saturating_sub(20))
|
||||
.take(80)
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
@@ -3,47 +3,8 @@
|
||||
pub mod api_fixtures;
|
||||
pub mod capture;
|
||||
pub mod html_normalizer;
|
||||
pub mod reference_worktree;
|
||||
pub mod servers;
|
||||
pub mod rust_server;
|
||||
|
||||
use std::env;
|
||||
|
||||
pub const TS_REFERENCE_COMMIT: &str = "4748f2f1e6589c325fca6391d6df3e3c3f6a0345^";
|
||||
pub const PARITY_RUN_ENV: &str = "FLUXER_ADMIN_PARITY_RUN";
|
||||
pub const PUBLIC_ROUTES_ENV: &str = "FLUXER_ADMIN_PARITY_PUBLIC_ROUTES";
|
||||
pub const PROTECTED_ROUTES_ENV: &str = "FLUXER_ADMIN_PARITY_PROTECTED_ROUTES";
|
||||
pub const TS_WORKTREE_ENV: &str = "FLUXER_ADMIN_PARITY_TS_WORKTREE";
|
||||
pub const TS_WORKTREE_ROOT_ENV: &str = "FLUXER_ADMIN_PARITY_WORKTREE_ROOT";
|
||||
pub const SKIP_TS_PREPARE_ENV: &str = "FLUXER_ADMIN_PARITY_SKIP_TS_PREPARE";
|
||||
pub const TEST_ADMIN_SECRET: &str = "test-admin-secret";
|
||||
pub const TEST_ADMIN_USER_ID: &str = "1130650140672000000";
|
||||
pub const TEST_ACCESS_TOKEN: &str = "parity-access-token";
|
||||
|
||||
pub fn env_flag(name: &str) -> bool {
|
||||
env::var(name)
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn route_list_from_env(name: &str, default: &[&str]) -> Vec<String> {
|
||||
match env::var(name) {
|
||||
Ok(value) => value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| {
|
||||
if value.starts_with('/') {
|
||||
value.to_owned()
|
||||
} else {
|
||||
format!("/{value}")
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => default.iter().map(|route| (*route).to_owned()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::{
|
||||
SKIP_TS_PREPARE_ENV, TS_REFERENCE_COMMIT, TS_WORKTREE_ENV, TS_WORKTREE_ROOT_ENV, env_flag,
|
||||
};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
pub fn ensure_reference_worktree(repo_root: &Path) -> Result<PathBuf, String> {
|
||||
let target_commit = git_stdout(repo_root, &["rev-parse", TS_REFERENCE_COMMIT])?;
|
||||
if let Ok(path) = env::var(TS_WORKTREE_ENV) {
|
||||
let path = PathBuf::from(path);
|
||||
validate_worktree(&path, &target_commit)?;
|
||||
return Ok(path);
|
||||
}
|
||||
let root = env::var(TS_WORKTREE_ROOT_ENV)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| repo_root.join("target/parity"));
|
||||
fs::create_dir_all(&root)
|
||||
.map_err(|error| format!("failed to create {}: {error}", root.display()))?;
|
||||
let worktree = root.join("fluxer-admin-ts-ref-4748f2f1-parent");
|
||||
if !worktree.exists() {
|
||||
run_git(
|
||||
repo_root,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
path_arg(&worktree).as_str(),
|
||||
TS_REFERENCE_COMMIT,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
validate_worktree(&worktree, &target_commit)?;
|
||||
Ok(worktree)
|
||||
}
|
||||
|
||||
pub fn prepare_reference_package(worktree: &Path) -> Result<(), String> {
|
||||
if env_flag(SKIP_TS_PREPARE_ENV) {
|
||||
return Ok(());
|
||||
}
|
||||
run_command(
|
||||
Command::new("pnpm")
|
||||
.current_dir(worktree)
|
||||
.args(["install", "--frozen-lockfile"]),
|
||||
"pnpm install --frozen-lockfile",
|
||||
)?;
|
||||
run_command(
|
||||
Command::new("pnpm")
|
||||
.current_dir(worktree)
|
||||
.args(["--filter", "@fluxer/config", "generate"]),
|
||||
"pnpm --filter @fluxer/config generate",
|
||||
)?;
|
||||
run_command(
|
||||
Command::new("pnpm")
|
||||
.current_dir(worktree)
|
||||
.args(["--filter", "fluxer_admin", "build:css"]),
|
||||
"pnpm --filter fluxer_admin build:css",
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_worktree(worktree: &Path, target_commit: &str) -> Result<(), String> {
|
||||
if !worktree.join("fluxer_admin/package.json").exists() {
|
||||
return Err(format!(
|
||||
"{} does not look like the TS reference worktree",
|
||||
worktree.display()
|
||||
));
|
||||
}
|
||||
let head = git_stdout(worktree, &["rev-parse", "HEAD"])?;
|
||||
if head != target_commit {
|
||||
return Err(format!(
|
||||
"{} is at {head}, expected {target_commit}",
|
||||
worktree.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_git(repo: &Path, args: &[&str]) -> Result<(), String> {
|
||||
run_command(Command::new("git").current_dir(repo).args(args), "git")
|
||||
}
|
||||
|
||||
fn git_stdout(repo: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.current_dir(repo)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git {} failed\nstdout:\n{}\nstderr:\n{}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
fn run_command(command: &mut Command, label: &str) -> Result<(), String> {
|
||||
let output = command
|
||||
.output()
|
||||
.map_err(|error| format!("failed to run {label}: {error}"))?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!(
|
||||
"{label} failed with status {}\nstdout:\n{}\nstderr:\n{}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
))
|
||||
}
|
||||
|
||||
fn path_arg(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::TEST_ADMIN_SECRET;
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::{net::TcpListener, task::JoinHandle, time::sleep};
|
||||
|
||||
pub struct RunningRustAdmin {
|
||||
base_url: String,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl RunningRustAdmin {
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningRustAdmin {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(api_endpoint: &str) -> Result<RunningRustAdmin, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind Rust admin server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read Rust admin address: {error}"))?
|
||||
.port();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let config = admin_config(port, api_endpoint, &base_url);
|
||||
let router = build_router(config);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
wait_for_health(&base_url).await?;
|
||||
Ok(RunningRustAdmin { base_url, handle })
|
||||
}
|
||||
|
||||
fn admin_config(port: u16, api_endpoint: &str, admin_endpoint: &str) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port,
|
||||
secret_key_base: TEST_ADMIN_SECRET.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: api_endpoint.to_owned(),
|
||||
media_endpoint: format!("{api_endpoint}/media"),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: "http://127.0.0.1:8088".to_owned(),
|
||||
kv_url: "redis://127.0.0.1:6379/0".to_owned(),
|
||||
oauth_client_id: "1234567890123456789".to_owned(),
|
||||
oauth_client_secret: "test-admin-oauth-secret".to_owned(),
|
||||
oauth_redirect_uri: format!("{admin_endpoint}/oauth2_callback"),
|
||||
build_version: "parity".to_owned(),
|
||||
release_channel: "parity".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_health(base_url: &str) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("failed to build health client: {error}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let url = format!("{}/_health", base_url.trim_end_matches('/'));
|
||||
let mut last_error = String::new();
|
||||
while Instant::now() < deadline {
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => last_error = format!("health returned {}", response.status()),
|
||||
Err(error) => last_error = error.to_string(),
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Err(format!("timed out waiting for {url}: {last_error}"))
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::TEST_ADMIN_SECRET;
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
};
|
||||
use std::{
|
||||
net::TcpListener as StdTcpListener,
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::{net::TcpListener, task::JoinHandle, time::sleep};
|
||||
|
||||
pub struct RunningRustAdmin {
|
||||
base_url: String,
|
||||
handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub struct RunningTsAdmin {
|
||||
base_url: String,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl RunningRustAdmin {
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl RunningTsAdmin {
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningRustAdmin {
|
||||
fn drop(&mut self) {
|
||||
self.handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RunningTsAdmin {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reserve_local_port() -> Result<u16, String> {
|
||||
let listener = StdTcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|error| format!("failed to reserve local port: {error}"))?;
|
||||
listener
|
||||
.local_addr()
|
||||
.map(|addr| addr.port())
|
||||
.map_err(|error| format!("failed to read reserved local port: {error}"))
|
||||
}
|
||||
|
||||
pub async fn start_rust_admin(api_endpoint: &str) -> Result<RunningRustAdmin, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("failed to bind Rust admin server: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("failed to read Rust admin address: {error}"))?
|
||||
.port();
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let config = admin_config(port, api_endpoint, &base_url);
|
||||
let router = build_router(config);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
wait_for_health(&base_url).await?;
|
||||
Ok(RunningRustAdmin { base_url, handle })
|
||||
}
|
||||
|
||||
pub async fn start_ts_admin(
|
||||
worktree: &Path,
|
||||
port: u16,
|
||||
api_endpoint: &str,
|
||||
) -> Result<RunningTsAdmin, String> {
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let mut command = Command::new("pnpm");
|
||||
command
|
||||
.current_dir(worktree)
|
||||
.args(["--filter", "fluxer_admin", "start"])
|
||||
.env("BUILD_VERSION", "parity")
|
||||
.env("RELEASE_CHANNEL", "parity");
|
||||
for (key, value) in ts_admin_env(port, api_endpoint, &base_url) {
|
||||
command.env(key, value);
|
||||
}
|
||||
let child = command
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to start TS admin server: {error}"))?;
|
||||
let mut server = RunningTsAdmin { base_url, child };
|
||||
if let Err(error) = wait_for_health(server.base_url()).await {
|
||||
let _ = server.child.kill();
|
||||
let _ = server.child.wait();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
fn admin_config(port: u16, api_endpoint: &str, admin_endpoint: &str) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port,
|
||||
secret_key_base: TEST_ADMIN_SECRET.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: api_endpoint.to_owned(),
|
||||
media_endpoint: format!("{api_endpoint}/media"),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: "http://127.0.0.1:8088".to_owned(),
|
||||
kv_url: "redis://127.0.0.1:6379/0".to_owned(),
|
||||
oauth_client_id: "1234567890123456789".to_owned(),
|
||||
oauth_client_secret: "test-admin-oauth-secret".to_owned(),
|
||||
oauth_redirect_uri: format!("{admin_endpoint}/oauth2_callback"),
|
||||
build_version: "parity".to_owned(),
|
||||
release_channel: "parity".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn ts_admin_env(
|
||||
port: u16,
|
||||
api_endpoint: &str,
|
||||
admin_endpoint: &str,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("FLUXER_ENV", "test".to_owned()),
|
||||
("NODE_ENV", "production".to_owned()),
|
||||
("FLUXER_BASE_DOMAIN", "127.0.0.1".to_owned()),
|
||||
("FLUXER_PUBLIC_SCHEME", "http".to_owned()),
|
||||
("FLUXER_PUBLIC_PORT", port.to_string()),
|
||||
("FLUXER_API_ENDPOINT", api_endpoint.to_owned()),
|
||||
("FLUXER_ADMIN_ENDPOINT", admin_endpoint.to_owned()),
|
||||
("FLUXER_APP_ENDPOINT", "http://127.0.0.1:8088".to_owned()),
|
||||
("FLUXER_MEDIA_ENDPOINT", format!("{api_endpoint}/media")),
|
||||
(
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
"https://static.example.test".to_owned(),
|
||||
),
|
||||
("FLUXER_CASSANDRA_HOSTS", "127.0.0.1".to_owned()),
|
||||
("FLUXER_CASSANDRA_KEYSPACE", "fluxer_test".to_owned()),
|
||||
("FLUXER_CASSANDRA_LOCAL_DC", "datacenter1".to_owned()),
|
||||
("FLUXER_CASSANDRA_USERNAME", "cassandra".to_owned()),
|
||||
("FLUXER_CASSANDRA_PASSWORD", "cassandra".to_owned()),
|
||||
("FLUXER_KV_URL", "redis://127.0.0.1:6379/0".to_owned()),
|
||||
("FLUXER_S3_ACCESS_KEY_ID", "test".to_owned()),
|
||||
("FLUXER_S3_SECRET_ACCESS_KEY", "test".to_owned()),
|
||||
(
|
||||
"FLUXER_MEDIA_PROXY_SECRET_KEY",
|
||||
"test-media-secret".to_owned(),
|
||||
),
|
||||
("FLUXER_ADMIN_PORT", port.to_string()),
|
||||
("FLUXER_ADMIN_SECRET_KEY_BASE", TEST_ADMIN_SECRET.to_owned()),
|
||||
(
|
||||
"FLUXER_ADMIN_OAUTH_CLIENT_SECRET",
|
||||
"test-admin-oauth-secret".to_owned(),
|
||||
),
|
||||
(
|
||||
"FLUXER_MARKETING_SECRET_KEY_BASE",
|
||||
"test-marketing-secret".to_owned(),
|
||||
),
|
||||
("FLUXER_APP_PROXY_PORT", "8773".to_owned()),
|
||||
(
|
||||
"FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT",
|
||||
format!("{api_endpoint}/media"),
|
||||
),
|
||||
(
|
||||
"FLUXER_GATEWAY_RPC_AUTH_TOKEN",
|
||||
"test-gateway-rpc-token".to_owned(),
|
||||
),
|
||||
("FLUXER_GATEWAY_PUSH_ENABLED", "false".to_owned()),
|
||||
("FLUXER_SUDO_MODE_SECRET", "test-sudo-secret".to_owned()),
|
||||
(
|
||||
"FLUXER_CONNECTION_INITIATION_SECRET",
|
||||
"test-connection-secret".to_owned(),
|
||||
),
|
||||
(
|
||||
"FLUXER_VAPID_PUBLIC_KEY",
|
||||
"test-vapid-public-key".to_owned(),
|
||||
),
|
||||
(
|
||||
"FLUXER_VAPID_PRIVATE_KEY",
|
||||
"test-vapid-private-key".to_owned(),
|
||||
),
|
||||
("FLUXER_VAPID_EMAIL", "[email protected]".to_owned()),
|
||||
("FLUXER_EMAIL_ENABLED", "false".to_owned()),
|
||||
("FLUXER_LIVEKIT_ENABLED", "false".to_owned()),
|
||||
("FLUXER_STRIPE_ENABLED", "true".to_owned()),
|
||||
("FLUXER_SEARCH_URL", "http://127.0.0.1:9200".to_owned()),
|
||||
("FLUXER_SEARCH_API_KEY", "test".to_owned()),
|
||||
("FLUXER_CAPTCHA_ENABLED", "false".to_owned()),
|
||||
("FLUXER_CAPTCHA_PROVIDER", "none".to_owned()),
|
||||
("FLUXER_SELF_HOSTED", "false".to_owned()),
|
||||
("FLUXER_DISCOVERY_ENABLED", "true".to_owned()),
|
||||
("FLUXER_DISABLE_RATE_LIMITS", "true".to_owned()),
|
||||
("FLUXER_TEST_MODE_ENABLED", "true".to_owned()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn wait_for_health(base_url: &str) -> Result<(), String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("failed to build health client: {error}"))?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let url = format!("{}/_health", base_url.trim_end_matches('/'));
|
||||
let mut last_error = String::new();
|
||||
while Instant::now() < deadline {
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => {
|
||||
last_error = format!("health returned {}", response.status());
|
||||
}
|
||||
Err(error) => {
|
||||
last_error = error.to_string();
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Err(format!("timed out waiting for {url}: {last_error}"))
|
||||
}
|
||||
@@ -276,6 +276,9 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
ipinfoApiKey: master.integrations.risk_integration.ipinfo_api_key || undefined,
|
||||
accountPolicyDsl: master.integrations.risk_integration.account_policy_dsl,
|
||||
},
|
||||
blocklistFeeds: {
|
||||
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
captcha: {
|
||||
enabled: master.integrations.captcha.enabled,
|
||||
provider: master.integrations.captcha.provider,
|
||||
|
||||
@@ -144,6 +144,23 @@ type ValidatorOptions<
|
||||
post?: Hook<T, E, P, Target, V>;
|
||||
};
|
||||
|
||||
export function inputValidationErrorFromZodIssues(issues: ZodError['issues']): InputValidationError {
|
||||
const errors: Array<ValidationError> = [];
|
||||
const localizedErrors: Array<LocalizedValidationError> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const issue of issues) {
|
||||
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
|
||||
const code = getValidationErrorCode(issue.message);
|
||||
const key = `${path}|${code}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const variables = extractVariablesFromIssue(issue);
|
||||
errors.push({path, message: code, code});
|
||||
localizedErrors.push({path, code, variables});
|
||||
}
|
||||
return new InputValidationError(errors, localizedErrors);
|
||||
}
|
||||
|
||||
export const Validator = <
|
||||
T extends ZodTypeAny,
|
||||
Target extends keyof ValidationTargets,
|
||||
@@ -246,20 +263,7 @@ export const Validator = <
|
||||
}
|
||||
}
|
||||
if (!result.success) {
|
||||
const errors: Array<ValidationError> = [];
|
||||
const localizedErrors: Array<LocalizedValidationError> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path.length > 0 ? issue.path.map(String).join('.') : 'root';
|
||||
const code = getValidationErrorCode(issue.message);
|
||||
const key = `${path}|${code}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const variables = extractVariablesFromIssue(issue);
|
||||
errors.push({path, message: code, code});
|
||||
localizedErrors.push({path, code, variables});
|
||||
}
|
||||
throw new InputValidationError(errors, localizedErrors);
|
||||
throw inputValidationErrorFromZodIssues(result.error.issues);
|
||||
}
|
||||
c.req.addValidatedData(target, result.data as ValidationTargets[Target]);
|
||||
await next();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {createUserID} from '../BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, fetchPage, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import type {
|
||||
AdminAuditLogRow,
|
||||
BannedAvatarHashRow,
|
||||
@@ -30,13 +30,7 @@ import {
|
||||
} from '../Tables';
|
||||
import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils';
|
||||
import {canonicalizeStoredPhrase} from '../utils/PhraseBlocklistNormalization';
|
||||
import type {
|
||||
AdminAuditLog,
|
||||
BannedIpEntry,
|
||||
BannedIpKind,
|
||||
DisposableEmailDomainPage,
|
||||
IAdminRepository,
|
||||
} from './IAdminRepository';
|
||||
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from './IAdminRepository';
|
||||
|
||||
const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({
|
||||
where: AdminAuditLogs.where.eq('log_id'),
|
||||
@@ -51,8 +45,6 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
|
||||
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
|
||||
where: SuspiciousEmailDomains.where.eq('domain'),
|
||||
});
|
||||
const createLoadSuspiciousEmailDomainsQuery = (limit?: number) =>
|
||||
limit ? SuspiciousEmailDomains.select({limit}) : SuspiciousEmailDomains.select();
|
||||
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
|
||||
where: DisposableEmailDomains.where.eq('domain'),
|
||||
});
|
||||
@@ -273,13 +265,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
|
||||
}
|
||||
|
||||
async listSuspiciousEmailDomains(limit?: number): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{
|
||||
domain: string;
|
||||
}>(createLoadSuspiciousEmailDomainsQuery(limit).bind({}));
|
||||
return rows.map((row) => row.domain);
|
||||
}
|
||||
|
||||
async isEmailDomainDisposable(domain: string): Promise<boolean> {
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
@@ -306,19 +291,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return rows.map((row) => row.domain);
|
||||
}
|
||||
|
||||
async listDisposableEmailDomainsPage(limit: number, pageState?: string | null): Promise<DisposableEmailDomainPage> {
|
||||
const page = await fetchPage<{
|
||||
domain: string;
|
||||
}>(createLoadDisposableEmailDomainsQuery().bind({}), undefined, {
|
||||
pageSize: limit,
|
||||
pageState,
|
||||
});
|
||||
return {
|
||||
domains: page.rows.map((row) => row.domain),
|
||||
pageState: page.pageState,
|
||||
};
|
||||
}
|
||||
|
||||
async isPhraseBanned(phrase: string): Promise<boolean> {
|
||||
const phraseLower = canonicalizeStoredPhrase(phrase);
|
||||
const result = await fetchOne<{
|
||||
|
||||
@@ -32,11 +32,6 @@ export interface BannedIpEntry {
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
export interface DisposableEmailDomainPage {
|
||||
domains: Array<string>;
|
||||
pageState: string | null;
|
||||
}
|
||||
|
||||
export abstract class IAdminRepository {
|
||||
abstract createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog>;
|
||||
|
||||
@@ -68,8 +63,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
|
||||
|
||||
abstract listSuspiciousEmailDomains(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
|
||||
|
||||
abstract addDisposableEmailDomain(domain: string): Promise<void>;
|
||||
@@ -78,8 +71,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract listDisposableEmailDomains(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract listDisposableEmailDomainsPage(limit: number, pageState?: string | null): Promise<DisposableEmailDomainPage>;
|
||||
|
||||
abstract isPhraseBanned(phrase: string): Promise<boolean>;
|
||||
|
||||
abstract banPhrase(phrase: string): Promise<void>;
|
||||
|
||||
@@ -13,11 +13,7 @@ import type {ILogger} from '../ILogger';
|
||||
import {JobLedgerRepository} from '../jobs/JobLedgerRepository';
|
||||
import {startAbuseReplicationSubscriber, stopAbuseReplicationSubscriber} from '../middleware/AbusiveIpAutoBanner';
|
||||
import {ipBanCache} from '../middleware/IpBanMiddleware';
|
||||
import {
|
||||
getRiskCacheManagerInstance,
|
||||
initializeServiceSingletons,
|
||||
shutdownReportService,
|
||||
} from '../middleware/ServiceMiddleware';
|
||||
import {initializeServiceSingletons, shutdownReportService} from '../middleware/ServiceMiddleware';
|
||||
import {
|
||||
ensureVoiceResourcesInitialized,
|
||||
getKVClient,
|
||||
@@ -40,57 +36,6 @@ import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
|
||||
import {WorkerService} from '../worker/WorkerService';
|
||||
|
||||
let jsConnectionManager: JetStreamConnectionManager | null = null;
|
||||
let riskCacheRefreshInterval: NodeJS.Timeout | null = null;
|
||||
let riskCacheRefreshInFlight = false;
|
||||
|
||||
const RISK_CACHE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
async function refreshRiskCache(logger: ILogger, source: 'startup' | 'interval'): Promise<void> {
|
||||
if (riskCacheRefreshInFlight) {
|
||||
return;
|
||||
}
|
||||
riskCacheRefreshInFlight = true;
|
||||
try {
|
||||
const result = await getRiskCacheManagerInstance().refresh();
|
||||
if (result.subtaskErrors.length > 0) {
|
||||
logger.warn({source, errors: result.subtaskErrors}, 'Risk cache refresh completed with errors');
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
source,
|
||||
disposableDomainCount: result.disposableDomainCount,
|
||||
},
|
||||
source === 'startup' ? 'Risk cache initialized on API startup' : 'Risk cache refresh complete on API',
|
||||
);
|
||||
} catch (error) {
|
||||
if (source === 'startup') {
|
||||
logger.warn({error}, 'Risk cache initialisation failed on API startup');
|
||||
return;
|
||||
}
|
||||
logger.warn({error}, 'Periodic risk cache refresh failed on API');
|
||||
} finally {
|
||||
riskCacheRefreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRiskCacheRefreshLoop(logger: ILogger): void {
|
||||
if (riskCacheRefreshInterval) {
|
||||
return;
|
||||
}
|
||||
riskCacheRefreshInterval = setInterval(() => {
|
||||
void refreshRiskCache(logger, 'interval');
|
||||
}, RISK_CACHE_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopRiskCacheRefreshLoop(): void {
|
||||
if (!riskCacheRefreshInterval) {
|
||||
return;
|
||||
}
|
||||
clearInterval(riskCacheRefreshInterval);
|
||||
riskCacheRefreshInterval = null;
|
||||
}
|
||||
|
||||
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
@@ -171,8 +116,6 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
logger.info('Profile substring blocklist cache initialized');
|
||||
await initializeServiceSingletons();
|
||||
logger.info('Service singletons initialized');
|
||||
await refreshRiskCache(logger, 'startup');
|
||||
startRiskCacheRefreshLoop(logger);
|
||||
if (!config.dev.testModeEnabled) {
|
||||
jsConnectionManager = new JetStreamConnectionManager({
|
||||
url: config.nats.jetStreamUrl,
|
||||
@@ -285,12 +228,6 @@ export function createShutdown(logger: ILogger): () => Promise<void> {
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down search service');
|
||||
}
|
||||
try {
|
||||
stopRiskCacheRefreshLoop();
|
||||
logger.info('Risk cache refresh loop shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down risk cache refresh loop');
|
||||
}
|
||||
try {
|
||||
ipBanCache.shutdown();
|
||||
logger.info('IP ban cache shut down');
|
||||
|
||||
@@ -4,7 +4,6 @@ import {registerAdminControllers} from '../admin/controllers/index';
|
||||
import {AuthController} from '../auth/AuthController';
|
||||
import {BlueskyOAuthController} from '../bluesky/BlueskyOAuthController';
|
||||
import {Config} from '../Config';
|
||||
import {CanaryTesterController} from '../canary_tester/CanaryTesterController';
|
||||
import {ChannelController} from '../channel/ChannelController';
|
||||
import type {APIConfig} from '../config/APIConfig';
|
||||
import {ConnectionController} from '../connection/ConnectionController';
|
||||
@@ -67,7 +66,6 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
TestHarnessController(routes);
|
||||
}
|
||||
UserController(routes);
|
||||
CanaryTesterController(routes);
|
||||
registerInboundSmsWebhook(routes);
|
||||
WebhookController(routes);
|
||||
OAuth2Controller(routes);
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {CANARY_TESTER_MIN_ACCOUNT_AGE_MS, CANARY_TESTERS_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import {CanaryTesterEmailVerificationRequiredError} from '@fluxer/errors/src/domains/auth/EmailVerificationRequiredError';
|
||||
import {AccountTooNewForGuildError} from '@fluxer/errors/src/domains/guild/AccountTooNewForGuildError';
|
||||
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
|
||||
import {SuccessResponse} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {extractTimestampFromSnowflakeAsDateBigInt} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {createGuildID} 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 {getEffectiveSuspiciousFlags} from '../user/UserHelpers';
|
||||
|
||||
export function CanaryTesterController(app: HonoApp) {
|
||||
app.post(
|
||||
'/users/@me/canary-tester/join',
|
||||
RateLimitMiddleware(RateLimitConfigs.USER_CANARY_TESTER_JOIN),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'join_canary_testers',
|
||||
summary: 'Join the canary testers guild',
|
||||
description:
|
||||
'Adds the authenticated user to the hardcoded Fluxer Testers guild used for canary feedback. Restricted to non-bot users with verified email, an account at least 30 minutes old, no effective suspicious-activity flags, and not banned from the target guild. Rate-limited; surfaced via the canary nagbar.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: ['Users'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id;
|
||||
if (!user.email || !user.emailVerified) {
|
||||
throw new CanaryTesterEmailVerificationRequiredError();
|
||||
}
|
||||
const accountCreatedAt = extractTimestampFromSnowflakeAsDateBigInt(BigInt(userId.toString()));
|
||||
if (Date.now() - accountCreatedAt.getTime() < CANARY_TESTER_MIN_ACCOUNT_AGE_MS) {
|
||||
throw new AccountTooNewForGuildError();
|
||||
}
|
||||
const effectiveFlags = getEffectiveSuspiciousFlags(user);
|
||||
if (effectiveFlags !== 0) {
|
||||
throw new AccountSuspiciousActivityError(effectiveFlags);
|
||||
}
|
||||
const guildService = ctx.get('guildService');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const guildId = createGuildID(BigInt(CANARY_TESTERS_GUILD_ID));
|
||||
await guildService.members.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
requestCache,
|
||||
joinSourceType: JoinSourceTypes.INSTANT_INVITE,
|
||||
});
|
||||
return ctx.json({success: true} satisfies SuccessResponse);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,13 @@ interface MarkAttachmentUploadCompletedInput {
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
interface GetPendingAttachmentUploadInput {
|
||||
uploadKey: string;
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
uploadMode?: AttachmentUploadMode;
|
||||
}
|
||||
|
||||
export class AttachmentUploadTraceRepository {
|
||||
async getByUploadKey(uploadKey: string): Promise<AttachmentUploadTraceByKeyRow | null> {
|
||||
return await fetchOne<AttachmentUploadTraceByKeyRow>(GET_UPLOAD_TRACE_BY_KEY_QUERY.bind({upload_key: uploadKey}));
|
||||
@@ -47,6 +54,20 @@ export class AttachmentUploadTraceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getPendingUpload(input: GetPendingAttachmentUploadInput): Promise<AttachmentUploadTraceByKeyRow | null> {
|
||||
const existing = await this.getByUploadKey(input.uploadKey);
|
||||
if (
|
||||
!existing ||
|
||||
existing.user_id !== input.userId ||
|
||||
existing.channel_id !== input.channelId ||
|
||||
existing.attachment_id != null ||
|
||||
(input.uploadMode !== undefined && existing.upload_mode !== input.uploadMode)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
async recordRequestedUpload(input: RecordAttachmentUploadRequestInput): Promise<AttachmentUploadTraceByKeyRow> {
|
||||
const now = input.requestedAt ?? new Date();
|
||||
const row: AttachmentUploadTraceByKeyRow = {
|
||||
@@ -89,7 +110,7 @@ export class AttachmentUploadTraceRepository {
|
||||
attachmentId: AttachmentID,
|
||||
): Promise<AttachmentUploadTraceByAttachmentRow | null> {
|
||||
const existing = await this.getByUploadKey(uploadKey);
|
||||
if (!existing) {
|
||||
if (!existing || existing.attachment_id != null) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
|
||||
@@ -278,7 +278,20 @@ export class AttachmentUploadService {
|
||||
await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const bucket = Config.s3.buckets.uploads;
|
||||
return Promise.all(
|
||||
uploads.map(async ({upload_filename, upload_id}) => {
|
||||
uploads.map(async ({upload_filename, upload_id}, index) => {
|
||||
const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({
|
||||
uploadKey: upload_filename,
|
||||
userId,
|
||||
channelId,
|
||||
uploadMode: 'presigned_multipart',
|
||||
});
|
||||
if (!pendingUpload) {
|
||||
throw InputValidationError.fromCode(
|
||||
`uploads.${index}.upload_filename`,
|
||||
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
|
||||
{filename: upload_filename},
|
||||
);
|
||||
}
|
||||
const parts = await runAttachmentStorageOperation(() =>
|
||||
this.storageService.listParts({
|
||||
bucket,
|
||||
|
||||
@@ -142,6 +142,7 @@ export class ChannelService {
|
||||
favoriteMemeRepository,
|
||||
guildAuditLogService,
|
||||
messagePersistenceService,
|
||||
attachmentUploadTraceRepository,
|
||||
limitConfigService,
|
||||
directMessageSpamMitigationService,
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {DirectMessageSpamMitigationService} from '../../user/services/DirectMessageSpamMitigationService';
|
||||
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
|
||||
import type {IChannelRepositoryAggregate} from '../repositories/IChannelRepositoryAggregate';
|
||||
import type {AttachmentUploadTraceRepository} from '../repositories/message/AttachmentUploadTraceRepository';
|
||||
import {MessageAnonymizationService} from './message/MessageAnonymizationService';
|
||||
import {MessageChannelAuthService} from './message/MessageChannelAuthService';
|
||||
import {MessageDeleteService} from './message/MessageDeleteService';
|
||||
@@ -66,6 +67,7 @@ export class MessageService {
|
||||
favoriteMemeRepository: IFavoriteMemeRepository,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
persistenceService: MessagePersistenceService,
|
||||
attachmentUploadTraceRepository: AttachmentUploadTraceRepository,
|
||||
limitConfigService: LimitConfigService,
|
||||
directMessageSpamMitigationService: DirectMessageSpamMitigationService,
|
||||
) {
|
||||
@@ -125,6 +127,7 @@ export class MessageService {
|
||||
processingService: this.processing,
|
||||
dispatchService: this.dispatch,
|
||||
embedAttachmentResolver: this.persistence.getEmbedAttachmentResolver(),
|
||||
attachmentUploadTraceRepository,
|
||||
operationsHelpers,
|
||||
limitConfigService,
|
||||
directMessageSpamMitigationService,
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
|
||||
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
|
||||
import type {IVirusScanService} from '@pkgs/virus_scan/src/IVirusScanService';
|
||||
import {temporaryFile} from 'tempy';
|
||||
import {createAttachmentID} from '../../../BrandedTypes';
|
||||
import {createAttachmentID, type UserID} from '../../../BrandedTypes';
|
||||
import {Config} from '../../../Config';
|
||||
import type {MessageAttachment} from '../../../database/types/MessageTypes';
|
||||
import {contentModerationService, type ModerationContext} from '../../../infrastructure/ContentModerationService';
|
||||
@@ -54,6 +54,7 @@ interface ProcessAttachmentParams {
|
||||
message: Message;
|
||||
attachment: AttachmentToProcess;
|
||||
index: number;
|
||||
uploadUserId: UserID;
|
||||
channel?: Channel;
|
||||
guild?: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
@@ -88,6 +89,7 @@ export class AttachmentProcessingService {
|
||||
async computeAttachments(params: {
|
||||
message: Message;
|
||||
attachments: Array<AttachmentToProcess>;
|
||||
uploadUserId: UserID;
|
||||
channel?: Channel;
|
||||
guild?: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
@@ -105,6 +107,7 @@ export class AttachmentProcessingService {
|
||||
message: params.message,
|
||||
attachment,
|
||||
index,
|
||||
uploadUserId: params.uploadUserId,
|
||||
channel: params.channel,
|
||||
guild: params.guild,
|
||||
member: params.member,
|
||||
@@ -132,26 +135,30 @@ export class AttachmentProcessingService {
|
||||
}
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
results.map(async (result) => {
|
||||
const bound = await this.attachmentUploadTraceRepository.bindAttachment(
|
||||
const bindingResults = await Promise.all(
|
||||
results.map(async (result, index) => ({
|
||||
index,
|
||||
result,
|
||||
bound: await this.attachmentUploadTraceRepository.bindAttachment(
|
||||
result.copyOperation.sourceKey,
|
||||
result.attachment.attachment_id,
|
||||
);
|
||||
if (!bound) {
|
||||
Logger.warn(
|
||||
{
|
||||
attachmentId: result.attachment.attachment_id.toString(),
|
||||
uploadKey: result.copyOperation.sourceKey,
|
||||
},
|
||||
'Missing attachment upload trace while binding processed attachment',
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
})),
|
||||
);
|
||||
for (const result of results) {
|
||||
void this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
|
||||
}
|
||||
const unboundResult = bindingResults.find(({bound}) => bound === null);
|
||||
if (unboundResult) {
|
||||
for (const result of results) {
|
||||
this.deleteUploadObject(result.copyOperation.destinationBucket, result.copyOperation.destinationKey);
|
||||
}
|
||||
throw InputValidationError.fromCode(
|
||||
`attachments.${unboundResult.index}.upload_filename`,
|
||||
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
|
||||
{filename: unboundResult.result.attachment.filename},
|
||||
);
|
||||
}
|
||||
const processedAttachments: Array<MessageAttachment> = results.map((result, index) => {
|
||||
const finalObject = copyResults[index];
|
||||
if (result.applyFinalObjectMetadata && finalObject) {
|
||||
@@ -171,6 +178,18 @@ export class AttachmentProcessingService {
|
||||
|
||||
private async processAttachment(params: ProcessAttachmentParams): Promise<ProcessedAttachment> {
|
||||
const {message, attachment, index, nsfwMode} = params;
|
||||
const pendingUpload = await this.attachmentUploadTraceRepository.getPendingUpload({
|
||||
uploadKey: attachment.upload_filename,
|
||||
userId: params.uploadUserId,
|
||||
channelId: message.channelId,
|
||||
});
|
||||
if (!pendingUpload) {
|
||||
throw InputValidationError.fromCode(
|
||||
`attachments.${index}.upload_filename`,
|
||||
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
|
||||
{filename: attachment.filename},
|
||||
);
|
||||
}
|
||||
const uploadedFile = await this.storageService.getObjectMetadata(
|
||||
Config.s3.buckets.uploads,
|
||||
attachment.upload_filename,
|
||||
|
||||
@@ -66,13 +66,24 @@ export class MessageEditService {
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
const [canEmbedLinks, canMentionEveryone] = await Promise.all([
|
||||
const hasNewAttachments =
|
||||
data.attachments?.some(
|
||||
(attachment) =>
|
||||
'upload_filename' in attachment &&
|
||||
typeof attachment.upload_filename === 'string' &&
|
||||
attachment.upload_filename.length > 0,
|
||||
) ?? false;
|
||||
const [canEmbedLinks, canMentionEveryone, canAttachFiles] = await Promise.all([
|
||||
hasPermission(Permissions.EMBED_LINKS),
|
||||
hasPermission(Permissions.MENTION_EVERYONE),
|
||||
hasPermission(Permissions.ATTACH_FILES),
|
||||
]);
|
||||
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (hasNewAttachments && !canAttachFiles) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (isOperationDisabled(guild, GuildOperations.SEND_MESSAGE)) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
@@ -155,6 +166,7 @@ export class MessageEditService {
|
||||
channel,
|
||||
guild,
|
||||
member,
|
||||
attachmentUploadUserId: userId,
|
||||
allowEmbeds: canEmbedLinks,
|
||||
isBot: user?.isBot,
|
||||
isBugHunterBot,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {MessageFlags, Permissions, SENDABLE_MESSAGE_FLAGS} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -84,6 +85,7 @@ interface CreateMessageParams {
|
||||
flags: number;
|
||||
embeds?: Array<RichEmbedRequest>;
|
||||
attachments?: Array<AttachmentToProcess>;
|
||||
attachmentUploadUserId?: UserID;
|
||||
processedAttachments?: Array<MessageAttachment>;
|
||||
stickerIds?: Array<StickerID>;
|
||||
messageReference?: MessageReference;
|
||||
@@ -292,12 +294,15 @@ export class MessagePersistenceService {
|
||||
if (!params.attachments || params.attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const uploadUserId = params.attachmentUploadUserId;
|
||||
assert(uploadUserId !== undefined, 'Attachment upload actor must be resolved before processing new attachments');
|
||||
return this.attachmentService.computeAttachments({
|
||||
message: {
|
||||
id: params.messageId,
|
||||
channelId: params.channelId,
|
||||
} as Message,
|
||||
attachments: params.attachments,
|
||||
uploadUserId,
|
||||
channel: params.channel,
|
||||
guild: params.guild,
|
||||
member: params.member,
|
||||
@@ -412,6 +417,7 @@ export class MessagePersistenceService {
|
||||
guild: GuildResponse | null;
|
||||
member?: GuildMemberResponse | null;
|
||||
allowEmbeds?: boolean;
|
||||
attachmentUploadUserId?: UserID;
|
||||
isBot?: boolean;
|
||||
isBugHunterBot?: boolean;
|
||||
locale?: string | null;
|
||||
@@ -493,9 +499,15 @@ export class MessagePersistenceService {
|
||||
}
|
||||
let processedNewAttachments: Array<MessageAttachment> = [];
|
||||
if (newAttachments.length > 0) {
|
||||
const uploadUserId = params.attachmentUploadUserId;
|
||||
assert(
|
||||
uploadUserId !== undefined,
|
||||
'Attachment upload actor must be resolved before processing new attachments',
|
||||
);
|
||||
const attachmentResult = await this.attachmentService.computeAttachments({
|
||||
message,
|
||||
attachments: newAttachments,
|
||||
uploadUserId,
|
||||
channel,
|
||||
guild,
|
||||
member,
|
||||
@@ -513,7 +525,8 @@ export class MessagePersistenceService {
|
||||
}
|
||||
hasChanges = true;
|
||||
}
|
||||
if (allowEmbeds && (data.embeds !== undefined || (data.content !== undefined && message.embeds.length === 0))) {
|
||||
const embedsExplicitlyProvided = data.embeds !== undefined;
|
||||
if (allowEmbeds && (embedsExplicitlyProvided || data.content !== undefined)) {
|
||||
const attachmentsForResolution = updatedRowData.attachments || [];
|
||||
const resolvedEmbeds = this.embedAttachmentResolver.resolveEmbedAttachmentUrls({
|
||||
embeds: data.embeds,
|
||||
@@ -531,7 +544,15 @@ export class MessagePersistenceService {
|
||||
nsfwMode: isNSFWAllowed ? 'allow' : 'block',
|
||||
isBugHunterBot: params.isBugHunterBot,
|
||||
});
|
||||
updatedRowData.embeds = initialEmbeds;
|
||||
if (embedsExplicitlyProvided) {
|
||||
updatedRowData.embeds = initialEmbeds;
|
||||
} else {
|
||||
const preservedEmbeds = message.embeds
|
||||
.map((embed) => embed.toMessageEmbed())
|
||||
.filter((embed) => embed.type === 'rich');
|
||||
const nextEmbeds = [...preservedEmbeds, ...(initialEmbeds ?? [])];
|
||||
updatedRowData.embeds = nextEmbeds.length > 0 ? nextEmbeds : null;
|
||||
}
|
||||
hasUncachedUrls = embedUrls;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder'
|
||||
import type {User} from '../../../models/User';
|
||||
import type {HonoEnv} from '../../../types/HonoEnv';
|
||||
import {parseJsonPreservingLargeIntegers} from '../../../utils/LosslessJsonParser';
|
||||
import {inputValidationErrorFromZodIssues} from '../../../Validator';
|
||||
import {type AttachmentRequestData, mergeUploadWithClientData, type UploadedAttachment} from '../../AttachmentDTOs';
|
||||
import type {IChannelRepository} from '../../IChannelRepository';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '../../MessageTypes';
|
||||
@@ -54,7 +55,7 @@ export async function parseMultipartMessageData(
|
||||
options?.onPayloadParsed?.(mergedJsonData);
|
||||
const validationResult = schema.safeParse(mergedJsonData);
|
||||
if (!validationResult.success) {
|
||||
throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
throw inputValidationErrorFromZodIssues(validationResult.error.issues);
|
||||
}
|
||||
const data = validationResult.data as Partial<MessageRequest> &
|
||||
Partial<MessageUpdateRequest> & {
|
||||
|
||||
@@ -53,6 +53,7 @@ import {assertGuildMemberCanCommunicate} from '../../../utils/GuildCommunication
|
||||
import type {AttachmentRequestData, AttachmentToProcess} from '../../AttachmentDTOs';
|
||||
import type {MessageRequest, MessageUpdateRequest} from '../../MessageTypes';
|
||||
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
|
||||
import type {AttachmentUploadTraceRepository} from '../../repositories/message/AttachmentUploadTraceRepository';
|
||||
import type {AuthenticatedChannel} from '../AuthenticatedChannel';
|
||||
import type {MessageChannelAuthService} from './MessageChannelAuthService';
|
||||
import type {DmNsfwContext} from './MessageContentService';
|
||||
@@ -88,6 +89,7 @@ interface MessageSendServiceDeps {
|
||||
dispatchService: MessageDispatchService;
|
||||
operationsHelpers: MessageOperationsHelpers;
|
||||
embedAttachmentResolver: MessageEmbedAttachmentResolver;
|
||||
attachmentUploadTraceRepository: AttachmentUploadTraceRepository;
|
||||
limitConfigService: LimitConfigService;
|
||||
directMessageSpamMitigationService: DirectMessageSpamMitigationService;
|
||||
}
|
||||
@@ -204,6 +206,17 @@ export class MessageSendService {
|
||||
return processed.length > 0 ? processed : undefined;
|
||||
}
|
||||
|
||||
private resolveWebhookAttachmentUploadUserId(
|
||||
webhook: Webhook,
|
||||
attachments?: Array<AttachmentRequestData>,
|
||||
): UserID | undefined {
|
||||
const uploadUserId = webhook.creatorId ?? undefined;
|
||||
if (uploadUserId === undefined && this.attachmentsToProcess(attachments) !== undefined) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
}
|
||||
return uploadUserId;
|
||||
}
|
||||
|
||||
private getOneToOneDmRecipientId(channel: Channel, senderId: UserID): UserID | null {
|
||||
if (channel.guildId || channel.type !== ChannelTypes.DM) {
|
||||
return null;
|
||||
@@ -241,10 +254,14 @@ export class MessageSendService {
|
||||
hasPermission(Permissions.ATTACH_FILES),
|
||||
]);
|
||||
const hasFavoriteMeme = data.favorite_meme_id != null;
|
||||
const hasUploadedAttachments = this.attachmentsToProcess(data.attachments) !== undefined;
|
||||
if (data.embeds && data.embeds.length > 0 && !canEmbedLinks) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (hasFavoriteMeme && (!canEmbedLinks || !canAttachFiles)) {
|
||||
if (hasFavoriteMeme && !canEmbedLinks) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if ((hasFavoriteMeme || hasUploadedAttachments) && !canAttachFiles) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
if (guild) {
|
||||
@@ -369,6 +386,7 @@ export class MessageSendService {
|
||||
await this.ensureAttachmentsExist({
|
||||
attachments: data.attachments,
|
||||
user,
|
||||
channelId,
|
||||
guildFeatures: guild?.features ?? null,
|
||||
});
|
||||
}
|
||||
@@ -435,6 +453,7 @@ export class MessageSendService {
|
||||
await this.ensureAttachmentsExist({
|
||||
attachments: data.attachments,
|
||||
user,
|
||||
channelId,
|
||||
guildFeatures: null,
|
||||
});
|
||||
}
|
||||
@@ -481,10 +500,12 @@ export class MessageSendService {
|
||||
private async ensureAttachmentsExist({
|
||||
attachments,
|
||||
user,
|
||||
channelId,
|
||||
guildFeatures,
|
||||
}: {
|
||||
attachments?: Array<AttachmentRequestData>;
|
||||
user: User;
|
||||
channelId: ChannelID;
|
||||
guildFeatures: Iterable<string> | null;
|
||||
}): Promise<void> {
|
||||
if (!attachments || attachments.length === 0) return;
|
||||
@@ -494,6 +515,18 @@ export class MessageSendService {
|
||||
for (let index = 0; index < attachments.length; index++) {
|
||||
const attachment = attachments[index];
|
||||
if (!('upload_filename' in attachment) || !attachment.upload_filename) continue;
|
||||
const pendingUpload = await this.deps.attachmentUploadTraceRepository.getPendingUpload({
|
||||
uploadKey: attachment.upload_filename,
|
||||
userId: user.id,
|
||||
channelId,
|
||||
});
|
||||
if (!pendingUpload) {
|
||||
throw InputValidationError.fromCode(
|
||||
`attachments.${index}.upload_filename`,
|
||||
ValidationErrorCodes.UPLOADED_ATTACHMENT_NOT_FOUND,
|
||||
{filename: attachment.filename},
|
||||
);
|
||||
}
|
||||
const metadata = await this.deps.storageService.getObjectMetadata(
|
||||
Config.s3.buckets.uploads,
|
||||
attachment.upload_filename,
|
||||
@@ -832,6 +865,7 @@ export class MessageSendService {
|
||||
await this.ensureAttachmentsExist({
|
||||
attachments: data.attachments,
|
||||
user,
|
||||
channelId,
|
||||
guildFeatures: guild?.features ?? null,
|
||||
});
|
||||
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
|
||||
@@ -920,6 +954,7 @@ export class MessageSendService {
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
embeds: data.embeds,
|
||||
attachments: attachmentsToProcess,
|
||||
attachmentUploadUserId: user.id,
|
||||
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
|
||||
stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined,
|
||||
messageReference,
|
||||
@@ -1139,6 +1174,7 @@ export class MessageSendService {
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
embeds: data.embeds,
|
||||
attachments: this.attachmentsToProcess(data.attachments),
|
||||
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
messageReference,
|
||||
messageSnapshots,
|
||||
guildId: channel.guildId,
|
||||
@@ -1220,6 +1256,7 @@ export class MessageSendService {
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
allowEmbeds: true,
|
||||
});
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
@@ -1268,6 +1305,7 @@ export class MessageSendService {
|
||||
await this.ensureAttachmentsExist({
|
||||
attachments: data.attachments,
|
||||
user,
|
||||
channelId,
|
||||
guildFeatures: null,
|
||||
});
|
||||
const {attachmentsToProcess, favoriteMemeAttachment} = await this.prepareMessageAttachments({
|
||||
@@ -1292,6 +1330,7 @@ export class MessageSendService {
|
||||
flags: data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0,
|
||||
embeds: data.embeds,
|
||||
attachments: attachmentsToProcess,
|
||||
attachmentUploadUserId: user.id,
|
||||
processedAttachments: favoriteMemeAttachment ? [favoriteMemeAttachment] : undefined,
|
||||
messageReference,
|
||||
messageSnapshots,
|
||||
|
||||
@@ -178,6 +178,9 @@ export interface APIConfig {
|
||||
ipinfoApiKey?: string;
|
||||
accountPolicyDsl?: unknown;
|
||||
};
|
||||
blocklistFeeds: {
|
||||
enabled: boolean;
|
||||
};
|
||||
captcha: {
|
||||
enabled: boolean;
|
||||
provider: 'hcaptcha' | 'turnstile' | 'none';
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {existsSync} from 'node:fs';
|
||||
import {getDefaultPostgresClient, initPostgres, quoteIdentifier, shutdownPostgres} from '@pkgs/postgres/src/Client';
|
||||
import cassandra from 'cassandra-driver';
|
||||
import {afterAll, beforeAll, describe, expect, test} from 'vitest';
|
||||
import {defineTable} from './CassandraTableDsl';
|
||||
import {Db} from './CassandraTypes';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor, pruneExpiredPostgresKvRows} from './PostgresKvQueryExecutor';
|
||||
|
||||
interface TestRow {
|
||||
tenant_id: string;
|
||||
item_id: bigint;
|
||||
created_at: Date;
|
||||
payload: Buffer | null;
|
||||
tags: Set<string> | null;
|
||||
counts: Map<string, bigint> | null;
|
||||
local_day: cassandra.types.LocalDate | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
function postgresHost(): string {
|
||||
return process.env.FLUXER_POSTGRES_TEST_HOST ?? (existsSync('/.dockerenv') ? 'host.docker.internal' : '127.0.0.1');
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const kvTable = `fluxer_kv_test_${randomUUID().replaceAll('-', '_')}`;
|
||||
const logicalTableName = `pg_kv_test_${randomUUID().replaceAll('-', '_')}`;
|
||||
const TestRows = defineTable<TestRow, 'tenant_id' | 'item_id', 'tenant_id'>({
|
||||
name: logicalTableName,
|
||||
columns: ['tenant_id', 'item_id', 'created_at', 'payload', 'tags', 'counts', 'local_day', 'note'],
|
||||
primaryKey: ['tenant_id', 'item_id'],
|
||||
partitionKey: ['tenant_id'],
|
||||
});
|
||||
|
||||
describe('PostgresKvQueryExecutor', () => {
|
||||
let executor: PostgresKvQueryExecutor;
|
||||
let initialized = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
await initPostgres({
|
||||
host: postgresHost(),
|
||||
port: Number(process.env.FLUXER_POSTGRES_PORT ?? 5432),
|
||||
database: process.env.FLUXER_POSTGRES_DATABASE ?? 'fluxer',
|
||||
username: process.env.FLUXER_POSTGRES_USERNAME ?? 'fluxer',
|
||||
password: process.env.FLUXER_POSTGRES_PASSWORD ?? 'fluxer',
|
||||
kvTable,
|
||||
maxConnections: 4,
|
||||
});
|
||||
initialized = true;
|
||||
const client = getDefaultPostgresClient();
|
||||
await ensurePostgresKvSchema(client);
|
||||
executor = new PostgresKvQueryExecutor(client);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!initialized) return;
|
||||
try {
|
||||
await getDefaultPostgresClient().query(`DROP TABLE IF EXISTS ${quoteIdentifier(kvTable)}`);
|
||||
} finally {
|
||||
await shutdownPostgres();
|
||||
}
|
||||
});
|
||||
|
||||
test('round trips typed values and conditional writes through a real Postgres KV table', async () => {
|
||||
const row: TestRow = {
|
||||
tenant_id: 'tenant-a',
|
||||
item_id: 10n,
|
||||
created_at: new Date('2026-01-02T03:04:05.006Z'),
|
||||
payload: Buffer.from('hello postgres kv'),
|
||||
tags: new Set(['alpha', 'beta']),
|
||||
counts: new Map([['seen', 5n]]),
|
||||
local_day: cassandra.types.LocalDate.fromString('2026-01-02'),
|
||||
note: 'first',
|
||||
};
|
||||
|
||||
await expect(executor.executeQuery<{['[applied]']: boolean}>(TestRows.insertIfNotExists(row))).resolves.toEqual([
|
||||
{'[applied]': true},
|
||||
]);
|
||||
await expect(
|
||||
executor.executeQuery<{['[applied]']: boolean}>(TestRows.insertIfNotExists({...row, note: 'second'})),
|
||||
).resolves.toEqual([{'[applied]': false}]);
|
||||
|
||||
const fetched = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: [TestRows.where.eq('tenant_id'), TestRows.where.eq('item_id')],
|
||||
limit: 1,
|
||||
}).bind({tenant_id: 'tenant-a', item_id: 10n}),
|
||||
);
|
||||
|
||||
expect(fetched).toHaveLength(1);
|
||||
expect(fetched[0]!.item_id).toBe(10n);
|
||||
expect(fetched[0]!.created_at.getTime()).toBe(row.created_at.getTime());
|
||||
expect(fetched[0]!.payload?.equals(row.payload!)).toBe(true);
|
||||
expect([...fetched[0]!.tags!.values()].sort()).toEqual(['alpha', 'beta']);
|
||||
expect(fetched[0]!.counts!.get('seen')).toBe(5n);
|
||||
expect(fetched[0]!.local_day?.toString()).toBe('2026-01-02');
|
||||
expect(fetched[0]!.note).toBe('first');
|
||||
});
|
||||
|
||||
test('filters, orders, patches, pages, and deletes logical rows', async () => {
|
||||
await executor.executeBatch(
|
||||
[
|
||||
TestRows.upsertAll({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 1n,
|
||||
created_at: new Date('2026-02-01T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'one',
|
||||
}),
|
||||
TestRows.upsertAll({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 2n,
|
||||
created_at: new Date('2026-02-02T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'two',
|
||||
}),
|
||||
TestRows.upsertAll({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 3n,
|
||||
created_at: new Date('2026-02-03T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'three',
|
||||
}),
|
||||
].map((query) => ({query: query.cql, params: query.params, meta: query.kvMeta})),
|
||||
);
|
||||
|
||||
const firstPage = await executor.executePagedQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: TestRows.where.eq('tenant_id'),
|
||||
orderBy: {col: 'item_id', direction: 'DESC'},
|
||||
}).bind({tenant_id: 'tenant-b'}),
|
||||
{pageSize: 2},
|
||||
);
|
||||
expect(firstPage.rows.map((row) => row.item_id)).toEqual([3n, 2n]);
|
||||
expect(firstPage.pageState).not.toBeNull();
|
||||
|
||||
const secondPage = await executor.executePagedQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: TestRows.where.eq('tenant_id'),
|
||||
orderBy: {col: 'item_id', direction: 'DESC'},
|
||||
}).bind({tenant_id: 'tenant-b'}),
|
||||
{pageSize: 2, pageState: firstPage.pageState},
|
||||
);
|
||||
expect(secondPage.rows.map((row) => row.item_id)).toEqual([1n]);
|
||||
expect(secondPage.pageState).toBeNull();
|
||||
|
||||
await executor.executeQuery(TestRows.patchByPk({tenant_id: 'tenant-b', item_id: 2n}, {note: Db.clear()}));
|
||||
const patched = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({where: [TestRows.where.eq('tenant_id'), TestRows.where.eq('item_id')], limit: 1}).bind({
|
||||
tenant_id: 'tenant-b',
|
||||
item_id: 2n,
|
||||
}),
|
||||
);
|
||||
expect(patched[0]!.note).toBeNull();
|
||||
|
||||
await executor.executeQuery(TestRows.delete({where: TestRows.where.eq('tenant_id')}).bind({tenant_id: 'tenant-b'}));
|
||||
const remaining = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({where: TestRows.where.eq('tenant_id')}).bind({tenant_id: 'tenant-b'}),
|
||||
);
|
||||
expect(remaining).toEqual([]);
|
||||
});
|
||||
|
||||
test('filters expired rows and prunes them physically', async () => {
|
||||
const client = getDefaultPostgresClient();
|
||||
await executor.executeQuery(
|
||||
TestRows.upsertAllWithTtl(
|
||||
{
|
||||
tenant_id: 'tenant-expired',
|
||||
item_id: 1n,
|
||||
created_at: new Date('2026-03-01T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'temporary',
|
||||
},
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
await sleep(1100);
|
||||
|
||||
const visibleRows = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({where: TestRows.where.eq('tenant_id')}).bind({tenant_id: 'tenant-expired'}),
|
||||
);
|
||||
expect(visibleRows).toEqual([]);
|
||||
|
||||
const physicalBefore = await client.query<{count: string}>(
|
||||
`SELECT count(*)::text AS count FROM ${quoteIdentifier(kvTable)} WHERE table_name = $1 AND row_data ->> 'tenant_id' = $2`,
|
||||
[logicalTableName, 'tenant-expired'],
|
||||
);
|
||||
expect(Number(physicalBefore.rows[0]!.count)).toBe(1);
|
||||
|
||||
const pruned = await pruneExpiredPostgresKvRows(client, 10);
|
||||
expect(pruned).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const physicalAfter = await client.query<{count: string}>(
|
||||
`SELECT count(*)::text AS count FROM ${quoteIdentifier(kvTable)} WHERE table_name = $1 AND row_data ->> 'tenant_id' = $2`,
|
||||
[logicalTableName, 'tenant-expired'],
|
||||
);
|
||||
expect(Number(physicalAfter.rows[0]!.count)).toBe(0);
|
||||
});
|
||||
|
||||
test('patching a logically expired row makes it visible again without keeping stale TTL', async () => {
|
||||
await executor.executeQuery(
|
||||
TestRows.upsertAllWithTtl(
|
||||
{
|
||||
tenant_id: 'tenant-resurrect',
|
||||
item_id: 1n,
|
||||
created_at: new Date('2026-04-01T00:00:00.000Z'),
|
||||
payload: null,
|
||||
tags: null,
|
||||
counts: null,
|
||||
local_day: null,
|
||||
note: 'temporary',
|
||||
},
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
await sleep(1100);
|
||||
await executor.executeQuery(
|
||||
TestRows.patchByPk({tenant_id: 'tenant-resurrect', item_id: 1n}, {note: Db.set('resurrected')}),
|
||||
);
|
||||
|
||||
const rows = await executor.executeQuery<TestRow>(
|
||||
TestRows.select({
|
||||
where: [TestRows.where.eq('tenant_id'), TestRows.where.eq('item_id')],
|
||||
limit: 1,
|
||||
}).bind({tenant_id: 'tenant-resurrect', item_id: 1n}),
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.note).toBe('resurrected');
|
||||
});
|
||||
});
|
||||
@@ -561,14 +561,14 @@ WHERE NOT $6`,
|
||||
|
||||
private async patch(meta: KvQueryMeta, params: CassandraParams, db: PostgresQueryable): Promise<void> {
|
||||
const key = rowKeyFromParams(meta, params);
|
||||
const existing = await this.getRow(meta, key, db);
|
||||
const base = existing ?? paramsRow(params, (meta.pkColumns ?? meta.table.primaryKey) as ReadonlyArray<string>);
|
||||
const stored = await this.getStoredRow(meta, key, db);
|
||||
const base = stored?.row ?? paramsRow(params, (meta.pkColumns ?? meta.table.primaryKey) as ReadonlyArray<string>);
|
||||
const next = {...base};
|
||||
for (const column of meta.patchKeys ?? []) {
|
||||
next[column] = column in params ? params[column] : null;
|
||||
}
|
||||
const ttl = ttlExpiresAt(meta, params);
|
||||
const expiresAt = ttl === undefined ? await this.getExpiresAt(meta, key, db) : ttl;
|
||||
const expiresAt = ttl === undefined ? (stored?.expiresAt ?? null) : ttl;
|
||||
await db.query(
|
||||
`INSERT INTO ${this.table} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, now())
|
||||
@@ -592,20 +592,20 @@ DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_da
|
||||
]);
|
||||
}
|
||||
|
||||
private async getRow(meta: KvQueryMeta, key: string, db: PostgresQueryable): Promise<Row | null> {
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT row_key, row_data FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
private async getStoredRow(
|
||||
meta: KvQueryMeta,
|
||||
key: string,
|
||||
db: PostgresQueryable,
|
||||
): Promise<{row: Row; expiresAt: Date | null} | null> {
|
||||
const result = await db.query<StoredRow & {expires_at: Date | null}>(
|
||||
`SELECT row_key, row_data, expires_at FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
[meta.table.name, key],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? decodeRow(row.row_data) : null;
|
||||
return row ? {row: decodeRow(row.row_data), expiresAt: row.expires_at ?? null} : null;
|
||||
}
|
||||
|
||||
private async getExpiresAt(meta: KvQueryMeta, key: string, db: PostgresQueryable): Promise<Date | null> {
|
||||
const result = await db.query<{expires_at: Date | null}>(
|
||||
`SELECT expires_at FROM ${this.table} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
|
||||
[meta.table.name, key],
|
||||
);
|
||||
return result.rows[0]?.expires_at ?? null;
|
||||
private async getRow(meta: KvQueryMeta, key: string, db: PostgresQueryable): Promise<Row | null> {
|
||||
return (await this.getStoredRow(meta, key, db))?.row ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,8 @@ type FormatMapping = {
|
||||
|
||||
const FORMAT_MAPPINGS: Record<DesktopFormat, Partial<Record<DesktopPlatform, FormatMapping>>> = {
|
||||
setup: {win32: {ext: '.exe', arch: {x64: 'x64', arm64: 'arm64'}}},
|
||||
dmg: {darwin: {ext: '.dmg', arch: {x64: 'x64', arm64: 'arm64'}}},
|
||||
zip: {darwin: {ext: '.zip', arch: {x64: 'x64', arm64: 'arm64'}}},
|
||||
dmg: {darwin: {ext: '.dmg', arch: {x64: ['universal', 'x64'], arm64: ['universal', 'arm64']}}},
|
||||
zip: {darwin: {ext: '.zip', arch: {x64: ['universal', 'x64'], arm64: ['universal', 'arm64']}}},
|
||||
appimage: {linux: {ext: '.AppImage', arch: {x64: 'x86_64', arm64: ['aarch64', 'arm64']}}},
|
||||
deb: {linux: {ext: '.deb', arch: {x64: 'amd64', arm64: 'arm64'}}},
|
||||
rpm: {linux: {ext: '.rpm', arch: {x64: 'x86_64', arm64: 'aarch64'}}},
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
|
||||
import {resolveClientGeoip} from '@pkgs/geoip/src/ResolveClientGeoip';
|
||||
import type {Hono} from 'hono';
|
||||
import {Config} from '../Config';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
|
||||
export function GeolocationController(app: Hono<HonoEnv>): void {
|
||||
app.get('/ip', RateLimitMiddleware(RateLimitConfigs.IP_GEO_LOOKUP), async (ctx) => {
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
const response = await resolveClientGeoip(ctx.req.raw, {
|
||||
maxmindDbPath: Config.geoip.maxmindDbPath,
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
return ctx.json(response);
|
||||
});
|
||||
app.get(
|
||||
'/ip',
|
||||
RateLimitMiddleware(RateLimitConfigs.IP_GEO_LOOKUP),
|
||||
OpenAPI({
|
||||
operationId: 'get_client_geolocation',
|
||||
summary: 'Get client geolocation',
|
||||
responseSchema: GeolocationResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: ['Geolocation'],
|
||||
description:
|
||||
'Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
const response = await resolveClientGeoip(ctx.req.raw, {
|
||||
maxmindDbPath: Config.geoip.maxmindDbPath,
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.options('/ip', (ctx) => {
|
||||
ctx.header('Access-Control-Allow-Origin', '*');
|
||||
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
@@ -21,8 +21,8 @@ const DEFAULT_GIF_SERVICE_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS = 3_000;
|
||||
const GIF_PROVIDER_META: GifProviderMeta = {
|
||||
name: 'klipy',
|
||||
displayName: 'KLIPY',
|
||||
attributionRequired: true,
|
||||
displayName: 'Klipy',
|
||||
attributionRequired: false,
|
||||
};
|
||||
const KLIPY_SHARE_ORIGIN = 'https://klipy.com';
|
||||
const KLIPY_SHARE_HOSTS = new Set(['klipy.com', 'www.klipy.com']);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/Insta
|
||||
import type {Hono} from 'hono';
|
||||
import {Config} from '../Config';
|
||||
import type {GifService} from '../gif/GifService';
|
||||
import type {IGifProvider} from '../gif/IGifProvider';
|
||||
import type {LimitConfigService} from '../limits/LimitConfigService';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
@@ -24,7 +25,18 @@ function buildDiscoveryStaticInput(
|
||||
): DiscoveryStaticInput {
|
||||
const apiClientEndpoint = Config.endpoints.apiClient;
|
||||
const apiPublicEndpoint = Config.endpoints.apiPublic;
|
||||
const gifProvider = gifService?.getProvider();
|
||||
let gifProvider: IGifProvider | undefined;
|
||||
if (gifService !== undefined) {
|
||||
gifProvider = gifService.getProvider();
|
||||
}
|
||||
let gifProviderName = 'klipy';
|
||||
let gifDisplayName = 'Klipy';
|
||||
let gifAttributionRequired = false;
|
||||
if (gifProvider !== undefined) {
|
||||
gifProviderName = gifProvider.meta.name;
|
||||
gifDisplayName = gifProvider.meta.displayName;
|
||||
gifAttributionRequired = gifProvider.meta.attributionRequired;
|
||||
}
|
||||
return {
|
||||
apiCodeVersion: API_CODE_VERSION,
|
||||
endpoints: {
|
||||
@@ -53,9 +65,9 @@ function buildDiscoveryStaticInput(
|
||||
emails_enabled: runtime.emailEnabled,
|
||||
},
|
||||
gif: {
|
||||
provider: gifProvider?.meta.name ?? 'klipy',
|
||||
display_name: gifProvider?.meta.displayName ?? 'KLIPY',
|
||||
attribution_required: gifProvider?.meta.attributionRequired ?? true,
|
||||
provider: gifProviderName,
|
||||
display_name: gifDisplayName,
|
||||
attribution_required: gifAttributionRequired,
|
||||
},
|
||||
push: {
|
||||
public_vapid_key: Config.push.publicVapidKey ?? null,
|
||||
|
||||
@@ -60,8 +60,6 @@ import {CassandraHistoricalOutcomeRepository} from '../risk/HistoricalOutcomeRep
|
||||
import {buildIpInfoCache, buildIpInfoRequestAuditLogger} from '../risk/IpInfoCacheFactory';
|
||||
import {CassandraRegistrationEventsRepository} from '../risk/RegistrationEventsRepository';
|
||||
import {CassandraRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {buildRiskCacheLoaders} from '../risk/RiskCacheLoaders';
|
||||
import {RiskCacheManager} from '../risk/RiskCacheManager';
|
||||
import {createRiskToolbox} from '../risk/RiskToolboxFactory';
|
||||
import {CassandraSuspiciousIpRepository} from '../risk/SuspiciousIpRepository';
|
||||
import {RpcService} from '../rpc/RpcService';
|
||||
@@ -192,24 +190,6 @@ export function shutdownReportService(): void {
|
||||
}
|
||||
}
|
||||
|
||||
let _riskCacheManager: RiskCacheManager | null = null;
|
||||
|
||||
function getRiskCacheManager(): RiskCacheManager {
|
||||
if (!_riskCacheManager) {
|
||||
_riskCacheManager = new RiskCacheManager({
|
||||
logger: Logger,
|
||||
...buildRiskCacheLoaders({
|
||||
adminRepository: getAdminRepository(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return _riskCacheManager;
|
||||
}
|
||||
|
||||
export function getRiskCacheManagerInstance(): RiskCacheManager {
|
||||
return getRiskCacheManager();
|
||||
}
|
||||
|
||||
let _inboundSmsChallengeService: InboundSmsChallengeService | null = null;
|
||||
|
||||
function getInboundSmsChallengeService(): InboundSmsChallengeService {
|
||||
@@ -311,13 +291,12 @@ function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
|
||||
_registrationRiskEvaluator = noopRegistrationRiskEvaluator;
|
||||
return _registrationRiskEvaluator;
|
||||
}
|
||||
const cacheManager = getRiskCacheManager();
|
||||
const ipInfoService = getIpInfoService();
|
||||
const ipInfoChecker = Config.risk.ipinfoApiKey ? createIpInfoChecker({ipInfoService}) : undefined;
|
||||
const cacheService = getCacheService();
|
||||
const reverseDnsLookup = createReverseDnsLookup({cacheService});
|
||||
const toolbox = createRiskToolbox({
|
||||
disposableDomainsRef: cacheManager.disposableDomainsRef,
|
||||
adminRepository: getAdminRepository(),
|
||||
ipInfoChecker,
|
||||
reverseDnsLookup,
|
||||
ipInfoService,
|
||||
|
||||
@@ -10800,6 +10800,64 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/ip": {
|
||||
"get": {
|
||||
"operationId": "get_client_geolocation",
|
||||
"summary": "Get client geolocation",
|
||||
"tags": ["Geolocation"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GeolocationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Get client geolocation"}},
|
||||
"description": "Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable."
|
||||
}
|
||||
},
|
||||
"/oauth2/@me": {
|
||||
"get": {
|
||||
"operationId": "get_current_user_oauth2",
|
||||
@@ -16130,73 +16188,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/@me/canary-tester/join": {
|
||||
"post": {
|
||||
"operationId": "join_canary_testers",
|
||||
"summary": "Join the canary testers guild",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SuccessResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Join the canary testers guild"}},
|
||||
"description": "Adds the authenticated user to the hardcoded Fluxer Testers guild used for canary feedback. Restricted to non-bot users with verified email, an account at least 30 minutes old, no effective suspicious-activity flags, and not banned from the target guild. Rate-limited; surfaced via the canary nagbar.",
|
||||
"security": [{"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/users/@me/channels": {
|
||||
"get": {
|
||||
"operationId": "list_private_channels",
|
||||
@@ -25206,7 +25197,6 @@
|
||||
"DISCRIMINATOR_REQUIRED",
|
||||
"EMAIL_SERVICE_NOT_TESTABLE",
|
||||
"EMAIL_VERIFICATION_REQUIRED",
|
||||
"CANARY_TESTER_EMAIL_VERIFICATION_REQUIRED",
|
||||
"DIRECT_MESSAGE_EMAIL_VERIFICATION_REQUIRED",
|
||||
"FRIEND_REQUEST_EMAIL_VERIFICATION_REQUIRED",
|
||||
"GUILD_CREATION_EMAIL_VERIFICATION_REQUIRED",
|
||||
@@ -25532,7 +25522,6 @@
|
||||
"Email verification is required for this action.",
|
||||
"Email verification is required for this action.",
|
||||
"Email verification is required for this action.",
|
||||
"Email verification is required for this action.",
|
||||
"Registration is closed on this instance.",
|
||||
"This registration is waiting for admin approval.",
|
||||
"This registration request was rejected.",
|
||||
@@ -31995,6 +31984,49 @@
|
||||
},
|
||||
"required": ["code", "type", "pack", "temporary"]
|
||||
},
|
||||
"GeolocationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"countryCode": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO 3166-1 alpha-2 country code resolved for the client, or null when it cannot be resolved"
|
||||
},
|
||||
"regionCode": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO 3166-2 subdivision code resolved for the client, or null when it cannot be resolved"
|
||||
},
|
||||
"latitude": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Approximate latitude of the client, or null when it cannot be resolved"
|
||||
},
|
||||
"longitude": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Approximate longitude of the client, or null when it cannot be resolved"
|
||||
},
|
||||
"ageRestrictedGeos": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/GeoEntry"},
|
||||
"description": "Locations where age restricted content requires an age check"
|
||||
},
|
||||
"ageBlockedGeos": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/GeoEntry"},
|
||||
"description": "Locations where age restricted content is unavailable"
|
||||
}
|
||||
},
|
||||
"required": ["countryCode", "regionCode", "latitude", "longitude", "ageRestrictedGeos", "ageBlockedGeos"]
|
||||
},
|
||||
"GeoEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"countryCode": {"type": "string", "description": "ISO 3166-1 alpha-2 country code"},
|
||||
"regionCode": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO 3166-2 subdivision code, or null when the entry covers the whole country"
|
||||
}
|
||||
},
|
||||
"required": ["countryCode", "regionCode"]
|
||||
},
|
||||
"OAuth2MeResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -38232,6 +38264,7 @@
|
||||
{"name": "Connections"},
|
||||
{"name": "Messages"},
|
||||
{"name": "Donations"},
|
||||
{"name": "Geolocation"},
|
||||
{"name": "Discovery"},
|
||||
{"name": "Emojis"},
|
||||
{"name": "Stickers"},
|
||||
|
||||
@@ -216,10 +216,6 @@ export const UserRateLimitConfigs = {
|
||||
bucket: 'user:harvest:download',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_CANARY_TESTER_JOIN: {
|
||||
bucket: 'user:canary_tester:join',
|
||||
config: {limit: 3, windowMs: ms('1 hour')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_ENTRANCE_SOUND_LIST: {
|
||||
bucket: 'user:entrance_sound:list',
|
||||
config: {limit: 30, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IAdminRepository} from '../admin/IAdminRepository';
|
||||
import type {RiskCacheLoaders} from './RiskCacheManager';
|
||||
|
||||
interface BuildRiskCacheLoadersDeps {
|
||||
adminRepository: Pick<IAdminRepository, 'listSuspiciousEmailDomains' | 'listDisposableEmailDomains'>;
|
||||
}
|
||||
|
||||
export function buildRiskCacheLoaders(deps: BuildRiskCacheLoadersDeps): RiskCacheLoaders {
|
||||
return {
|
||||
loadDisposableDomains: async () => {
|
||||
const [suspicious, disposable] = await Promise.all([
|
||||
deps.adminRepository.listSuspiciousEmailDomains(),
|
||||
deps.adminRepository.listDisposableEmailDomains(),
|
||||
]);
|
||||
const set = new Set<string>();
|
||||
for (const d of suspicious) set.add(d.toLowerCase());
|
||||
for (const d of disposable) set.add(d.toLowerCase());
|
||||
return set;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
interface MutableRef<T> {
|
||||
current: T;
|
||||
}
|
||||
|
||||
export type ReadonlyRiskCacheRef<T> = {
|
||||
readonly current: T;
|
||||
};
|
||||
|
||||
interface RiskCacheManagerLogger {
|
||||
info(payload: object, msg: string): void;
|
||||
warn(payload: object, msg: string): void;
|
||||
}
|
||||
|
||||
export interface RiskCacheLoaders {
|
||||
loadDisposableDomains: () => Promise<ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
interface RiskCacheManagerOptions extends RiskCacheLoaders {
|
||||
logger?: RiskCacheManagerLogger;
|
||||
}
|
||||
|
||||
interface RiskCacheRefreshResult {
|
||||
disposableDomainCount: number;
|
||||
subtaskErrors: ReadonlyArray<{
|
||||
step: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class RiskCacheManager {
|
||||
private readonly _disposable: MutableRef<ReadonlySet<string>> = {current: new Set()};
|
||||
readonly disposableDomainsRef: ReadonlyRiskCacheRef<ReadonlySet<string>> = this._disposable;
|
||||
private readonly logger: RiskCacheManagerLogger | undefined;
|
||||
private readonly loaders: RiskCacheLoaders;
|
||||
|
||||
constructor(opts: RiskCacheManagerOptions) {
|
||||
this.logger = opts.logger;
|
||||
this.loaders = {
|
||||
loadDisposableDomains: opts.loadDisposableDomains,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(): Promise<RiskCacheRefreshResult> {
|
||||
const subtaskErrors: Array<{
|
||||
step: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
try {
|
||||
const set = await this.loaders.loadDisposableDomains();
|
||||
this._disposable.current = set;
|
||||
this.logger?.info({count: set.size}, 'RiskCacheManager: loaded disposable domains from DB');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger?.warn({step: 'disposable_domains', err: message}, 'RiskCacheManager: subtask failed');
|
||||
subtaskErrors.push({step: 'disposable_domains', error: message});
|
||||
}
|
||||
return {
|
||||
disposableDomainCount: this._disposable.current.size,
|
||||
subtaskErrors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {IAdminRepository} from '../admin/IAdminRepository';
|
||||
import {createDisposableDomainChecker} from './adapters/DisposableDomainChecker';
|
||||
import {createDnsMxChecker, type MxResolver, NodeDnsMxResolver} from './adapters/DnsMxChecker';
|
||||
import {createDomainAgeChecker} from './adapters/DomainAgeChecker';
|
||||
@@ -13,13 +14,12 @@ import {analyzeRegistrationTiming} from './adapters/RegistrationTimingAnalyzer';
|
||||
import {analyzeUserAgent} from './adapters/UserAgentAnalyzer';
|
||||
import {createVelocityAdapter, type IRegistrationEventsRepository} from './adapters/VelocityAdapter';
|
||||
import type {IRiskHistoryRepository} from './HistoricalOutcomeRepository';
|
||||
import type {ReadonlyRiskCacheRef} from './RiskCacheManager';
|
||||
import type {RiskToolbox} from './RiskToolbox';
|
||||
import type {IpInfoAnonymousResult, ReverseDnsResult} from './RiskTypes';
|
||||
import type {ISuspiciousIpRepository} from './SuspiciousIpRepository';
|
||||
|
||||
interface RiskToolboxFactoryOptions {
|
||||
disposableDomainsRef: ReadonlyRiskCacheRef<ReadonlySet<string>>;
|
||||
adminRepository: Pick<IAdminRepository, 'isEmailDomainSuspicious' | 'isEmailDomainDisposable'>;
|
||||
ipInfoChecker?: (ip: string) => Promise<IpInfoAnonymousResult>;
|
||||
reverseDnsLookup?: (ip: string) => Promise<ReverseDnsResult>;
|
||||
ipInfoService: IpInfoService;
|
||||
@@ -32,7 +32,7 @@ interface RiskToolboxFactoryOptions {
|
||||
}
|
||||
|
||||
export function createRiskToolbox(opts: RiskToolboxFactoryOptions): RiskToolbox {
|
||||
const checkDomainDisposable = createDisposableDomainChecker({disposableDomainsRef: opts.disposableDomainsRef});
|
||||
const checkDomainDisposable = createDisposableDomainChecker({adminRepository: opts.adminRepository});
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({ipInfoService: opts.ipInfoService});
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({ipInfoService: opts.ipInfoService});
|
||||
const checkMx = createDnsMxChecker({
|
||||
|
||||
@@ -97,7 +97,6 @@ export interface EmailSyntaxResult {
|
||||
export interface DisposableCheckResult {
|
||||
domain: string;
|
||||
isDisposable: boolean;
|
||||
listSize: number;
|
||||
}
|
||||
|
||||
export interface MxCheckResult {
|
||||
|
||||
@@ -54,7 +54,6 @@ function createToolbox(
|
||||
checkDomainDisposable: async ({domain}) => ({
|
||||
domain,
|
||||
isDisposable: false,
|
||||
listSize: 0,
|
||||
}),
|
||||
checkMx: async ({domain}) => ({
|
||||
domain,
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IAdminRepository} from '../../admin/IAdminRepository';
|
||||
import type {DisposableCheckResult} from '../RiskTypes';
|
||||
|
||||
interface DisposableDomainCheckerContext {
|
||||
disposableDomainsRef: {
|
||||
readonly current: ReadonlySet<string>;
|
||||
};
|
||||
adminRepository: Pick<IAdminRepository, 'isEmailDomainSuspicious' | 'isEmailDomainDisposable'>;
|
||||
}
|
||||
|
||||
export function createDisposableDomainChecker(ctx: DisposableDomainCheckerContext) {
|
||||
return async function checkDomainDisposable(args: {domain: string}): Promise<DisposableCheckResult> {
|
||||
const domain = args.domain.toLowerCase().trim();
|
||||
const set = ctx.disposableDomainsRef.current;
|
||||
const [suspicious, disposable] = await Promise.all([
|
||||
ctx.adminRepository.isEmailDomainSuspicious(domain),
|
||||
ctx.adminRepository.isEmailDomainDisposable(domain),
|
||||
]);
|
||||
return {
|
||||
domain,
|
||||
isDisposable: set.has(domain),
|
||||
listSize: set.size,
|
||||
isDisposable: suspicious || disposable,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import http from 'node:http';
|
||||
import type {AddressInfo} from 'node:net';
|
||||
import {type FakeNcmecFailure, FakeNcmecServer} from './FakeNcmecServer';
|
||||
|
||||
const host = process.env.FAKE_NCMEC_HOST ?? '127.0.0.1';
|
||||
const port = readPort('FAKE_NCMEC_PORT', 9090);
|
||||
const controlHost = process.env.FAKE_NCMEC_CONTROL_HOST ?? '127.0.0.1';
|
||||
const controlPort = readPort('FAKE_NCMEC_CONTROL_PORT', port + 1);
|
||||
const publicHost = process.env.FAKE_NCMEC_PUBLIC_HOST ?? (host === '0.0.0.0' ? '<reachable-host-or-ip>' : host);
|
||||
const username = process.env.FAKE_NCMEC_USERNAME ?? 'usr123';
|
||||
const password = process.env.FAKE_NCMEC_PASSWORD ?? 'pswd123';
|
||||
|
||||
if (host === controlHost && port === controlPort) {
|
||||
throw new Error('FAKE_NCMEC_CONTROL_PORT must differ from FAKE_NCMEC_PORT when both servers bind the same host');
|
||||
}
|
||||
|
||||
const fake = new FakeNcmecServer({username, password});
|
||||
|
||||
function log(...parts: Array<unknown>): void {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[fake-ncmec ${ts}]`, ...parts);
|
||||
}
|
||||
|
||||
await fake.start({
|
||||
host,
|
||||
port,
|
||||
onRequest: (entry) => {
|
||||
log(
|
||||
`${entry.remoteAddress ?? '?'} ${entry.method} ${entry.path} -> ${entry.statusCode} ${entry.durationMs}ms (${entry.requestId})`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const controlServer = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
||||
log(`control ${req.method} ${url.pathname}`);
|
||||
if (req.method === 'GET' && url.pathname === '/_state') {
|
||||
res.writeHead(200, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify(fake.getReports(), null, 2));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname.startsWith('/_report/')) {
|
||||
const reportId = decodeURIComponent(url.pathname.slice('/_report/'.length));
|
||||
const report = fake.getReport(reportId);
|
||||
if (!report) {
|
||||
res.writeHead(404, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify({error: `Unknown report ${reportId}`}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify(report, null, 2));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/_reset') {
|
||||
fake.reset();
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/_fail') {
|
||||
const mode = (url.searchParams.get('mode') ?? 'none') as FakeNcmecFailure;
|
||||
if (!VALID_FAILURE_MODES.has(mode)) {
|
||||
res.writeHead(400, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify({error: `Invalid failure mode ${mode}`}));
|
||||
return;
|
||||
}
|
||||
fake.setFailure(mode);
|
||||
res.writeHead(200, {'content-type': 'application/json'});
|
||||
res.end(JSON.stringify({failure: mode}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found. Try GET /_state, POST /_reset, POST /_fail?mode=...');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => controlServer.listen(controlPort, controlHost, () => resolve()));
|
||||
|
||||
const address = controlServer.address() as AddressInfo;
|
||||
|
||||
log(`NCMEC-compatible fake server ready`);
|
||||
|
||||
log(` NCMEC bind → http://${fake.host}:${fake.port}/ispws`);
|
||||
|
||||
log(` NCMEC base URL → http://${publicHost}:${fake.port}/ispws`);
|
||||
|
||||
log(` control endpoint → http://${address.address}:${address.port}/_state`);
|
||||
|
||||
log(` credentials → ${username}:${password}`);
|
||||
|
||||
if (publicHost.startsWith('<')) {
|
||||
log(' set FAKE_NCMEC_PUBLIC_HOST to the hostname or IP your prod API can reach');
|
||||
}
|
||||
|
||||
log(' point prod Config.ncmec.baseUrl at the NCMEC base URL above');
|
||||
|
||||
function shutdown(signal: string): void {
|
||||
log(`received ${signal}, shutting down`);
|
||||
Promise.allSettled([fake.stop(), new Promise<void>((resolve) => controlServer.close(() => resolve()))]).then(() =>
|
||||
process.exit(0),
|
||||
);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
const VALID_FAILURE_MODES = new Set<FakeNcmecFailure>([
|
||||
'none',
|
||||
'submit',
|
||||
'upload',
|
||||
'fileinfo',
|
||||
'finish',
|
||||
'retract',
|
||||
'unauthorized',
|
||||
]);
|
||||
|
||||
function readPort(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (!Number.isInteger(value) || value < 0 || value > 65535) {
|
||||
throw new Error(`${name} must be an integer between 0 and 65535`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import type {AddressInfo} from 'node:net';
|
||||
import {Readable} from 'node:stream';
|
||||
import {XMLParser, XMLValidator} from 'fast-xml-parser';
|
||||
|
||||
export type FakeNcmecFailure = 'none' | 'submit' | 'upload' | 'fileinfo' | 'finish' | 'retract' | 'unauthorized';
|
||||
|
||||
export interface FakeNcmecFileRecord {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
md5: string;
|
||||
submittedDetails: boolean;
|
||||
detailsXml: string | null;
|
||||
}
|
||||
|
||||
export interface FakeNcmecReportRecord {
|
||||
reportId: string;
|
||||
reportXml: string;
|
||||
files: Array<FakeNcmecFileRecord>;
|
||||
finished: boolean;
|
||||
retracted: boolean;
|
||||
authUsername: string;
|
||||
}
|
||||
|
||||
interface FakeNcmecServerOptions {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface FakeNcmecServerStartOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
onRequest?: (entry: FakeNcmecRequestLogEntry) => void;
|
||||
}
|
||||
|
||||
interface FakeNcmecRequestLogEntry {
|
||||
method: string;
|
||||
path: string;
|
||||
statusCode: number;
|
||||
requestId: string;
|
||||
durationMs: number;
|
||||
remoteAddress: string | null;
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({ignoreAttributes: false, parseTagValue: false, trimValues: true});
|
||||
|
||||
export class FakeNcmecServer {
|
||||
private server: http.Server | null = null;
|
||||
private listenPort = 0;
|
||||
private listenHost = '127.0.0.1';
|
||||
private onRequest: ((entry: FakeNcmecRequestLogEntry) => void) | null = null;
|
||||
private reportCounter = 1000000;
|
||||
private fileCounter = 1;
|
||||
private requestCounter = 1;
|
||||
private readonly reports = new Map<string, FakeNcmecReportRecord>();
|
||||
private failureMode: FakeNcmecFailure = 'none';
|
||||
|
||||
constructor(private readonly options: FakeNcmecServerOptions) {}
|
||||
|
||||
async start(startOptions: FakeNcmecServerStartOptions = {}): Promise<void> {
|
||||
if (this.server) {
|
||||
throw new Error('FakeNcmecServer already started');
|
||||
}
|
||||
this.listenHost = startOptions.host ?? '127.0.0.1';
|
||||
this.onRequest = startOptions.onRequest ?? null;
|
||||
this.server = http.createServer((req, res) => this.handle(req, res));
|
||||
await new Promise<void>((resolve) => this.server!.listen(startOptions.port ?? 0, this.listenHost, () => resolve()));
|
||||
this.listenPort = (this.server!.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.server) return;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
this.server = null;
|
||||
this.listenPort = 0;
|
||||
this.onRequest = null;
|
||||
}
|
||||
|
||||
get baseUrl(): string {
|
||||
if (!this.server) throw new Error('FakeNcmecServer not started');
|
||||
return `http://${this.listenHost}:${this.listenPort}/ispws`;
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
if (!this.server) throw new Error('FakeNcmecServer not started');
|
||||
return this.listenHost;
|
||||
}
|
||||
|
||||
get port(): number {
|
||||
if (!this.server) throw new Error('FakeNcmecServer not started');
|
||||
return this.listenPort;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.reports.clear();
|
||||
this.reportCounter = 1000000;
|
||||
this.fileCounter = 1;
|
||||
this.requestCounter = 1;
|
||||
this.failureMode = 'none';
|
||||
}
|
||||
|
||||
setFailure(mode: FakeNcmecFailure): void {
|
||||
this.failureMode = mode;
|
||||
}
|
||||
|
||||
getReports(): Array<FakeNcmecReportRecord> {
|
||||
return [...this.reports.values()];
|
||||
}
|
||||
|
||||
getReport(reportId: string): FakeNcmecReportRecord | null {
|
||||
return this.reports.get(reportId) ?? null;
|
||||
}
|
||||
|
||||
private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
const pathname = url.pathname.replace(/^\/ispws/, '') || '/';
|
||||
const requestId = `req-${this.requestCounter++}`;
|
||||
const startedAt = Date.now();
|
||||
res.setHeader('Request-ID', requestId);
|
||||
res.on('finish', () => {
|
||||
this.onRequest?.({
|
||||
method: req.method ?? 'GET',
|
||||
path: pathname,
|
||||
statusCode: res.statusCode,
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
remoteAddress: req.socket.remoteAddress ?? null,
|
||||
});
|
||||
});
|
||||
if (this.failureMode === 'unauthorized') {
|
||||
return this.sendStatus(res, 401, '');
|
||||
}
|
||||
if (!this.checkBasicAuth(req)) {
|
||||
return this.sendStatus(res, 401, '');
|
||||
}
|
||||
const injectedHeader = (req.headers['x-test-fail'] ?? '').toString() as FakeNcmecFailure;
|
||||
const effectiveFailure: FakeNcmecFailure = injectedHeader || this.failureMode;
|
||||
if (req.method === 'GET' && pathname === '/status') {
|
||||
const remoteIp = req.socket.remoteAddress ?? 'unknown';
|
||||
return this.sendXml(
|
||||
res,
|
||||
200,
|
||||
ok(
|
||||
`<responseDescription>Remote User : ${this.options.username}, Remote Ip : ${remoteIp}</responseDescription>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (req.method === 'GET' && pathname === '/xsd') {
|
||||
return this.sendText(res, 200, '<schema></schema>');
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
return this.sendStatus(res, 405, '');
|
||||
}
|
||||
const request = await this.toWebRequest(req, url);
|
||||
switch (pathname) {
|
||||
case '/submit':
|
||||
return this.handleSubmit(request, res, effectiveFailure);
|
||||
case '/upload':
|
||||
return this.handleUpload(request, res, effectiveFailure);
|
||||
case '/fileinfo':
|
||||
return this.handleFileInfo(request, res, effectiveFailure);
|
||||
case '/finish':
|
||||
return this.handleFinish(request, res, effectiveFailure);
|
||||
case '/retract':
|
||||
return this.handleRetract(request, res, effectiveFailure);
|
||||
default:
|
||||
return this.sendStatus(res, 404, '');
|
||||
}
|
||||
} catch (error) {
|
||||
this.sendStatus(res, 500, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSubmit(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'submit') {
|
||||
return this.sendXml(res, 200, errorXml(1100, 'Save failed'));
|
||||
}
|
||||
if (!isXmlContentType(request)) {
|
||||
return this.sendXml(res, 200, errorXml(4000, 'Invalid request'));
|
||||
}
|
||||
const body = await request.text();
|
||||
if (XMLValidator.validate(body) !== true) {
|
||||
return this.sendXml(res, 200, errorXml(4110, 'Malformed XML submittal'));
|
||||
}
|
||||
const report = parseReportDocument(body);
|
||||
if (!hasRequiredReportFields(report)) {
|
||||
return this.sendXml(res, 200, errorXml(4100, 'Validation failed'));
|
||||
}
|
||||
const reportId = String(this.reportCounter++);
|
||||
this.reports.set(reportId, {
|
||||
reportId,
|
||||
reportXml: body,
|
||||
files: [],
|
||||
finished: false,
|
||||
retracted: false,
|
||||
authUsername: this.options.username,
|
||||
});
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId>`));
|
||||
}
|
||||
|
||||
private async handleUpload(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'upload') {
|
||||
return this.sendXml(res, 200, errorXml(1111, 'File upload failed'));
|
||||
}
|
||||
const form = await request.formData();
|
||||
const reportId = String(form.get('id') ?? '');
|
||||
const file = form.get('file');
|
||||
const record = this.reports.get(reportId);
|
||||
if (!reportId || !record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
if (!(file instanceof Blob)) {
|
||||
return this.sendXml(res, 200, errorXml(4200, 'Malformed file submittal'));
|
||||
}
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const md5 = crypto.createHash('md5').update(buffer).digest('hex');
|
||||
const fileId = `file-${this.fileCounter++}`;
|
||||
record.files.push({
|
||||
fileId,
|
||||
filename: (file as File).name || 'upload.bin',
|
||||
size: buffer.byteLength,
|
||||
md5,
|
||||
submittedDetails: false,
|
||||
detailsXml: null,
|
||||
});
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId><fileId>${fileId}</fileId><hash>${md5}</hash>`));
|
||||
}
|
||||
|
||||
private async handleFileInfo(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'fileinfo') {
|
||||
return this.sendXml(res, 200, errorXml(1300, 'Update failed'));
|
||||
}
|
||||
if (!isXmlContentType(request)) {
|
||||
return this.sendXml(res, 200, errorXml(4000, 'Invalid request'));
|
||||
}
|
||||
const body = await request.text();
|
||||
if (XMLValidator.validate(body) !== true) {
|
||||
return this.sendXml(res, 200, errorXml(4110, 'Malformed XML submittal'));
|
||||
}
|
||||
const fileDetails = parseFileDetailsDocument(body);
|
||||
if (!hasFileDetailsFields(fileDetails)) {
|
||||
return this.sendXml(res, 200, errorXml(4100, 'Validation failed'));
|
||||
}
|
||||
const reportId = nonBlankString(fileDetails?.reportId);
|
||||
const fileId = nonBlankString(fileDetails?.fileId);
|
||||
const record = reportId ? this.reports.get(reportId) : null;
|
||||
if (!record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
const file = record.files.find((f) => f.fileId === fileId);
|
||||
if (!file) {
|
||||
return this.sendXml(res, 200, errorXml(5002, 'File does not exist'));
|
||||
}
|
||||
file.submittedDetails = true;
|
||||
file.detailsXml = body;
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId>`));
|
||||
}
|
||||
|
||||
private async handleFinish(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'finish') {
|
||||
return this.sendXml(res, 200, errorXml(1300, 'Update failed'));
|
||||
}
|
||||
const form = await request.formData();
|
||||
const reportId = String(form.get('id') ?? '');
|
||||
const record = this.reports.get(reportId);
|
||||
if (!record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
record.finished = true;
|
||||
const files = record.files.map((f) => ` <fileId>${f.fileId}</fileId>`).join('\n');
|
||||
const body = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<reportDoneResponse>
|
||||
<responseCode>0</responseCode>
|
||||
<reportId>${reportId}</reportId>
|
||||
<files>
|
||||
${files}
|
||||
</files>
|
||||
</reportDoneResponse>`;
|
||||
return this.sendXml(res, 200, body);
|
||||
}
|
||||
|
||||
private async handleRetract(request: Request, res: http.ServerResponse, failure: FakeNcmecFailure): Promise<void> {
|
||||
if (failure === 'retract') {
|
||||
return this.sendXml(res, 200, errorXml(1300, 'Update failed'));
|
||||
}
|
||||
const form = await request.formData();
|
||||
const reportId = String(form.get('id') ?? '');
|
||||
const record = this.reports.get(reportId);
|
||||
if (!record) {
|
||||
return this.sendXml(res, 200, errorXml(5001, 'Report does not exist'));
|
||||
}
|
||||
if (record.retracted) {
|
||||
return this.sendXml(res, 200, errorXml(5101, 'Report already retracted'));
|
||||
}
|
||||
if (record.finished) {
|
||||
return this.sendXml(res, 200, errorXml(5102, 'Report already finished'));
|
||||
}
|
||||
record.retracted = true;
|
||||
return this.sendXml(res, 200, ok(`<reportId>${reportId}</reportId>`));
|
||||
}
|
||||
|
||||
private checkBasicAuth(req: http.IncomingMessage): boolean {
|
||||
const header = req.headers.authorization ?? '';
|
||||
if (!header.toLowerCase().startsWith('basic ')) return false;
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf-8');
|
||||
const idx = decoded.indexOf(':');
|
||||
if (idx === -1) return false;
|
||||
const user = decoded.slice(0, idx);
|
||||
const pass = decoded.slice(idx + 1);
|
||||
return user === this.options.username && pass === this.options.password;
|
||||
}
|
||||
|
||||
private async toWebRequest(req: http.IncomingMessage, url: URL): Promise<Request> {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (v === undefined) continue;
|
||||
if (Array.isArray(v)) headers.set(k, v.join(','));
|
||||
else headers.set(k, String(v));
|
||||
}
|
||||
const method = req.method ?? 'GET';
|
||||
const body = method === 'GET' || method === 'HEAD' ? undefined : (Readable.toWeb(req) as ReadableStream);
|
||||
return new Request(url, {method, headers, body, duplex: 'half'} as RequestInit & {
|
||||
duplex: 'half';
|
||||
});
|
||||
}
|
||||
|
||||
private sendXml(res: http.ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, {'content-type': 'application/xml; charset=utf-8'});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
private sendText(res: http.ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, {'content-type': 'text/plain; charset=utf-8'});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
private sendStatus(res: http.ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status);
|
||||
res.end(body);
|
||||
}
|
||||
}
|
||||
|
||||
function ok(extra: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<reportResponse>
|
||||
<responseCode>0</responseCode>
|
||||
<responseDescription>Success</responseDescription>
|
||||
${extra}
|
||||
</reportResponse>`;
|
||||
}
|
||||
|
||||
function errorXml(code: number, description: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<reportResponse>
|
||||
<responseCode>${code}</responseCode>
|
||||
<responseDescription>${description}</responseDescription>
|
||||
</reportResponse>`;
|
||||
}
|
||||
|
||||
function isXmlContentType(request: Request): boolean {
|
||||
return (request.headers.get('content-type') ?? '').toLowerCase().includes('text/xml');
|
||||
}
|
||||
|
||||
function parseReportDocument(xml: string): Record<string, unknown> | null {
|
||||
const parsed = xmlParser.parse(xml) as Record<string, unknown>;
|
||||
return objectField(parsed, 'report');
|
||||
}
|
||||
|
||||
function parseFileDetailsDocument(xml: string): Record<string, unknown> | null {
|
||||
const parsed = xmlParser.parse(xml) as Record<string, unknown>;
|
||||
return objectField(parsed, 'fileDetails');
|
||||
}
|
||||
|
||||
function hasRequiredReportFields(report: Record<string, unknown> | null): boolean {
|
||||
const incidentSummary = objectField(report, 'incidentSummary');
|
||||
const reporter = objectField(report, 'reporter');
|
||||
const reportingPerson = objectField(reporter, 'reportingPerson');
|
||||
return Boolean(
|
||||
nonBlankString(incidentSummary?.incidentType) &&
|
||||
nonBlankString(incidentSummary?.incidentDateTime) &&
|
||||
nonBlankString(reportingPerson?.email),
|
||||
);
|
||||
}
|
||||
|
||||
function hasFileDetailsFields(fileDetails: Record<string, unknown> | null): boolean {
|
||||
return Boolean(nonBlankString(fileDetails?.reportId) && nonBlankString(fileDetails?.fileId));
|
||||
}
|
||||
|
||||
function objectField(value: unknown, key: string): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const field = (value as Record<string, unknown>)[key];
|
||||
if (!field || typeof field !== 'object' || Array.isArray(field)) return null;
|
||||
return field as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nonBlankString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,7 +3,26 @@
|
||||
import crypto from 'node:crypto';
|
||||
import {XMLParser, XMLValidator} from 'fast-xml-parser';
|
||||
import {HttpResponse, http, type RequestHandler} from 'msw';
|
||||
import type {FakeNcmecFailure, FakeNcmecFileRecord, FakeNcmecReportRecord} from '../../fake-ncmec/FakeNcmecServer';
|
||||
|
||||
type FakeNcmecFailure = 'none' | 'submit' | 'upload' | 'fileinfo' | 'finish' | 'retract' | 'unauthorized';
|
||||
|
||||
interface FakeNcmecFileRecord {
|
||||
fileId: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
md5: string;
|
||||
submittedDetails: boolean;
|
||||
detailsXml: string | null;
|
||||
}
|
||||
|
||||
interface FakeNcmecReportRecord {
|
||||
reportId: string;
|
||||
reportXml: string;
|
||||
files: Array<FakeNcmecFileRecord>;
|
||||
finished: boolean;
|
||||
retracted: boolean;
|
||||
authUsername: string;
|
||||
}
|
||||
|
||||
const BASE_URL = 'https://ncmec.test/ispws';
|
||||
const USERNAME = 'usr123';
|
||||
|
||||
@@ -11,6 +11,7 @@ interface CronDefinition {
|
||||
taskType: WorkerTaskName;
|
||||
payload: WorkerJobPayload;
|
||||
cronExpression: string;
|
||||
ledger: boolean;
|
||||
lastFired: number;
|
||||
}
|
||||
|
||||
@@ -89,12 +90,19 @@ export class CronScheduler {
|
||||
this.kvClient = kvClient;
|
||||
}
|
||||
|
||||
upsert(id: string, taskType: WorkerTaskName, payload: WorkerJobPayload, cronExpression: string): void {
|
||||
upsert(
|
||||
id: string,
|
||||
taskType: WorkerTaskName,
|
||||
payload: WorkerJobPayload,
|
||||
cronExpression: string,
|
||||
options: {ledger: boolean},
|
||||
): void {
|
||||
this.definitions.set(id, {
|
||||
id,
|
||||
taskType,
|
||||
payload,
|
||||
cronExpression,
|
||||
ledger: options.ledger,
|
||||
lastFired: 0,
|
||||
});
|
||||
}
|
||||
@@ -133,7 +141,7 @@ export class CronScheduler {
|
||||
if (!acquired) {
|
||||
continue;
|
||||
}
|
||||
await this.workerService.addJob(def.taskType, def.payload, {jobKey});
|
||||
await this.workerService.addJob(def.taskType, def.payload, {jobKey, skipLedger: !def.ledger});
|
||||
this.logger.debug({cronId: def.id, taskType: def.taskType}, 'Cron job fired');
|
||||
} catch (error) {
|
||||
this.logger.error({err: error, cronId: def.id, taskType: def.taskType}, 'Failed to enqueue cron job');
|
||||
|
||||
@@ -41,21 +41,27 @@ const SEARCH_REQUIRED_TASKS = new Set<string>([
|
||||
]);
|
||||
|
||||
function registerCronJobs(cron: CronScheduler): void {
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *');
|
||||
cron.upsert('processBunnyPurgeQueue', 'processBunnyPurgeQueue', {}, '*/10 * * * * *');
|
||||
cron.upsert('processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions', {}, '0 */10 * * * *');
|
||||
cron.upsert('userProcessPendingDeletions', 'userProcessPendingDeletions', {}, '0 * * * * *');
|
||||
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *');
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *');
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *');
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *');
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *');
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *');
|
||||
cron.upsert('syncDisposableEmailDomains', 'syncDisposableEmailDomains', {}, '0 */30 * * * *');
|
||||
cron.upsert('syncUrlBlocklists', 'syncUrlBlocklists', {}, '0 0 */6 * * *');
|
||||
cron.upsert('syncFileShaBlocklists', 'syncFileShaBlocklists', {}, '0 0 */12 * * *');
|
||||
cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *');
|
||||
Logger.info('Cron jobs registered successfully');
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('processBunnyPurgeQueue', 'processBunnyPurgeQueue', {}, '*/10 * * * * *', {ledger: false});
|
||||
cron.upsert('processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions', {}, '0 */10 * * * *', {
|
||||
ledger: false,
|
||||
});
|
||||
cron.upsert('userProcessPendingDeletions', 'userProcessPendingDeletions', {}, '0 * * * * *', {ledger: false});
|
||||
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *', {
|
||||
ledger: false,
|
||||
});
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false});
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false});
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *', {ledger: false});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
cron.upsert('syncDisposableEmailDomains', 'syncDisposableEmailDomains', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncUrlBlocklists', 'syncUrlBlocklists', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncFileShaBlocklists', 'syncFileShaBlocklists', {}, '0 0 */12 * * *', {ledger: true});
|
||||
}
|
||||
cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *', {ledger: false});
|
||||
Logger.info({blocklistFeeds: Config.blocklistFeeds.enabled}, 'Cron jobs registered successfully');
|
||||
}
|
||||
|
||||
function workerLanesRequireSearch(activeWorkerLanes: ReadonlyArray<WorkerLaneDefinition>): boolean {
|
||||
@@ -191,10 +197,16 @@ export async function startWorkerMain(): Promise<void> {
|
||||
setInjectedWorkerService(workerService);
|
||||
dependencies = await initializeWorkerDependencies(snowflakeService);
|
||||
setWorkerDependencies(dependencies);
|
||||
const didClaimEmailSync = await dependencies.kvClient.setnx('sync:email_domains:initialized', '1');
|
||||
if (didClaimEmailSync) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await workerService.addJob('syncDisposableEmailDomains', {});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
const didClaimEmailSync = await dependencies.kvClient.setnx(
|
||||
'sync:email_domains:initialized',
|
||||
'1',
|
||||
ms('6 hours') / 1000,
|
||||
);
|
||||
if (didClaimEmailSync) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await workerService.addJob('syncDisposableEmailDomains', {});
|
||||
}
|
||||
}
|
||||
cron = new CronScheduler(workerService, Logger, dependencies.kvClient);
|
||||
registerCronJobs(cron);
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {MediaProxyNsfwMode} from '../../infrastructure/IMediaService';
|
||||
import {Logger} from '../../Logger';
|
||||
import type {Channel} from '../../models/Channel';
|
||||
import {Embed} from '../../models/Embed';
|
||||
import {Message} from '../../models/Message';
|
||||
import {deleteMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
|
||||
import * as UnfurlerUtils from '../../utils/UnfurlerUtils';
|
||||
@@ -368,13 +367,15 @@ async function updateMessageEmbeds(
|
||||
orderedEmbeds: Array<MessageEmbed>,
|
||||
): Promise<Message | null> {
|
||||
const existingEmbeds = (freshMessage.embeds ?? []).map((embed) => embed.toMessageEmbed());
|
||||
if (areEmbedsEquivalent(existingEmbeds, orderedEmbeds)) {
|
||||
const preservedEmbeds = existingEmbeds.filter((embed) => embed.type === 'rich');
|
||||
const nextEmbeds = [...preservedEmbeds, ...orderedEmbeds];
|
||||
if (areEmbedsEquivalent(existingEmbeds, nextEmbeds)) {
|
||||
Logger.debug({messageId: freshMessage.id.toString()}, 'Embeds unchanged, skipping update');
|
||||
return freshMessage;
|
||||
}
|
||||
const messageWithEmbeds = new Message({
|
||||
...freshMessage.toRow(),
|
||||
embeds: orderedEmbeds.length > 0 ? orderedEmbeds : null,
|
||||
embeds: nextEmbeds.length > 0 ? nextEmbeds : null,
|
||||
});
|
||||
await channelRepository.updateEmbeds(messageWithEmbeds);
|
||||
return messageWithEmbeds;
|
||||
@@ -382,7 +383,6 @@ async function updateMessageEmbeds(
|
||||
|
||||
interface DispatchEmbedUpdateParams {
|
||||
latestMessage: Message;
|
||||
orderedEmbeds: Array<MessageEmbed>;
|
||||
channel: Channel;
|
||||
guildId: GuildID | null;
|
||||
gatewayService: IGatewayService;
|
||||
@@ -390,15 +390,13 @@ interface DispatchEmbedUpdateParams {
|
||||
|
||||
async function dispatchEmbedUpdate({
|
||||
latestMessage,
|
||||
orderedEmbeds,
|
||||
channel,
|
||||
guildId,
|
||||
gatewayService,
|
||||
}: DispatchEmbedUpdateParams): Promise<void> {
|
||||
const embedObjects = orderedEmbeds.length > 0 ? orderedEmbeds.map((e) => new Embed(e)) : latestMessage.embeds;
|
||||
const messageWithUpdatedEmbeds = new Message({
|
||||
...latestMessage.toRow(),
|
||||
embeds: embedObjects.map((e) => e.toMessageEmbed()),
|
||||
embeds: latestMessage.embeds.map((e) => e.toMessageEmbed()),
|
||||
});
|
||||
const messageData = await buildBroadcastMessageData({
|
||||
channel,
|
||||
@@ -512,7 +510,6 @@ const extractEmbeds: WorkerTaskHandler = async (payload, helpers) => {
|
||||
if (!(latestMessage.flags & MessageFlags.SUPPRESS_EMBEDS)) {
|
||||
await dispatchEmbedUpdate({
|
||||
latestMessage,
|
||||
orderedEmbeds,
|
||||
channel,
|
||||
guildId,
|
||||
gatewayService,
|
||||
|
||||
@@ -21,7 +21,6 @@ const SOURCES = [
|
||||
'https://raw.githubusercontent.com/vrittech/disposable-email/main/disposable_domains.txt',
|
||||
'https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blocklist.conf',
|
||||
];
|
||||
const CURRENT_DOMAIN_PAGE_SIZE = 10000;
|
||||
const WRITE_PROGRESS_INTERVAL = 500;
|
||||
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
@@ -120,16 +119,7 @@ function normaliseDomain(raw: string): string | null {
|
||||
|
||||
async function loadCurrentDisposableEmailDomains(): Promise<Set<string>> {
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
const currentSet = new Set<string>();
|
||||
let pageState: string | null = null;
|
||||
do {
|
||||
const page = await adminRepository.listDisposableEmailDomainsPage(CURRENT_DOMAIN_PAGE_SIZE, pageState);
|
||||
for (const domain of page.domains) {
|
||||
currentSet.add(domain);
|
||||
}
|
||||
pageState = page.pageState;
|
||||
} while (pageState !== null);
|
||||
return currentSet;
|
||||
return new Set(await adminRepository.listDisposableEmailDomains());
|
||||
}
|
||||
|
||||
async function throwIfCancelled(helpers: WorkerTaskHelpers): Promise<void> {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"build": "pnpm wasm:codegen && pnpm generate:colors && pnpm generate:message-layout && pnpm generate:theme-variables && pnpm generate:masks && pnpm generate:css-types && pnpm lingui:compile && rm -rf dist && rspack build --mode production && pnpm tsx scripts/build-sw.mjs",
|
||||
"dev": "TOKIO_WORKER_THREADS=4 RAYON_NUM_THREADS=4 cargo run --manifest-path ../tools/ci/Cargo.toml -- app-dev-server",
|
||||
"test": "vitest run",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts src/features/member/state/MemberListStateMachines.bench.ts src/features/member/state/MemberSidebar.bench.ts",
|
||||
"bench:member-list": "vitest bench --run src/features/member/utils/MemberListRangeUtils.bench.ts src/features/member/state/MemberSidebar.bench.ts",
|
||||
"bench:gif-picker": "vitest bench --run src/features/channel/components/pickers/gif/GifPickerStateMachine.bench.ts src/features/channel/components/pickers/gif/GifPickerGridData.bench.ts src/features/channel/components/pickers/gif/GifPickerLoadingSkeletonGridLayout.bench.ts src/features/channel/components/pickers/shared/MasonryListComputer.bench.ts",
|
||||
"bench:messages": "vitest bench --run src/features/messaging/utils/MessageGroupingUtils.bench.ts src/features/channel/components/ChannelMessageStreamUtils.bench.ts src/features/messaging/components/markdown/MarkdownRendering.bench.ts src/features/channel/components/MessageAttachmentUtils.bench.ts src/features/messaging/utils/MessagePaginationUtils.bench.ts src/features/platform/utils/ScrollPosition.bench.ts src/features/messaging/state/ReactionStateMachine.bench.ts",
|
||||
"test:watch": "vitest",
|
||||
@@ -184,6 +184,9 @@
|
||||
"@fluxer/snowflake": "workspace:*",
|
||||
"@fluxer/voice_engine_v2": "workspace:*",
|
||||
"@hcaptcha/react-hcaptcha": "catalog:",
|
||||
"@lexical/history": "catalog:",
|
||||
"@lexical/react": "catalog:",
|
||||
"@lexical/utils": "catalog:",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lingui/core": "catalog:",
|
||||
"@lingui/react": "catalog:",
|
||||
@@ -192,7 +195,6 @@
|
||||
"@phosphor-icons/react": "catalog:",
|
||||
"@pkgs/list_utils": "workspace:*",
|
||||
"@pkgs/number_utils": "workspace:*",
|
||||
"@protontech/tidy-url": "catalog:",
|
||||
"@radix-ui/react-checkbox": "catalog:",
|
||||
"@radix-ui/react-radio-group": "catalog:",
|
||||
"@radix-ui/react-switch": "catalog:",
|
||||
@@ -214,6 +216,7 @@
|
||||
"idna-uts46-hx": "catalog:",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"katex": "catalog:",
|
||||
"lexical": "catalog:",
|
||||
"libphonenumber-js": "catalog:",
|
||||
"livekit-client": "workspace:*",
|
||||
"lodash": "catalog:",
|
||||
@@ -238,6 +241,7 @@
|
||||
"react-hotkeys-hook": "catalog:",
|
||||
"rxjs": "catalog:",
|
||||
"thumbhash": "catalog:",
|
||||
"tiny-invariant": "catalog:",
|
||||
"uint8array-extras": "catalog:",
|
||||
"unique-names-generator": "catalog:",
|
||||
"upng-js": "^2.1.0",
|
||||
|
||||
@@ -461,7 +461,7 @@ export default () => {
|
||||
priority: 55,
|
||||
reuseExistingChunk: true,
|
||||
enforce: true,
|
||||
chunks: 'async',
|
||||
chunks: (chunk) => !chunk.canBeInitial() && !isWorkerPath({chunk}),
|
||||
},
|
||||
livekit: {
|
||||
test: /[\\/]node_modules[\\/](livekit-client|@livekit)[\\/]/,
|
||||
|
||||
@@ -20,7 +20,7 @@ const LARGE_STATUS_CUTOUT_GUTTER_RATIO = 0.2;
|
||||
|
||||
const STATUS_CONFIG: Record<number, StatusConfig> = {
|
||||
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
|
||||
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
|
||||
20: {statusSize: 10, cutoutRadius: 6, cutoutCenter: 17},
|
||||
24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20},
|
||||
32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27},
|
||||
36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30},
|
||||
@@ -181,16 +181,19 @@ function generateAvatarMaskStatusTyping(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingHeight = status.size;
|
||||
const typingRx = status.outerRadius;
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
const leftCx = status.cx - typingExtension + typingBridgeShift;
|
||||
const rightCx = status.cx + typingBridgeShift;
|
||||
const y = status.cy - typingRx;
|
||||
const bridgeHeight = typingRx * 2;
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<rect fill="black" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
<circle fill="black" cx="${rightCx}" cy="${status.cy}" r="${typingRx}" />
|
||||
<rect fill="black" x="${leftCx}" y="${y}" width="${typingExtension}" height="${bridgeHeight}" />
|
||||
<circle fill="black" cx="${leftCx}" cy="${status.cy}" r="${typingRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
@@ -285,7 +288,7 @@ function generateStatusTyping(size: number): string {
|
||||
const rx = status.outerRadius;
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const x = status.cx - status.size / 2 - typingExtension + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
|
||||
}
|
||||
@@ -404,12 +407,17 @@ for (const size of SIZES) {
|
||||
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
|
||||
const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingExtensionPx = Math.max(0, typingWidthPx - status.size);
|
||||
const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingWidth = typingWidthPx / size;
|
||||
const typingHeight = status.size / size;
|
||||
const typingX = cx - typingWidth / 2 + typingBridgeShift;
|
||||
const typingX =
|
||||
(status.cx - status.size / 2 - typingExtensionPx + typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingY = cy - typingHeight / 2;
|
||||
const typingRx = status.outerRadius / size;
|
||||
const typingCutoutLeftCx =
|
||||
(status.cx - typingExtensionPx + typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingCutoutRightCx = (status.cx + typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingCutoutY = (status.cy - status.outerRadius) / size;
|
||||
const typingCutoutHeight = (status.outerRadius * 2) / size;
|
||||
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
|
||||
@@ -449,7 +457,9 @@ for (const size of SIZES) {
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
|
||||
<circle fill="black" cx="${formatNumber(typingCutoutRightCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
|
||||
<rect fill="black" x="${formatNumber(typingCutoutLeftCx)}" y="${formatNumber(typingCutoutY)}" width="${formatNumber(typingExtensionPx / size)}" height="${formatNumber(typingCutoutHeight)}" />
|
||||
<circle fill="black" cx="${formatNumber(typingCutoutLeftCx)}" cy="${formatNumber(cy)}" r="${formatNumber(typingRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
|
||||
@@ -327,7 +327,7 @@ const CONFIG: Config = {
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
@@ -344,7 +344,7 @@ const CONFIG: Config = {
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
@@ -410,7 +410,7 @@ const CONFIG: Config = {
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-textarea', position: 0.3},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
@@ -438,8 +438,8 @@ const CONFIG: Config = {
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(258, calc(10% * var(--saturation-factor)), 2%) 20%
|
||||
var(--background-secondary-alt) 90%,
|
||||
hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
@@ -744,8 +744,8 @@ hsl(258, calc(10% * var(--saturation-factor)), 0%) 10%
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
var(--background-secondary-alt) 90%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 10%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'legacyDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
|
||||
@@ -238,21 +238,63 @@ function readSourceVariables(appDir: string): {
|
||||
return {darkDefaults, lightDefaults, sources};
|
||||
}
|
||||
|
||||
function findMatchingParen(text: string, openIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openIndex; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (character === '(') depth += 1;
|
||||
if (character === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function splitVarArguments(inner: string): {dependency: string; fallback: string | null} {
|
||||
let depth = 0;
|
||||
for (let index = 0; index < inner.length; index += 1) {
|
||||
const character = inner[index];
|
||||
if (character === '(') depth += 1;
|
||||
if (character === ')') depth -= 1;
|
||||
if (character === ',' && depth === 0) {
|
||||
return {dependency: inner.slice(0, index).trim(), fallback: inner.slice(index + 1).trim()};
|
||||
}
|
||||
}
|
||||
return {dependency: inner.trim(), fallback: null};
|
||||
}
|
||||
|
||||
function resolveVariableValue(name: string, values: ReadonlyMap<string, string>, stack = new Set<string>()): string {
|
||||
const value = values.get(name);
|
||||
if (!value) return '';
|
||||
return value.replace(
|
||||
/var\(\s*(--[a-zA-Z0-9_-]+)(?:\s*,\s*([^)]+))?\)/g,
|
||||
(full, dependency: string, fallback?: string) => {
|
||||
if (dependency === '--saturation-factor') return full;
|
||||
if (stack.has(dependency)) return fallback?.trim() ?? full;
|
||||
const dependencyValue = values.get(dependency);
|
||||
if (!dependencyValue) return fallback?.trim() ?? full;
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
while (cursor < value.length) {
|
||||
const start = value.indexOf('var(', cursor);
|
||||
if (start === -1) {
|
||||
result += value.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
const close = findMatchingParen(value, start + 3);
|
||||
if (close === -1) {
|
||||
result += value.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
result += value.slice(cursor, start);
|
||||
const full = value.slice(start, close + 1);
|
||||
const {dependency, fallback} = splitVarArguments(value.slice(start + 4, close));
|
||||
if (dependency === '--saturation-factor') {
|
||||
result += full;
|
||||
} else if (stack.has(dependency) || !values.get(dependency)) {
|
||||
result += fallback ?? full;
|
||||
} else {
|
||||
const nextStack = new Set(stack);
|
||||
nextStack.add(name);
|
||||
return resolveVariableValue(dependency, values, nextStack);
|
||||
},
|
||||
);
|
||||
result += resolveVariableValue(dependency, values, nextStack);
|
||||
}
|
||||
cursor = close + 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getGroupId(name: string): string {
|
||||
|
||||
+13
-10
@@ -9,7 +9,6 @@ import ScreenReader from '@app/features/accessibility/state/ScreenReader';
|
||||
import {DndContext} from '@app/features/app/components/layout/DndContext';
|
||||
import GlobalOverlays from '@app/features/app/components/layout/GlobalOverlays';
|
||||
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
|
||||
import {NativeTrafficLightsBackdrop} from '@app/features/app/components/layout/NativeTrafficLightsBackdrop';
|
||||
import {useDesktopAllowTransparency} from '@app/features/app/hooks/useDesktopAllowTransparency';
|
||||
import {useDesktopElectronBridges} from '@app/features/app/hooks/useDesktopElectronBridges';
|
||||
import {useDocumentClassToggle} from '@app/features/app/hooks/useDocumentClassToggle';
|
||||
@@ -27,6 +26,7 @@ import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ensureAutostartDefaultEnabled} from '@app/features/platform/utils/Autostart';
|
||||
@@ -62,7 +62,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
|
||||
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
|
||||
import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen';
|
||||
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
|
||||
@@ -72,6 +71,7 @@ import {msg} from '@lingui/core/macro';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {IconContext} from '@phosphor-icons/react';
|
||||
import {reaction} from 'mobx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
@@ -89,10 +89,9 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
|
||||
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
|
||||
const isVoiceCallFullscreenActive = VoiceCallFullscreen.isActive;
|
||||
const isRootDocumentFullscreen = useIsRootDocumentFullscreen();
|
||||
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('app');
|
||||
const layoutVariantContextValue = useMemo(
|
||||
@@ -138,6 +137,15 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
}, []);
|
||||
useEffect(
|
||||
() =>
|
||||
reaction(
|
||||
() => GatewayConnection.sessionId,
|
||||
(sessionId) => MemberSidebar.synchronizeGatewaySession(sessionId),
|
||||
{fireImmediately: true},
|
||||
),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const clearForeignPortalHost = (): void => {
|
||||
const activePortalHost = getActivePortalHost();
|
||||
@@ -202,12 +210,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
>
|
||||
{i18n._(SKIP_TO_CONTENT_DESCRIPTOR)}
|
||||
</a>
|
||||
<NativeTrafficLightsBackdrop
|
||||
variant={layoutVariant}
|
||||
hidden={isVoiceCallFullscreenActive}
|
||||
data-flx="app.app.app-wrapper.native-traffic-lights-backdrop"
|
||||
/>
|
||||
{isNative && !isMacOS && !useSystemTitleBar && !isRootDocumentFullscreen && (
|
||||
{isNative && !useSystemTitleBar && !isRootDocumentFullscreen && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.app.app-wrapper.native-titlebar" />
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -86,6 +86,7 @@ var {
|
||||
--z-index-toast: 50000;
|
||||
--z-index-titlebar: 100000;
|
||||
--native-titlebar-height: 2rem;
|
||||
--macos-traffic-light-inset: 78px;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
@@ -97,13 +98,37 @@ var {
|
||||
--media-border-radius: 0.25rem;
|
||||
|
||||
--input-container-padding: 0.625rem;
|
||||
--footer-row-height: 4.5rem;
|
||||
--input-container-min-height: var(--footer-row-height);
|
||||
--list-row-min-height: 4.5rem;
|
||||
--input-wrapper-padding-x: 0.5rem;
|
||||
--input-wrapper-padding-bottom: 0.5rem;
|
||||
--textarea-top-bar-height: 2.5rem;
|
||||
--textarea-line-height: 1.375rem;
|
||||
--textarea-content-offset: calc((var(--user-area-content-height) - var(--textarea-line-height)) / 2);
|
||||
|
||||
--footer-box-height: 3.625rem;
|
||||
--footer-box-inset: 0.375rem;
|
||||
--footer-box-inset-inline: var(--footer-box-inset);
|
||||
--footer-box-radius: var(--radius-lg);
|
||||
--footer-box-inner-inset: 0.5rem;
|
||||
--outline-frame-border-width: 0.0625rem;
|
||||
--composer-mobile-box-height: 3rem;
|
||||
--composer-action-gap: 0.25rem;
|
||||
--floating-surface-ring-color: color-mix(in srgb, var(--background-modifier-accent) 20%, transparent);
|
||||
--floating-surface-ring-color-strong: color-mix(in srgb, var(--background-modifier-accent) 45%, transparent);
|
||||
|
||||
--footer-box-padding-y: max(0rem, calc((var(--footer-box-height) - var(--textarea-button-height, 2rem)) / 2));
|
||||
--composer-mobile-padding-y: max(
|
||||
0rem,
|
||||
calc((var(--composer-mobile-box-height) - var(--textarea-button-height, 2rem)) / 2)
|
||||
);
|
||||
--footer-row-height: calc(var(--footer-box-height) + var(--footer-box-inset) * 2);
|
||||
--input-container-min-height: var(--footer-row-height);
|
||||
--textarea-min-height: var(--footer-box-height);
|
||||
--textarea-padding-y: var(--footer-box-padding-y);
|
||||
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
|
||||
--composer-box-inset-inline: min(var(--footer-box-inset), var(--chat-horizontal-padding, var(--spacing-4)));
|
||||
--composer-box-padding-inline: max(
|
||||
0rem,
|
||||
calc(var(--chat-horizontal-padding, var(--spacing-4)) - var(--composer-box-inset-inline))
|
||||
);
|
||||
|
||||
--typing-indicator-height: 1rem;
|
||||
--typing-pill-height: 1rem;
|
||||
@@ -112,12 +137,11 @@ var {
|
||||
--typing-avatar-size: 0.75rem;
|
||||
--typing-indicator-animation-size: 1rem;
|
||||
--typing-indicator-gap: 0px;
|
||||
--typing-upload-column-width: calc(
|
||||
var(--user-area-content-height) +
|
||||
(var(--textarea-side-button-padding, 0.34375rem) * 2)
|
||||
);
|
||||
|
||||
--spoiler-border-radius: 0.375rem;
|
||||
--markup-restricted-inline-icon-baseline-shift: -0.125em;
|
||||
--markup-restricted-inline-emoji-size: 1.25em;
|
||||
--markup-restricted-inline-emoji-baseline-shift: -0.18em;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--emoji-size-emoji: 1.5em;
|
||||
@@ -139,16 +163,21 @@ var {
|
||||
--spacing-24: 6rem;
|
||||
|
||||
--layout-guild-list-width: 4.5rem;
|
||||
--layout-sidebar-width: 16.875rem;
|
||||
--layout-sidebar-width: 20rem;
|
||||
--layout-header-height: 3.5rem;
|
||||
--layout-user-area-height: var(--input-container-min-height);
|
||||
--layout-user-area-height: var(--footer-box-height);
|
||||
--layout-user-area-reserved-height: 0px;
|
||||
--layout-mobile-bottom-nav-reserved-height: 0px;
|
||||
--user-area-content-height: 2.25rem;
|
||||
--user-area-padding-y: calc((var(--layout-user-area-height) - var(--user-area-content-height)) / 2);
|
||||
--user-area-padding-x: var(--spacing-4);
|
||||
--user-area-box-inset-block-end: calc(var(--footer-box-inset) + var(--outline-frame-border-width));
|
||||
--user-area-content-height: var(--textarea-button-height, 2rem);
|
||||
--user-area-padding-y: var(--footer-box-padding-y);
|
||||
--user-area-padding-x: var(--footer-box-inner-inset);
|
||||
--user-area-avatar-lead: max(
|
||||
var(--footer-box-inner-inset),
|
||||
calc(var(--layout-guild-list-width) / 2 - var(--footer-box-inset-inline) - var(--user-area-content-height) / 2)
|
||||
);
|
||||
--voice-connection-padding-y: var(--spacing-2);
|
||||
--footer-row-padding-y: var(--user-area-padding-y);
|
||||
--voice-connection-padding-x: var(--footer-box-inner-inset);
|
||||
--layout-header-popout-width: calc(var(--layout-sidebar-width) - (var(--spacing-4) * 2));
|
||||
|
||||
--layout-gap: var(--spacing-4);
|
||||
@@ -224,7 +253,7 @@ html.allow-transparency body {
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
html.platform-native:not(.platform-macos):not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
html.platform-native:not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -1319,8 +1319,7 @@ class Accessibility {
|
||||
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
|
||||
if (validated.scrollToBottomOnMessageSend !== undefined)
|
||||
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
|
||||
if (validated.sequentialFileSend !== undefined)
|
||||
this.sequentialFileSend = validated.sequentialFileSend;
|
||||
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
|
||||
if (validated.showNeko !== undefined && validated.showNeko !== this.showNeko) {
|
||||
this.showNeko = validated.showNeko;
|
||||
persistLocalShowNeko(validated.showNeko);
|
||||
@@ -1463,14 +1462,7 @@ class Accessibility {
|
||||
|
||||
async applyZoom(level: number): Promise<void> {
|
||||
const zoomLevel = clampZoomLevel(level);
|
||||
const electronApi = (
|
||||
window as {
|
||||
electron?: {
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
};
|
||||
}
|
||||
).electron;
|
||||
applyAppZoomToDocument(zoomLevel * 100, electronApi);
|
||||
applyAppZoomToDocument(zoomLevel * 100, window.electron);
|
||||
}
|
||||
|
||||
async applyStoredZoom(): Promise<void> {
|
||||
|
||||
@@ -52,7 +52,7 @@ async function cleanupRuntimeStateOnCrash(): Promise<void> {
|
||||
|
||||
export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
@@ -111,7 +111,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
}, [stackTraceText]);
|
||||
return (
|
||||
<div className={errorFallbackStyles.errorFallbackContainer} data-flx="app.error-fallback.div">
|
||||
{isNative && !isMacOS && !useSystemTitleBar && (
|
||||
{isNative && !useSystemTitleBar && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.error-fallback.native-titlebar" />
|
||||
)}
|
||||
<FluxerIcon className={errorFallbackStyles.errorFallbackIcon} data-flx="app.error-fallback.fluxer-icon" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createRangesForSection,
|
||||
setHighlightRanges,
|
||||
} from '@app/features/messaging/utils/CSSHighlightSearch';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {AccessibilityInlineContent} from '@app/features/user/components/modals/tabs/accessibility_tab/AccessibilityTabInline';
|
||||
import {AccountSecurityInlineTab} from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityTabInline';
|
||||
import {AppearanceInlineContent} from '@app/features/user/components/modals/tabs/appearance_tab/AppearanceTabInline';
|
||||
@@ -149,7 +150,7 @@ const SettingsSection: React.FC<SettingsSectionProps> = observer(
|
||||
/>
|
||||
</span>
|
||||
<CaretRightIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={clsx(styles.expandIcon, isExpanded && styles.expandIconExpanded)}
|
||||
data-flx="app.all-settings-renderer.settings-section.expand-icon"
|
||||
|
||||
+8
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {SettingsModalHeader} from '@app/features/app/components/dialogs/components/SettingsModalHeader';
|
||||
import {resolveSettingsTitle} from '@app/features/app/components/dialogs/shared/SettingsContentPresentation';
|
||||
import {
|
||||
SettingsModalDesktopContent,
|
||||
SettingsModalDesktopScroll,
|
||||
@@ -117,6 +118,11 @@ export const DesktopChannelSettingsView: React.FC<DesktopChannelSettingsViewProp
|
||||
const useOverride = SettingsSidebar.useOverride;
|
||||
const activeTabPanelId = selectedTab ? `channel-settings-tabpanel-${selectedTab}` : undefined;
|
||||
const activeTabId = selectedTab ? `channel-settings-tab-${selectedTab}` : undefined;
|
||||
const fallbackSettingsTitle = i18n._(
|
||||
isCategory ? CATEGORY_SETTINGS_LABEL_DESCRIPTOR : CHANNEL_SETTINGS_LABEL_DESCRIPTOR,
|
||||
);
|
||||
const currentTabLabel = currentTab ? currentTab.label : null;
|
||||
const settingsTitle = resolveSettingsTitle(currentTabLabel, fallbackSettingsTitle);
|
||||
const scrollKey = useMemo(
|
||||
() => `channel-settings-${channel.id}-${selectedTab ?? 'none'}`,
|
||||
[channel.id, selectedTab],
|
||||
@@ -263,10 +269,8 @@ export const DesktopChannelSettingsView: React.FC<DesktopChannelSettingsViewProp
|
||||
data-flx="app.desktop-channel-settings-view.settings-modal-desktop-content"
|
||||
>
|
||||
<SettingsModalHeader
|
||||
title={
|
||||
currentTab?.label ||
|
||||
i18n._(isCategory ? CATEGORY_SETTINGS_LABEL_DESCRIPTOR : CHANNEL_SETTINGS_LABEL_DESCRIPTOR)
|
||||
}
|
||||
title={settingsTitle}
|
||||
pageLinkHref={null}
|
||||
showUnsavedBanner={showUnsavedBanner}
|
||||
flashBanner={flashBanner}
|
||||
tabData={tabData}
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ import {isStockCommunityGuild} from '@app/features/guild/utils/GuildCommunityUti
|
||||
import {openMessageHistoryThresholdSettings} from '@app/features/guild/utils/guild_tabs/GuildOverviewTabUtils';
|
||||
import {BACK_TO_SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -246,7 +247,7 @@ export const DesktopGuildSettingsView: React.FC<DesktopGuildSettingsViewProps> =
|
||||
{i18n._(MEMBERS_PAGE_ACTION_DESCRIPTOR)}
|
||||
</span>
|
||||
<ArrowSquareOutIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={styles.externalTabIcon}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SIGN_OUT_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -269,7 +270,7 @@ const BreadcrumbTitle: React.FC<BreadcrumbTitleProps> = ({parentLabel, currentLa
|
||||
{parentLabel}
|
||||
</button>
|
||||
<CaretRightIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={styles.breadcrumbChevron}
|
||||
aria-hidden="true"
|
||||
|
||||
+10
-16
@@ -7,6 +7,8 @@ import {
|
||||
MobileSettingsDangerItem,
|
||||
MobileSettingsList,
|
||||
} from '@app/features/app/components/dialogs/shared/MobileSettingsComponents';
|
||||
import {resolveSettingsTitle} from '@app/features/app/components/dialogs/shared/SettingsContentPresentation';
|
||||
import {usePreservedScrollerPosition} from '@app/features/app/components/dialogs/shared/UsePreservedScrollerPosition';
|
||||
import {ChannelDeleteModal} from '@app/features/channel/components/modals/ChannelDeleteModal';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {DELETE_CATEGORY_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors';
|
||||
@@ -15,7 +17,7 @@ import channelStyles from '@app/features/guild/components/modals/GuildSettingsMo
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import styles from '@app/features/user/components/modals/UserSettingsModal.module.css';
|
||||
import type {
|
||||
ChannelSettingsTab,
|
||||
@@ -30,7 +32,7 @@ import {TrashIcon} from '@phosphor-icons/react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {type UIEvent, useCallback, useEffect, useRef} from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const DELETE_CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Delete channel',
|
||||
@@ -102,19 +104,10 @@ export const MobileChannelSettingsView: React.FC<MobileChannelSettingsViewProps>
|
||||
});
|
||||
const showMobileList = mobileNav.isRootView;
|
||||
const showMobileContent = !mobileNav.isRootView;
|
||||
const listScrollPositionRef = useRef(0);
|
||||
const listScrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const handleListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
listScrollPositionRef.current = event.currentTarget.scrollTop;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!showMobileList) return;
|
||||
const scroller = listScrollerRef.current;
|
||||
if (!scroller) return;
|
||||
const target = listScrollPositionRef.current;
|
||||
if (target === 0) return;
|
||||
scroller.scrollTo({to: target, animate: false});
|
||||
}, [showMobileList]);
|
||||
const {scrollerRef: listScrollerRef, handleScroll: handleListScroll} = usePreservedScrollerPosition(showMobileList);
|
||||
const currentTabLabel = currentTab ? currentTab.label : null;
|
||||
const currentViewTitle = mobileNav.currentView ? mobileNav.currentView.title : null;
|
||||
const mobileTitle = resolveSettingsTitle(currentTabLabel, currentViewTitle);
|
||||
const dangerAction = canManageChannel ? (
|
||||
<MobileSettingsDangerItem
|
||||
icon={TrashIcon}
|
||||
@@ -177,7 +170,8 @@ export const MobileChannelSettingsView: React.FC<MobileChannelSettingsViewProps>
|
||||
data-flx="app.mobile-channel-settings-view.mobile-header-content--2"
|
||||
>
|
||||
<MobileHeaderWithBanner
|
||||
title={currentTab.label || mobileNav.currentView?.title}
|
||||
pageLinkHref={null}
|
||||
title={mobileTitle}
|
||||
onBack={handleBack}
|
||||
showUnsavedBanner={showUnsavedBanner}
|
||||
flashBanner={flashBanner}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SEARCH_SETTINGS_FIELD_LABEL_DESCRIPTOR,
|
||||
SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -91,7 +92,7 @@ export const SettingsSearch: React.FC<SettingsSearchProps> = observer(
|
||||
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
|
||||
data-flx="app.settings-search.clear-button"
|
||||
>
|
||||
<XIcon size={14} weight="bold" data-flx="app.settings-search.x-icon" />
|
||||
<XIcon size={remFromPx(14)} weight="bold" data-flx="app.settings-search.x-icon" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
) : undefined;
|
||||
@@ -110,7 +111,11 @@ export const SettingsSearch: React.FC<SettingsSearchProps> = observer(
|
||||
placeholder={placeholder ?? i18n._(SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR)}
|
||||
aria-label={i18n._(SEARCH_SETTINGS_FIELD_LABEL_DESCRIPTOR)}
|
||||
leftIcon={
|
||||
<MagnifyingGlassIcon size={16} weight="bold" data-flx="app.settings-search.magnifying-glass-icon" />
|
||||
<MagnifyingGlassIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.settings-search.magnifying-glass-icon"
|
||||
/>
|
||||
}
|
||||
rightElement={rightElement}
|
||||
data-flx="app.settings-search.input.query-change.text"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {formatSlowmodeTime} from '@app/features/channel/components/SlowmodeIndicator';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {isForwardableChannelType} from './ForwardChannelEligibility';
|
||||
|
||||
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
|
||||
message: 'Sending messages is disabled in this community',
|
||||
comment: 'Short label in the settings dialog forward channel selection.',
|
||||
});
|
||||
const MEMBER_TIMED_OUT_DESCRIPTOR = msg({
|
||||
message: "You're on timeout in this community",
|
||||
comment: 'Short label in the settings dialog forward channel selection. Keep the tone plain and specific.',
|
||||
});
|
||||
const SEND_MESSAGES_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{sendMessagesPermissionLabel}" permission to send messages in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the user lacks the Send Messages permission in the target channel. Permission name is interpolated.',
|
||||
});
|
||||
const EMBED_LINKS_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{embedLinksPermissionLabel}" permission to embed links in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message contains embeds and the user lacks Embed Links in the target channel.',
|
||||
});
|
||||
const ATTACH_FILES_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{attachFilesPermissionLabel}" permission to attach files in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message has attachments and the user lacks Attach Files in the target channel.',
|
||||
});
|
||||
const SLOWMODE_WAIT_DESCRIPTOR = msg({
|
||||
message: 'Slowmode · wait {remaining}',
|
||||
comment:
|
||||
'Short label in the settings dialog forward channel selection. Preserve {remaining}; it is inserted by code.',
|
||||
});
|
||||
|
||||
const FORWARD_CHANNEL_RESULT_LIMIT = 100;
|
||||
|
||||
export interface ForwardMessageMediaSelection {
|
||||
readonly hasAttachments: boolean;
|
||||
readonly hasEmbeds: boolean;
|
||||
}
|
||||
|
||||
interface ResolveForwardMessageMediaSelectionArgs {
|
||||
readonly message: Message;
|
||||
readonly override: ForwardMessageMediaSelection | undefined;
|
||||
}
|
||||
|
||||
function hasSnapshotAttachments(message: Message): boolean {
|
||||
if (message.attachments.length > 0) return true;
|
||||
const snapshots = message.messageSnapshots;
|
||||
if (snapshots === undefined) return false;
|
||||
return snapshots.some((snapshot) => {
|
||||
const attachments = snapshot.attachments;
|
||||
return attachments != null && attachments.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hasSnapshotEmbeds(message: Message): boolean {
|
||||
if (message.embeds.length > 0) return true;
|
||||
const snapshots = message.messageSnapshots;
|
||||
if (snapshots === undefined) return false;
|
||||
return snapshots.some((snapshot) => {
|
||||
const embeds = snapshot.embeds;
|
||||
return embeds != null && embeds.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveForwardMessageMediaSelection({
|
||||
message,
|
||||
override,
|
||||
}: ResolveForwardMessageMediaSelectionArgs): ForwardMessageMediaSelection {
|
||||
if (override !== undefined) return override;
|
||||
return Object.freeze({
|
||||
hasAttachments: hasSnapshotAttachments(message),
|
||||
hasEmbeds: hasSnapshotEmbeds(message),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ForwardChannelObservation {
|
||||
readonly canAttachFiles: boolean;
|
||||
readonly canEmbedLinks: boolean;
|
||||
readonly canSendMessages: boolean;
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly displayName: string;
|
||||
readonly guildMessagesDisabled: boolean;
|
||||
readonly guildName: string | null;
|
||||
readonly memberTimedOut: boolean;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
readonly slowmodeRemainingMs: number;
|
||||
}
|
||||
|
||||
export interface ForwardChannelOption {
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly disableReason: string | null;
|
||||
readonly displayName: string;
|
||||
readonly guildName: string | null;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
readonly slowmodeRemainingMs: number;
|
||||
}
|
||||
|
||||
interface IndexedForwardChannelOption extends ForwardChannelOption {
|
||||
readonly channelNameSearchValue: string;
|
||||
readonly displayNameSearchValue: string;
|
||||
readonly guildNameSearchValue: string;
|
||||
readonly isPersonalNotes: boolean;
|
||||
readonly isSource: boolean;
|
||||
readonly recentRank: number | null;
|
||||
}
|
||||
|
||||
interface BuildForwardChannelIndexRequest {
|
||||
readonly excludedChannelId: string;
|
||||
readonly i18n: I18n;
|
||||
readonly mediaSelection: ForwardMessageMediaSelection;
|
||||
readonly observations: ReadonlyArray<ForwardChannelObservation>;
|
||||
readonly recentChannelIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
interface ForwardChannelSelectionDisabledRequest {
|
||||
readonly maxSelections: number;
|
||||
readonly option: ForwardChannelOption;
|
||||
readonly selectedChannelIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export class ForwardChannelIndex {
|
||||
private readonly i18n: I18n;
|
||||
private readonly options: ReadonlyArray<IndexedForwardChannelOption>;
|
||||
private readonly optionsByChannelId: ReadonlyMap<string, IndexedForwardChannelOption>;
|
||||
|
||||
constructor({
|
||||
excludedChannelId,
|
||||
i18n,
|
||||
mediaSelection,
|
||||
observations,
|
||||
recentChannelIds,
|
||||
}: BuildForwardChannelIndexRequest) {
|
||||
this.i18n = i18n;
|
||||
const recentRanks = ForwardChannelIndex.buildRecentRanks(recentChannelIds);
|
||||
const options = observations
|
||||
.filter((observation) => isForwardableChannelType(observation.channel.type))
|
||||
.map((observation) => this.buildOption({excludedChannelId, mediaSelection, observation, recentRanks}));
|
||||
options.sort((left, right) => this.compareOptions(left, right));
|
||||
this.options = Object.freeze(options);
|
||||
this.optionsByChannelId = new Map(options.map((option) => [option.channel.id, option]));
|
||||
}
|
||||
|
||||
filter(searchQuery: string): ReadonlyArray<ForwardChannelOption> {
|
||||
if (searchQuery.trim().length === 0) {
|
||||
return this.options.slice(0, FORWARD_CHANNEL_RESULT_LIMIT);
|
||||
}
|
||||
const normalizedQuery = searchQuery.toLowerCase();
|
||||
const personalNotesSearchValue = this.i18n._(PERSONAL_NOTES_DESCRIPTOR).toLowerCase();
|
||||
const matches: Array<IndexedForwardChannelOption> = [];
|
||||
for (const option of this.options) {
|
||||
if (!ForwardChannelIndex.matchesSearch({normalizedQuery, personalNotesSearchValue, option})) continue;
|
||||
matches.push(option);
|
||||
if (matches.length === FORWARD_CHANNEL_RESULT_LIMIT) break;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
isSelectionDisabled({maxSelections, option, selectedChannelIds}: ForwardChannelSelectionDisabledRequest): boolean {
|
||||
if (option.disableReason != null) return true;
|
||||
if (selectedChannelIds.has(option.channel.id)) return false;
|
||||
return selectedChannelIds.size >= maxSelections;
|
||||
}
|
||||
|
||||
select(selectedChannelIds: ReadonlySet<string>): ReadonlyArray<ForwardChannelOption> {
|
||||
const selected: Array<IndexedForwardChannelOption> = [];
|
||||
for (const channelId of selectedChannelIds) {
|
||||
const option = this.optionsByChannelId.get(channelId);
|
||||
if (option != null) selected.push(option);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static buildRecentRanks(recentChannelIds: ReadonlyArray<string>): ReadonlyMap<string, number> {
|
||||
return new Map(recentChannelIds.map((channelId, index) => [channelId, index]));
|
||||
}
|
||||
|
||||
private static matchesSearch({
|
||||
normalizedQuery,
|
||||
personalNotesSearchValue,
|
||||
option,
|
||||
}: {
|
||||
readonly normalizedQuery: string;
|
||||
readonly personalNotesSearchValue: string;
|
||||
readonly option: IndexedForwardChannelOption;
|
||||
}): boolean {
|
||||
if (option.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true;
|
||||
if (option.displayNameSearchValue.includes(normalizedQuery)) return true;
|
||||
if (option.channelNameSearchValue.includes(normalizedQuery)) return true;
|
||||
return option.guildNameSearchValue.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
private buildOption({
|
||||
excludedChannelId,
|
||||
mediaSelection,
|
||||
observation,
|
||||
recentRanks,
|
||||
}: {
|
||||
readonly excludedChannelId: string;
|
||||
readonly mediaSelection: ForwardMessageMediaSelection;
|
||||
readonly observation: ForwardChannelObservation;
|
||||
readonly recentRanks: ReadonlyMap<string, number>;
|
||||
}): IndexedForwardChannelOption {
|
||||
const permissionIssue = this.resolvePermissionIssue(observation, mediaSelection);
|
||||
const disableReason = this.resolveDisableReason(observation, permissionIssue);
|
||||
const recentRankValue = recentRanks.get(observation.channel.id);
|
||||
let recentRank: number | null = null;
|
||||
if (recentRankValue !== undefined) recentRank = recentRankValue;
|
||||
let channelNameSearchValue = '';
|
||||
if (observation.channel.name) channelNameSearchValue = observation.channel.name.toLowerCase();
|
||||
let guildNameSearchValue = '';
|
||||
if (observation.guildName != null) guildNameSearchValue = observation.guildName.toLowerCase();
|
||||
return Object.freeze({
|
||||
categoryName: observation.categoryName,
|
||||
channel: observation.channel,
|
||||
channelNameSearchValue,
|
||||
disableReason,
|
||||
displayName: observation.displayName,
|
||||
displayNameSearchValue: observation.displayName.toLowerCase(),
|
||||
guildName: observation.guildName,
|
||||
guildNameSearchValue,
|
||||
isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES,
|
||||
isSource: observation.channel.id === excludedChannelId,
|
||||
recentRank,
|
||||
slowmodeEnabled: observation.slowmodeEnabled,
|
||||
slowmodeRemainingMs: observation.slowmodeRemainingMs,
|
||||
});
|
||||
}
|
||||
|
||||
private compareOptions(left: IndexedForwardChannelOption, right: IndexedForwardChannelOption): number {
|
||||
if (left.isSource !== right.isSource) return left.isSource ? 1 : -1;
|
||||
const leftUnavailable = left.disableReason != null;
|
||||
const rightUnavailable = right.disableReason != null;
|
||||
if (leftUnavailable !== rightUnavailable) return leftUnavailable ? 1 : -1;
|
||||
if (left.recentRank != null && right.recentRank != null) return left.recentRank - right.recentRank;
|
||||
if (left.recentRank != null) return -1;
|
||||
if (right.recentRank != null) return 1;
|
||||
return left.displayNameSearchValue.localeCompare(right.displayNameSearchValue);
|
||||
}
|
||||
|
||||
private resolveDisableReason(observation: ForwardChannelObservation, permissionIssue: string | null): string | null {
|
||||
if (permissionIssue != null) return permissionIssue;
|
||||
if (observation.slowmodeRemainingMs <= 0) return null;
|
||||
const remaining = formatSlowmodeTime(observation.slowmodeRemainingMs, this.i18n.locale);
|
||||
return this.i18n._(SLOWMODE_WAIT_DESCRIPTOR, {remaining});
|
||||
}
|
||||
|
||||
private resolvePermissionIssue(
|
||||
observation: ForwardChannelObservation,
|
||||
mediaSelection: ForwardMessageMediaSelection,
|
||||
): string | null {
|
||||
if (observation.guildMessagesDisabled) return this.i18n._(GUILD_MESSAGES_DISABLED_DESCRIPTOR);
|
||||
if (observation.memberTimedOut) return this.i18n._(MEMBER_TIMED_OUT_DESCRIPTOR);
|
||||
if (!observation.canSendMessages) {
|
||||
const sendMessagesPermissionLabel = formatPermissionLabel(this.i18n, Permissions.SEND_MESSAGES);
|
||||
return this.i18n._(SEND_MESSAGES_PERMISSION_REQUIRED_DESCRIPTOR, {sendMessagesPermissionLabel});
|
||||
}
|
||||
if (mediaSelection.hasEmbeds && !observation.canEmbedLinks) {
|
||||
const embedLinksPermissionLabel = formatPermissionLabel(this.i18n, Permissions.EMBED_LINKS);
|
||||
return this.i18n._(EMBED_LINKS_PERMISSION_REQUIRED_DESCRIPTOR, {embedLinksPermissionLabel});
|
||||
}
|
||||
if (mediaSelection.hasAttachments && !observation.canAttachFiles) {
|
||||
const attachFilesPermissionLabel = formatPermissionLabel(this.i18n, Permissions.ATTACH_FILES);
|
||||
return this.i18n._(ATTACH_FILES_PERMISSION_REQUIRED_DESCRIPTOR, {attachFilesPermissionLabel});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user