Compare commits

...
Author SHA1 Message Date
HampusandGitHub 78d81dd407 fix(api): re-evaluate link embeds when a message is edited (#1622) 2026-08-15 13:22:14 +02:00
HampusandGitHub 6ef0f1fbe1 fix(app): rename class names that content blockers treat as ads (#1621) 2026-08-15 13:16:44 +02:00
HampusandGitHub 2106102fc5 fix(gateway): enable push by default unless explicitly disabled (#1620) 2026-08-15 13:14:00 +02:00
HampusandGitHub e2d7460f14 feat(schema): add an opt-out setting for the mobile splash zoom animation (#1619) 2026-08-15 13:13:44 +02:00
HampusandGitHub e956e6fb4a fix(gateway): drop the invalid group field from FCM android notifications (#1618) 2026-08-15 13:13:26 +02:00
HampusandGitHub 4e9b8fa1a6 feat(api): document the client geolocation route in OpenAPI (#1617) 2026-08-15 13:13:22 +02:00
HampusandGitHub fca5f63b9e docs: default to dark theme and note desktop self-hosting support (#1616) 2026-08-15 13:05:27 +02:00
ea1fac588a chore(marketing): advance pointer bcf8c4f → 81f925a (#1615)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-15 13:04:59 +02:00
fb4fd19d01 chore(marketing): advance pointer de6f8fe → bcf8c4f (#1614)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-15 12:39:45 +02:00
cb64c05129 chore(marketing): advance pointer e16301c → de6f8fe (#1609)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-15 03:38:45 +02:00
72fd1728d1 chore(i18n): update public marketing catalogs (#1610)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-15 03:38:42 +02:00
HampusandGitHub 6497e396c5 fix(desktop): verify bundled per-arch native artifacts for universal builds (#1608) 2026-08-15 02:44:12 +02:00
HampusandGitHub 2943a8fe46 fix(desktop): verify packaged per-arch native artifacts for universal builds (#1607) 2026-08-15 02:26:56 +02:00
HampusandGitHub 18cb423e95 fix(desktop): drop unused node-mac-permissions dependency (#1606) 2026-08-15 02:13:08 +02:00
HampusandGitHub 6dcc137d0e fix(desktop): allow per-arch native addons in universal macOS builds (#1605) 2026-08-15 02:00:43 +02:00
HampusandGitHub 8ff2eb0ca7 fix(desktop): expect per-arch native artifacts for universal builds (#1604) 2026-08-15 01:47:40 +02:00
HampusandGitHub 6e4c055ebd feat(desktop): build macOS as a universal binary (#1603) 2026-08-15 01:25:35 +02:00
3588090f22 chore(marketing): advance pointer eecefd5 → e16301c (#1601)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-15 00:09:55 +02:00
237b8ddfbf chore(i18n): update public marketing catalogs (#1602)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-15 00:09:50 +02:00
HampusandGitHub 3dab64d040 fix(ci): repair release history lookup (#1600) 2026-08-14 23:47:42 +02:00
HampusandGitHub 0e4e03b879 feat: refresh marketing content and catalogs (#1599) 2026-08-14 23:42:53 +02:00
HampusandGitHub b8218f906b build: add marketing web fonts and branding assets (#1598) 2026-08-14 22:35:58 +02:00
dd6dd827b5 chore(marketing): advance pointer f6ce662 → 9926afb (#1597)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-14 22:24:44 +02:00
7922876b81 chore(i18n): update public marketing catalogs (#1596)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-14 22:15:42 +02:00
152f64aac7 chore(marketing): advance pointer 5297a20 → f6ce662 (#1595)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-14 21:46:22 +02:00
HampusandGitHub 08566fc244 build: extract marketing and simplify releases (#1594) 2026-08-14 21:14:13 +02:00
HampusandGitHub beb906753f fix(api): eliminate idle Postgres I/O on self-hosted instances (#1593) 2026-08-14 19:55:53 +02:00
HampusandGitHub 8a9b12e6a1 fix: resolve KLIPY media through the items endpoint (#1592) 2026-08-14 19:38:37 +02:00
HampusandGitHub 01432bc682 fix(app): rework composer layout and footer alignment (#1591) 2026-08-14 19:28:22 +02:00
HampusandGitHub d56c1e5674 fix: repair CI failures and remove slowmode reset (#1588) 2026-08-14 00:19:36 +02:00
HampusandGitHub 70509fb978 fix(app): format slowmode tooltip values (#1587) 2026-08-13 23:37:13 +02:00
HampusandGitHub ab46d16d12 fix(app): keep slowmode reset visible and localize markers (#1586) 2026-08-13 22:44:54 +02:00
HampusandGitHub 27f459e6bd fix(app): restore composer menus and present action styling (#1585) 2026-08-13 21:44:16 +02:00
HampusandGitHub 5cc92469aa fix(app): allow custom slowmode values (#1584) 2026-08-13 21:18:09 +02:00
HampusandGitHub 09ea748479 fix(app): remove stale DM list gaps (#1583) 2026-08-13 21:10:41 +02:00
HampusandGitHub 614ee3a54b fix(app): stack slowmode slider layout (#1582) 2026-08-13 20:47:48 +02:00
terneraandGitHub 7be5b0589d fix(app): fix upload progress bar from getting stuck at zero (#1581) 2026-08-13 20:46:57 +02:00
HampusandGitHub 42cdc1f877 fix(app): backport rendering improvements (#1580) 2026-08-13 19:52:00 +02:00
HampusandGitHub 0c291a01da fix(media-proxy): reject AVIF tracks with zero timescales (#1579) 2026-08-13 14:59:08 +02:00
HampusandGitHub dba1ba1112 fix(api): enforce attachment upload provenance (#1578) 2026-08-13 14:49:46 +02:00
HampusandGitHub f7324ee73c fix(media-proxy): force SVG and PDF downloads (#1575) 2026-08-13 13:56:59 +02:00
HampusandGitHub 10fc79ab37 test: remove infrastructure-dependent integration suites (#1573) 2026-08-12 16:56:25 +02:00
HampusandGitHub f88b0f69b2 feat: remove Canary Testers guild integration (#1572) 2026-08-12 15:40:33 +02:00
1088 changed files with 130415 additions and 192967 deletions
-5
View File
@@ -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"
+1 -2
View File
@@ -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": [
-7
View File
@@ -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:
+1 -3
View File
@@ -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
View File
@@ -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/
+7
View File
@@ -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
+18
View File
@@ -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
```
+3 -1
View File
@@ -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/**/*
+46 -65
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+31 -84
View File
@@ -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}"
+36 -39
View File
@@ -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[@]}"
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
-60
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
+1 -26
View File
@@ -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
-473
View File
@@ -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
-51
View File
@@ -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 }}"
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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"
-282
View File
@@ -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 }}"
+1 -51
View File
@@ -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
-2
View File
@@ -41,8 +41,6 @@
/app-dist-output/
/artifacts/
/release-input/
/release-out/
/s3_payload/
/upload_staging/
+4
View File
@@ -0,0 +1,4 @@
[submodule "fluxer_marketing"]
path = fluxer_marketing
url = https://github.com/fluxerapp/marketing.git
update = none
Generated
-481
View File
@@ -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
View File
@@ -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",
-1
View File
@@ -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
+1 -1
View File
@@ -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
-140
View File
@@ -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"
-1
View File
@@ -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",
+3 -76
View File
@@ -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))
}
-40
View File
@@ -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>()
}
+1 -40
View File
@@ -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()
}
+89
View File
@@ -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}"))
}
-233
View File
@@ -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}"))
}
+3
View File
@@ -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,
+18 -14
View File
@@ -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 -30
View File
@@ -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>;
+1 -64
View File
@@ -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,
+3
View File
@@ -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');
+2 -2
View File
@@ -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,
+102 -69
View File
@@ -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({
-1
View File
@@ -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,
};
};
}
-127
View File
@@ -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';
+10 -2
View File
@@ -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');
+31 -19
View File
@@ -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> {
+6 -2
View File
@@ -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",
+1 -1
View File
@@ -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)[\\/]/,
+19 -9
View File
@@ -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)}" />
+7 -7
View File
@@ -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},
+52 -10
View File
@@ -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
View File
@@ -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}
+44 -15
View File
@@ -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"
@@ -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}
@@ -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"
@@ -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