mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 12:42:27 +09:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d81dd407 | ||
|
|
6ef0f1fbe1 | ||
|
|
2106102fc5 | ||
|
|
e2d7460f14 | ||
|
|
e956e6fb4a | ||
|
|
4e9b8fa1a6 | ||
|
|
fca5f63b9e | ||
|
|
ea1fac588a | ||
|
|
fb4fd19d01 | ||
|
|
cb64c05129 | ||
|
|
72fd1728d1 | ||
|
|
6497e396c5 | ||
|
|
2943a8fe46 | ||
|
|
18cb423e95 | ||
|
|
6dcc137d0e | ||
|
|
8ff2eb0ca7 | ||
|
|
6e4c055ebd | ||
|
|
3588090f22 | ||
|
|
237b8ddfbf | ||
|
|
3dab64d040 | ||
|
|
0e4e03b879 | ||
|
|
b8218f906b | ||
|
|
dd6dd827b5 | ||
|
|
7922876b81 | ||
|
|
152f64aac7 | ||
|
|
08566fc244 | ||
|
|
beb906753f |
@@ -38,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
|
||||
@@ -358,7 +353,6 @@ volumes:
|
||||
fluxer-api-node-modules:
|
||||
fluxer-app-node-modules:
|
||||
fluxer-desktop-node-modules:
|
||||
fluxer-marketing-node-modules:
|
||||
fluxer-admin-node-modules:
|
||||
package-config-node-modules:
|
||||
package-constants-node-modules:
|
||||
|
||||
@@ -71,7 +71,7 @@ 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
|
||||
|
||||
|
||||
+3
-2
@@ -5,8 +5,11 @@
|
||||
/.direnv/
|
||||
/.fluxer/
|
||||
/.git/
|
||||
**/.git
|
||||
**/.git/**
|
||||
/.github/
|
||||
/.pnpm-store/
|
||||
/fluxer_marketing
|
||||
|
||||
**/.env
|
||||
**/.env.*.local
|
||||
@@ -46,8 +49,6 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/release-input/
|
||||
/release-out/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/.github/CODEOWNERS @fluxerapp/developers
|
||||
/.github/workflows/ @fluxerapp/developers
|
||||
/fluxer_marketing @fluxerapp/developers
|
||||
/.gitmodules @fluxerapp/developers
|
||||
/.github/workflows/dispatch-private-marketing-build.yaml @fluxerapp/developers
|
||||
/packages/i18n/marketing/ @fluxerapp/developers
|
||||
/scripts/setup-private-marketing.sh @fluxerapp/developers
|
||||
@@ -89,3 +89,21 @@ Submit translations through [Weblate](https://weblate.fluxer.tools), not through
|
||||
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
|
||||
## Private marketing project
|
||||
|
||||
The marketing implementation is maintained in a private repository at the `fluxer_marketing` submodule path. The public workspace, bootstrap, checks, and development stack work without initializing it.
|
||||
|
||||
Authorized maintainers can initialize only that submodule and install its independent dependencies:
|
||||
|
||||
```sh
|
||||
./scripts/setup-private-marketing.sh
|
||||
pnpm --dir fluxer_marketing install --frozen-lockfile
|
||||
cargo metadata --locked --manifest-path fluxer_marketing/Cargo.toml
|
||||
```
|
||||
|
||||
To run the private marketing service in the local development stack and direct application links to it, add this override to the ignored `config/env/local.env` file:
|
||||
|
||||
```sh
|
||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
||||
```
|
||||
|
||||
@@ -24,7 +24,9 @@ f:gateway:
|
||||
- any-glob-to-any-file: fluxer_gateway/**/*
|
||||
f:marketing:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_marketing/**/*
|
||||
- any-glob-to-any-file:
|
||||
- fluxer_marketing
|
||||
- packages/i18n/marketing/**/*
|
||||
f:media_proxy:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: fluxer_media_proxy/**/*
|
||||
|
||||
@@ -32,17 +32,15 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this image fragment is uploaded. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-${{ inputs.image }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -55,6 +53,8 @@ jobs:
|
||||
name: resolve metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
@@ -79,6 +79,10 @@ jobs:
|
||||
needs: meta
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 75
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -96,7 +100,7 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ github.token }}
|
||||
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
@@ -119,6 +123,9 @@ jobs:
|
||||
needs: [meta, build]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -132,71 +139,45 @@ jobs:
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ github.token }}
|
||||
- name: create and push multi-arch manifest
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish
|
||||
--component "${{ inputs.image }}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
MOVING_TAGS: ${{ inputs.moving-tags }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_args=( "-t" "${IMAGE}:${VERSION}" )
|
||||
tag_args=()
|
||||
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
|
||||
for raw in "${moving[@]}"; do
|
||||
t="$(echo "$raw" | xargs)"
|
||||
[ -n "$t" ] && tag_args+=( "-t" "${IMAGE}:${t}" )
|
||||
tag="$(echo "$raw" | xargs)"
|
||||
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
|
||||
done
|
||||
docker buildx imagetools create "${tag_args[@]}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Write GitHub release image fragment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE_REF: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
MOVING_TAGS: ${{ inputs.moving-tags }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-image
|
||||
--build-version "${VERSION}"
|
||||
--image "${{ inputs.image }}"
|
||||
--image-ref "${IMAGE_REF}"
|
||||
--moving-tags "${MOVING_TAGS}"
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-${{ inputs.image }}
|
||||
path: release-out/fragments/fluxer-release-fragment-image-${{ inputs.image }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release
|
||||
if: ${{ inputs['finalise-release'] }}
|
||||
needs: [meta, merge]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: finalise GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${VERSION}"
|
||||
if (( ${#tag_args[@]} > 0 )); then
|
||||
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
|
||||
fi
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-admin
|
||||
dockerfile: fluxer_admin/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-api
|
||||
dockerfile: fluxer_api/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,12 +28,10 @@ jobs:
|
||||
|
||||
build:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-app-proxy-self-hosted
|
||||
dockerfile: fluxer_app_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
extra-build-args: |
|
||||
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
|
||||
|
||||
@@ -9,41 +9,23 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: publish-fluxer-app-proxy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -54,9 +36,10 @@ jobs:
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
@@ -79,6 +62,10 @@ jobs:
|
||||
needs: meta
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
@@ -117,13 +104,6 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
|
||||
--step generate_asset_manifest
|
||||
|
||||
- name: Upload asset manifest handoff
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: app-proxy-assets-manifest
|
||||
path: app-dist-output/dist/assets-manifest.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -139,6 +119,10 @@ jobs:
|
||||
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:
|
||||
@@ -172,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:
|
||||
@@ -180,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:
|
||||
@@ -201,58 +183,30 @@ jobs:
|
||||
echo "amd64 digest: ${amd64_digest}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
-t "${IMAGE}:v1" \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${IMAGE}@${amd64_digest}" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- name: Write GitHub release app-proxy fragment
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-app-proxy
|
||||
publish
|
||||
--component fluxer-app-proxy
|
||||
--build-version "${VERSION}"
|
||||
--image fluxer-app-proxy
|
||||
--image-ref "${IMAGE_REF}"
|
||||
--moving-tags "v1,latest"
|
||||
--asset-manifest release-input/app-proxy/assets-manifest.txt
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-fluxer-app-proxy
|
||||
path: release-out/fragments/fluxer-release-fragment-app-proxy.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release
|
||||
if: ${{ inputs['finalise-release'] != false }}
|
||||
needs: [meta, merge]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: finalise GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${VERSION}"
|
||||
docker buildx imagetools create
|
||||
-t "${IMAGE}:v1"
|
||||
-t "${IMAGE}:latest"
|
||||
"${IMAGE}:${VERSION}"
|
||||
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
skip_targets:
|
||||
description: Comma-separated platforms or targets to skip, such as windows, macos-arm64, linux-x64.
|
||||
description: Comma-separated platforms or targets to skip, such as windows, macos, linux-x64.
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -46,6 +46,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
@@ -81,6 +83,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 25
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
@@ -107,6 +111,10 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||
@@ -233,7 +241,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
targets: ${{ matrix.platform == 'macos' && (matrix.arch == 'arm64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin') || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||
targets: ${{ matrix.platform == 'macos' && 'aarch64-apple-darwin,x86_64-apple-darwin' || (matrix.arch == 'arm64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu') }}
|
||||
|
||||
- name: Install MSVC ARM64 build tools
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
@@ -460,8 +468,8 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_artifacts_unix
|
||||
|
||||
- name: Normalize updater YAML (arm64)
|
||||
if: matrix.arch == 'arm64'
|
||||
- name: Normalize updater YAML (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step normalise_updater_yaml
|
||||
@@ -492,6 +500,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
DISPLAY_CHANNEL: ${{ needs.meta.outputs.channel }}
|
||||
@@ -544,34 +554,14 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_summary
|
||||
|
||||
- name: Write GitHub release desktop fragment
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-desktop
|
||||
--build-version "${{ needs.meta.outputs.version }}"
|
||||
--channel "${{ needs.meta.outputs.build_channel }}"
|
||||
--test-build "${{ needs.meta.outputs.test_build }}"
|
||||
--s3-prefix "${{ needs.meta.outputs.s3_prefix }}"
|
||||
--payload-root s3_payload
|
||||
--source-sha "${{ needs.meta.outputs.source_sha }}"
|
||||
- name: Upload GitHub release fragment
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-desktop
|
||||
path: release-out/fragments/fluxer-release-fragment-desktop-${{ needs.meta.outputs.build_channel }}.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Cleanup S3 handoff
|
||||
if: ${{ success() }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step cleanup_handoff
|
||||
|
||||
finalise_release:
|
||||
name: Finalise GitHub desktop release
|
||||
publish_release:
|
||||
name: Publish GitHub desktop release
|
||||
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
|
||||
needs:
|
||||
- meta
|
||||
@@ -579,6 +569,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -589,18 +581,23 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
|
||||
- name: Finalise GitHub desktop release
|
||||
- name: Publish GitHub desktop release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ needs.meta.outputs.version }}"
|
||||
--source-sha "${{ needs.meta.outputs.source_sha }}"
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_args=(
|
||||
release publish
|
||||
--component "fluxer-desktop-${CHANNEL}"
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,12 +28,9 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-docs
|
||||
dockerfile: fluxer_docs/Dockerfile
|
||||
context: fluxer_docs
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-gateway
|
||||
dockerfile: fluxer_gateway/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-gifs
|
||||
dockerfile: fluxer_gifs/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: build marketing
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Build release approved."
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-marketing
|
||||
dockerfile: fluxer_marketing/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-media-proxy
|
||||
dockerfile: fluxer_media_proxy/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-messages
|
||||
dockerfile: fluxer_messages/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-snowflakes
|
||||
dockerfile: fluxer_snowflakes/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-static
|
||||
dockerfile: fluxer_static/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-unfurl
|
||||
dockerfile: fluxer_unfurl/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -9,28 +9,6 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
finalise-release:
|
||||
description: "Publish the GitHub Release after this workflow completes. Set false when an orchestrator will finalise the release."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
approval-required:
|
||||
description: "Require the protected builds environment approval before this build runs."
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -40,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
approve:
|
||||
name: approve build release
|
||||
if: ${{ format('{0}', inputs['approval-required']) != 'false' }}
|
||||
permissions: {}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
@@ -50,11 +28,8 @@ jobs:
|
||||
|
||||
image:
|
||||
needs: approve
|
||||
if: ${{ !cancelled() && (needs.approve.result == 'success' || needs.approve.result == 'skipped') }}
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
with:
|
||||
image: fluxer-users
|
||||
dockerfile: fluxer_users/Dockerfile
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
finalise-release: ${{ inputs['finalise-release'] != false }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,473 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: deploy service
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
service:
|
||||
description: "Helm chart name to deploy"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- api
|
||||
- app-proxy
|
||||
- admin
|
||||
- docs
|
||||
- marketing
|
||||
- media-proxy
|
||||
- gateway
|
||||
- messages
|
||||
- search
|
||||
- snowflakes
|
||||
- users
|
||||
- unfurl
|
||||
- uploads
|
||||
- worker
|
||||
channel:
|
||||
description: "Release channel (stable or canary)"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- stable
|
||||
- canary
|
||||
image-tag:
|
||||
description: "Docker image tag to deploy (Fluxer CalVer: YYYY.MDD.MICRO)"
|
||||
type: string
|
||||
required: true
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version to inject into runtime env vars"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow-rollback:
|
||||
description: "Allow deploying an older image tag than the newest GHCR tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
service:
|
||||
description: "Helm chart name to deploy"
|
||||
type: string
|
||||
required: true
|
||||
channel:
|
||||
description: "Release channel (stable or canary)"
|
||||
type: string
|
||||
required: true
|
||||
image-tag:
|
||||
description: "Docker image tag to deploy (Fluxer CalVer: YYYY.MDD.MICRO)"
|
||||
type: string
|
||||
required: true
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version to inject into runtime env vars"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow-rollback:
|
||||
description: "Allow deploying an older image tag than the newest GHCR tag"
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
KUBE_CONFIG:
|
||||
required: true
|
||||
GHCR_USERNAME:
|
||||
required: false
|
||||
GHCR_TOKEN:
|
||||
required: false
|
||||
|
||||
env:
|
||||
GHCR_OWNER: ${{ github.repository_owner }}
|
||||
GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy ${{ inputs.service }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
environment: ${{ inputs.channel }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: install helm
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310
|
||||
|
||||
- name: configure kubectl
|
||||
shell: bash
|
||||
env:
|
||||
KUBE_CONFIG_B64: ${{ secrets.KUBE_CONFIG }}
|
||||
run: |
|
||||
mkdir -p "$HOME/.kube"
|
||||
printf '%s' "$KUBE_CONFIG_B64" | base64 -d > "$HOME/.kube/config"
|
||||
chmod 600 "$HOME/.kube/config"
|
||||
|
||||
- name: resolve helm args
|
||||
id: helm
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_SERVICE: ${{ inputs.service }}
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
INPUT_IMAGE_TAG: ${{ inputs['image-tag'] }}
|
||||
INPUT_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: |
|
||||
SERVICE="$INPUT_SERVICE"
|
||||
CHANNEL="$INPUT_CHANNEL"
|
||||
TAG="$INPUT_IMAGE_TAG"
|
||||
BUILD_VERSION="$INPUT_BUILD_VERSION"
|
||||
GHCR_REGISTRY="${GHCR_REGISTRY:?GHCR_REGISTRY is required}"
|
||||
if [[ -z "$BUILD_VERSION" ]]; then
|
||||
BUILD_VERSION="$TAG"
|
||||
fi
|
||||
CALVER_RE='^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.(0|[1-9][0-9]{0,5})$'
|
||||
if [[ ! "$TAG" =~ $CALVER_RE ]]; then
|
||||
echo "::error::image-tag must be a Fluxer CalVer tag (YYYY.MDD.MICRO). Channel tags, latest tags, and suffixed tags are not deployable."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$BUILD_VERSION" =~ $CALVER_RE ]]; then
|
||||
echo "::error::build-version must be a Fluxer CalVer value (YYYY.MDD.MICRO)."
|
||||
exit 1
|
||||
fi
|
||||
CHART_DIR="./deploy/helm/${SERVICE}"
|
||||
VALUES_ARGS="-f ${CHART_DIR}/values.yaml"
|
||||
SETS=""
|
||||
BUILD_PATHS=""
|
||||
DEPLOY_IMAGE=""
|
||||
SYNC_WORKER_RELEASE=""
|
||||
SYNC_WORKER_CHART_DIR=""
|
||||
SYNC_WORKER_VALUES_ARGS=""
|
||||
SYNC_WORKER_SETS=""
|
||||
case "$SERVICE" in
|
||||
uploads)
|
||||
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::uploads deployments are stable-only (single relay serves both channels)."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-uploads"
|
||||
DEPLOY_IMAGE="fluxer-media-proxy"
|
||||
SETS="--set-string app.name=uploads --set-string app.image=fluxer-media-proxy --set-string app.tag=${TAG} --set-string app.config=stable"
|
||||
SETS="${SETS} --set-string app.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string app.build.channel=stable"
|
||||
;;
|
||||
api|app-proxy|admin|docs|marketing)
|
||||
BASE_IMAGE="fluxer-${SERVICE}"
|
||||
if [[ "$SERVICE" == "docs" && "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::docs deployments are stable-only."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CHANNEL" == "canary" ]]; then
|
||||
NAME="${SERVICE}-canary"
|
||||
else
|
||||
NAME="${SERVICE}"
|
||||
fi
|
||||
DEPLOY_IMAGE="${BASE_IMAGE}"
|
||||
RELEASE="fluxer-${SERVICE}-${CHANNEL}"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.${CHANNEL}.prod.yaml"
|
||||
SETS="--set-string app.name=${NAME} --set-string app.image=${DEPLOY_IMAGE} --set-string app.tag=${TAG}"
|
||||
SETS="${SETS} --set-string app.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string app.build.channel=${CHANNEL}"
|
||||
;;
|
||||
media-proxy)
|
||||
if [[ "$CHANNEL" != "canary" ]]; then
|
||||
echo "::error::Media-proxy deployments are only supported on the canary lane."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-media-proxy"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string mediaProxy.image=fluxer-media-proxy --set-string staticProxy.image=fluxer-media-proxy --set-string mediaProxy.tag=${TAG} --set-string staticProxy.tag=${TAG} --set mediaProxy.replicas=16 --set staticProxy.replicas=4 --set-string mediaProxy.nsfwServiceEndpoint=http://int.flx-nyc-misc1.srv.fluxer.dev:8000"
|
||||
BUILD_PATHS="mediaProxy staticProxy"
|
||||
;;
|
||||
gateway)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::gateway deployments are stable-only."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-gateway"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string gateway.image=${DEPLOY_IMAGE} --set-string gateway.tag=${TAG}"
|
||||
BUILD_PATHS="gateway"
|
||||
;;
|
||||
worker)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::Worker deployments are only supported on the stable lane."
|
||||
exit 1
|
||||
fi
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
DEPLOY_IMAGE="fluxer-api"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string workerRealtime.image=fluxer-api --set-string workerUnfurl.image=fluxer-api --set-string workerLifecycle.image=fluxer-api --set-string workerBatch.image=fluxer-api --set-string workerRealtime.tag=${TAG} --set-string workerUnfurl.tag=${TAG} --set-string workerLifecycle.tag=${TAG} --set-string workerBatch.tag=${TAG}"
|
||||
BUILD_PATHS="workerRealtime workerUnfurl workerLifecycle workerBatch"
|
||||
;;
|
||||
messages|search|snowflakes|users|unfurl)
|
||||
if [[ "$CHANNEL" != "stable" ]]; then
|
||||
echo "::error::Shared microservice deployments are stable-only; canary traffic selection is done by the callers."
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_IMAGE="fluxer-${SERVICE}"
|
||||
RELEASE="fluxer-${SERVICE}"
|
||||
VALUES_ARGS="${VALUES_ARGS} -f ${CHART_DIR}/values.prod.yaml"
|
||||
SETS="--set-string svc.image=${DEPLOY_IMAGE} --set-string svc.tag=${TAG}"
|
||||
SETS="${SETS} --set-string svc.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string svc.build.channel=stable"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown service chart: ${SERVICE}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
for BUILD_PATH in $BUILD_PATHS; do
|
||||
SETS="${SETS} --set-string ${BUILD_PATH}.build.version=${BUILD_VERSION}"
|
||||
SETS="${SETS} --set-string ${BUILD_PATH}.build.channel=${CHANNEL}"
|
||||
done
|
||||
SETS="--set-string global.registry=${GHCR_REGISTRY} ${SETS}"
|
||||
if [[ "$SERVICE" == "api" && "$CHANNEL" == "canary" ]]; then
|
||||
SYNC_WORKER_RELEASE="fluxer-worker"
|
||||
SYNC_WORKER_CHART_DIR="./deploy/helm/worker"
|
||||
SYNC_WORKER_VALUES_ARGS="-f ${SYNC_WORKER_CHART_DIR}/values.yaml -f ${SYNC_WORKER_CHART_DIR}/values.prod.yaml"
|
||||
SYNC_WORKER_SETS="--set-string workerRealtime.image=fluxer-api --set-string workerUnfurl.image=fluxer-api --set-string workerLifecycle.image=fluxer-api --set-string workerBatch.image=fluxer-api"
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string workerRealtime.tag=${TAG} --set-string workerUnfurl.tag=${TAG} --set-string workerLifecycle.tag=${TAG} --set-string workerBatch.tag=${TAG}"
|
||||
for BUILD_PATH in workerRealtime workerUnfurl workerLifecycle workerBatch; do
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string ${BUILD_PATH}.build.version=${BUILD_VERSION}"
|
||||
SYNC_WORKER_SETS="${SYNC_WORKER_SETS} --set-string ${BUILD_PATH}.build.channel=${CHANNEL}"
|
||||
done
|
||||
SYNC_WORKER_SETS="--set-string global.registry=${GHCR_REGISTRY} ${SYNC_WORKER_SETS}"
|
||||
fi
|
||||
{
|
||||
echo "chart-dir=${CHART_DIR}"
|
||||
echo "release=${RELEASE}"
|
||||
echo "values-args=${VALUES_ARGS}"
|
||||
echo "sets=${SETS}"
|
||||
echo "deploy-image=${DEPLOY_IMAGE}"
|
||||
echo "deploy-tag=${TAG}"
|
||||
echo "sync-worker-release=${SYNC_WORKER_RELEASE}"
|
||||
echo "sync-worker-chart-dir=${SYNC_WORKER_CHART_DIR}"
|
||||
echo "sync-worker-values-args=${SYNC_WORKER_VALUES_ARGS}"
|
||||
echo "sync-worker-sets=${SYNC_WORKER_SETS}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: helm dependency update
|
||||
shell: bash
|
||||
run: |
|
||||
helm dependency update "${{ steps.helm.outputs.chart-dir }}"
|
||||
if [[ -n "${{ steps.helm.outputs.sync-worker-chart-dir }}" ]]; then
|
||||
helm dependency update "${{ steps.helm.outputs.sync-worker-chart-dir }}"
|
||||
fi
|
||||
|
||||
- name: prepare docker config
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
run: |
|
||||
echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config" >> "$GITHUB_ENV"
|
||||
mkdir -p "${RUNNER_TEMP}/docker-config"
|
||||
|
||||
- name: configure ghcr auth
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_USERNAME: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
auth="$(printf '%s:%s' "$GHCR_USERNAME" "$GHCR_TOKEN" | base64 | tr -d '\n')"
|
||||
printf '{"auths":{"ghcr.io":{"auth":"%s"}}}\n' "$auth" > "$DOCKER_CONFIG/config.json"
|
||||
|
||||
- name: verify deploy image exists
|
||||
if: steps.helm.outputs.deploy-image != ''
|
||||
shell: bash
|
||||
run: |
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
echo "Verifying ${IMAGE_REF}"
|
||||
docker manifest inspect "${IMAGE_REF}" > /dev/null
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
- name: verify api deploy uses latest image
|
||||
if: ${{ steps.helm.outputs.deploy-image == 'fluxer-api' && !inputs['allow-rollback'] }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GHCR_OWNER: ${{ env.GHCR_OWNER }}
|
||||
DEPLOY_TAG: ${{ steps.helm.outputs.deploy-tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CALVER_RE='^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.(0|[1-9][0-9]{0,5})$'
|
||||
OWNER_TYPE="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL:-https://api.github.com}/repos/${GITHUB_REPOSITORY}" \
|
||||
| jq -r '.owner.type'
|
||||
)"
|
||||
case "$OWNER_TYPE" in
|
||||
Organization) PACKAGE_OWNER_PATH="orgs/${GHCR_OWNER}" ;;
|
||||
User) PACKAGE_OWNER_PATH="users/${GHCR_OWNER}" ;;
|
||||
*)
|
||||
echo "::error::Unsupported GitHub owner type for package lookup: ${OWNER_TYPE}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
LATEST_TAG="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"${GITHUB_API_URL:-https://api.github.com}/${PACKAGE_OWNER_PATH}/packages/container/fluxer-api/versions?per_page=100" \
|
||||
| jq -r --arg re "$CALVER_RE" '
|
||||
[.[].metadata.container.tags[]? |
|
||||
select(test($re)) |
|
||||
{tag: ., parts: (split(".") | map(tonumber))}
|
||||
] | max_by(.parts) | .tag // empty
|
||||
'
|
||||
)"
|
||||
|
||||
if [[ -z "$LATEST_TAG" ]]; then
|
||||
echo "::error::Could not resolve the latest fluxer-api CalVer tag from GHCR."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DEPLOY_TAG" != "$LATEST_TAG" ]]; then
|
||||
echo "::error::Refusing to deploy fluxer-api:${DEPLOY_TAG}; latest GHCR tag is fluxer-api:${LATEST_TAG}. Re-run with allow-rollback=true only for an intentional rollback."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: approve api image for admission policy
|
||||
if: ${{ inputs.service == 'api' }}
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
DEPLOYMENT="api"
|
||||
if [[ "$INPUT_CHANNEL" == "canary" ]]; then
|
||||
DEPLOYMENT="api-canary"
|
||||
fi
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
PREVIOUS_IMAGE="$(kubectl -n fluxer get deployment "$DEPLOYMENT" -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null || true)"
|
||||
PREVIOUS_TAG=""
|
||||
if [[ -n "$PREVIOUS_IMAGE" && "$PREVIOUS_IMAGE" != "$IMAGE_REF" && "$PREVIOUS_IMAGE" == *:* ]]; then
|
||||
PREVIOUS_TAG="${PREVIOUS_IMAGE##*:}"
|
||||
else
|
||||
PREVIOUS_IMAGE=""
|
||||
fi
|
||||
kubectl -n fluxer create configmap fluxer-api-approved-image \
|
||||
--from-literal=tag="${{ steps.helm.outputs.deploy-tag }}" \
|
||||
--from-literal=image="${IMAGE_REF}" \
|
||||
--from-literal=previousTag="${PREVIOUS_TAG}" \
|
||||
--from-literal=previousImage="${PREVIOUS_IMAGE}" \
|
||||
--dry-run=client -o yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
- name: ensure api admission policy
|
||||
if: ${{ inputs.service == 'api' }}
|
||||
shell: bash
|
||||
run: kubectl apply -f deploy/k8s/fluxer-api-approved-image-policy.yaml
|
||||
|
||||
- name: helm upgrade
|
||||
shell: bash
|
||||
run: |
|
||||
RELEASE="${{ steps.helm.outputs.release }}"
|
||||
CHART_DIR="${{ steps.helm.outputs.chart-dir }}"
|
||||
VALUES_ARGS="${{ steps.helm.outputs.values-args }}"
|
||||
SETS="${{ steps.helm.outputs.sets }}"
|
||||
wait_for_release_idle() {
|
||||
local release="$1"
|
||||
local max_checks="$2"
|
||||
local check=0
|
||||
local status="unknown"
|
||||
while (( check < max_checks )); do
|
||||
check=$((check + 1))
|
||||
status=$(helm status "$release" -n fluxer -o json 2>/dev/null | jq -r '.info.status // "unknown"' || echo "unknown")
|
||||
if [[ "$status" != pending-* ]]; then
|
||||
echo "Release ${release} is ${status}; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "Release ${release} is ${status}; waiting 10s (${check}/${max_checks})."
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::Release ${release} still ${status} after ${max_checks} checks; forcing rollback."
|
||||
if helm rollback "$release" -n fluxer --wait --timeout 5m 2>&1; then
|
||||
echo "Rollback succeeded; continuing."
|
||||
return 0
|
||||
fi
|
||||
echo "::error::Release ${release} is stuck in ${status} and rollback failed."
|
||||
return 1
|
||||
}
|
||||
helm_upgrade_with_retries() {
|
||||
local release="$1"
|
||||
local chart_dir="$2"
|
||||
local values_args="$3"
|
||||
local sets="$4"
|
||||
local values_args_array=()
|
||||
local sets_array=()
|
||||
read -r -a values_args_array <<< "$values_args"
|
||||
read -r -a sets_array <<< "$sets"
|
||||
wait_for_release_idle "$release" 18
|
||||
local max_attempts=4
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
echo "Running helm upgrade for ${release}, attempt ${attempt}/${max_attempts}."
|
||||
set +e
|
||||
upgrade_output=$(helm upgrade --install "$release" \
|
||||
"$chart_dir" \
|
||||
"${values_args_array[@]}" \
|
||||
-n fluxer \
|
||||
"${sets_array[@]}" \
|
||||
--wait --timeout 20m --atomic --history-max 10 2>&1)
|
||||
exit_code=$?
|
||||
set -e
|
||||
printf '%s\n' "$upgrade_output"
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! grep -q "another operation (install/upgrade/rollback) is in progress" <<< "$upgrade_output"; then
|
||||
return "$exit_code"
|
||||
fi
|
||||
if [[ $attempt -eq $max_attempts ]]; then
|
||||
echo "::error::Helm upgrade failed for ${release} after ${max_attempts} attempts because another operation remained in progress."
|
||||
return "$exit_code"
|
||||
fi
|
||||
wait_for_release_idle "$release" 18
|
||||
done
|
||||
}
|
||||
helm_upgrade_with_retries "$RELEASE" "$CHART_DIR" "$VALUES_ARGS" "$SETS"
|
||||
if [[ -n "${{ steps.helm.outputs.sync-worker-release }}" ]]; then
|
||||
helm_upgrade_with_retries \
|
||||
"${{ steps.helm.outputs.sync-worker-release }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-chart-dir }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-values-args }}" \
|
||||
"${{ steps.helm.outputs.sync-worker-sets }}"
|
||||
fi
|
||||
|
||||
- name: seal api admission approved image
|
||||
if: ${{ success() && inputs.service == 'api' }}
|
||||
shell: bash
|
||||
run: |
|
||||
IMAGE_REF="${GHCR_REGISTRY}/${{ steps.helm.outputs.deploy-image }}:${{ steps.helm.outputs.deploy-tag }}"
|
||||
kubectl -n fluxer create configmap fluxer-api-approved-image \
|
||||
--from-literal=tag="${{ steps.helm.outputs.deploy-tag }}" \
|
||||
--from-literal=image="${IMAGE_REF}" \
|
||||
--from-literal=previousTag="" \
|
||||
--from-literal=previousImage="" \
|
||||
--dry-run=client -o yaml \
|
||||
| kubectl apply -f -
|
||||
|
||||
- name: recover stuck release on failure
|
||||
if: failure() || cancelled()
|
||||
shell: bash
|
||||
run: |
|
||||
RELEASE="${{ steps.helm.outputs.release }}"
|
||||
for RELEASE in "$RELEASE" "${{ steps.helm.outputs.sync-worker-release }}"; do
|
||||
if [[ -z "$RELEASE" ]]; then
|
||||
continue
|
||||
fi
|
||||
STATUS=$(helm status "$RELEASE" -n fluxer -o json 2>/dev/null | jq -r '.info.status' 2>/dev/null || echo "unknown")
|
||||
if [[ "$STATUS" == "pending-upgrade" || "$STATUS" == "pending-install" || "$STATUS" == "pending-rollback" ]]; then
|
||||
echo "::warning::Release ${RELEASE} stuck in ${STATUS}, rolling back..."
|
||||
helm rollback "$RELEASE" -n fluxer --wait --timeout 5m || true
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,220 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: Dispatch private marketing build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_marketing
|
||||
- Cargo.toml
|
||||
- fluxer_common/**
|
||||
- packages/fonts/manifest.json
|
||||
- packages/fonts/NOTICE.md
|
||||
- packages/fonts/LICENSE-IBM-PLEX.txt
|
||||
- packages/fonts/css/locale-fallbacks.css
|
||||
- packages/fonts/files/FluxerSans/**
|
||||
- packages/fonts/files/FluxerMono/**
|
||||
- packages/fonts/marketing/**
|
||||
- packages/i18n/marketing/**
|
||||
- fluxer_static/marketing/branding/**
|
||||
- .github/workflows/dispatch-private-marketing-build.yaml
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: private-marketing-dispatch
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: resolve exact private build metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
parent_sha: ${{ steps.inputs.outputs.parent_sha }}
|
||||
gitlink_sha: ${{ steps.inputs.outputs.gitlink_sha }}
|
||||
build_version: ${{ steps.inputs.outputs.build_version }}
|
||||
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
|
||||
steps:
|
||||
- name: Resolve trusted build inputs
|
||||
id: inputs
|
||||
env:
|
||||
EVENT_AFTER: ${{ github.event.after }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PARENT_SHA: ${{ github.sha }}
|
||||
PUBLIC_REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$PUBLIC_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$EVENT_AFTER" == "$PARENT_SHA" ]]
|
||||
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
|
||||
(( 10#$RUN_ATTEMPT <= 10 ))
|
||||
|
||||
main_sha="$(gh api "repos/$PUBLIC_REPOSITORY/git/ref/heads/main" --jq .object.sha)"
|
||||
[[ "$main_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
main_comparison="$(gh api "repos/$PUBLIC_REPOSITORY/compare/$PARENT_SHA...$main_sha")"
|
||||
main_status="$(jq -r .status <<<"$main_comparison")"
|
||||
[[ "$main_status" == "identical" || "$main_status" == "ahead" ]]
|
||||
[[ "$(jq -r .merge_base_commit.sha <<<"$main_comparison")" == "$PARENT_SHA" ]]
|
||||
|
||||
commit="$(gh api "repos/$PUBLIC_REPOSITORY/git/commits/$PARENT_SHA")"
|
||||
[[ "$(jq -r .sha <<<"$commit")" == "$PARENT_SHA" ]]
|
||||
tree_sha="$(jq -r .tree.sha <<<"$commit")"
|
||||
[[ "$tree_sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
entry="$(
|
||||
gh api "repos/$PUBLIC_REPOSITORY/git/trees/$tree_sha" |
|
||||
jq -cer '[.tree[] | select(.path == "fluxer_marketing")] | if length == 1 then .[0] else error("expected exactly one marketing gitlink") end'
|
||||
)"
|
||||
mode="$(jq -r .mode <<<"$entry")"
|
||||
type="$(jq -r .type <<<"$entry")"
|
||||
gitlink_sha="$(jq -r .sha <<<"$entry")"
|
||||
path="$(jq -r .path <<<"$entry")"
|
||||
if [[ "$mode" != "160000" || "$type" != "commit" || "$path" != "fluxer_marketing" || ! "$gitlink_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Public parent does not contain a valid fluxer_marketing gitlink."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run="$(gh api "repos/$PUBLIC_REPOSITORY/actions/runs/$RUN_ID")"
|
||||
[[ "$(jq -r .id <<<"$run")" == "$RUN_ID" ]]
|
||||
[[ "$(jq -r .run_attempt <<<"$run")" == "$RUN_ATTEMPT" ]]
|
||||
[[ "$(jq -r .event <<<"$run")" == "push" ]]
|
||||
[[ "$(jq -r .head_sha <<<"$run")" == "$PARENT_SHA" ]]
|
||||
run_created_at="$(jq -r .created_at <<<"$run")"
|
||||
[[ "$run_created_at" =~ ^[1-9][0-9]{3}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]
|
||||
run_created_epoch="$(date -u -d "$run_created_at" +%s)"
|
||||
[[ "$run_created_epoch" =~ ^[1-9][0-9]*$ ]]
|
||||
build_epoch=$((run_created_epoch + 10#$RUN_ATTEMPT - 1))
|
||||
read -r year month day time_segment <<<"$(date -u -d "@$build_epoch" '+%Y %m %d %H%M%S')"
|
||||
month="$((10#$month))"
|
||||
micro="$((10#$time_segment))"
|
||||
build_version="$year.$month$day.$micro"
|
||||
[[ "$build_version" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
correlation_id="public-${RUN_ID}-${RUN_ATTEMPT}"
|
||||
[[ "$correlation_id" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
{
|
||||
echo "parent_sha=$PARENT_SHA"
|
||||
echo "gitlink_sha=$gitlink_sha"
|
||||
echo "build_version=$build_version"
|
||||
echo "correlation_id=$correlation_id"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
|
||||
dispatch:
|
||||
name: dispatch exact private build
|
||||
needs: metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 65
|
||||
environment: private-marketing-dispatch
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Validate trusted build inputs
|
||||
env:
|
||||
DISPATCH_ENABLED: ${{ vars.MARKETING_DISPATCH_ENABLED }}
|
||||
EXPECTED_PARENT_SHA: ${{ github.sha }}
|
||||
EXPECTED_CORRELATION_ID: public-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$GITHUB_EVENT_NAME" == "push" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/main" ]]
|
||||
[[ "$GITHUB_REPOSITORY" == "fluxerapp/fluxer" ]]
|
||||
[[ "$PARENT_SHA" == "$EXPECTED_PARENT_SHA" ]]
|
||||
[[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$GITLINK_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$BUILD_VERSION" =~ ^[1-9][0-9]{3}\.[1-9][0-9]{2,3}\.([0-9]|[1-9][0-9]{0,5})$ ]]
|
||||
[[ "$CORRELATION_ID" == "$EXPECTED_CORRELATION_ID" ]]
|
||||
[[ "$CORRELATION_ID" =~ ^[A-Za-z0-9._:-]{1,64}$ ]]
|
||||
if [[ "$DISPATCH_ENABLED" != "true" ]]; then
|
||||
echo "::error::Private marketing dispatch is intentionally disabled until the package cutover guard completes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create private dispatch token
|
||||
id: private-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.FLUXER_CI_APP_ID }}
|
||||
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
|
||||
owner: fluxerapp
|
||||
repositories: marketing
|
||||
permission-actions: write
|
||||
|
||||
- name: Dispatch exact private build
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api --method POST repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/dispatches \
|
||||
--field ref=main \
|
||||
--field "inputs[parent_sha]=$PARENT_SHA" \
|
||||
--field "inputs[gitlink_sha]=$GITLINK_SHA" \
|
||||
--field "inputs[build_version]=$BUILD_VERSION" \
|
||||
--field "inputs[correlation_id]=$CORRELATION_ID"
|
||||
|
||||
- name: Wait for private build conclusion
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.private-token.outputs.token }}
|
||||
PARENT_SHA: ${{ needs.metadata.outputs.parent_sha }}
|
||||
GITLINK_SHA: ${{ needs.metadata.outputs.gitlink_sha }}
|
||||
BUILD_VERSION: ${{ needs.metadata.outputs.build_version }}
|
||||
CORRELATION_ID: ${{ needs.metadata.outputs.correlation_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected_title="marketing-build correlation=$CORRELATION_ID parent=$PARENT_SHA gitlink=$GITLINK_SHA version=$BUILD_VERSION"
|
||||
deadline=$((SECONDS + 3600))
|
||||
run_id=""
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
count="$(jq 'length' <<<"$matches")"
|
||||
if [[ "$count" == "1" ]]; then
|
||||
run_id="$(jq -r '.[0].id' <<<"$matches")"
|
||||
break
|
||||
fi
|
||||
if [[ "$count" != "0" ]]; then
|
||||
echo "::error::Private build correlation matched multiple workflow runs."
|
||||
exit 1
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
if [[ -z "$run_id" ]]; then
|
||||
echo "::error::Timed out waiting for the private build dispatch to appear."
|
||||
exit 1
|
||||
fi
|
||||
while (( SECONDS < deadline )); do
|
||||
runs="$(gh api "repos/fluxerapp/marketing/actions/workflows/build-marketing.yaml/runs?event=workflow_dispatch&per_page=100" --jq '[.workflow_runs[] | {id, event, display_title, status, conclusion}]')"
|
||||
matches="$(jq --arg title "$expected_title" '[.[] | select(.event == "workflow_dispatch" and .display_title == $title)]' <<<"$runs")"
|
||||
if [[ "$(jq 'length' <<<"$matches")" != "1" || "$(jq -r '.[0].id' <<<"$matches")" != "$run_id" ]]; then
|
||||
echo "::error::Private build correlation is missing or ambiguous."
|
||||
exit 1
|
||||
fi
|
||||
run="$(jq '.[0]' <<<"$matches")"
|
||||
status="$(jq -r '.status' <<<"$run")"
|
||||
conclusion="$(jq -r '.conclusion // empty' <<<"$run")"
|
||||
if [[ "$status" == "completed" ]]; then
|
||||
if [[ "$conclusion" != "success" ]]; then
|
||||
echo "::error::Private marketing build concluded with $conclusion."
|
||||
exit 1
|
||||
fi
|
||||
echo "Private marketing build completed successfully."
|
||||
exit 0
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
echo "::error::Timed out waiting for the private marketing build."
|
||||
exit 1
|
||||
@@ -1,51 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: finalise release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes)"
|
||||
type: string
|
||||
required: true
|
||||
fragment-run-id:
|
||||
description: "Workflow run id that produced the release-fragment-* artifacts"
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
finalise:
|
||||
name: finalise GitHub release manifest
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
gh run download "${{ inputs.fragment-run-id }}"
|
||||
--pattern "release-fragment-*"
|
||||
--dir release-out/fragments
|
||||
- name: Finalise release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ inputs.build-version }}"
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales fluxer_marketing/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
echo "No source catalog changes."
|
||||
exit 0
|
||||
fi
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
git config user.name "fluxer-ci[bot]"
|
||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||
git switch -c "$SOURCE_BRANCH"
|
||||
git add fluxer_app/src/features/i18n/locales fluxer_marketing/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git commit -m "chore(i18n): refresh source catalogs"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git fetch origin "$SOURCE_BRANCH" || true
|
||||
|
||||
@@ -77,14 +77,14 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n)" ]]; then
|
||||
echo "No generated catalog changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "fluxer-ci[bot]"
|
||||
git config user.email "${{ vars.FLUXER_CI_APP_USER_ID }}+fluxer-ci[bot]@users.noreply.github.com"
|
||||
git add fluxer_app/src/features/i18n/locales packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git add fluxer_app/src/features/i18n/locales packages/i18n/marketing packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
git commit -m "i18n: compile Weblate catalogs"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
|
||||
git push origin "HEAD:$WEBLATE_BRANCH"
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: release all builds
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
concurrency:
|
||||
group: release-all-${{ inputs['build-version'] || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
approve:
|
||||
name: approve release build
|
||||
runs-on: ubuntu-24.04
|
||||
environment: builds
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: approved
|
||||
run: echo "Release build approved."
|
||||
|
||||
meta:
|
||||
name: resolve metadata
|
||||
needs: approve
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
build_version: ${{ steps.vars.outputs.build_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: set variables
|
||||
id: vars
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- resolve-calver
|
||||
--github-output
|
||||
|
||||
build_admin:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-admin.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_api:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-api.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_app_proxy:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-app-proxy.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_app_proxy_self_hosted:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-app-proxy-self-hosted.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_docs:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-docs.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_gateway:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-gateway.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_gifs:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-gifs.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_marketing:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-marketing.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_media_proxy:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-media-proxy.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_messages:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-messages.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_snowflakes:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-snowflakes.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_static:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-static.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_unfurl:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-unfurl.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
build_users:
|
||||
needs: meta
|
||||
uses: ./.github/workflows/build-users.yaml
|
||||
with:
|
||||
build-version: ${{ needs.meta.outputs.build_version }}
|
||||
finalise-release: false
|
||||
approval-required: false
|
||||
secrets: inherit
|
||||
|
||||
release_assets:
|
||||
name: package Helm/self-hosting
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
needs:
|
||||
- meta
|
||||
- build_admin
|
||||
- build_api
|
||||
- build_app_proxy
|
||||
- build_app_proxy_self_hosted
|
||||
- build_docs
|
||||
- build_gateway
|
||||
- build_gifs
|
||||
- build_marketing
|
||||
- build_media_proxy
|
||||
- build_messages
|
||||
- build_snowflakes
|
||||
- build_static
|
||||
- build_unfurl
|
||||
- build_users
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310
|
||||
- name: Publish self-hosting bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-self-hosting
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
- name: Publish Helm chart bundle
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
publish-helm
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
- name: Upload release asset fragments
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: release-fragment-release-assets
|
||||
path: release-out/fragments/*.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
finalise:
|
||||
name: finalise GitHub release manifest
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
needs:
|
||||
- meta
|
||||
- build_admin
|
||||
- build_api
|
||||
- build_app_proxy
|
||||
- build_app_proxy_self_hosted
|
||||
- build_docs
|
||||
- build_gateway
|
||||
- build_gifs
|
||||
- build_marketing
|
||||
- build_media_proxy
|
||||
- build_messages
|
||||
- build_snowflakes
|
||||
- build_static
|
||||
- build_unfurl
|
||||
- build_users
|
||||
- release_assets
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- name: Download GitHub release fragments
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
||||
with:
|
||||
pattern: release-fragment-*
|
||||
path: release-out/fragments
|
||||
merge-multiple: true
|
||||
- name: Finalise GitHub release manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- release
|
||||
finalise
|
||||
--build-version "${{ needs.meta.outputs.build_version }}"
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
libwebp-dev
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm --filter fluxer_admin --filter fluxer_marketing install
|
||||
run: pnpm --filter fluxer_admin install
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
@@ -41,8 +41,6 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/release-input/
|
||||
/release-out/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "fluxer_marketing"]
|
||||
path = fluxer_marketing
|
||||
url = https://github.com/fluxerapp/marketing.git
|
||||
update = none
|
||||
Generated
-443
@@ -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",
|
||||
@@ -1975,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"
|
||||
@@ -2217,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"
|
||||
@@ -2445,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"
|
||||
@@ -2816,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"
|
||||
@@ -2853,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"
|
||||
@@ -2928,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"
|
||||
@@ -2960,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"
|
||||
@@ -3020,8 +2675,6 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -3029,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"
|
||||
@@ -3177,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"
|
||||
@@ -3278,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"
|
||||
@@ -3293,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"
|
||||
@@ -3934,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"
|
||||
@@ -3987,8 +3579,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5284,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"
|
||||
@@ -5396,7 +4975,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5728,12 +5306,6 @@ 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"
|
||||
@@ -6471,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"
|
||||
@@ -6496,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"
|
||||
@@ -6605,7 +6164,6 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6614,7 +6172,6 @@ version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@ members = [
|
||||
"fluxer_admin",
|
||||
"fluxer_app_proxy",
|
||||
"fluxer_common",
|
||||
"fluxer_marketing",
|
||||
"fluxer_media_proxy",
|
||||
"fluxer_gifs",
|
||||
"fluxer_svc",
|
||||
@@ -13,12 +12,12 @@ members = [
|
||||
"tools/content/update-frozen-snapshot",
|
||||
"tools/dev",
|
||||
"tools/i18n_auto",
|
||||
"tools/marketing/update-gettext-catalogs",
|
||||
"fluxer_users",
|
||||
"fluxer_unfurl",
|
||||
"packages/markdown_parser/rust",
|
||||
]
|
||||
exclude = [
|
||||
"fluxer_marketing",
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.19.16",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.23.1",
|
||||
|
||||
@@ -145,7 +145,6 @@
|
||||
"!fluxer_static",
|
||||
"!packages/fonts",
|
||||
"!fluxer_admin/static/htmx.min.js",
|
||||
"!fluxer_marketing/static/htmx.min.js",
|
||||
"!fluxer_api/src/api/openapi/openapi.json"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
|
||||
Vendored
+1
-1
@@ -17,7 +17,7 @@ FLUXER_GATEWAY_ENDPOINT=ws://localhost:8088/gateway
|
||||
FLUXER_MEDIA_ENDPOINT=http://localhost:8088/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT=http://localhost:8088
|
||||
FLUXER_ADMIN_ENDPOINT=http://localhost:8088/admin
|
||||
FLUXER_MARKETING_ENDPOINT=http://localhost:8088/marketing
|
||||
FLUXER_MARKETING_ENDPOINT=https://fluxer.app
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {createUserID} from '../BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, fetchPage, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import {deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import type {
|
||||
AdminAuditLogRow,
|
||||
BannedAvatarHashRow,
|
||||
@@ -30,13 +30,7 @@ import {
|
||||
} from '../Tables';
|
||||
import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils';
|
||||
import {canonicalizeStoredPhrase} from '../utils/PhraseBlocklistNormalization';
|
||||
import type {
|
||||
AdminAuditLog,
|
||||
BannedIpEntry,
|
||||
BannedIpKind,
|
||||
DisposableEmailDomainPage,
|
||||
IAdminRepository,
|
||||
} from './IAdminRepository';
|
||||
import type {AdminAuditLog, BannedIpEntry, BannedIpKind, IAdminRepository} from './IAdminRepository';
|
||||
|
||||
const FETCH_AUDIT_LOG_BY_ID_QUERY = AdminAuditLogs.select({
|
||||
where: AdminAuditLogs.where.eq('log_id'),
|
||||
@@ -51,8 +45,6 @@ const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
|
||||
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
|
||||
where: SuspiciousEmailDomains.where.eq('domain'),
|
||||
});
|
||||
const createLoadSuspiciousEmailDomainsQuery = (limit?: number) =>
|
||||
limit ? SuspiciousEmailDomains.select({limit}) : SuspiciousEmailDomains.select();
|
||||
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
|
||||
where: DisposableEmailDomains.where.eq('domain'),
|
||||
});
|
||||
@@ -273,13 +265,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
|
||||
}
|
||||
|
||||
async listSuspiciousEmailDomains(limit?: number): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{
|
||||
domain: string;
|
||||
}>(createLoadSuspiciousEmailDomainsQuery(limit).bind({}));
|
||||
return rows.map((row) => row.domain);
|
||||
}
|
||||
|
||||
async isEmailDomainDisposable(domain: string): Promise<boolean> {
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
@@ -306,19 +291,6 @@ export class AdminRepository implements IAdminRepository {
|
||||
return rows.map((row) => row.domain);
|
||||
}
|
||||
|
||||
async listDisposableEmailDomainsPage(limit: number, pageState?: string | null): Promise<DisposableEmailDomainPage> {
|
||||
const page = await fetchPage<{
|
||||
domain: string;
|
||||
}>(createLoadDisposableEmailDomainsQuery().bind({}), undefined, {
|
||||
pageSize: limit,
|
||||
pageState,
|
||||
});
|
||||
return {
|
||||
domains: page.rows.map((row) => row.domain),
|
||||
pageState: page.pageState,
|
||||
};
|
||||
}
|
||||
|
||||
async isPhraseBanned(phrase: string): Promise<boolean> {
|
||||
const phraseLower = canonicalizeStoredPhrase(phrase);
|
||||
const result = await fetchOne<{
|
||||
|
||||
@@ -32,11 +32,6 @@ export interface BannedIpEntry {
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
export interface DisposableEmailDomainPage {
|
||||
domains: Array<string>;
|
||||
pageState: string | null;
|
||||
}
|
||||
|
||||
export abstract class IAdminRepository {
|
||||
abstract createAuditLog(log: AdminAuditLogRow): Promise<AdminAuditLog>;
|
||||
|
||||
@@ -68,8 +63,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
|
||||
|
||||
abstract listSuspiciousEmailDomains(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
|
||||
|
||||
abstract addDisposableEmailDomain(domain: string): Promise<void>;
|
||||
@@ -78,8 +71,6 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract listDisposableEmailDomains(limit?: number): Promise<Array<string>>;
|
||||
|
||||
abstract listDisposableEmailDomainsPage(limit: number, pageState?: string | null): Promise<DisposableEmailDomainPage>;
|
||||
|
||||
abstract isPhraseBanned(phrase: string): Promise<boolean>;
|
||||
|
||||
abstract banPhrase(phrase: string): Promise<void>;
|
||||
|
||||
@@ -13,11 +13,7 @@ import type {ILogger} from '../ILogger';
|
||||
import {JobLedgerRepository} from '../jobs/JobLedgerRepository';
|
||||
import {startAbuseReplicationSubscriber, stopAbuseReplicationSubscriber} from '../middleware/AbusiveIpAutoBanner';
|
||||
import {ipBanCache} from '../middleware/IpBanMiddleware';
|
||||
import {
|
||||
getRiskCacheManagerInstance,
|
||||
initializeServiceSingletons,
|
||||
shutdownReportService,
|
||||
} from '../middleware/ServiceMiddleware';
|
||||
import {initializeServiceSingletons, shutdownReportService} from '../middleware/ServiceMiddleware';
|
||||
import {
|
||||
ensureVoiceResourcesInitialized,
|
||||
getKVClient,
|
||||
@@ -40,57 +36,6 @@ import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
|
||||
import {WorkerService} from '../worker/WorkerService';
|
||||
|
||||
let jsConnectionManager: JetStreamConnectionManager | null = null;
|
||||
let riskCacheRefreshInterval: NodeJS.Timeout | null = null;
|
||||
let riskCacheRefreshInFlight = false;
|
||||
|
||||
const RISK_CACHE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
async function refreshRiskCache(logger: ILogger, source: 'startup' | 'interval'): Promise<void> {
|
||||
if (riskCacheRefreshInFlight) {
|
||||
return;
|
||||
}
|
||||
riskCacheRefreshInFlight = true;
|
||||
try {
|
||||
const result = await getRiskCacheManagerInstance().refresh();
|
||||
if (result.subtaskErrors.length > 0) {
|
||||
logger.warn({source, errors: result.subtaskErrors}, 'Risk cache refresh completed with errors');
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
source,
|
||||
disposableDomainCount: result.disposableDomainCount,
|
||||
},
|
||||
source === 'startup' ? 'Risk cache initialized on API startup' : 'Risk cache refresh complete on API',
|
||||
);
|
||||
} catch (error) {
|
||||
if (source === 'startup') {
|
||||
logger.warn({error}, 'Risk cache initialisation failed on API startup');
|
||||
return;
|
||||
}
|
||||
logger.warn({error}, 'Periodic risk cache refresh failed on API');
|
||||
} finally {
|
||||
riskCacheRefreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRiskCacheRefreshLoop(logger: ILogger): void {
|
||||
if (riskCacheRefreshInterval) {
|
||||
return;
|
||||
}
|
||||
riskCacheRefreshInterval = setInterval(() => {
|
||||
void refreshRiskCache(logger, 'interval');
|
||||
}, RISK_CACHE_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopRiskCacheRefreshLoop(): void {
|
||||
if (!riskCacheRefreshInterval) {
|
||||
return;
|
||||
}
|
||||
clearInterval(riskCacheRefreshInterval);
|
||||
riskCacheRefreshInterval = null;
|
||||
}
|
||||
|
||||
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
|
||||
return async (): Promise<void> => {
|
||||
try {
|
||||
@@ -171,8 +116,6 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
logger.info('Profile substring blocklist cache initialized');
|
||||
await initializeServiceSingletons();
|
||||
logger.info('Service singletons initialized');
|
||||
await refreshRiskCache(logger, 'startup');
|
||||
startRiskCacheRefreshLoop(logger);
|
||||
if (!config.dev.testModeEnabled) {
|
||||
jsConnectionManager = new JetStreamConnectionManager({
|
||||
url: config.nats.jetStreamUrl,
|
||||
@@ -285,12 +228,6 @@ export function createShutdown(logger: ILogger): () => Promise<void> {
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down search service');
|
||||
}
|
||||
try {
|
||||
stopRiskCacheRefreshLoop();
|
||||
logger.info('Risk cache refresh loop shut down');
|
||||
} catch (error) {
|
||||
logger.error({error}, 'Error shutting down risk cache refresh loop');
|
||||
}
|
||||
try {
|
||||
ipBanCache.shutdown();
|
||||
logger.info('IP ban cache shut down');
|
||||
|
||||
@@ -295,10 +295,7 @@ export class MessagePersistenceService {
|
||||
return null;
|
||||
}
|
||||
const uploadUserId = params.attachmentUploadUserId;
|
||||
assert(
|
||||
uploadUserId !== undefined,
|
||||
'Attachment upload actor must be resolved before processing new attachments',
|
||||
);
|
||||
assert(uploadUserId !== undefined, 'Attachment upload actor must be resolved before processing new attachments');
|
||||
return this.attachmentService.computeAttachments({
|
||||
message: {
|
||||
id: params.messageId,
|
||||
@@ -528,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,
|
||||
@@ -546,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;
|
||||
}
|
||||
|
||||
@@ -178,6 +178,9 @@ export interface APIConfig {
|
||||
ipinfoApiKey?: string;
|
||||
accountPolicyDsl?: unknown;
|
||||
};
|
||||
blocklistFeeds: {
|
||||
enabled: boolean;
|
||||
};
|
||||
captcha: {
|
||||
enabled: boolean;
|
||||
provider: 'hcaptcha' | 'turnstile' | 'none';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -60,8 +60,6 @@ import {CassandraHistoricalOutcomeRepository} from '../risk/HistoricalOutcomeRep
|
||||
import {buildIpInfoCache, buildIpInfoRequestAuditLogger} from '../risk/IpInfoCacheFactory';
|
||||
import {CassandraRegistrationEventsRepository} from '../risk/RegistrationEventsRepository';
|
||||
import {CassandraRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {buildRiskCacheLoaders} from '../risk/RiskCacheLoaders';
|
||||
import {RiskCacheManager} from '../risk/RiskCacheManager';
|
||||
import {createRiskToolbox} from '../risk/RiskToolboxFactory';
|
||||
import {CassandraSuspiciousIpRepository} from '../risk/SuspiciousIpRepository';
|
||||
import {RpcService} from '../rpc/RpcService';
|
||||
@@ -192,24 +190,6 @@ export function shutdownReportService(): void {
|
||||
}
|
||||
}
|
||||
|
||||
let _riskCacheManager: RiskCacheManager | null = null;
|
||||
|
||||
function getRiskCacheManager(): RiskCacheManager {
|
||||
if (!_riskCacheManager) {
|
||||
_riskCacheManager = new RiskCacheManager({
|
||||
logger: Logger,
|
||||
...buildRiskCacheLoaders({
|
||||
adminRepository: getAdminRepository(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
return _riskCacheManager;
|
||||
}
|
||||
|
||||
export function getRiskCacheManagerInstance(): RiskCacheManager {
|
||||
return getRiskCacheManager();
|
||||
}
|
||||
|
||||
let _inboundSmsChallengeService: InboundSmsChallengeService | null = null;
|
||||
|
||||
function getInboundSmsChallengeService(): InboundSmsChallengeService {
|
||||
@@ -311,13 +291,12 @@ function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
|
||||
_registrationRiskEvaluator = noopRegistrationRiskEvaluator;
|
||||
return _registrationRiskEvaluator;
|
||||
}
|
||||
const cacheManager = getRiskCacheManager();
|
||||
const ipInfoService = getIpInfoService();
|
||||
const ipInfoChecker = Config.risk.ipinfoApiKey ? createIpInfoChecker({ipInfoService}) : undefined;
|
||||
const cacheService = getCacheService();
|
||||
const reverseDnsLookup = createReverseDnsLookup({cacheService});
|
||||
const toolbox = createRiskToolbox({
|
||||
disposableDomainsRef: cacheManager.disposableDomainsRef,
|
||||
adminRepository: getAdminRepository(),
|
||||
ipInfoChecker,
|
||||
reverseDnsLookup,
|
||||
ipInfoService,
|
||||
|
||||
@@ -10800,6 +10800,64 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/ip": {
|
||||
"get": {
|
||||
"operationId": "get_client_geolocation",
|
||||
"summary": "Get client geolocation",
|
||||
"tags": ["Geolocation"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/GeolocationResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Get client geolocation"}},
|
||||
"description": "Resolves the approximate location of the requesting client from its IP address, together with the locations where age restricted content is gated or unavailable."
|
||||
}
|
||||
},
|
||||
"/oauth2/@me": {
|
||||
"get": {
|
||||
"operationId": "get_current_user_oauth2",
|
||||
@@ -31926,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": {
|
||||
@@ -38163,6 +38264,7 @@
|
||||
{"name": "Connections"},
|
||||
{"name": "Messages"},
|
||||
{"name": "Donations"},
|
||||
{"name": "Geolocation"},
|
||||
{"name": "Discovery"},
|
||||
{"name": "Emojis"},
|
||||
{"name": "Stickers"},
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IAdminRepository} from '../admin/IAdminRepository';
|
||||
import type {RiskCacheLoaders} from './RiskCacheManager';
|
||||
|
||||
interface BuildRiskCacheLoadersDeps {
|
||||
adminRepository: Pick<IAdminRepository, 'listSuspiciousEmailDomains' | 'listDisposableEmailDomains'>;
|
||||
}
|
||||
|
||||
export function buildRiskCacheLoaders(deps: BuildRiskCacheLoadersDeps): RiskCacheLoaders {
|
||||
return {
|
||||
loadDisposableDomains: async () => {
|
||||
const [suspicious, disposable] = await Promise.all([
|
||||
deps.adminRepository.listSuspiciousEmailDomains(),
|
||||
deps.adminRepository.listDisposableEmailDomains(),
|
||||
]);
|
||||
const set = new Set<string>();
|
||||
for (const d of suspicious) set.add(d.toLowerCase());
|
||||
for (const d of disposable) set.add(d.toLowerCase());
|
||||
return set;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
interface MutableRef<T> {
|
||||
current: T;
|
||||
}
|
||||
|
||||
export type ReadonlyRiskCacheRef<T> = {
|
||||
readonly current: T;
|
||||
};
|
||||
|
||||
interface RiskCacheManagerLogger {
|
||||
info(payload: object, msg: string): void;
|
||||
warn(payload: object, msg: string): void;
|
||||
}
|
||||
|
||||
export interface RiskCacheLoaders {
|
||||
loadDisposableDomains: () => Promise<ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
interface RiskCacheManagerOptions extends RiskCacheLoaders {
|
||||
logger?: RiskCacheManagerLogger;
|
||||
}
|
||||
|
||||
interface RiskCacheRefreshResult {
|
||||
disposableDomainCount: number;
|
||||
subtaskErrors: ReadonlyArray<{
|
||||
step: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class RiskCacheManager {
|
||||
private readonly _disposable: MutableRef<ReadonlySet<string>> = {current: new Set()};
|
||||
readonly disposableDomainsRef: ReadonlyRiskCacheRef<ReadonlySet<string>> = this._disposable;
|
||||
private readonly logger: RiskCacheManagerLogger | undefined;
|
||||
private readonly loaders: RiskCacheLoaders;
|
||||
|
||||
constructor(opts: RiskCacheManagerOptions) {
|
||||
this.logger = opts.logger;
|
||||
this.loaders = {
|
||||
loadDisposableDomains: opts.loadDisposableDomains,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(): Promise<RiskCacheRefreshResult> {
|
||||
const subtaskErrors: Array<{
|
||||
step: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
try {
|
||||
const set = await this.loaders.loadDisposableDomains();
|
||||
this._disposable.current = set;
|
||||
this.logger?.info({count: set.size}, 'RiskCacheManager: loaded disposable domains from DB');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger?.warn({step: 'disposable_domains', err: message}, 'RiskCacheManager: subtask failed');
|
||||
subtaskErrors.push({step: 'disposable_domains', error: message});
|
||||
}
|
||||
return {
|
||||
disposableDomainCount: this._disposable.current.size,
|
||||
subtaskErrors,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {IAdminRepository} from '../admin/IAdminRepository';
|
||||
import {createDisposableDomainChecker} from './adapters/DisposableDomainChecker';
|
||||
import {createDnsMxChecker, type MxResolver, NodeDnsMxResolver} from './adapters/DnsMxChecker';
|
||||
import {createDomainAgeChecker} from './adapters/DomainAgeChecker';
|
||||
@@ -13,13 +14,12 @@ import {analyzeRegistrationTiming} from './adapters/RegistrationTimingAnalyzer';
|
||||
import {analyzeUserAgent} from './adapters/UserAgentAnalyzer';
|
||||
import {createVelocityAdapter, type IRegistrationEventsRepository} from './adapters/VelocityAdapter';
|
||||
import type {IRiskHistoryRepository} from './HistoricalOutcomeRepository';
|
||||
import type {ReadonlyRiskCacheRef} from './RiskCacheManager';
|
||||
import type {RiskToolbox} from './RiskToolbox';
|
||||
import type {IpInfoAnonymousResult, ReverseDnsResult} from './RiskTypes';
|
||||
import type {ISuspiciousIpRepository} from './SuspiciousIpRepository';
|
||||
|
||||
interface RiskToolboxFactoryOptions {
|
||||
disposableDomainsRef: ReadonlyRiskCacheRef<ReadonlySet<string>>;
|
||||
adminRepository: Pick<IAdminRepository, 'isEmailDomainSuspicious' | 'isEmailDomainDisposable'>;
|
||||
ipInfoChecker?: (ip: string) => Promise<IpInfoAnonymousResult>;
|
||||
reverseDnsLookup?: (ip: string) => Promise<ReverseDnsResult>;
|
||||
ipInfoService: IpInfoService;
|
||||
@@ -32,7 +32,7 @@ interface RiskToolboxFactoryOptions {
|
||||
}
|
||||
|
||||
export function createRiskToolbox(opts: RiskToolboxFactoryOptions): RiskToolbox {
|
||||
const checkDomainDisposable = createDisposableDomainChecker({disposableDomainsRef: opts.disposableDomainsRef});
|
||||
const checkDomainDisposable = createDisposableDomainChecker({adminRepository: opts.adminRepository});
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({ipInfoService: opts.ipInfoService});
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({ipInfoService: opts.ipInfoService});
|
||||
const checkMx = createDnsMxChecker({
|
||||
|
||||
@@ -97,7 +97,6 @@ export interface EmailSyntaxResult {
|
||||
export interface DisposableCheckResult {
|
||||
domain: string;
|
||||
isDisposable: boolean;
|
||||
listSize: number;
|
||||
}
|
||||
|
||||
export interface MxCheckResult {
|
||||
|
||||
@@ -54,7 +54,6 @@ function createToolbox(
|
||||
checkDomainDisposable: async ({domain}) => ({
|
||||
domain,
|
||||
isDisposable: false,
|
||||
listSize: 0,
|
||||
}),
|
||||
checkMx: async ({domain}) => ({
|
||||
domain,
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IAdminRepository} from '../../admin/IAdminRepository';
|
||||
import type {DisposableCheckResult} from '../RiskTypes';
|
||||
|
||||
interface DisposableDomainCheckerContext {
|
||||
disposableDomainsRef: {
|
||||
readonly current: ReadonlySet<string>;
|
||||
};
|
||||
adminRepository: Pick<IAdminRepository, 'isEmailDomainSuspicious' | 'isEmailDomainDisposable'>;
|
||||
}
|
||||
|
||||
export function createDisposableDomainChecker(ctx: DisposableDomainCheckerContext) {
|
||||
return async function checkDomainDisposable(args: {domain: string}): Promise<DisposableCheckResult> {
|
||||
const domain = args.domain.toLowerCase().trim();
|
||||
const set = ctx.disposableDomainsRef.current;
|
||||
const [suspicious, disposable] = await Promise.all([
|
||||
ctx.adminRepository.isEmailDomainSuspicious(domain),
|
||||
ctx.adminRepository.isEmailDomainDisposable(domain),
|
||||
]);
|
||||
return {
|
||||
domain,
|
||||
isDisposable: set.has(domain),
|
||||
listSize: set.size,
|
||||
isDisposable: suspicious || disposable,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface CronDefinition {
|
||||
taskType: WorkerTaskName;
|
||||
payload: WorkerJobPayload;
|
||||
cronExpression: string;
|
||||
ledger: boolean;
|
||||
lastFired: number;
|
||||
}
|
||||
|
||||
@@ -89,12 +90,19 @@ export class CronScheduler {
|
||||
this.kvClient = kvClient;
|
||||
}
|
||||
|
||||
upsert(id: string, taskType: WorkerTaskName, payload: WorkerJobPayload, cronExpression: string): void {
|
||||
upsert(
|
||||
id: string,
|
||||
taskType: WorkerTaskName,
|
||||
payload: WorkerJobPayload,
|
||||
cronExpression: string,
|
||||
options: {ledger: boolean},
|
||||
): void {
|
||||
this.definitions.set(id, {
|
||||
id,
|
||||
taskType,
|
||||
payload,
|
||||
cronExpression,
|
||||
ledger: options.ledger,
|
||||
lastFired: 0,
|
||||
});
|
||||
}
|
||||
@@ -133,7 +141,7 @@ export class CronScheduler {
|
||||
if (!acquired) {
|
||||
continue;
|
||||
}
|
||||
await this.workerService.addJob(def.taskType, def.payload, {jobKey});
|
||||
await this.workerService.addJob(def.taskType, def.payload, {jobKey, skipLedger: !def.ledger});
|
||||
this.logger.debug({cronId: def.id, taskType: def.taskType}, 'Cron job fired');
|
||||
} catch (error) {
|
||||
this.logger.error({err: error, cronId: def.id, taskType: def.taskType}, 'Failed to enqueue cron job');
|
||||
|
||||
@@ -41,21 +41,27 @@ const SEARCH_REQUIRED_TASKS = new Set<string>([
|
||||
]);
|
||||
|
||||
function registerCronJobs(cron: CronScheduler): void {
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *');
|
||||
cron.upsert('processBunnyPurgeQueue', 'processBunnyPurgeQueue', {}, '*/10 * * * * *');
|
||||
cron.upsert('processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions', {}, '0 */10 * * * *');
|
||||
cron.upsert('userProcessPendingDeletions', 'userProcessPendingDeletions', {}, '0 * * * * *');
|
||||
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *');
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *');
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *');
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *');
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *');
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *');
|
||||
cron.upsert('syncDisposableEmailDomains', 'syncDisposableEmailDomains', {}, '0 */30 * * * *');
|
||||
cron.upsert('syncUrlBlocklists', 'syncUrlBlocklists', {}, '0 0 */6 * * *');
|
||||
cron.upsert('syncFileShaBlocklists', 'syncFileShaBlocklists', {}, '0 0 */12 * * *');
|
||||
cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *');
|
||||
Logger.info('Cron jobs registered successfully');
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('processBunnyPurgeQueue', 'processBunnyPurgeQueue', {}, '*/10 * * * * *', {ledger: false});
|
||||
cron.upsert('processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions', {}, '0 */10 * * * *', {
|
||||
ledger: false,
|
||||
});
|
||||
cron.upsert('userProcessPendingDeletions', 'userProcessPendingDeletions', {}, '0 * * * * *', {ledger: false});
|
||||
cron.upsert('processPremiumStateReconciliationQueue', 'processPremiumStateReconciliationQueue', {}, '0 * * * * *', {
|
||||
ledger: false,
|
||||
});
|
||||
cron.upsert('processExpiredPremiumSweep', 'processExpiredPremiumSweep', {}, '0 0 * * * *', {ledger: false});
|
||||
cron.upsert('processInactivityDeletions', 'processInactivityDeletions', {}, '0 0 */6 * * *', {ledger: false});
|
||||
cron.upsert('expireAttachments', 'expireAttachments', {}, '0 0 */12 * * *', {ledger: false});
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *', {ledger: false});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
cron.upsert('syncDisposableEmailDomains', 'syncDisposableEmailDomains', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncUrlBlocklists', 'syncUrlBlocklists', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncFileShaBlocklists', 'syncFileShaBlocklists', {}, '0 0 */12 * * *', {ledger: true});
|
||||
}
|
||||
cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *', {ledger: false});
|
||||
Logger.info({blocklistFeeds: Config.blocklistFeeds.enabled}, 'Cron jobs registered successfully');
|
||||
}
|
||||
|
||||
function workerLanesRequireSearch(activeWorkerLanes: ReadonlyArray<WorkerLaneDefinition>): boolean {
|
||||
@@ -191,10 +197,16 @@ export async function startWorkerMain(): Promise<void> {
|
||||
setInjectedWorkerService(workerService);
|
||||
dependencies = await initializeWorkerDependencies(snowflakeService);
|
||||
setWorkerDependencies(dependencies);
|
||||
const didClaimEmailSync = await dependencies.kvClient.setnx('sync:email_domains:initialized', '1');
|
||||
if (didClaimEmailSync) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await workerService.addJob('syncDisposableEmailDomains', {});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
const didClaimEmailSync = await dependencies.kvClient.setnx(
|
||||
'sync:email_domains:initialized',
|
||||
'1',
|
||||
ms('6 hours') / 1000,
|
||||
);
|
||||
if (didClaimEmailSync) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await workerService.addJob('syncDisposableEmailDomains', {});
|
||||
}
|
||||
}
|
||||
cron = new CronScheduler(workerService, Logger, dependencies.kvClient);
|
||||
registerCronJobs(cron);
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {MediaProxyNsfwMode} from '../../infrastructure/IMediaService';
|
||||
import {Logger} from '../../Logger';
|
||||
import type {Channel} from '../../models/Channel';
|
||||
import {Embed} from '../../models/Embed';
|
||||
import {Message} from '../../models/Message';
|
||||
import {deleteMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup';
|
||||
import * as UnfurlerUtils from '../../utils/UnfurlerUtils';
|
||||
@@ -368,13 +367,15 @@ async function updateMessageEmbeds(
|
||||
orderedEmbeds: Array<MessageEmbed>,
|
||||
): Promise<Message | null> {
|
||||
const existingEmbeds = (freshMessage.embeds ?? []).map((embed) => embed.toMessageEmbed());
|
||||
if (areEmbedsEquivalent(existingEmbeds, orderedEmbeds)) {
|
||||
const preservedEmbeds = existingEmbeds.filter((embed) => embed.type === 'rich');
|
||||
const nextEmbeds = [...preservedEmbeds, ...orderedEmbeds];
|
||||
if (areEmbedsEquivalent(existingEmbeds, nextEmbeds)) {
|
||||
Logger.debug({messageId: freshMessage.id.toString()}, 'Embeds unchanged, skipping update');
|
||||
return freshMessage;
|
||||
}
|
||||
const messageWithEmbeds = new Message({
|
||||
...freshMessage.toRow(),
|
||||
embeds: orderedEmbeds.length > 0 ? orderedEmbeds : null,
|
||||
embeds: nextEmbeds.length > 0 ? nextEmbeds : null,
|
||||
});
|
||||
await channelRepository.updateEmbeds(messageWithEmbeds);
|
||||
return messageWithEmbeds;
|
||||
@@ -382,7 +383,6 @@ async function updateMessageEmbeds(
|
||||
|
||||
interface DispatchEmbedUpdateParams {
|
||||
latestMessage: Message;
|
||||
orderedEmbeds: Array<MessageEmbed>;
|
||||
channel: Channel;
|
||||
guildId: GuildID | null;
|
||||
gatewayService: IGatewayService;
|
||||
@@ -390,15 +390,13 @@ interface DispatchEmbedUpdateParams {
|
||||
|
||||
async function dispatchEmbedUpdate({
|
||||
latestMessage,
|
||||
orderedEmbeds,
|
||||
channel,
|
||||
guildId,
|
||||
gatewayService,
|
||||
}: DispatchEmbedUpdateParams): Promise<void> {
|
||||
const embedObjects = orderedEmbeds.length > 0 ? orderedEmbeds.map((e) => new Embed(e)) : latestMessage.embeds;
|
||||
const messageWithUpdatedEmbeds = new Message({
|
||||
...latestMessage.toRow(),
|
||||
embeds: embedObjects.map((e) => e.toMessageEmbed()),
|
||||
embeds: latestMessage.embeds.map((e) => e.toMessageEmbed()),
|
||||
});
|
||||
const messageData = await buildBroadcastMessageData({
|
||||
channel,
|
||||
@@ -512,7 +510,6 @@ const extractEmbeds: WorkerTaskHandler = async (payload, helpers) => {
|
||||
if (!(latestMessage.flags & MessageFlags.SUPPRESS_EMBEDS)) {
|
||||
await dispatchEmbedUpdate({
|
||||
latestMessage,
|
||||
orderedEmbeds,
|
||||
channel,
|
||||
guildId,
|
||||
gatewayService,
|
||||
|
||||
@@ -21,7 +21,6 @@ const SOURCES = [
|
||||
'https://raw.githubusercontent.com/vrittech/disposable-email/main/disposable_domains.txt',
|
||||
'https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blocklist.conf',
|
||||
];
|
||||
const CURRENT_DOMAIN_PAGE_SIZE = 10000;
|
||||
const WRITE_PROGRESS_INTERVAL = 500;
|
||||
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
@@ -120,16 +119,7 @@ function normaliseDomain(raw: string): string | null {
|
||||
|
||||
async function loadCurrentDisposableEmailDomains(): Promise<Set<string>> {
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
const currentSet = new Set<string>();
|
||||
let pageState: string | null = null;
|
||||
do {
|
||||
const page = await adminRepository.listDisposableEmailDomainsPage(CURRENT_DOMAIN_PAGE_SIZE, pageState);
|
||||
for (const domain of page.domains) {
|
||||
currentSet.add(domain);
|
||||
}
|
||||
pageState = page.pageState;
|
||||
} while (pageState !== null);
|
||||
return currentSet;
|
||||
return new Set(await adminRepository.listDisposableEmailDomains());
|
||||
}
|
||||
|
||||
async function throwIfCancelled(helpers: WorkerTaskHelpers): Promise<void> {
|
||||
|
||||
+1
-1
@@ -230,7 +230,7 @@
|
||||
background-color: var(--settings-border-color, var(--background-modifier-accent));
|
||||
}
|
||||
|
||||
.additionalContent {
|
||||
.extraContent {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -366,7 +366,7 @@ export const MobileSettingsList = observer(function MobileSettingsList<T extends
|
||||
)}
|
||||
{additionalContent && (
|
||||
<div
|
||||
className={styles.additionalContent}
|
||||
className={styles.extraContent}
|
||||
data-flx="app.mobile-settings-components.mobile-settings-list.additional-content"
|
||||
>
|
||||
{additionalContent}
|
||||
|
||||
+3
-3
@@ -198,7 +198,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
.categoryAddButton {
|
||||
display: flex;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
@@ -216,11 +216,11 @@
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.addButton:hover {
|
||||
.categoryAddButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addButtonIcon {
|
||||
.categoryAddButtonIcon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
@@ -581,7 +581,7 @@ const FavoriteCategoryItem = observer(
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={favoritesChannelListStyles.addButton}
|
||||
className={favoritesChannelListStyles.categoryAddButton}
|
||||
aria-label={i18n._(ADD_CHANNEL_DESCRIPTOR)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -591,7 +591,7 @@ const FavoriteCategoryItem = observer(
|
||||
>
|
||||
<PlusIcon
|
||||
weight="bold"
|
||||
className={favoritesChannelListStyles.addButtonIcon}
|
||||
className={favoritesChannelListStyles.categoryAddButtonIcon}
|
||||
data-flx="app.favorites-channel-list-content.favorite-category-item.plus-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -455,11 +455,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.addGuildButton {
|
||||
.createGuildButton {
|
||||
composes: guildListItem;
|
||||
}
|
||||
|
||||
.addGuildButtonIcon {
|
||||
.createGuildButtonIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -478,11 +478,11 @@
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.addGuildButton:hover
|
||||
.addGuildButtonIcon,
|
||||
.createGuildButton:hover
|
||||
.createGuildButtonIcon,
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.addGuildButtonIcon:hover,
|
||||
.addGuildButton.contextMenuHover .addGuildButtonIcon {
|
||||
.createGuildButtonIcon:hover,
|
||||
.createGuildButton.contextMenuHover .createGuildButtonIcon {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const AddGuildButton = observer(() => {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx(guildStyles.addGuildButton, contextMenuOpen && guildStyles.contextMenuHover)}
|
||||
className={clsx(guildStyles.createGuildButton, contextMenuOpen && guildStyles.contextMenuHover)}
|
||||
data-flx="app.sidebar-nav.add-guild-button.div"
|
||||
>
|
||||
<Tooltip
|
||||
@@ -131,7 +131,7 @@ export const AddGuildButton = observer(() => {
|
||||
>
|
||||
<motion.div
|
||||
ref={iconRef}
|
||||
className={guildStyles.addGuildButtonIcon}
|
||||
className={guildStyles.createGuildButtonIcon}
|
||||
animate={{borderRadius: shouldShowHoverState ? '30%' : '50%'}}
|
||||
initial={{borderRadius: shouldShowHoverState ? '30%' : '50%'}}
|
||||
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const DiscoveryButton = observer(() => {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={guildStyles.addGuildButton} data-flx="app.sidebar-nav.discovery-button.div">
|
||||
<div className={guildStyles.createGuildButton} data-flx="app.sidebar-nav.discovery-button.div">
|
||||
<Tooltip
|
||||
position="right"
|
||||
size="large"
|
||||
@@ -62,7 +62,7 @@ export const DiscoveryButton = observer(() => {
|
||||
>
|
||||
<motion.div
|
||||
ref={iconRef}
|
||||
className={guildStyles.addGuildButtonIcon}
|
||||
className={guildStyles.createGuildButtonIcon}
|
||||
animate={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
|
||||
initial={{borderRadius: isHovering || isSelected ? '30%' : '50%'}}
|
||||
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}}
|
||||
|
||||
@@ -67,7 +67,7 @@ export const DownloadButton = observer(() => {
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
return (
|
||||
<div
|
||||
className={clsx(guildStyles.addGuildButton, contextMenuOpen && guildStyles.contextMenuHover)}
|
||||
className={clsx(guildStyles.createGuildButton, contextMenuOpen && guildStyles.contextMenuHover)}
|
||||
data-flx="app.sidebar-nav.download-button.div"
|
||||
>
|
||||
<Tooltip
|
||||
@@ -94,7 +94,7 @@ export const DownloadButton = observer(() => {
|
||||
>
|
||||
<motion.div
|
||||
ref={iconRef}
|
||||
className={guildStyles.addGuildButtonIcon}
|
||||
className={guildStyles.createGuildButtonIcon}
|
||||
animate={{borderRadius: shouldShowHoverState ? '30%' : '50%'}}
|
||||
initial={{borderRadius: shouldShowHoverState ? '30%' : '50%'}}
|
||||
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}}
|
||||
|
||||
@@ -68,7 +68,7 @@ export const HelpButton = observer(() => {
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
return (
|
||||
<div
|
||||
className={clsx(guildStyles.addGuildButton, contextMenuOpen && guildStyles.contextMenuHover)}
|
||||
className={clsx(guildStyles.createGuildButton, contextMenuOpen && guildStyles.contextMenuHover)}
|
||||
data-flx="app.sidebar-nav.help-button.div"
|
||||
>
|
||||
<Tooltip
|
||||
@@ -101,7 +101,7 @@ export const HelpButton = observer(() => {
|
||||
>
|
||||
<motion.div
|
||||
ref={iconRef}
|
||||
className={guildStyles.addGuildButtonIcon}
|
||||
className={guildStyles.createGuildButtonIcon}
|
||||
animate={{borderRadius: shouldShowHoverState ? '30%' : '50%'}}
|
||||
initial={{borderRadius: shouldShowHoverState ? '30%' : '50%'}}
|
||||
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.07, ease: 'easeOut'}}
|
||||
|
||||
@@ -163,11 +163,11 @@
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.adminForm {
|
||||
.accountSetupForm {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adminForm > form {
|
||||
.accountSetupForm > form {
|
||||
margin-top: 0;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -559,7 +559,7 @@ export const AdminAccountStep = observer(({theme}: {theme: ThemeType}) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
|
||||
<div className={styles.adminForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
|
||||
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
|
||||
<AuthRegisterFormCore
|
||||
fields={{
|
||||
showEmail: true,
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
border-color: color-mix(in srgb, var(--brand-primary) 45%, transparent 55%);
|
||||
}
|
||||
|
||||
.addReactionButton {
|
||||
.reactionPickerButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -159,8 +159,8 @@
|
||||
color 0.1s ease;
|
||||
}
|
||||
|
||||
.addReactionButton:hover,
|
||||
.addReactionButtonActive {
|
||||
.reactionPickerButton:hover,
|
||||
.reactionPickerButtonActive {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ export const MessageReactions = observer(
|
||||
<button
|
||||
ref={addReactionButtonRef}
|
||||
type="button"
|
||||
className={clsx(styles.addReactionButton, emojiPickerOpen && styles.addReactionButtonActive)}
|
||||
className={clsx(styles.reactionPickerButton, emojiPickerOpen && styles.reactionPickerButtonActive)}
|
||||
aria-label={i18n._(ADD_REACTION_DESCRIPTOR)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={emojiPickerOpen}
|
||||
@@ -376,7 +376,7 @@ export const MessageReactions = observer(
|
||||
<button
|
||||
ref={addReactionButtonRef}
|
||||
type="button"
|
||||
className={clsx(styles.addReactionButton, emojiPickerOpen && styles.addReactionButtonActive)}
|
||||
className={clsx(styles.reactionPickerButton, emojiPickerOpen && styles.reactionPickerButtonActive)}
|
||||
aria-label={i18n._(ADD_REACTION_DESCRIPTOR)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={emojiPickerOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.addFriendContainer {
|
||||
.friendRequestContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.addFriendContainer {
|
||||
.friendRequestContainer {
|
||||
padding: 1rem;
|
||||
padding-bottom: calc(1rem + 3rem);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export const AddFriendView = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const showVerificationSlateOnly = Users.currentUser?.verified === false;
|
||||
return (
|
||||
<div className={styles.addFriendContainer} data-flx="channel.direct-message.add-friend-view.add-friend-container">
|
||||
<div
|
||||
className={styles.friendRequestContainer}
|
||||
data-flx="channel.direct-message.add-friend-view.add-friend-container"
|
||||
>
|
||||
<div className={styles.card} data-flx="channel.direct-message.add-friend-view.card">
|
||||
{showVerificationSlateOnly ? (
|
||||
<AddFriendForm data-flx="channel.direct-message.add-friend-view.add-friend-form" />
|
||||
|
||||
@@ -68,12 +68,12 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.addRoleButton {
|
||||
.assignRoleButton {
|
||||
cursor: pointer;
|
||||
transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.addRoleButtonIcon {
|
||||
.assignRoleButtonIcon {
|
||||
display: flex;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
@@ -83,8 +83,8 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addRoleButtonIcon:hover,
|
||||
.addRoleButtonIcon[data-context-menu-open='true'] {
|
||||
.assignRoleButtonIcon:hover,
|
||||
.assignRoleButtonIcon[data-context-menu-open='true'] {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -449,7 +449,7 @@ export const AddRoleButton: React.FC<{
|
||||
<FocusRing offset={-2} data-flx="guild.role-management.add-role-button.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.addRoleButton, styles.addRoleButtonIcon)}
|
||||
className={clsx(styles.assignRoleButton, styles.assignRoleButtonIcon)}
|
||||
onClick={handleClick}
|
||||
aria-label={i18n._(ADD_ROLE_DESCRIPTOR)}
|
||||
data-flx="guild.role-management.add-role-button.add-role-button.click"
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.advancedView {
|
||||
.detailedView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
@@ -394,7 +394,7 @@ const InviteModalContent = observer(function InviteModalContent({
|
||||
data-flx="invite.invite-modal.recipient-list"
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.advancedView} data-flx="invite.invite-modal.advanced-view">
|
||||
<div className={styles.detailedView} data-flx="invite.invite-modal.advanced-view">
|
||||
<Combobox
|
||||
label={i18n._(EXPIRE_AFTER_DESCRIPTOR)}
|
||||
options={maxAgeOptions}
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.addRedirectButton {
|
||||
.createRedirectButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ export const ApplicationInfoSection: React.FC<ApplicationInfoSectionProps> = ({
|
||||
<Button
|
||||
variant="primary"
|
||||
fitContent
|
||||
className={styles.addRedirectButton}
|
||||
className={styles.createRedirectButton}
|
||||
onClick={onAddRedirect}
|
||||
data-flx="user.applications-tab.application-detail.application-info-section.add-redirect-button"
|
||||
>
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.addButtonContainer {
|
||||
.newEngineButtonContainer {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -338,7 +338,7 @@ const EngineSection: React.FC<EngineSectionProps> = observer(
|
||||
</SwitchGroup>
|
||||
)}
|
||||
<div
|
||||
className={styles.addButtonContainer}
|
||||
className={styles.newEngineButtonContainer}
|
||||
data-flx="user.chat-settings-tab.search-engines-tab.engine-section.add-button-container"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -364,7 +364,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.advancedStatsAccordion {
|
||||
.detailedStatsAccordion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
@@ -373,7 +373,7 @@
|
||||
border-top: 0.0625rem solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.advancedStatsButton {
|
||||
.detailedStatsButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -389,11 +389,11 @@
|
||||
}
|
||||
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.advancedStatsButton:hover {
|
||||
.detailedStatsButton:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.advancedStatsButtonText {
|
||||
.detailedStatsButtonText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -405,7 +405,7 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.advancedStatsCaret {
|
||||
.detailedStatsCaret {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -413,12 +413,12 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.advancedStatsContent {
|
||||
.detailedStatsContent {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.advancedStatsInner {
|
||||
.detailedStatsInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
|
||||
+6
-6
@@ -137,26 +137,26 @@ const AdvancedStatsAccordion = observer(function AdvancedStatsAccordion({
|
||||
const transition = Accessibility.useReducedMotion ? INSTANT_TRANSITION : ADVANCED_STATS_TRANSITION;
|
||||
return (
|
||||
<div
|
||||
className={styles.advancedStatsAccordion}
|
||||
className={styles.detailedStatsAccordion}
|
||||
data-flx="voice.voice-connection-status.advanced-stats-accordion.advanced-stats-accordion"
|
||||
>
|
||||
<FocusRing offset={-2} data-flx="voice.voice-connection-status.advanced-stats-accordion.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.advancedStatsButton}
|
||||
className={styles.detailedStatsButton}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
data-flx="voice.voice-connection-status.advanced-stats-accordion.advanced-stats-button"
|
||||
>
|
||||
<span
|
||||
className={styles.advancedStatsButtonText}
|
||||
className={styles.detailedStatsButtonText}
|
||||
data-flx="voice.voice-connection-status.advanced-stats-accordion.advanced-stats-button-text"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<motion.span
|
||||
className={styles.advancedStatsCaret}
|
||||
className={styles.detailedStatsCaret}
|
||||
aria-hidden
|
||||
animate={{rotate: expanded ? 180 : 0}}
|
||||
transition={transition}
|
||||
@@ -178,7 +178,7 @@ const AdvancedStatsAccordion = observer(function AdvancedStatsAccordion({
|
||||
<motion.div
|
||||
key="advanced-stats"
|
||||
id={contentId}
|
||||
className={styles.advancedStatsContent}
|
||||
className={styles.detailedStatsContent}
|
||||
initial={Accessibility.useReducedMotion ? {height: 'auto', opacity: 1} : {height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={Accessibility.useReducedMotion ? {height: 'auto', opacity: 1} : {height: 0, opacity: 0}}
|
||||
@@ -186,7 +186,7 @@ const AdvancedStatsAccordion = observer(function AdvancedStatsAccordion({
|
||||
data-flx="voice.voice-connection-status.advanced-stats-accordion.advanced-stats-content"
|
||||
>
|
||||
<div
|
||||
className={styles.advancedStatsInner}
|
||||
className={styles.detailedStatsInner}
|
||||
data-flx="voice.voice-connection-status.advanced-stats-accordion.advanced-stats-inner"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -31,15 +31,22 @@ const provisioningProfile = isCanary
|
||||
? 'build_resources/profiles/Fluxer_Canary.provisionprofile'
|
||||
: 'build_resources/profiles/Fluxer.provisionprofile';
|
||||
const supportedTargetArchs = ['x64', 'arm64'];
|
||||
const supportedMacTargetArchs = [...supportedTargetArchs, 'universal'];
|
||||
const electronArch = process.env.ELECTRON_ARCH;
|
||||
const cliTargetArch = supportedTargetArchs.find((arch) => process.argv.includes(`--${arch}`)) || null;
|
||||
const cliTargetArch = supportedMacTargetArchs.find((arch) => process.argv.includes(`--${arch}`)) || null;
|
||||
const targetNativeArch = electronArch || cliTargetArch;
|
||||
|
||||
if (electronArch && !supportedTargetArchs.includes(electronArch)) {
|
||||
if (electronArch && !supportedMacTargetArchs.includes(electronArch)) {
|
||||
throw new Error(`Unsupported ELECTRON_ARCH: ${electronArch}`);
|
||||
}
|
||||
|
||||
const targetArchs = electronArch ? [electronArch] : supportedTargetArchs;
|
||||
if (targetNativeArch === 'universal' && targetPlatform !== 'darwin') {
|
||||
throw new Error(`ELECTRON_ARCH=universal is only supported for macOS builds, received platform ${targetPlatform}`);
|
||||
}
|
||||
|
||||
const targetArchs =
|
||||
electronArch && electronArch !== 'universal' ? [electronArch] : supportedTargetArchs;
|
||||
const macTargetArchs = targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
|
||||
const winTargets = [
|
||||
{
|
||||
target: 'dir',
|
||||
@@ -382,8 +389,10 @@ function normalizeArch(arch) {
|
||||
if (arch === 'x64' || arch === 'arm64') {
|
||||
return arch;
|
||||
}
|
||||
if (arch === 'universal') return 'universal';
|
||||
if (arch === 1) return 'x64';
|
||||
if (arch === 3) return 'arm64';
|
||||
if (arch === 4) return 'universal';
|
||||
return electronArch || process.arch;
|
||||
}
|
||||
|
||||
@@ -413,6 +422,16 @@ function addWindowsGameCaptureArtifacts(artifacts, tag, arch) {
|
||||
}
|
||||
|
||||
function expectedNativeRuntimeArtifacts(platform, arch) {
|
||||
if (platform === 'darwin' && arch === 'universal') {
|
||||
return [
|
||||
...expectedNativeRuntimeArtifactsForArch(platform, 'arm64'),
|
||||
...expectedNativeRuntimeArtifactsForArch(platform, 'x64'),
|
||||
];
|
||||
}
|
||||
return expectedNativeRuntimeArtifactsForArch(platform, arch);
|
||||
}
|
||||
|
||||
function expectedNativeRuntimeArtifactsForArch(platform, arch) {
|
||||
const tag = platformTag(platform, arch);
|
||||
if (!tag) return [];
|
||||
const artifacts = [];
|
||||
@@ -583,6 +602,12 @@ async function fileExists(filePath) {
|
||||
});
|
||||
}
|
||||
|
||||
function darwinMachOArchFromLabel(label) {
|
||||
if (label.includes('darwin-arm64')) return 'arm64';
|
||||
if (label.includes('darwin-x64')) return 'x86_64';
|
||||
return null;
|
||||
}
|
||||
|
||||
function expectedDarwinMachOArch(arch) {
|
||||
if (arch === 'x64') return 'x86_64';
|
||||
if (arch === 'arm64') return 'arm64';
|
||||
@@ -613,17 +638,20 @@ async function darwinMachOLoadCommands(filePath) {
|
||||
|
||||
async function verifyDarwinNativeArchitectures(platform, arch, entries, stage) {
|
||||
if (platform !== 'darwin') return;
|
||||
const isUniversal = arch === 'universal';
|
||||
const expectedArch = expectedDarwinMachOArch(arch);
|
||||
if (!expectedArch) return;
|
||||
if (!expectedArch && !isUniversal) return;
|
||||
const mismatches = [];
|
||||
for (const entry of entries) {
|
||||
if (!(await fileExists(entry.path))) continue;
|
||||
const entryExpectedArch = isUniversal ? darwinMachOArchFromLabel(entry.label) : expectedArch;
|
||||
if (!entryExpectedArch) continue;
|
||||
const archs = await darwinMachOArchitectures(entry.path);
|
||||
const fileTypes = await darwinMachOFileTypes(entry.path);
|
||||
if (!archs.includes(expectedArch)) {
|
||||
mismatches.push(`${entry.label}: has ${archs.join(', ') || '<none>'}; expected ${expectedArch}`);
|
||||
if (!archs.includes(entryExpectedArch)) {
|
||||
mismatches.push(`${entry.label}: has ${archs.join(', ') || '<none>'}; expected ${entryExpectedArch}`);
|
||||
}
|
||||
if (arch === 'x64' && archs.includes('x86_64h') && !archs.includes('x86_64')) {
|
||||
if (entryExpectedArch === 'x86_64' && archs.includes('x86_64h') && !archs.includes('x86_64')) {
|
||||
mismatches.push(`${entry.label}: has x86_64h only; expected baseline x86_64 for Intel compatibility`);
|
||||
}
|
||||
if (fileTypes.length === 0 || fileTypes.some((fileType) => fileType !== 'BUNDLE' && fileType !== 'DYLIB')) {
|
||||
@@ -1176,6 +1204,7 @@ module.exports = {
|
||||
},
|
||||
mac: {
|
||||
category: 'public.app-category.social-networking',
|
||||
x64ArchFiles: '**/@fluxer/**/*.node',
|
||||
minimumSystemVersion: macOSMinimumSystemVersion,
|
||||
icon: `build_resources/${iconDir}/_compiled/AppIcon.icns`,
|
||||
darkModeSupport: true,
|
||||
@@ -1190,11 +1219,11 @@ module.exports = {
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
arch: targetArchs,
|
||||
arch: macTargetArchs,
|
||||
},
|
||||
{
|
||||
target: 'zip',
|
||||
arch: targetArchs,
|
||||
arch: macTargetArchs,
|
||||
},
|
||||
],
|
||||
extendInfo: {
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"@fluxer/win-game-capture": "file:./native/win-game-capture",
|
||||
"@fluxer/win-process-loopback": "file:./native/win-process-loopback",
|
||||
"@fluxer/windows-input-hook": "file:./native/windows-input-hook",
|
||||
"electron-log": "5.4.3",
|
||||
"node-mac-permissions": "2.5.0"
|
||||
"electron-log": "5.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ const electronExternals = [
|
||||
'velopack',
|
||||
'@fluxer/webauthn',
|
||||
'@fluxer/webrtc-sender',
|
||||
'node-mac-permissions',
|
||||
'hunspell-asm',
|
||||
];
|
||||
const pathAliasPlugin = {
|
||||
@@ -220,6 +219,16 @@ function platformTag(platform, arch) {
|
||||
}
|
||||
|
||||
function expectedNativeRuntimeArtifacts(platform = process.platform, arch = process.env.ELECTRON_ARCH || process.arch) {
|
||||
if (platform === 'darwin' && arch === 'universal') {
|
||||
return [
|
||||
...expectedNativeRuntimeArtifactsForArch(platform, 'arm64'),
|
||||
...expectedNativeRuntimeArtifactsForArch(platform, 'x64'),
|
||||
];
|
||||
}
|
||||
return expectedNativeRuntimeArtifactsForArch(platform, arch);
|
||||
}
|
||||
|
||||
function expectedNativeRuntimeArtifactsForArch(platform, arch) {
|
||||
const tag = platformTag(platform, arch);
|
||||
if (!tag) return [];
|
||||
const artifacts = [];
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Run your own Fluxer instance with Docker Compose. This guide takes you from a fresh server to a working self-hosted instance with the web app, API, gateway, admin dashboard, media uploads, search, storage, and voice signaling behind one public hostname.
|
||||
|
||||
!!! info "Desktop client support is coming very soon"
|
||||
The desktop client cannot connect to self-hosted instances yet, so reach your instance through the web app in the meantime. Support is actively being worked on and lands very soon. You can track the progress in [issue #1088](https://github.com/fluxerapp/fluxer/issues/1088#issuecomment-4951728735).
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Linux server or VM that can run Docker Engine.
|
||||
|
||||
@@ -338,16 +338,16 @@ logo = "assets/logo.svg"
|
||||
# Read more:
|
||||
# - https://zensical.org/docs/setup/colors/
|
||||
# ----------------------------------------------------------------------------
|
||||
[[project.theme.palette]]
|
||||
scheme = "default"
|
||||
toggle.icon = "lucide/sun"
|
||||
toggle.name = "Switch to dark mode"
|
||||
|
||||
[[project.theme.palette]]
|
||||
scheme = "slate"
|
||||
toggle.icon = "lucide/moon"
|
||||
toggle.name = "Switch to light mode"
|
||||
|
||||
[[project.theme.palette]]
|
||||
scheme = "default"
|
||||
toggle.icon = "lucide/sun"
|
||||
toggle.name = "Switch to dark mode"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# The "extra" section contains miscellaneous settings.
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@@ -67,7 +67,7 @@ env_gateway_base_config() ->
|
||||
<<"port">> => env_int("FLUXER_GATEWAY_PORT", 8771),
|
||||
<<"gateway_role">> => env_optional_binary("FLUXER_GATEWAY_ROLE"),
|
||||
<<"rpc_auth_token">> => env_binary("FLUXER_GATEWAY_RPC_AUTH_TOKEN", <<>>),
|
||||
<<"push_enabled">> => env_bool("FLUXER_GATEWAY_PUSH_ENABLED", false),
|
||||
<<"push_enabled">> => env_bool("FLUXER_GATEWAY_PUSH_ENABLED", true),
|
||||
<<"logger_level">> => env_binary("FLUXER_GATEWAY_LOGGER_LEVEL", <<"info">>),
|
||||
<<"api_rpc_endpoint">> => env_optional_binary("FLUXER_GATEWAY_API_RPC_ENDPOINT"),
|
||||
<<"cluster_enabled">> => env_bool("FLUXER_GATEWAY_CLUSTER_ENABLED", false),
|
||||
|
||||
@@ -64,15 +64,14 @@ build_notification_message(DeviceToken, Payload) ->
|
||||
),
|
||||
Data = build_notification_data(Payload, Title, Body, Tag, ImageUrl),
|
||||
Group = resolve_notification_group(maps:get(<<"data">>, Payload, #{}), Tag),
|
||||
AndroidNotification = build_android_notification(Tag, ImageUrl, Group),
|
||||
AndroidNotification = build_android_notification(Tag, ImageUrl),
|
||||
wrap_notification_message(DeviceToken, NotificationBody, Data, AndroidNotification, Group).
|
||||
|
||||
-spec build_android_notification(binary(), binary() | undefined, binary()) -> map().
|
||||
build_android_notification(Tag, ImageUrl, Group) ->
|
||||
-spec build_android_notification(binary(), binary() | undefined) -> map().
|
||||
build_android_notification(Tag, ImageUrl) ->
|
||||
maybe_put(<<"image">>, ImageUrl, #{
|
||||
<<"channel_id">> => <<"fluxer_default_push">>,
|
||||
<<"tag">> => Tag,
|
||||
<<"group">> => Group,
|
||||
<<"click_action">> => <<"FLUXER_MESSAGE">>
|
||||
}).
|
||||
|
||||
|
||||
Submodule
+1
Submodule fluxer_marketing added at 81f925a9a3
@@ -1,44 +0,0 @@
|
||||
[package]
|
||||
name = "fluxer_marketing"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
accept-language = "3.1.0"
|
||||
ammonia = "4.1.2"
|
||||
anyhow = "1.0.102"
|
||||
axum = { version = "0.8.9", features = ["macros"] }
|
||||
base64 = "0.22.1"
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
comrak = { version = "0.52.0", default-features = false }
|
||||
cookie = "0.18.1"
|
||||
email_address = "0.2.9"
|
||||
gettext = "0.4.0"
|
||||
hmac = "0.13.0"
|
||||
icu_datetime = "2.2.0"
|
||||
icu_locale = "2.2.0"
|
||||
maud = { version = "0.27.0", features = ["axum"] }
|
||||
mime_guess = "2.0.5"
|
||||
moka = { version = "0.12.15", features = ["future", "sync"] }
|
||||
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
sha2 = "0.11.0"
|
||||
time = { version = "0.3.47", features = ["formatting", "macros", "parsing"] }
|
||||
tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal"] }
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.6.11", features = ["compression-gzip", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
[build-dependencies]
|
||||
polib = "0.3.0"
|
||||
serde_json = "1.0.150"
|
||||
sha2 = "0.11.0"
|
||||
syn = { version = "2.0.117", features = ["full", "visit"] }
|
||||
|
||||
[dev-dependencies]
|
||||
http-body-util = "0.1.3"
|
||||
@@ -1,76 +0,0 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
FROM rust:1-bookworm AS builder
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates nodejs npm pkg-config \
|
||||
&& npm install -g [email protected] \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY patches patches
|
||||
COPY fluxer_marketing/package.json fluxer_marketing/package.json
|
||||
RUN pnpm install --frozen-lockfile --filter fluxer_marketing --config.block-exotic-subdeps=false
|
||||
|
||||
COPY Cargo.lock Cargo.lock
|
||||
COPY fluxer_common fluxer_common
|
||||
COPY fluxer_marketing fluxer_marketing
|
||||
COPY packages/fonts/manifest.json packages/fonts/manifest.json
|
||||
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
|
||||
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
|
||||
COPY packages/fonts/css/locale-fallbacks.css packages/fonts/css/locale-fallbacks.css
|
||||
COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
|
||||
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
|
||||
RUN printf '%s\n' \
|
||||
'[workspace]' \
|
||||
'members = ["fluxer_common", "fluxer_marketing"]' \
|
||||
'resolver = "2"' \
|
||||
'' \
|
||||
'[workspace.package]' \
|
||||
'edition = "2024"' \
|
||||
'license = "AGPL-3.0-or-later"' \
|
||||
> Cargo.toml
|
||||
RUN pnpm install --frozen-lockfile --filter fluxer_marketing --config.block-exotic-subdeps=false
|
||||
RUN OXIDE_VERSION="$(node -p "require('./fluxer_marketing/package.json').optionalDependencies['@tailwindcss/oxide-linux-x64-gnu']")" \
|
||||
&& case "${TARGETARCH}" in \
|
||||
amd64) OXIDE_PKG="@tailwindcss/oxide-linux-x64-gnu" ;; \
|
||||
arm64) OXIDE_PKG="@tailwindcss/oxide-linux-arm64-gnu" ;; \
|
||||
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& npm install --prefix /tmp/tailwind-oxide --no-audit --no-fund "${OXIDE_PKG}@${OXIDE_VERSION}" \
|
||||
&& mkdir -p node_modules/@tailwindcss \
|
||||
&& cp -R "/tmp/tailwind-oxide/node_modules/${OXIDE_PKG}" "node_modules/${OXIDE_PKG}" \
|
||||
&& rm -rf /tmp/tailwind-oxide
|
||||
|
||||
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
|
||||
|
||||
RUN cargo test -p fluxer_marketing \
|
||||
&& cargo build --release -p fluxer_marketing \
|
||||
&& cp target/release/fluxer_marketing /usr/local/bin/fluxer-marketing
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
ARG BUILD_VERSION=""
|
||||
|
||||
WORKDIR /usr/local/bin
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /usr/local/bin/fluxer-marketing /usr/local/bin/fluxer-marketing
|
||||
|
||||
ENV BUILD_VERSION="${BUILD_VERSION}"
|
||||
ENV FLUXER_MARKETING_HOST="0.0.0.0"
|
||||
ENV FLUXER_MARKETING_PORT="8080"
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["/usr/local/bin/fluxer-marketing"]
|
||||
@@ -1,985 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use syn::{
|
||||
Expr, ExprLit, ExprMacro, Ident, Lit, Macro, Token, braced,
|
||||
parse::{Parse, ParseStream},
|
||||
visit::{self, Visit},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LocaleInfo {
|
||||
code: &'static str,
|
||||
variant: &'static str,
|
||||
rtl: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Descriptor {
|
||||
const_name: String,
|
||||
key: String,
|
||||
message: String,
|
||||
comment: String,
|
||||
placeholders: BTreeSet<String>,
|
||||
}
|
||||
|
||||
const LOCALES: &[LocaleInfo] = &[
|
||||
LocaleInfo {
|
||||
code: "ar",
|
||||
variant: "Ar",
|
||||
rtl: true,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "bg",
|
||||
variant: "Bg",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "cs",
|
||||
variant: "Cs",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "da",
|
||||
variant: "Da",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "de",
|
||||
variant: "De",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "el",
|
||||
variant: "El",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "en-GB",
|
||||
variant: "EnGb",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "en-US",
|
||||
variant: "EnUs",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "es-419",
|
||||
variant: "Es419",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "es-ES",
|
||||
variant: "EsEs",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "fi",
|
||||
variant: "Fi",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "fr",
|
||||
variant: "Fr",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "he",
|
||||
variant: "He",
|
||||
rtl: true,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "hi",
|
||||
variant: "Hi",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "hr",
|
||||
variant: "Hr",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "hu",
|
||||
variant: "Hu",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "id",
|
||||
variant: "Id",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "it",
|
||||
variant: "It",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "ja",
|
||||
variant: "Ja",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "ko",
|
||||
variant: "Ko",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "lt",
|
||||
variant: "Lt",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "nl",
|
||||
variant: "Nl",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "no",
|
||||
variant: "No",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "pl",
|
||||
variant: "Pl",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "pt-BR",
|
||||
variant: "PtBr",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "ro",
|
||||
variant: "Ro",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "ru",
|
||||
variant: "Ru",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "sv-SE",
|
||||
variant: "SvSe",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "th",
|
||||
variant: "Th",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "tr",
|
||||
variant: "Tr",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "uk",
|
||||
variant: "Uk",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "vi",
|
||||
variant: "Vi",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "zh-CN",
|
||||
variant: "ZhCn",
|
||||
rtl: false,
|
||||
},
|
||||
LocaleInfo {
|
||||
code: "zh-TW",
|
||||
variant: "ZhTw",
|
||||
rtl: false,
|
||||
},
|
||||
];
|
||||
|
||||
fn main() {
|
||||
let manifest_dir =
|
||||
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR missing"));
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR missing"));
|
||||
let locale_dir = manifest_dir.join("locales");
|
||||
let descriptors_path = manifest_dir.join("src/i18n/descriptors.rs");
|
||||
let generated_dir = out_dir.join("i18n");
|
||||
fs::create_dir_all(&generated_dir).expect("failed to create generated i18n dir");
|
||||
|
||||
println!("cargo:rerun-if-changed=src/i18n/descriptors.rs");
|
||||
println!("cargo:rerun-if-changed=locales");
|
||||
println!("cargo:rerun-if-changed=package.json");
|
||||
println!("cargo:rerun-if-changed=src/styles/app.css");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=content");
|
||||
|
||||
let descriptors = parse_descriptors(&descriptors_path);
|
||||
validate_gettext_catalogs(&locale_dir, &generated_dir, &descriptors);
|
||||
write_generated_i18n(&generated_dir.join("generated.rs"));
|
||||
build_fonts(&manifest_dir, &out_dir);
|
||||
build_tailwind(&manifest_dir, &out_dir);
|
||||
}
|
||||
|
||||
fn parse_descriptors(path: &Path) -> Vec<Descriptor> {
|
||||
let mut descriptors = Vec::new();
|
||||
let mut seen_names = BTreeSet::new();
|
||||
let mut seen_keys = BTreeSet::new();
|
||||
let mut macro_count = 0;
|
||||
for descriptor_file in descriptor_source_files(path) {
|
||||
let source = fs::read_to_string(&descriptor_file)
|
||||
.unwrap_or_else(|err| panic!("failed to read {}: {}", descriptor_file.display(), err));
|
||||
let syntax = syn::parse_file(&source)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {}", descriptor_file.display(), err));
|
||||
let mut visitor = MarketingMessageVisitor::new(&descriptor_file);
|
||||
visitor.visit_file(&syntax);
|
||||
macro_count += visitor.macro_count;
|
||||
for descriptor in visitor.descriptors {
|
||||
let const_name = descriptor.const_name.clone();
|
||||
let key = descriptor.key.clone();
|
||||
let message = descriptor.message.clone();
|
||||
let comment = descriptor.comment.clone();
|
||||
if !seen_names.insert(const_name.clone()) {
|
||||
panic!("duplicate marketing descriptor const: {}", const_name);
|
||||
}
|
||||
if !seen_keys.insert(key.clone()) {
|
||||
panic!("duplicate marketing descriptor key: {}", key);
|
||||
}
|
||||
if message.trim().is_empty() {
|
||||
panic!(
|
||||
"descriptor {} has an empty American English source string",
|
||||
const_name
|
||||
);
|
||||
}
|
||||
if comment.trim().len() < 24 {
|
||||
panic!(
|
||||
"descriptor {} needs a contextual translator comment",
|
||||
const_name
|
||||
);
|
||||
}
|
||||
let placeholders = extract_placeholders(&message);
|
||||
validate_descriptor_comment(&const_name, &comment, &placeholders);
|
||||
descriptors.push(Descriptor {
|
||||
const_name: descriptor.const_name,
|
||||
key: descriptor.key,
|
||||
message: descriptor.message,
|
||||
comment: descriptor.comment,
|
||||
placeholders,
|
||||
});
|
||||
}
|
||||
}
|
||||
if macro_count != descriptors.len() {
|
||||
panic!(
|
||||
"parsed {} marketing_message! descriptors from {}, but found {} macro invocations",
|
||||
descriptors.len(),
|
||||
path.display(),
|
||||
macro_count,
|
||||
);
|
||||
}
|
||||
if descriptors.is_empty() {
|
||||
panic!(
|
||||
"no marketing_message! descriptors found in {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
descriptors
|
||||
}
|
||||
|
||||
struct MarketingMessageVisitor<'a> {
|
||||
descriptor_file: &'a Path,
|
||||
descriptors: Vec<Descriptor>,
|
||||
macro_count: usize,
|
||||
}
|
||||
|
||||
impl<'a> MarketingMessageVisitor<'a> {
|
||||
fn new(descriptor_file: &'a Path) -> Self {
|
||||
Self {
|
||||
descriptor_file,
|
||||
descriptors: Vec::new(),
|
||||
macro_count: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ast> Visit<'ast> for MarketingMessageVisitor<'_> {
|
||||
fn visit_macro(&mut self, node: &'ast Macro) {
|
||||
if is_marketing_message_macro(node) {
|
||||
self.macro_count += 1;
|
||||
let input = node
|
||||
.parse_body::<MarketingMessageInput>()
|
||||
.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to parse marketing_message! descriptor in {}: {}",
|
||||
self.descriptor_file.display(),
|
||||
err
|
||||
)
|
||||
});
|
||||
self.descriptors
|
||||
.push(input.into_descriptor(self.descriptor_file));
|
||||
}
|
||||
visit::visit_macro(self, node);
|
||||
}
|
||||
}
|
||||
|
||||
struct MarketingMessageInput {
|
||||
const_name: Ident,
|
||||
key: Expr,
|
||||
message: Expr,
|
||||
comment: Expr,
|
||||
}
|
||||
|
||||
impl MarketingMessageInput {
|
||||
fn into_descriptor(self, descriptor_file: &Path) -> Descriptor {
|
||||
let const_name = self.const_name.to_string();
|
||||
if !const_name
|
||||
.chars()
|
||||
.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
|
||||
{
|
||||
panic!(
|
||||
"marketing descriptor const must be uppercase snake case in {}: {}",
|
||||
descriptor_file.display(),
|
||||
const_name
|
||||
);
|
||||
}
|
||||
let key = expect_string_literal(&self.key, &const_name, "key", descriptor_file);
|
||||
let message = parse_descriptor_message(descriptor_file, &const_name, &self.message);
|
||||
let comment = expect_string_literal(&self.comment, &const_name, "comment", descriptor_file);
|
||||
Descriptor {
|
||||
const_name,
|
||||
key,
|
||||
message,
|
||||
comment,
|
||||
placeholders: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Parse for MarketingMessageInput {
|
||||
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||
input.parse::<Token![pub]>()?;
|
||||
input.parse::<Token![const]>()?;
|
||||
let const_name = input.parse()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
|
||||
let body;
|
||||
braced!(body in input);
|
||||
let key = parse_expected_field(&body, "key")?;
|
||||
body.parse::<Token![,]>()?;
|
||||
let message = parse_expected_field(&body, "message")?;
|
||||
body.parse::<Token![,]>()?;
|
||||
let comment = parse_expected_field(&body, "comment")?;
|
||||
if body.peek(Token![,]) {
|
||||
body.parse::<Token![,]>()?;
|
||||
}
|
||||
if !body.is_empty() {
|
||||
return Err(body.error("unexpected descriptor field"));
|
||||
}
|
||||
|
||||
input.parse::<Token![;]>()?;
|
||||
if !input.is_empty() {
|
||||
return Err(input.error("unexpected tokens after descriptor"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
const_name,
|
||||
key,
|
||||
message,
|
||||
comment,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_expected_field(input: ParseStream, expected_name: &str) -> syn::Result<Expr> {
|
||||
let name: Ident = input.parse()?;
|
||||
if name != expected_name {
|
||||
return Err(syn::Error::new(
|
||||
name.span(),
|
||||
format!("expected `{expected_name}` field"),
|
||||
));
|
||||
}
|
||||
input.parse::<Token![:]>()?;
|
||||
input.parse()
|
||||
}
|
||||
|
||||
fn is_marketing_message_macro(node: &Macro) -> bool {
|
||||
if node.path.leading_colon.is_some() {
|
||||
return false;
|
||||
}
|
||||
let segments = node
|
||||
.path
|
||||
.segments
|
||||
.iter()
|
||||
.map(|segment| segment.ident.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
matches!(
|
||||
segments.as_slice(),
|
||||
[name] if name == "marketing_message"
|
||||
) || matches!(
|
||||
segments.as_slice(),
|
||||
[root, name] if root == "crate" && name == "marketing_message"
|
||||
)
|
||||
}
|
||||
|
||||
fn expect_string_literal(
|
||||
expr: &Expr,
|
||||
const_name: &str,
|
||||
field_name: &str,
|
||||
descriptor_file: &Path,
|
||||
) -> String {
|
||||
if let Expr::Lit(ExprLit {
|
||||
lit: Lit::Str(value),
|
||||
..
|
||||
}) = expr
|
||||
{
|
||||
return value.value();
|
||||
}
|
||||
panic!(
|
||||
"descriptor {} field {} must be a string literal in {}",
|
||||
const_name,
|
||||
field_name,
|
||||
descriptor_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_descriptor_message(descriptor_file: &Path, const_name: &str, message: &Expr) -> String {
|
||||
if let Expr::Lit(ExprLit {
|
||||
lit: Lit::Str(value),
|
||||
..
|
||||
}) = message
|
||||
{
|
||||
return value.value();
|
||||
}
|
||||
let Expr::Macro(ExprMacro { mac, .. }) = message else {
|
||||
panic!(
|
||||
"descriptor {} message must be a string literal or include_str!(...) in {}",
|
||||
const_name,
|
||||
descriptor_file.display()
|
||||
);
|
||||
};
|
||||
if !is_include_str_macro(mac) {
|
||||
panic!(
|
||||
"descriptor {} message must be a string literal or include_str!(...) in {}",
|
||||
const_name,
|
||||
descriptor_file.display()
|
||||
);
|
||||
}
|
||||
let relative_path = mac.parse_body::<syn::LitStr>().unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"invalid include_str! descriptor message in {} for {}: {}",
|
||||
descriptor_file.display(),
|
||||
const_name,
|
||||
err
|
||||
)
|
||||
});
|
||||
let source_path = descriptor_file
|
||||
.parent()
|
||||
.expect("descriptor file should have a parent directory")
|
||||
.join(relative_path.value());
|
||||
fs::read_to_string(&source_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read descriptor source {}: {}",
|
||||
source_path.display(),
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_include_str_macro(node: &Macro) -> bool {
|
||||
node.path.leading_colon.is_none()
|
||||
&& node.path.segments.len() == 1
|
||||
&& node.path.segments[0].ident == "include_str"
|
||||
}
|
||||
|
||||
fn validate_descriptor_comment(const_name: &str, comment: &str, placeholders: &BTreeSet<String>) {
|
||||
if placeholders.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lowercase_comment = comment.to_ascii_lowercase();
|
||||
let mentions_placeholder_handling = lowercase_comment.contains("placeholder")
|
||||
|| placeholders.iter().all(|placeholder| {
|
||||
comment.contains(&format!("{{{placeholder}}}")) || comment.contains(placeholder)
|
||||
});
|
||||
if !mentions_placeholder_handling {
|
||||
panic!(
|
||||
"descriptor {} uses placeholders {:?} but its translator comment does not explain placeholder handling",
|
||||
const_name, placeholders,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor_source_files(path: &Path) -> Vec<PathBuf> {
|
||||
let mut files = vec![path.to_path_buf()];
|
||||
let Some(stem) = path.file_stem().and_then(|value| value.to_str()) else {
|
||||
return files;
|
||||
};
|
||||
let dir = path.with_file_name(stem);
|
||||
if dir.is_dir() {
|
||||
let mut children = fs::read_dir(&dir)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!("failed to read descriptor dir {}: {}", dir.display(), err)
|
||||
})
|
||||
.map(|entry| entry.expect("failed to read descriptor dir entry").path())
|
||||
.filter(|child| child.extension().and_then(|ext| ext.to_str()) == Some("rs"))
|
||||
.collect::<Vec<_>>();
|
||||
children.sort();
|
||||
files.extend(children);
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
fn validate_gettext_catalogs(locale_dir: &Path, generated_dir: &Path, descriptors: &[Descriptor]) {
|
||||
let mut locale_codes = BTreeSet::new();
|
||||
for locale in LOCALES {
|
||||
locale_codes.insert(locale.code);
|
||||
let po_path = locale_dir.join(format!("{}.po", locale.code));
|
||||
if !po_path.exists() {
|
||||
panic!("missing gettext catalog: {}", po_path.display());
|
||||
}
|
||||
}
|
||||
for entry in fs::read_dir(locale_dir).expect("failed to read locale dir") {
|
||||
let entry = entry.expect("failed to read locale entry");
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) == Some("po") {
|
||||
let code = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.expect("invalid locale file name");
|
||||
if !locale_codes.contains(code) {
|
||||
panic!(
|
||||
"unexpected gettext catalog without Locale enum entry: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let source_messages = descriptors
|
||||
.iter()
|
||||
.map(|descriptor| (descriptor.key.clone(), descriptor))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for locale in LOCALES {
|
||||
let po_path = locale_dir.join(format!("{}.po", locale.code));
|
||||
let catalog = polib::po_file::parse(&po_path)
|
||||
.unwrap_or_else(|err| panic!("failed to parse {}: {}", po_path.display(), err));
|
||||
let mut seen = BTreeSet::new();
|
||||
for message in catalog.messages() {
|
||||
if message.is_fuzzy() {
|
||||
panic!(
|
||||
"{} has fuzzy translation for {}",
|
||||
locale.code,
|
||||
message.msgid()
|
||||
);
|
||||
}
|
||||
let key = message
|
||||
.msgctxt()
|
||||
.unwrap_or_else(|| panic!("message without msgctxt in {}", po_path.display()));
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let expected = source_messages
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{} has extra gettext key {}", locale.code, key));
|
||||
if message.msgid() != expected.message {
|
||||
panic!(
|
||||
"{} has msgid drift for {} ({}): expected {:?}, got {:?}",
|
||||
locale.code,
|
||||
key,
|
||||
expected.const_name,
|
||||
expected.message,
|
||||
message.msgid(),
|
||||
);
|
||||
}
|
||||
if message.extracted_comments().trim() != expected.comment {
|
||||
panic!(
|
||||
"{} translator comment drift for {} ({}). Run cargo run --manifest-path tools/marketing/update-gettext-catalogs/Cargo.toml from the repository root.",
|
||||
locale.code, key, expected.const_name,
|
||||
);
|
||||
}
|
||||
let msgstr = message
|
||||
.msgstr()
|
||||
.expect("marketing messages should be singular");
|
||||
if msgstr.trim().is_empty() {
|
||||
panic!("{} has empty translation for {}", locale.code, key);
|
||||
}
|
||||
if locale.code == "en-US" && msgstr != expected.message {
|
||||
panic!("en-US msgstr must match descriptor source for {}", key);
|
||||
}
|
||||
let actual_placeholders = extract_placeholders(msgstr);
|
||||
if actual_placeholders != expected.placeholders {
|
||||
panic!(
|
||||
"{} placeholder mismatch for {}: expected {:?}, got {:?}",
|
||||
locale.code, key, expected.placeholders, actual_placeholders,
|
||||
);
|
||||
}
|
||||
if !seen.insert(key.to_owned()) {
|
||||
panic!("{} has duplicate gettext key {}", locale.code, key);
|
||||
}
|
||||
}
|
||||
for key in source_messages.keys() {
|
||||
if !seen.contains(key) {
|
||||
panic!("{} is missing gettext key {}", locale.code, key);
|
||||
}
|
||||
}
|
||||
let mo_path = generated_dir.join(format!("{}.mo", locale_file_stem(locale.code)));
|
||||
polib::mo_file::compile_from_po(&po_path, &mo_path)
|
||||
.unwrap_or_else(|err| panic!("failed to compile {}: {}", po_path.display(), err));
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_placeholders(input: &str) -> BTreeSet<String> {
|
||||
let mut result = BTreeSet::new();
|
||||
let bytes = input.as_bytes();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] != b'{' {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index + 1;
|
||||
let Some(end_offset) = input[start..].find('}') else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
let end = start + end_offset;
|
||||
let candidate = &input[start..end];
|
||||
if is_placeholder_name(candidate) {
|
||||
result.insert(candidate.to_owned());
|
||||
}
|
||||
index = end + 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_placeholder_name(value: &str) -> bool {
|
||||
let mut chars = value.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
if !(first == '_' || first.is_ascii_alphabetic()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn write_generated_i18n(path: &Path) {
|
||||
let mut output = String::new();
|
||||
output.push_str("// SPDX-License-Identifier: AGPL-3.0-or-later\n");
|
||||
output.push_str("// @generated by fluxer_marketing/build.rs\n\n");
|
||||
output.push_str("#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]\n");
|
||||
output.push_str("pub enum Locale {\n");
|
||||
for locale in LOCALES {
|
||||
output.push_str(&format!("\t{},\n", locale.variant));
|
||||
}
|
||||
output.push_str("}\n\n");
|
||||
output.push_str("impl Locale {\n");
|
||||
output.push_str("\tpub const DEFAULT: Self = Self::EnUs;\n");
|
||||
output.push_str("\tpub const ALL: &'static [Self] = &[\n");
|
||||
for locale in LOCALES {
|
||||
output.push_str(&format!("\t\tSelf::{},\n", locale.variant));
|
||||
}
|
||||
output.push_str("\t];\n");
|
||||
output.push_str("\tpub const fn code(self) -> &'static str {\n\t\tmatch self {\n");
|
||||
for locale in LOCALES {
|
||||
output.push_str(&format!(
|
||||
"\t\t\tSelf::{} => {:?},\n",
|
||||
locale.variant, locale.code
|
||||
));
|
||||
}
|
||||
output.push_str("\t\t}\n\t}\n");
|
||||
output.push_str("\tpub const fn is_rtl(self) -> bool {\n\t\tmatch self {\n");
|
||||
for locale in LOCALES {
|
||||
output.push_str(&format!(
|
||||
"\t\t\tSelf::{} => {},\n",
|
||||
locale.variant, locale.rtl
|
||||
));
|
||||
}
|
||||
output.push_str("\t\t}\n\t}\n");
|
||||
output.push_str("\tpub const fn catalog_bytes(self) -> &'static [u8] {\n\t\tmatch self {\n");
|
||||
for locale in LOCALES {
|
||||
output.push_str(&format!(
|
||||
"\t\t\tSelf::{} => include_bytes!(concat!(env!(\"OUT_DIR\"), \"/i18n/{}.mo\")),\n",
|
||||
locale.variant,
|
||||
locale_file_stem(locale.code),
|
||||
));
|
||||
}
|
||||
output.push_str("\t\t}\n\t}\n");
|
||||
output.push_str("}\n");
|
||||
fs::write(path, output).expect("failed to write generated i18n Rust");
|
||||
}
|
||||
|
||||
const BUNDLED_FAMILIES: &[&str] = &["FluxerSans", "FluxerMono"];
|
||||
|
||||
const BUNDLED_WEIGHTS: &[u64] = &[400, 500, 600, 700];
|
||||
|
||||
const EXPECTED_FACE_COUNT: usize = 16;
|
||||
|
||||
struct Face {
|
||||
css_family: String,
|
||||
weight: u64,
|
||||
style: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
struct Asset {
|
||||
name: String,
|
||||
content_type: &'static str,
|
||||
}
|
||||
|
||||
fn build_fonts(manifest_dir: &Path, out_dir: &Path) {
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/manifest.json");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/css/locale-fallbacks.css");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerSans");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/files/FluxerMono");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/NOTICE.md");
|
||||
println!("cargo:rerun-if-changed=../packages/fonts/LICENSE-IBM-PLEX.txt");
|
||||
|
||||
let package_dir = manifest_dir.join("../packages/fonts");
|
||||
let fonts_dir = out_dir.join("static").join("fonts");
|
||||
let _ = fs::remove_dir_all(&fonts_dir);
|
||||
fs::create_dir_all(&fonts_dir).expect("failed to create generated font dir");
|
||||
|
||||
let mut assets = Vec::new();
|
||||
let notice = emit_asset(
|
||||
&fonts_dir,
|
||||
&package_dir.join("NOTICE.md"),
|
||||
"text/plain; charset=utf-8",
|
||||
&mut assets,
|
||||
);
|
||||
let plex_license = emit_asset(
|
||||
&fonts_dir,
|
||||
&package_dir.join("LICENSE-IBM-PLEX.txt"),
|
||||
"text/plain; charset=utf-8",
|
||||
&mut assets,
|
||||
);
|
||||
|
||||
let faces = select_faces(&package_dir);
|
||||
assert_eq!(
|
||||
faces.len(),
|
||||
EXPECTED_FACE_COUNT,
|
||||
"packages/fonts no longer offers the {} Latin-core faces fluxer_marketing renders; \
|
||||
reconcile BUNDLED_FAMILIES/BUNDLED_WEIGHTS with the manifest",
|
||||
EXPECTED_FACE_COUNT
|
||||
);
|
||||
|
||||
let mut stylesheet = String::from("/* SPDX-License-Identifier: AGPL-3.0-or-later */\n");
|
||||
stylesheet.push_str(
|
||||
"/* @generated by fluxer_marketing/build.rs from packages/fonts. Do not edit by hand. */\n\n",
|
||||
);
|
||||
stylesheet.push_str(&format!(
|
||||
"/*\n\
|
||||
\x20* IBM Plex is licensed under the SIL Open Font License 1.1.\n\
|
||||
\x20* The IBM Plex faces below are Modified Versions renamed to \"Fluxer Sans\", so OFL\n\
|
||||
\x20* clause 3 requires the disclosure to travel with them. It is served beside them:\n\
|
||||
\x20* ./{notice}\n\
|
||||
\x20* ./{plex_license}\n\
|
||||
\x20*\n\
|
||||
\x20* Every url() below is relative, so it resolves against this stylesheet's own\n\
|
||||
\x20* directory. That keeps the sheet correct under any FLUXER_MARKETING_BASE_PATH\n\
|
||||
\x20* without the build having to know the runtime base path.\n\
|
||||
\x20*/\n"
|
||||
));
|
||||
for face in &faces {
|
||||
let source = package_dir.join("files").join(&face.source);
|
||||
let file = emit_asset(&fonts_dir, &source, "font/woff2", &mut assets);
|
||||
stylesheet.push_str(&format!(
|
||||
"@font-face {{\n\
|
||||
\tfont-family: '{}';\n\
|
||||
\tsrc: url('{file}') format('woff2');\n\
|
||||
\tfont-weight: {};\n\
|
||||
\tfont-style: {};\n\
|
||||
\tfont-display: swap;\n\
|
||||
}}\n",
|
||||
face.css_family, face.weight, face.style
|
||||
));
|
||||
}
|
||||
|
||||
let fallbacks_path = package_dir.join("css").join("locale-fallbacks.css");
|
||||
let fallbacks = fs::read_to_string(&fallbacks_path)
|
||||
.unwrap_or_else(|err| panic!("failed to read {}: {err}", fallbacks_path.display()));
|
||||
stylesheet.push('\n');
|
||||
stylesheet.push_str(strip_generated_banner(&fallbacks));
|
||||
|
||||
let stylesheet_name = write_hashed(
|
||||
&fonts_dir,
|
||||
"fonts.css",
|
||||
stylesheet.as_bytes(),
|
||||
"text/css; charset=utf-8",
|
||||
&mut assets,
|
||||
);
|
||||
|
||||
write_font_asset_table(out_dir, &stylesheet_name, &assets);
|
||||
}
|
||||
|
||||
fn strip_generated_banner(css: &str) -> &str {
|
||||
let mut rest = css;
|
||||
while let Some(line_end) = rest.find('\n') {
|
||||
let line = rest[..line_end].trim();
|
||||
if line.is_empty() || (line.starts_with("/*") && line.ends_with("*/")) {
|
||||
rest = &rest[line_end + 1..];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
rest
|
||||
}
|
||||
|
||||
fn select_faces(package_dir: &Path) -> Vec<Face> {
|
||||
let manifest_path = package_dir.join("manifest.json");
|
||||
let raw = fs::read_to_string(&manifest_path).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to read {}: {err}. packages/fonts is generated by \
|
||||
`python3 tools/fonts/build_fonts.py`.",
|
||||
manifest_path.display()
|
||||
)
|
||||
});
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_str(&raw).expect("failed to parse packages/fonts/manifest.json");
|
||||
let families = manifest["families"]
|
||||
.as_array()
|
||||
.expect("packages/fonts/manifest.json has no families array");
|
||||
|
||||
let mut faces = Vec::new();
|
||||
for wanted in BUNDLED_FAMILIES {
|
||||
let family = families
|
||||
.iter()
|
||||
.find(|family| family["id"].as_str() == Some(wanted))
|
||||
.unwrap_or_else(|| panic!("packages/fonts/manifest.json has no family {wanted}"));
|
||||
assert_eq!(
|
||||
family["latinCore"].as_bool(),
|
||||
Some(true),
|
||||
"{wanted} is no longer a Latin-core family; it would need unicode-range gating"
|
||||
);
|
||||
let css_family = family["cssFamily"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{wanted} has no cssFamily"))
|
||||
.to_owned();
|
||||
for face in family["faces"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("{wanted} has no faces array"))
|
||||
{
|
||||
let weight = face["weight"].as_u64().expect("face has no weight");
|
||||
if !BUNDLED_WEIGHTS.contains(&weight) {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
face["unicodeRange"].is_null(),
|
||||
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
|
||||
face["file"]
|
||||
);
|
||||
faces.push(Face {
|
||||
css_family: css_family.clone(),
|
||||
weight,
|
||||
style: face["style"]
|
||||
.as_str()
|
||||
.expect("face has no style")
|
||||
.to_owned(),
|
||||
source: face["file"].as_str().expect("face has no file").to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
fn emit_asset(
|
||||
fonts_dir: &Path,
|
||||
source: &Path,
|
||||
content_type: &'static str,
|
||||
assets: &mut Vec<Asset>,
|
||||
) -> String {
|
||||
let bytes =
|
||||
fs::read(source).unwrap_or_else(|err| panic!("failed to read {}: {err}", source.display()));
|
||||
let file_name = source
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_else(|| panic!("{} has no file name", source.display()));
|
||||
write_hashed(fonts_dir, file_name, &bytes, content_type, assets)
|
||||
}
|
||||
|
||||
fn write_hashed(
|
||||
fonts_dir: &Path,
|
||||
file_name: &str,
|
||||
bytes: &[u8],
|
||||
content_type: &'static str,
|
||||
assets: &mut Vec<Asset>,
|
||||
) -> String {
|
||||
let (stem, extension) = file_name
|
||||
.rsplit_once('.')
|
||||
.unwrap_or_else(|| panic!("{file_name} has no extension to hash around"));
|
||||
let digest = Sha256::digest(bytes);
|
||||
let hash: String = digest
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let name = format!("{stem}.{hash}.{extension}");
|
||||
fs::write(fonts_dir.join(&name), bytes)
|
||||
.unwrap_or_else(|err| panic!("failed to write {name}: {err}"));
|
||||
assets.push(Asset {
|
||||
name: name.clone(),
|
||||
content_type,
|
||||
});
|
||||
name
|
||||
}
|
||||
|
||||
fn write_font_asset_table(out_dir: &Path, stylesheet_name: &str, assets: &[Asset]) {
|
||||
let mut generated = String::from(
|
||||
"// @generated by fluxer_marketing/build.rs from packages/fonts. Do not edit by hand.\n\n",
|
||||
);
|
||||
generated.push_str(&format!(
|
||||
"/// File name of the content-hashed `@font-face` stylesheet, relative to `/static/fonts/`.\npub const STYLESHEET_FILE_NAME: &str = {stylesheet_name:?};\n\n"
|
||||
));
|
||||
generated.push_str("/// `(file name, content type, bytes)` for everything served under `/static/fonts/`.\npub static ASSETS: &[(&str, &str, &[u8])] = &[\n");
|
||||
for asset in assets {
|
||||
generated.push_str(&format!(
|
||||
" ({:?}, {:?}, include_bytes!(concat!(env!(\"OUT_DIR\"), \"/static/fonts/{}\"))),\n",
|
||||
asset.name, asset.content_type, asset.name
|
||||
));
|
||||
}
|
||||
generated.push_str("];\n");
|
||||
fs::write(out_dir.join("static").join("fonts.rs"), generated)
|
||||
.expect("failed to write generated font asset table");
|
||||
}
|
||||
|
||||
fn build_tailwind(manifest_dir: &Path, out_dir: &Path) {
|
||||
let output_dir = out_dir.join("static");
|
||||
fs::create_dir_all(&output_dir).expect("failed to create generated static dir");
|
||||
let input = manifest_dir.join("src/styles/app.css");
|
||||
let output = output_dir.join("app.css");
|
||||
let candidates = [
|
||||
manifest_dir.join("node_modules/.bin/tailwindcss"),
|
||||
manifest_dir.join("../node_modules/.bin/tailwindcss"),
|
||||
];
|
||||
let cli = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.exists())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"tailwindcss CLI not found. Expected one of: {}",
|
||||
candidates
|
||||
.iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
});
|
||||
let status = Command::new(cli)
|
||||
.arg("-i")
|
||||
.arg(&input)
|
||||
.arg("-o")
|
||||
.arg(&output)
|
||||
.arg("--minify")
|
||||
.arg("--cwd")
|
||||
.arg(manifest_dir)
|
||||
.status()
|
||||
.expect("failed to run tailwindcss");
|
||||
if !status.success() {
|
||||
panic!("tailwindcss failed with status {}", status);
|
||||
}
|
||||
}
|
||||
|
||||
fn locale_file_stem(code: &str) -> String {
|
||||
code.replace('-', "_")
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
---
|
||||
title: "How I built Fluxer, a Discord-like chat app"
|
||||
slug: "how-i-built-fluxer-a-discord-like-chat-app"
|
||||
description: "Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities."
|
||||
author: "Hampus Kraft"
|
||||
published_at: "2026-01-24T14:00:00Z"
|
||||
updated_at: "2026-05-24T12:00:00Z"
|
||||
feature_image: "/blog/assets/how-i-built-fluxer-cover-1280.jpg"
|
||||
feature_image_alt: "How I built Fluxer, a Discord-like chat app"
|
||||
source_url: "https://fluxer.app/blog/how-i-built-fluxer-a-discord-like-chat-app"
|
||||
tags:
|
||||
- "News"
|
||||
---
|
||||
|
||||
> [Discord will require a face scan or ID for full access next month](https://www.theverge.com/tech/875309/discord-age-verification-global-roll-out)
|
||||
>
|
||||
> Age verification for all.
|
||||
>
|
||||
> Source: The Verge / Stevie Bonifield
|
||||
|
||||
I'm Hampus Kraft, a 23-year-old software developer from Sweden, nearing completion of my BSc in Computer Engineering at KTH Royal Institute of Technology. You can find my LinkedIn page [here](https://www.linkedin.com/in/hampuskraft/). In Sweden, our resident and company registration databases are [public records](https://www.allabolag.se/foretag/fluxer-platform-ab/brandbergen/datacenters/2KJCA7DI5YDLG).
|
||||
|
||||
Ever since the pandemic hit in 2020, while I was still in senior high school, I've been fascinated by Discord, the instant messaging and VoIP chat app that became the default home for so many online communities.
|
||||
|
||||
Fluxer is the community chat app I kept coming back to for five years: familiar, free and open source (AGPLv3), and built so people can run their own instance.
|
||||
|
||||
## Fluxer in 60 seconds
|
||||
|
||||
<aside class="blog-definition-card">
|
||||
<div class="blog-definition-card__term">
|
||||
<span>flux</span>
|
||||
<span>/flŭks/</span>
|
||||
</div>
|
||||
<div class="blog-definition-card__part">noun</div>
|
||||
<ol class="blog-definition-card__definitions">
|
||||
<li>A continuing movement, especially in large numbers of things.</li>
|
||||
<li>Constant or frequent change; fluctuation.</li>
|
||||
</ol>
|
||||
<p class="blog-definition-card__example">"Flux capacitor ... fluxing!" (Back to the Future)</p>
|
||||
</aside>
|
||||
|
||||
Why use Fluxer when Discord is free and works well?
|
||||
|
||||
If Discord does what you need, and you're fine relying on a closed, investor-driven app that has reportedly [filed confidential IPO paperwork](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/), then you may not need Fluxer. Fluxer is for people who want an open, self-hostable alternative.
|
||||
|
||||
> [Discord's IPO could happen in March | TechCrunch](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/)
|
||||
>
|
||||
> Discord reportedly filed confidential IPO paperwork and has pinned its hopes on a debut in March.
|
||||
>
|
||||
> Source: TechCrunch / Julie Bort
|
||||
|
||||
Fluxer is for people who want a different model: free, open source, self-hostable software, with an optional hosted instance run by an independent European company that helps pay for the open source work. You shouldn't have to give up the basics you like just to leave Discord: Fluxer keeps the familiar shape of modern community chat while making the software open and self-hostable.
|
||||
|
||||
Fluxer can succeed without Discord getting worse. Discord's network effect is hard to beat head-on, so I'm starting where switching already makes sense: technical users and communities that value control and openness, and want software they can run on their own terms. You don't need to be technical for Fluxer to be yours: many people prefer a European-owned instance that has clearer reasons to treat users well.
|
||||
|
||||
Fluxer is free, open source, and self-hostable. The public code should be useful to people who run their own instance, not only to Fluxer.app, and no community should have to depend on one company surviving. The hosted instance should fund hosting and development work that also helps the open source project:
|
||||
|
||||
- Fluxer.app is the hosted instance. It's free to use, and Plutonium gives hosted users higher limits while helping pay for hosting, support, and open source development. There was also a limited-time lifetime Visionary plan for early supporters; it sold out at around 1,000 copies before I stopped it.
|
||||
- [Donations](/donate) are open for people and organisations who self-host Fluxer, or who want the open source project to keep improving.
|
||||
- For people who self-host and want a closer community around it, I plan to offer a one-time Operator Pass at $199 or €199. It helps fund the open source work and gives self-hosters a smaller place to get help from other self-hosters and the Fluxer team, share ideas, and make feedback heard before it gets buried on GitHub.
|
||||
- Managed hosting comes later. Right now, the priority is making self-hosting reliable first.
|
||||
|
||||
If the hosted free tier limits are too tight, you can always run your own instance. Fluxer will keep the software free of feature paywalls, licence key checks, upgrade-for-quota gates, and [SSO tax](https://sso.tax/).
|
||||
|
||||
## Now, my backstory
|
||||
|
||||
### 2017 to 2020
|
||||
|
||||
I started using Discord in 2017, and my interest only grew. By January 2019, I'd joined Discord Testers, their crowd-sourced bug reporting programme, and by April 2019 I'd earned enough XP to unlock the Bug Hunter badge. Around the same time, I was accepted into Discord's HypeSquad Events programme, though I never got the chance to represent Discord at any events.
|
||||
|
||||
### 2020 to 2022
|
||||
|
||||
When the pandemic hit, studying from home gave me more time to learn. Until then, I'd treated programming casually, but I kept returning to the project.
|
||||
|
||||
During my senior high school years, I worked on early prototypes of what is now Fluxer. My final graduation project became that prototype, plus a technical report on what I'd learned from studying Discord's technical blog posts and architecture and how I applied those ideas in practice. When I graduated in summer 2022, I received a small $200 scholarship from the school, and the title "Web Guru of the Year."
|
||||
|
||||
### 2022 to 2023
|
||||
|
||||
I've been a student at [KTH Royal Institute of Technology](https://www.kth.se/en) since autumn 2022. By summer 2023, I decided to focus more actively on Fluxer, but my studies still took up much of my time, so progress came in bursts.
|
||||
|
||||
In October 2022, I co-authored a medium-severity bug bounty report for a permission bypass vulnerability and submitted it to Discord's security team. The report earned my co-author and me a shared award of $1,500.
|
||||
|
||||
While studying, I also led web development for a popular Minecraft: Bedrock Edition server with nearly 6 million registered players. I built and ran the web systems, working on API design, security, billing, and reliability. It taught me how to keep large services running, and I still help maintain those codebases when needed.
|
||||
|
||||
### 2023 to 2024
|
||||
|
||||
In July 2023, I released the first private alpha of Fluxer to a few dozen testers. Since then, I've changed the tech stack many times before settling on what I run today.
|
||||
|
||||
The current stack is heavily inspired by Discord and, to some extent, WhatsApp. It uses boring, proven pieces for realtime delivery, internal messaging, fast shared state, and durable persistence. I did try simpler alternatives because I wanted less complexity, but I kept reinventing the wheel for problems that proven technologies already solve well.
|
||||
|
||||
Those changes, along with continued research into Discord's architecture, shaped the stack. I dug through Reddit and Hacker News posts from Discord employees, public conversations with their engineers, and many blog posts and postmortems, then tested the ideas in code to see where they held up. At the same time, my KTH studies in computer science, network security, software testing, and distributed systems gave me the background to judge the trade-offs.
|
||||
|
||||
My degree programme also puts a lot of weight on applying knowledge in practice. In spring 2024, I joined a project course where we delivered an internal tool for Giesecke+Devrient, an international security technology company.
|
||||
|
||||
### 2025
|
||||
|
||||
In January 2025, I grew wary of how Discord handled my personal data and where the app was headed. I built [Discorch](https://discorch.org/), a tool that guides you through Discord's undocumented privacy request process to bulk delete messages. It produces a CSV plus instructions for contacting Discord's privacy team to request deletion.
|
||||
|
||||
As more people used it, Discord kept changing the process and eventually restricted it, including blocking deletion of your own DM messages through this route because you can still delete those manually. The route now only works for servers and groups you've left.
|
||||
|
||||
In spring 2025, I reported a security vulnerability to Discord and was awarded $2,000. Later that summer, I registered [Fluxer Platform AB](https://www.allabolag.se/foretag/fluxer-platform-ab/brandbergen/datacenters/2KJCA7DI5YDLG), a Swedish limited liability company, which required a minimum deposit of $2,000.
|
||||
|
||||
<figure class="blog-media blog-media--gif">
|
||||
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-freebie-poster.jpg">
|
||||
<source src="/blog/assets/tenor-freebie.webm" type="video/webm" />
|
||||
<source src="/blog/assets/tenor-freebie.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption>Maeby from Arrested Development: "Well, that was a freebie."</figcaption>
|
||||
</figure>
|
||||
|
||||
By summer 2025, I'd finished most of my university courses and spent the summer writing my bachelor's thesis with a fellow student for the supportive and welcoming Intelligent Heart Technology Lab at KTH. The thesis was [published in September 2025](https://urn.kb.se/resolve?urn=urn%3Anbn%3Ase%3Akth%3Adiva-371447).
|
||||
|
||||
Between September and December 2025, I worked day and night to make Fluxer ready for public release. Getting the architecture, feature set, and web client ready for real users by myself was the hardest work I'd done.
|
||||
|
||||
On 25 October 2025, I opened the first private beta because I needed funding to keep going. It was a small group of around 30 testers who could invite people they knew, and a handful bought Visionary at $299 each while I worked towards feature completion.
|
||||
|
||||
### 2026
|
||||
|
||||
On 2 January 2026, I opened Fluxer up to everyone. I tried a [Show HN](https://news.ycombinator.com/item?id=46468725), which didn't go far, and launched on [Product Hunt](https://www.producthunt.com/products/fluxer/launches/fluxer), which gained a little more traction. A few more people bought Visionary, enough to keep me going, and I started polishing things towards a self-hosting release.
|
||||
|
||||
There was effectively no marketing. Then a Bluesky post after Discord's IPO plans leaked travelled further than expected, and Discord's age-verification announcement on 9 February 2026 changed the scale of the project overnight. Fluxer grew to about 195,000 users, with a peak of around 11,000 concurrent connections, while the hosted setup was still built for a much smaller load and I was the only full-time engineer.
|
||||
|
||||
> After Discord's age-verification announcement, Visionary sold out quickly: around 1,000 copies at $299 each before sales were paused. Visionaries are recognised in-app with a numbered badge that shows how early they supported Fluxer.
|
||||
|
||||
Visionary was never about FOMO; I didn't know what was coming. I capped it at 1,000 slots with an expiration date in October 2026 because that seemed far above likely demand. It sold out in a matter of days.
|
||||
|
||||
Fluxer uses familiar community-chat patterns and includes the features people expect from this kind of app. In several areas it already does more than the other open source options, and it stays close to classic Discord where that model works.
|
||||
|
||||
The team is still tiny, but the day-to-day load is shared now. The goal is still simple: free and open source software, self-hosting without licence-key checks, and a hosted instance whose job is to serve users and fund the open source work.
|
||||
|
||||
You can support Fluxer through [Plutonium](/plutonium), a custom [donation](/donate), or issues and PRs in [the GitHub repo](http://github.com/fluxerapp/fluxer). That support goes into hosting, documentation, code review, and the work needed to keep both Fluxer.app and the self-hosted release moving.
|
||||
|
||||
> [GitHub - fluxerapp/fluxer: A free and open source instant messaging and VoIP chat app built for friends, groups, and communities.](http://github.com/fluxerapp/fluxer)
|
||||
>
|
||||
> A free and open source instant messaging and VoIP chat app built for friends, groups, and communities. - fluxerapp/fluxer
|
||||
>
|
||||
> Source: GitHub / fluxerapp
|
||||
|
||||
## Fluxer's backend
|
||||
|
||||
Discord's backend scaling work is unusually well documented, and their engineering posts have been a big reference point for me. Start here:
|
||||
|
||||
- [How Discord Scaled Elixir to 5,000,000 Concurrent Users](https://discord.com/blog/how-discord-scaled-elixir-to-5-000-000-concurrent-users)
|
||||
- [Using Rust to Scale Elixir for 11 Million Concurrent Users](https://discord.com/blog/using-rust-to-scale-elixir-for-11-million-concurrent-users)
|
||||
- [How Discord Stores Trillions of Messages](https://discord.com/blog/how-discord-stores-trillions-of-messages)
|
||||
- [How Discord Reduced WebSocket Traffic by 40%](https://discord.com/blog/how-discord-reduced-websocket-traffic-by-40-percent)
|
||||
|
||||
I respect their engineers, even if I'm not a fan of some of Discord's business decisions. Meanwhile, a usable self-hosted alternative still doesn't really exist, and building something good enough to replace what people already use takes an enormous amount of time and energy, since people usually only pay once an app feels close to what they expect.
|
||||
|
||||
What kept me going was years of work to understand Discord's architecture in depth: Hacker News and Reddit threads, postmortems and blog posts, relevant courses at KTH, and professional experience along the way. I've also been active in Discord's meta communities for years, alongside fellow enthusiasts, security researchers, and bug hunters, and I've spoken with Discord engineers directly a few times.
|
||||
|
||||
That knowledge of Discord's development, and of people's frustrations with it, is what I'm putting to work on an alternative to closed community chat apps. Fluxer should be more than a Discord clone.
|
||||
|
||||
### What the backend looks like now
|
||||
|
||||
At launch, I described Fluxer as mostly TypeScript with a bit of Erlang. That was accurate at the time. Today Fluxer.app is a set of focused services, because different parts of the app have different needs.
|
||||
|
||||
Most of what people touch every day goes through the API. It's a TypeScript service running on Node with [Hono](https://hono.dev/), and it owns the parts of Fluxer that change the most: accounts, authentication, communities, channels, messages, attachments, billing, reports, downloads, unfurls, and the public HTTP API. Cassandra stores durable data on Fluxer.app, Valkey handles fast shared state, and NATS carries internal messages. The Worker uses the same TypeScript stack for jobs that shouldn't slow down a request, such as attachment expiry, CDN purges, search refreshes, billing reconciliation, trust and safety syncs, data exports, and bulk deletion.
|
||||
|
||||
The Gateway is where Fluxer stops looking like a normal web app. It's plain Erlang on OTP 28, and it owns the live side of the system: WebSocket connections, sessions, event fanout, presence, guild routing, voice and call state, and push notification delivery. In production it's split into specialised tiers for websocket connections, sessions, guilds, presence, calls, and push. The websocket tier stays stateless, while the stateful tiers own the live data they're responsible for, which makes production rollouts and failures easier to contain.
|
||||
|
||||
Media lives in Rust because it's CPU-heavy, memory-sensitive, and exposed to untrusted input. The Media Proxy runs on axum and Tokio and handles uploads, thumbnails, metadata, transforms, external media, static files, and the upload relay. The heavy lifting goes through libvips, libheif, and FFmpeg behind a hand-written C shim, with Rust wrapped around it for memory safety, strict input limits, bounded concurrency, and request coalescing. If a hundred people open the same image, they share one transform instead of making the system do the same work a hundred times.
|
||||
|
||||
Rust also sits behind the API for hot paths that benefit from a smaller, more predictable service. In the code that's public today, that means services for users, messages, link unfurling, and Snowflake ID generation, modelled on [the data services Discord built between its API and its database](https://discord.com/blog/how-discord-stores-trillions-of-messages). They use NATS-based RPC, caching, and request coalescing where it helps. Production search runs on Elasticsearch, and self-hosted instances can choose Meilisearch when they want a lighter search backend. The Snowflake service uses the same 2015 epoch and 64-bit layout as Discord's, so IDs sort by time the same way.
|
||||
|
||||
NATS carries internal RPC and background jobs, the Gateway calls back into the API when it needs app data, and Valkey handles caching, rate limits, locks, cache invalidation, pub/sub, and small internal queues.
|
||||
|
||||
The API is the authority for accounts, permissions, billing, and app data. The Gateway is the authority for live sessions, routing, presence, and real-time delivery.
|
||||
|
||||
> The public repository lagged until 15 June 2026 because the growth spike forced urgent anti-abuse and production work while I kept Fluxer.app stable. The sync separated Fluxer.app deployment settings from reusable server code and moved the remaining operator work into the open.
|
||||
|
||||
A typical self-hosted deployment is much smaller than the hosted setup. The Kubernetes clustering and role-split tiers described above are specific to Fluxer.app; you shouldn't need to recreate my production cluster to run your own instance. The small setup keeps the app services together and needs only a database, Valkey, and NATS. The goal is Docker images plus a web setup wizard that walks you through configuring an instance. Small instances can run on a Raspberry Pi.
|
||||
|
||||
The Electron desktop path starts with custom backends through in-app account switching in the regular client, so you can run your own instance without waiting for full federation.
|
||||
|
||||
### Why three languages?
|
||||
|
||||
A polyglot backend sounds like a maintenance tax, and it can be. It pays off here because the three languages line up with three very different kinds of work, while the fast-moving majority of the app stays in just one of them.
|
||||
|
||||
Most of Fluxer is ordinary app code: accounts, permissions, billing, moderation, settings, REST endpoints. It's I/O-bound and changes constantly, so what matters is how fast one person can move through it. That work lives in TypeScript, end to end. The same language runs the API, the Worker, and the web and desktop clients, and they share types, validation, constants, and even a Discord-compatible Markdown parser (written in Rust, compiled to WebAssembly) across the wire. One person can follow a feature from a button in the client to the row in the database without switching mental models. For a codebase built mostly by one developer, that shared surface is the main reason I can keep moving fast.
|
||||
|
||||
The real-time layer is a very different problem: hundreds of thousands of long-lived connections, each needing isolation, supervision, and cheap concurrency. Erlang/OTP was built for exactly that, and trying to rebuild it in a general-purpose language is how you end up living [Virding's First Rule](https://rvirding.blogspot.com/2008/01/virdings-first-rule-of-programming.html) (quoted below). Discord and WhatsApp both proved the model at a scale Fluxer won't reach for years.
|
||||
|
||||
The third kind of work is CPU-bound and sensitive to latency and memory: decoding untrusted media, and shielding the database from read amplification. There, a garbage collector and an interpreter get in the way. Rust gives predictable memory, no GC pauses, safe concurrency, and a safe way to wrap the C media libraries. It's also the language [Discord reached for](https://discord.com/blog/using-rust-to-scale-elixir-for-11-million-concurrent-users) when the BEAM VM's garbage collector struggled with large data structures.
|
||||
|
||||
So the split is: keep the fast-moving majority in the language that lets me move fastest, and use specialised runtimes only where the work needs them. A small team keeps moving while the parts that need to scale have room to scale.
|
||||
|
||||
### Why choose Erlang/OTP?
|
||||
|
||||
The Erlang runtime system is designed for distributed, fault-tolerant, soft real-time, highly available, non-stop applications, with hot swapping to change code without stopping the system.
|
||||
|
||||
<figure class="blog-media blog-media--video">
|
||||
<video class="blog-embed-video" controls preload="metadata" poster="/blog/assets/erlang-the-movie-poster.jpg">
|
||||
<source src="/blog/assets/erlang-the-movie.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption>Hello Mike. Hello Joe. Hello Mike. Hello Robert. Hello Joe. Hello Mike. Hello Robert. Hello Mike. Hello. Looks like we fixed the bug!</figcaption>
|
||||
</figure>
|
||||
|
||||
It was originally developed as proprietary software at Ericsson, a Swedish telecom company, by Joe Armstrong (who wrote his PhD thesis at my university, KTH, [in the year I was born](https://erlang.org/download/armstrong_thesis_2003.pdf)), Robert Virding, and Mike Williams in 1986. It was released as open source in 1998 and is maintained by the OTP unit at Ericsson.
|
||||
|
||||
Notable large-scale users include WhatsApp, plus Discord via Elixir (which compiles to bytecode for the same BEAM virtual machine that Erlang runs on). WhatsApp is far larger than Discord: more than 3 billion MAU, largely built on Erlang, with a famously small engineering team.
|
||||
|
||||
I tried several real-time architectures before accepting this rule:
|
||||
|
||||
> "Any sufficiently complicated concurrent program in another language contains an ad hoc informally-specified bug-ridden slow implementation of half of Erlang."
|
||||
>
|
||||
> [Robert Virding](https://rvirding.blogspot.com/2008/01/virdings-first-rule-of-programming.html), co-creator of Erlang
|
||||
|
||||
Fluxer's real-time system is heavily inspired by Discord and wire-compatible with enough of Discord's protocol to make porting existing Gateway bots easier. A websocket connection identifies itself, a session process owns that live client state, and guild and presence processes decide which sessions should receive each event. Short disconnects can replay missed events instead of rebuilding the whole initial state, while member and presence updates stay lazy, incremental, and compressed. This is the same direction Discord went when they [cut their WebSocket traffic by 40%](https://discord.com/blog/how-discord-reduced-websocket-traffic-by-40-percent).
|
||||
|
||||
A big guild's member list can hold hundreds of thousands of entries that change constantly, and your client only ever wants a small, sorted slice of it. Fluxer keeps the member rows in ETS, but the sorted index behind rank and range reads lives in a Rust NIF, just like the approach Discord described when their BEAM-side sorted member list created too much garbage-collector and allocator pressure. The NIF owns a compact order-statistic tree, so Erlang keeps supervising the guild process while Rust handles the insert/delete/rank/range work that needs predictable memory behaviour.
|
||||
|
||||
Kudos to the engineering team at Discord for the inspiration.
|
||||
|
||||
### Why choose Cassandra?
|
||||
|
||||
> Fluxer.app uses Cassandra in production today. Self-hosting is being designed to support Postgres too, because smaller instances shouldn't have to run the same database setup as the hosted service.
|
||||
|
||||
For a long time, ScyllaDB was my database of choice, and Discord also migrated to it after running Cassandra. As of December 2024, though, ScyllaDB is [no longer open source software](https://news.ycombinator.com/item?id=42457680), which is a deal-breaker for me.
|
||||
|
||||
<figure class="blog-bsky-embed">
|
||||
<a class="blog-bsky-card" href="https://bsky.app/profile/jacob.gold/post/3ldmwlqy6gk24" target="_blank" rel="noopener noreferrer">
|
||||
<span class="blog-bsky-header">
|
||||
<span class="blog-bsky-author">
|
||||
<img src="/blog/assets/bsky-jake-gold-avatar.jpg" alt="" width="48" height="48" loading="lazy" decoding="async" />
|
||||
<span>
|
||||
<span class="blog-bsky-name">Jake Gold</span>
|
||||
<span>@jacob.gold</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="blog-bsky-text">I completely understand @scylladb.com's challenge with maintaining an open source/open core business model.<br><br>On the other hand, I'm not sure I would have chosen ScyllaDB for Bluesky PBC's infra if there wasn't an open source version.<br><br>So this is unfortunate but I can't blame them for doing what they need to do.</span>
|
||||
<span class="blog-bsky-link-card">
|
||||
<img src="/blog/assets/bsky-scylladb-link-thumb.jpg" alt="" width="600" height="314" loading="lazy" decoding="async" />
|
||||
<span>
|
||||
<span class="blog-bsky-link-title">Why We're Moving to a Source Available Licence - ScyllaDB</span>
|
||||
<span>ScyllaDB is moving to a source available licence. Learn why, directly from CEO and co-founder Dor Laor.</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
Cassandra has trade-offs. I keep using it because it fits Fluxer's write-heavy, event-driven design.
|
||||
|
||||
First, Cassandra makes it hard to be accidentally inefficient. You have to think upfront about how your data is modelled and queried, because inefficient queries are either impossible or explicitly opt-in through features like [ALLOW FILTERING](https://docs.datastax.com/en/cql-oss/3.3/cql/cql_reference/cqlSelect.html#:~:text=Only%20use%20ALLOW%20FILTERING).
|
||||
|
||||
Second, Cassandra prioritises high write throughput, and Fluxer is built to keep reads low. When you send a message, the database does very little: it validates your auth session (a read usually served from cache) and writes the message row. Permissions are answered by the Erlang Gateway over RPC, which keeps an in-memory cache of everything needed to compute permissions in a community (called a guild internally), so a permission check is a fast internal call instead of a query. Clients mostly read from the database when starting a new session to populate initial state; after that, everything stays in sync through the event dispatching system.
|
||||
|
||||
Third, operationally, Cassandra avoids a class of problems I've struggled with in traditional RDBMS setups at hosted-service scale. Postgres is the right direction for smaller self-hosted instances because people already know how to run it, but Fluxer.app has different constraints. Cassandra keeps the hosted deployment away from JOIN planning, index tuning, lock-heavy migrations, and many of the schema-change risks that show up under load.
|
||||
|
||||
Finally, Cassandra fits Fluxer's message rows. Unfurled links, file attachments, and similar metadata work well as embedded documents. Cassandra gives you rich types like sets and maps, along with user-defined types that can be nested, typed, and stored efficiently, so a single document doesn't need extra table queries or untyped JSON blobs.
|
||||
|
||||
Building Fluxer's hosted data model around relational joins doesn't fit the access patterns. The app's already shaped like an event-driven document and key-value system, so a NoSQL database fits the hosted service better.
|
||||
|
||||
### You mentioned Postgres for self-hosted instances?
|
||||
|
||||
Yes. The persistence layer is being shaped around explicit access patterns rather than ad hoc SQL sprinkled through the app. Cassandra is the hosted production backend today, and the same query model is meant to support Postgres for self-hosted instances.
|
||||
|
||||
Because Cassandra behaves like a key-key-value (KKV) store (partition key + clustering key, where the clustering key identifies a specific row within a partition), I already had to design my tables and queries so that every lookup requires the full key, or at least a sufficiently specific leading part of it for SELECT queries.
|
||||
|
||||
In Cassandra, secondary indexes are typically implemented at the application level using additional tables optimised for alternative access patterns, maintained on writes via logged batches, with optional denormalisation depending on how important it is to skip an extra read for the full primary row. Materialised views and native secondary indexes exist, but their caveats can make them risky in production.
|
||||
|
||||
Given those constraints, the first Postgres backend is careful on purpose: a KV-shaped storage layer keyed like the hosted database, with prefix range queries where listing is needed. It keeps the self-hosted path close to the production data model and leaves room to add relational tables later where they clearly help.
|
||||
|
||||
Other database backends can come later, but the priority is one reliable self-hosted path first.
|
||||
|
||||
## Fluxer's frontend
|
||||
|
||||
Fluxer's web app codebase is complex. The PWA is much better in the canary client than it was at launch. To try the newest client work, use the canary desktop build at [canary.fluxer.app/download](https://canary.fluxer.app/download) or the canary web client at [web.canary.fluxer.app](https://web.canary.fluxer.app).
|
||||
|
||||
Canary has fixed hundreds of bugs and adds major voice and video work: screen sharing with audio, text in voice, DM call fixes, a redesigned input and output device system, more mic processing controls, and DeepFilterNet3 noise suppression.
|
||||
|
||||
The web app has to handle the details people notice immediately: infinite scrolling, stable scroll position, bounded caches, jumping in time, state reconciliation, unread handling, and Discord-compatible Markdown. Those parts are tedious, but they're the difference between a usable client and a demo.
|
||||
|
||||
As of 15 June 2026, the native app, built with Flutter, has moved past Visionary testing. The iOS app is in a limited TestFlight beta with a small group of Plutonium subscribers, since TestFlight slots are capped, and the Android app is available now as an APK from [github.com/fluxerapp/flutter_client](https://github.com/fluxerapp/flutter_client). Both are early alphas, so expect missing features and bugs while they move towards a public release. The mobile app code is open source.
|
||||
|
||||
Unlike Discord, Fluxer welcomes client changes: custom themes, non-malicious account automation, third-party clients, and anything else that tickles your fancy. It's all open source anyway, so you're welcome to send changes that fit the goals of the project. Just open an issue first to discuss it :)
|
||||
|
||||
### Electron? Right to jail, right away
|
||||
|
||||
Fluxer currently uses Electron. I know, I know. I'm not too happy about it either. Tauri isn't mature enough for what I need yet, and I ran into hurdles there that I didn't have in Electron. In the spirit of choosing boring technology, Electron unfortunately wins. A lot of apps give Electron a bad name, but it doesn't have to be that way.
|
||||
|
||||
Tauri uses the system webview. That sounds great on paper, but it leaves the desktop app at the mercy of whatever runtime the OS provides, with whatever bugs come with it. And when those bugs happen, you can't ship a fix by updating your runtime, because the runtime is tied to OS updates.
|
||||
|
||||
I'll look at Tauri again when there's a mature, supported option for shipping a consistent runtime with it, like CEF ([cef-rs](https://github.com/tauri-apps/cef-rs)).
|
||||
|
||||
> [GitHub - tauri-apps/cef-rs](https://github.com/tauri-apps/cef-rs)
|
||||
>
|
||||
> Contribute to tauri-apps/cef-rs development by creating an account on GitHub.
|
||||
>
|
||||
> Source: GitHub / tauri-apps
|
||||
|
||||
For most people, Electron is an acceptable choice because it gives Fluxer a consistent desktop runtime while sharing the same client base as the web app.
|
||||
|
||||
I believe in the web platform for this kind of application. It's mature, cross-platform, well understood, and good at complex interfaces that need to run on many devices. It's also the platform I know best, which is part of why Fluxer could ship with a PWA from day one.
|
||||
|
||||
The first PWA had mobile issues, but it worked, and canary has improved it a lot. You can see that work today while the native Flutter app moves through its iOS TestFlight beta and open Android APK toward a public release.
|
||||
|
||||
The best mobile client is native, which is why the Flutter iOS and Android app comes first. Flutter can target desktop later, and that may become a better option than Electron for low-end devices down the line. Until then, the priority is simple: ship a reliable client that behaves consistently across web and desktop.
|
||||
|
||||
### The LLMephant in the room
|
||||
|
||||
I've used LLMs sparingly as a troubleshooting and planning aid on Fluxer. Fluxer isn't vibe-coded, and describing it that way would be false and unfair to years of work. I'd never outsource my judgement to an LLM; limited use was a necessary compromise while I was carrying too much of the project alone, when the alternative was abandoning Fluxer or raising venture capital to ship it. I'm strongly opposed to the venture-capital path for Fluxer, and open source contributions now mean I no longer need to handle every part of the project by myself. Fluxer's core predates LLMs becoming normal in software development. The architecture, safety decisions, technical choices, and product direction are mine, and I only ship changes I understand and can explain. I expect the same from external contributors.
|
||||
|
||||
## Who is building Fluxer now?
|
||||
|
||||
Fluxer is no longer a one-person project, but the team is still small for something this big.
|
||||
|
||||
I'm still the only person who has worked across the whole codebase from the beginning, and I spend most of my time building the app, working on backend architecture and reliability, and handling production operations. Support, trust and safety, abuse prevention, billing, accounting, and internal tools have all spent time on my desk too.
|
||||
|
||||
Around that, someone is helping with direction and safety: where Fluxer goes, report review, policy work, and safety tooling. An engineer focused on systems works on scaling, monitoring, CDN work, internal tooling, and voice server deployment. The native iOS and Android app has a paid contributor focused on the mobile app, and a newer team member is taking support and billing load off my plate.
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
### What about the open web?
|
||||
|
||||
Chat apps have swallowed useful knowledge that used to live on the public web, then made it invisible to search engines, archives, and people who aren't logged in.
|
||||
|
||||
> [Discord seeks to solve a problem that it created | TechCrunch](https://techcrunch.com/2025/05/23/discord-seeks-to-solve-a-problem-that-it-created/)
|
||||
>
|
||||
> Conversations on Discord can be hard to follow. Discord SVP Peter Sellis proposes making forum-like features, or using AI summaries.
|
||||
>
|
||||
> Source: TechCrunch / Amanda Silberling
|
||||
|
||||
> With LLMs, Sellis said, Discord could take a long, meandering conversation and turn it into "something that could be more sharable and syndicated across the web." However, he said that he and his team hadn't "seen a solution that we feel great about yet."
|
||||
|
||||
Fluxer disagrees with Discord's starting point here. LLMs shouldn't turn people's "meandering conversations" into web content. If something becomes public, it should be because a person or community chose to publish it.
|
||||
|
||||
This feature uses the web itself: public pages with stable URLs, server-rendered posts, clear titles, search indexing, archivable pages, RSS and Atom feeds, and links people can share anywhere. People's posts remain their posts.
|
||||
|
||||
Publishing stays opt-in, for communities that benefit from putting parts of their forum-style spaces on the open web: open source projects, developer communities, modding groups, creator communities, research groups, support forums, and any other space where public answers are meant to be searchable and useful later. Private chat stays private.
|
||||
|
||||
### This looks a lot like Discord!
|
||||
|
||||
Yes, on purpose. Community chat has a shape people already understand: server list, channel list, message timeline, member list, composer, and voice controls. Discord didn't invent that; it built on patterns already familiar from IRC, TeamSpeak, Slack, forums, game launchers, and older community tools.
|
||||
|
||||
Fluxer values a clear first screen over novelty. A Discord-like app needs to feel easy to understand for people coming from Discord. Familiarity makes switching less painful, keeps muscle memory intact, and lets Fluxer focus on the parts people actually care about: ownership, openness, privacy, performance, self-hosting, federation, safety, and who the app answers to.
|
||||
|
||||
Copyright works the same way. It protects specific expression, not the general idea of putting servers, channels, messages, members, and voice controls where people expect them. In the US, [17 U.S.C. § 102(b)](https://www.copyright.gov/title17/92chap1.html#102) says copyright doesn't cover ideas, procedures, systems, or methods of operation. [TRIPS Article 9(2)](https://www.wto.org/english/docs_e/legal_e/trips_e.htm) says the same internationally. EU software law says the ideas and principles behind interfaces aren't protected by copyright, and the CJEU held in [SAS Institute v World Programming](https://curia.europa.eu/jcms/upload/docs/application/pdf/2012-05/cp120053en.pdf) that functionality isn't expression. Nobody owns the basic pattern of a usable community chat interface.
|
||||
|
||||
<figure class="blog-media blog-media--image">
|
||||
<picture>
|
||||
<source type="image/avif" srcset="/blog/assets/discord-ui-revolutionary-640.avif 640w, /blog/assets/discord-ui-revolutionary-960.avif 960w, /blog/assets/discord-ui-revolutionary-1280.avif 1280w, /blog/assets/discord-ui-revolutionary-1881.avif 1881w" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
<source type="image/webp" srcset="/blog/assets/discord-ui-revolutionary-640.webp 640w, /blog/assets/discord-ui-revolutionary-960.webp 960w, /blog/assets/discord-ui-revolutionary-1280.webp 1280w, /blog/assets/discord-ui-revolutionary-1881.webp 1881w" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
<source type="image/png" srcset="/blog/assets/discord-ui-revolutionary-640.png 640w, /blog/assets/discord-ui-revolutionary-960.png 960w, /blog/assets/discord-ui-revolutionary-1280.png 1280w, /blog/assets/discord-ui-revolutionary-1881.png 1881w" sizes="(max-width: 768px) 100vw, 768px" />
|
||||
<img class="blog-embed-image" src="/blog/assets/discord-ui-revolutionary-1280.png" alt="A comparison image showing similar community chat layouts across older apps." loading="lazy" decoding="async" />
|
||||
</picture>
|
||||
<figcaption>Source: <a href="https://imgur.com/whenever-someone-says-discords-ui-is-revolutionary-b5kdlfM" target="_blank" rel="noopener noreferrer">Imgur</a>, "Whenever someone says Discord's UI is revolutionary."</figcaption>
|
||||
</figure>
|
||||
|
||||
Changing the layout only because people might compare it to Discord would make the app harder to use. Fluxer changes things where it actually improves the app; forcing people to relearn where messages, channels, servers, and voice calls live just adds friction.
|
||||
|
||||
I've tried several different takes on the UX, and they ended up messy. I've also tried newer apps that recreate the Discord experience while trying too hard to feel new, and so have many people I've spoken to. They're harder to use than they need to be.
|
||||
|
||||
Is Discord's UX perfect? Of course not, and parts of it have got worse over time. But the older Discord model is still one of the easiest ways to understand a modern community chat app. Most open source options fail because they neither feel familiar enough to switch to nor complete enough to replace what people already use. The result may be principled, but it feels worse to most people. Classic Discord also feels nostalgic for many of us longtime users, from before the redesigns. Fluxer can improve the details while keeping a working mental model people already understand.
|
||||
|
||||
Fluxer will go its own way over time. As self-hosting, public web publishing, multi-backend support, federation, moderation tooling, voice and video, and client customisation get better, Fluxer becomes more clearly its own thing. It starts from something familiar and changes where it has a better answer.
|
||||
|
||||
Fluxer also lets you fully customise your client's look with custom CSS, and UI experiments are welcome when they improve usability.
|
||||
|
||||
### Why the name Fluxer, and why Plutonium?
|
||||
|
||||
The honest origin is simple: Back to the Future is my favourite film series.
|
||||
|
||||
In the film, the flux capacitor is the thing that makes time travel possible. The DeLorean needs to hit 88 mph and draw 1.21 gigawatts, first from a plutonium-powered reactor. Fluxer and Plutonium are both little nods to that. Yes, naming your subscription after fictional nuclear fuel is a bit silly, which is part of the fun.
|
||||
|
||||
There's a more literal meaning too. Flux means change, movement, fluctuation. Chat is constantly moving: new messages, new people, new context, old conversations becoming something else over time. A chat app is almost never in a fixed state.
|
||||
|
||||
The logo comes from the same idea. The approximately-equal sign fits something in flux: recognisable, but always changing.
|
||||
|
||||
The name also points at the broader goal: a better path for community chat.
|
||||
|
||||
### How long did Fluxer take to build?
|
||||
|
||||
The first version started around 2020, while I was still in high school during the pandemic. It became my graduation project, then a long-running side project tested by friends while I studied and worked on other things.
|
||||
|
||||
The final run-up to public release began after I finished my bachelor's thesis in summer 2025. The private beta opened in October 2025, and Fluxer was publicly released on 2 January 2026.
|
||||
|
||||
The launch was quiet at first. Then Discord IPO news and Discord's age-verification announcement on 9 February 2026 put Fluxer in front of far more people than I expected. The hosted instance grew to around 195,000 users, with a peak of about 11,000 concurrent connections, while the team was still effectively one full-time engineer. The team is larger now, but still small.
|
||||
|
||||
### Why build a chat app at all?
|
||||
|
||||
Community chat has been my focus for years. I've been deep in Discord's world since 2017: testing, reading engineering posts, following architecture discussions, reporting bugs, and talking to people who understand that world.
|
||||
|
||||
Fluxer started as a technical challenge but became about more than code over time. I wanted a modern community chat app without one company controlling the app, data, network, and business model. Self-hosters, technical communities, open source projects, and creators all need software they can trust, customise, and move away from.
|
||||
|
||||
Fluxer preserves the parts of this kind of chat that work, makes the software free and open source, and builds towards decentralisation so the future is no longer decided by one company.
|
||||
|
||||
### I've built a Discord bot. Will it work on Fluxer?
|
||||
|
||||
Partly. Fluxer's HTTP API and WebSocket Gateway API are heavily inspired by Discord's and, in many cases, directly wire-compatible with it. That means you can reuse existing Discord libraries and abstractions in many languages.
|
||||
|
||||
For example, you can use the [core libraries from discord.js](https://discord.js.org/docs/packages/core/main) directly. It's a bit more low-level, but enough for many bots, and [the Fluxer API docs](https://docs.fluxer.app/) guide you through a quick start with this approach. Adapting existing Discord libraries can also work. Or build your own community-maintained Fluxer SDK and email me ([[email protected]](mailto:[email protected])) to get it featured in the docs, or submit a pull request in [the GitHub repository](https://github.com/fluxerapp/fluxer).
|
||||
|
||||
> The API and self-hosting docs are being cleaned up in public after the 15 June 2026 repository sync. If something is unclear, file an issue or email me.
|
||||
|
||||
Slash commands and similar features are still coming. If you want that work to happen sooner, Plutonium and donations help buy the time to build and publish it properly.
|
||||
|
||||
### Why do attachments expire?
|
||||
|
||||
Because storage costs money, and Fluxer has no venture capital funding.
|
||||
|
||||
Large media adds up quickly. If every image, video, and random file stayed forever, the hosted instance would become free cloud storage with a chat app attached, which breaks the economics of a bootstrapped app trying to stay independent.
|
||||
|
||||
Expiry is based on file size. Small files last much longer, with the smallest lasting about three years, and files can be renewed a little when they're accessed. The exact behaviour is documented in [how attachment expiry works](/help/attachment-expiry).
|
||||
|
||||
Attachment expiry keeps storage under control and also protects privacy; most attachments have no reason to live forever. If you self-host Fluxer, the policy is yours to configure, including whether expiry is enabled and how much storage your instance allows.
|
||||
|
||||
### Will Fluxer become like Discord?
|
||||
|
||||
Fluxer's structure is meant to keep exits open. Fluxer.app funds the shared codebase through hosted revenue, including Plutonium. Its role is to improve the software for people who use the hosted instance and people who run it elsewhere. The software is intended to be self-hostable, and the long-term plan includes self-hosting, data portability, multiple backends in one client, and federation.
|
||||
|
||||
Open source makes trust easier because running your own instance is possible.
|
||||
|
||||
### Why monetise Fluxer?
|
||||
|
||||
Because running a real-time chat app costs real money, and so does maintaining the open source version people can run themselves. Compute, storage, bandwidth, monitoring, safety tooling, payment processing, support, and people's time all have to be paid for somehow.
|
||||
|
||||
The question is who the app has to answer to. Venture capital can make an app look free for a while, but it usually comes with pressure to grow faster, extract more, and eventually answer to investors before users.
|
||||
|
||||
The model is to keep a generous free tier, charge for higher limits on the hosted instance, accept donations from people who want Fluxer to keep going, and keep self-hosting free. Hosted revenue should buy time to improve the shared codebase, with public development at the centre of the work.
|
||||
|
||||
### Is Fluxer free and open source?
|
||||
|
||||
Yes. Fluxer is free and open source. The public repository is [github.com/fluxerapp/fluxer](https://github.com/fluxerapp/fluxer), and the goal is that the hosted Fluxer.app service and self-hosted instances share the same useful base. The hosted instance pays for the work and tests it with real traffic, while instance operators get the tools they need to run Fluxer themselves.
|
||||
|
||||
Until 15 June 2026, the public repository lagged because the growth spike forced urgent anti-abuse and production work while I kept Fluxer.app stable. The sync separated Fluxer.app deployment settings from instance settings other people can use, and moved the remaining operator work into the open.
|
||||
|
||||
Pull requests are open, and Fluxer is open by default.
|
||||
|
||||
### Will self-hosting cost money?
|
||||
|
||||
No. Running your own Fluxer instance requires no licence key, paid tier, or special enterprise unlock. You're responsible for hosting, uptime, moderation, safety, and legal duties, but the software itself is free to run.
|
||||
|
||||
The Operator Pass is planned for when the docs and setup guides are solid. It's a $199 or €199 one-time purchase for self-hosters who want to support the open source work and join the Operators community: a smaller place to get help from other self-hosters and the Fluxer team, share ideas, and make feedback heard before it gets buried on GitHub. The docs stay public.
|
||||
|
||||
### Federation?
|
||||
|
||||
Federation remains a major goal, and the order matters. People who run and use Matrix complain about specific things: large federated rooms can be slow and expensive to join, presence and device-list updates can create surprising background load, federation failures can leave rooms or encrypted messages half-working, and moderation gets harder when abuse, media, bans, and bridges cross instance boundaries.
|
||||
|
||||
The self-hosting path starts with account switching for custom backends in the Electron desktop app. The next client step is simultaneous connections to multiple backends without making you switch between workspaces. The goal is one client that can show several instances together before Fluxer has the unified identity and authentication model that full federation needs.
|
||||
|
||||
True federation can come after that, with OAuth2-based authentication against remote instances and a clearer model for where identity and data live.
|
||||
|
||||
### How does moderation work?
|
||||
|
||||
Each Fluxer instance is responsible for its own moderation. Fluxer Platform AB operates the hosted Fluxer.app instance, so that's where the company is responsible for reports, safety enforcement, legal compliance, and abuse prevention.
|
||||
|
||||
The 15 June 2026 repository sync also separated moderation, abuse-prevention, and operator tooling from Fluxer.app-specific settings. Instance operators should get the full toolset in a form they can actually use, with the remaining work happening in public.
|
||||
|
||||
Fluxer Platform AB is also a registered electronic service provider (ESP) with access to the CyberTipline Reporting API from the [National Center for Missing & Exploited Children (NCMEC)](https://www.missingkids.org/home).
|
||||
|
||||
Once federation exists, moderation stays local to the instance enforcing it. A ban on one instance stays local unless other instances choose to share or honour that signal.
|
||||
|
||||
### Why do you follow local laws in my country or state?
|
||||
|
||||
For self-hosted instances, this is the responsibility of the person running the instance. If you run your own instance, you decide where you provide service and what legal risk you accept.
|
||||
|
||||
For the hosted Fluxer.app instance, Fluxer has to follow the law where it serves traffic. Some recent age-verification laws are invasive enough that I'd rather restrict access than collect government IDs or biometric data from everyone.
|
||||
|
||||
As of 24 May 2026, Fluxer restricts NSFW access in the United Kingdom and Brazil. In the UK, Fluxer can offer a less invasive optional adult check through a $0.00 credit card authorisation. Brazil currently lacks a private enough path I'm comfortable with. Mississippi requires age verification for access to the whole service, so the hosted Fluxer.app instance blocks access from Mississippi instead.
|
||||
|
||||
The current details are kept in [regional restrictions](/help/regional-restrictions) and [minimum age requirements](/help/minimum-age).
|
||||
|
||||
### Where does Fluxer run?
|
||||
|
||||
Fluxer.app currently runs in US East on Vultr. That location gives good connectivity to both North America and Europe, which helps message loading and real-time delivery.
|
||||
|
||||
I'm paying attention to the global legal and data issues. The US CLOUD Act and questions about where data lives are real. Longer term, federation makes it possible to separate accounts and communities by region, including EU-only hosting, and moving more systems to Europe remains an option.
|
||||
|
||||
Voice and video are already more distributed. Current RTC regions include Sydney, São Paulo, Santiago, Frankfurt, Stockholm, Warsaw, Madrid, Mumbai, Singapore, Seoul, Johannesburg, Newark, Atlanta, Dallas, Seattle, and Los Angeles.
|
||||
|
||||
### What happens when Google shuts down Tenor?
|
||||
|
||||
Fluxer currently relies on Tenor for GIF search in the app, and Google is shutting down the current Tenor API on 30 June 2026. Fluxer already has KLIPY support in the codebase. KLIPY was built by former Tenor people and is the planned replacement when the current Tenor API stops working.
|
||||
|
||||
GIFs are proxied through Fluxer either way, so the privacy promise stays the same: providers don't see your IP address just because you searched for or viewed a GIF through the app.
|
||||
|
||||
### End-to-end encryption?
|
||||
|
||||
Matrix does offer E2EE, but the complexity of the protocol and its client implementations often comes at the expense of what people actually want.
|
||||
|
||||
> [Why We Abandoned Matrix (2024) | Hacker News](https://news.ycombinator.com/item?id=46376201)
|
||||
>
|
||||
> Source: Hacker News
|
||||
|
||||
Most people want a Discord alternative they can use day to day: fast clients, search, profiles and statuses, custom emoji, roles and permissions, voice and video, and moderation tools. Fluxer's priorities are there first. Adding E2EE to text messaging makes the app much harder to build and maintain, and Fluxer is focusing first on a stable community chat app that covers the basics people expect.
|
||||
|
||||
Text on Fluxer has no end-to-end encryption today. Optional E2EE is still planned where it fits: personal notes, calendar data, DMs, and small groups. E2EE for large communities is out of scope.
|
||||
|
||||
Voice is further along. Canary already uses LiveKit's built-in E2EE support for voice and video in testing communities that have it turned on. It rolls out to everyone soon, then becomes required as supported clients catch up.
|
||||
|
||||
### Native mobile app?
|
||||
|
||||
Yes. The native app is built with Flutter for iOS and Android. As of 15 June 2026, the iOS app is in a limited TestFlight beta with a small group of Plutonium subscribers, since TestFlight slots are capped, and the Android app is available now as an APK from [github.com/fluxerapp/flutter_client](https://github.com/fluxerapp/flutter_client). Both are early alphas, with a public release to follow.
|
||||
|
||||
The Flutter app can also target desktop later, making it an alternative to Electron for low-end devices. For now, iOS and Android come first.
|
||||
|
||||
The canary PWA has improved in the meantime. To use the newest client work before stable, try [canary.fluxer.app/download](https://canary.fluxer.app/download) or [web.canary.fluxer.app](https://web.canary.fluxer.app).
|
||||
|
||||
### Can I help localise Fluxer?
|
||||
|
||||
Yes. Fluxer already supports 34 locales across the app, email templates, marketing site, and similar places:
|
||||
|
||||
- العربية
|
||||
- Български
|
||||
- 简体中文
|
||||
- 繁體中文
|
||||
- Hrvatski
|
||||
- Čeština
|
||||
- Dansk
|
||||
- Nederlands
|
||||
- English (United Kingdom)
|
||||
- English (United States)
|
||||
- Suomi
|
||||
- Français
|
||||
- Deutsch
|
||||
- Ελληνικά
|
||||
- עברית
|
||||
- हिन्दी
|
||||
- Magyar
|
||||
- Bahasa Indonesia
|
||||
- Italiano
|
||||
- 日本語
|
||||
- 한국어
|
||||
- Lietuvių
|
||||
- Norsk
|
||||
- Polski
|
||||
- Português (Brasil)
|
||||
- Română
|
||||
- Русский
|
||||
- Español (Latinoamérica)
|
||||
- Español (España)
|
||||
- Svenska (Sverige)
|
||||
- ไทย
|
||||
- Türkçe
|
||||
- Українська
|
||||
- Tiếng Việt
|
||||
|
||||
Localisation matters. Fluxer shouldn't assume everyone speaks English, and it needs to feel usable internationally whether English is your first language or not.
|
||||
|
||||
Because the app changes quickly and the team is small, many translations are drafted and kept up to date with LLM help. LLM literally means large *language* model, and this is one place the tool fits: it saves humans from starting every locale from a blank page and helps non-English speakers get a better app sooner.
|
||||
|
||||
That first pass only gets Fluxer started. Native speakers catch what a model can miss: tone, terminology, awkward phrasing, and cultural details. People have told me the current localisation is already usable in many languages, but I still want native speakers involved before treating it as something people can rely on.
|
||||
|
||||
Localisation is moving into a self-hosted Weblate instance so the work can happen in the open. To improve an existing locale or add a new one, email [[email protected]](mailto:[email protected]).
|
||||
|
||||
### Do you have a Contributor Licence Agreement?
|
||||
|
||||
No. Fluxer had a CLA early on, mostly because the expected path at the time looked more like self-hosting support and companies running Fluxer themselves. A CLA would have made it easier to offer a separate commercial licence to organisations whose policies prohibit AGPLv3 software.
|
||||
|
||||
After Fluxer took off as a hosted app for regular users, that deal stopped making sense. The CLA is gone now that pull requests are open and the public repo has caught up.
|
||||
|
||||
### Where's the roadmap?
|
||||
|
||||
> [Roadmap 2026](/blog/roadmap-2026)
|
||||
>
|
||||
> The current 2026 roadmap for Fluxer: canary, mobile, self-hosting, localisation, federation, voice and video, and the backend reliability work behind it.
|
||||
>
|
||||
> Source: Fluxer Blog / Hampus Kraft
|
||||
|
||||
## Closing thoughts
|
||||
|
||||
The best ways to help are using Fluxer, buying Plutonium if the hosted instance works for you, donating to support the open source work directly, reporting bugs well, and giving feedback in the community.
|
||||
|
||||
Fluxer should stay independent and bootstrapped, with the hosted instance funding the systems and people needed to keep the open source app moving. Self-hosters should benefit from that work too.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]) if you're a content creator, run an open source project, manage a community of any size, or can help with CDN, trust and safety, or moderation work. Fellow independent, bootstrapped, privacy-first alternatives and press inquiries are welcome too.
|
||||
|
||||
Fluxer should become a real alternative to Discord without losing why it was started.
|
||||
|
||||
See you in the Fluxerverse!
|
||||
|
||||
<figure class="blog-media blog-media--gif">
|
||||
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-delorean-poster.jpg">
|
||||
<source src="/blog/assets/tenor-delorean.webm" type="video/webm" />
|
||||
<source src="/blog/assets/tenor-delorean.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption>A DeLorean time machine lifts off, heading for new adventures.</figcaption>
|
||||
</figure>
|
||||
@@ -1,181 +0,0 @@
|
||||
---
|
||||
title: "Mobile clients and Fluxer v2"
|
||||
slug: "mobile-clients-and-fluxer-v2"
|
||||
description: "Fluxer v2 is out, mobile clients are open source, self-hosting is improving, and public development is moving back to GitHub."
|
||||
author: "Hampus Kraft"
|
||||
published_at: "2026-06-15T12:00:00Z"
|
||||
updated_at: "2026-06-15T12:00:00Z"
|
||||
feature_image: "/blog/assets/mobile-clients-and-fluxer-v2-feature-image-1280.jpg"
|
||||
feature_image_alt: "Mobile clients and Fluxer v2"
|
||||
source_url: "https://fluxer.app/blog/mobile-clients-and-fluxer-v2"
|
||||
tags:
|
||||
- "News"
|
||||
---
|
||||
|
||||
Hey Fluxers,
|
||||
|
||||
Hampus here. I know it sounds like a broken record to say the last few months have been intense, but there's no better word for it. We have a lot to cover, and I don't want to go further without saying thank you.
|
||||
|
||||
Fluxer has grown to more than 300,000 users. Today we're launching the mobile apps, big self-hosting and safety improvements, the reopening of pull requests, $1,500 in developer bounties, and a growing list of clients, tools, and bots. Thanks to @Fen and @Xeon, Fluxer is now also on Flathub.
|
||||
|
||||
> [Install Fluxer on Linux with Flathub](https://flathub.org/en/apps/app.fluxer.Fluxer)
|
||||
>
|
||||
> Fluxer is available on Flathub for Linux users, with x86_64 and aarch64 builds.
|
||||
>
|
||||
> Source: Flathub
|
||||
|
||||
We couldn't do this without all of you talking about Fluxer, subscribing to Plutonium, donating, filing bug reports, building things, testing rough edges, and helping us find what needs to be better. Volunteers have collectively put thousands of hours into Fluxer over the last few months. We're grateful to every one of you. Without you, none of this would be possible.
|
||||
|
||||
In the same spirit, we're glad to welcome a few new staff members: @Lilith, @Ferret, and @Stefan. They bring experience across support, safety, engineering, product, and more. Expect to see them around.
|
||||
|
||||
If you're new here, these two posts explain how Fluxer got here and what was next on the roadmap:
|
||||
|
||||
> [How I built Fluxer, a Discord-like chat app](/blog/how-i-built-fluxer-a-discord-like-chat-app)
|
||||
>
|
||||
> The backstory, architecture, and reasoning behind Fluxer.
|
||||
>
|
||||
> Source: Fluxer Blog / Hampus Kraft
|
||||
|
||||
> [Roadmap 2026](/blog/roadmap-2026)
|
||||
>
|
||||
> The current roadmap for mobile, self-hosting, federation, voice and video, and backend reliability.
|
||||
>
|
||||
> Source: Fluxer Blog / Hampus Kraft
|
||||
|
||||
Before the announcements, one quick note: the [self-hosting get-started guide](https://docs.fluxer.app/operator/get-started/) is live. The rest of the self-hosting documentation is still being finalised, and the API documentation is being cleaned up too. We don't expect either to take more than a few days longer.
|
||||
|
||||
With that said, we promised one launch: an open source mobile app. We're also finally announcing the long-awaited Fluxer v2.
|
||||
|
||||
## Fluxer v2
|
||||
|
||||
It's here. Fluxer v2.
|
||||
|
||||
<figure class="blog-media blog-media--gif">
|
||||
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-cable-guy-well-look-who-decided-to-show-poster.jpg">
|
||||
<source src="/blog/assets/tenor-cable-guy-well-look-who-decided-to-show.webm" type="video/webm" />
|
||||
<source src="/blog/assets/tenor-cable-guy-well-look-who-decided-to-show.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption>Jim Carrey as Chip Douglas in The Cable Guy: "Well, look who decided to show!"</figcaption>
|
||||
</figure>
|
||||
|
||||
Fluxer v2 is what we've been calling a large refactor and a set of infrastructure stability improvements. Most of the app stayed intact. Fluxer is still very much a polyglot codebase in TypeScript, Erlang, and Rust, using each one for the parts it is good at. We worked on it privately for the last two months because the code was moving fast enough that pushing broken builds to everyone would have helped nobody. The refactor includes a lot:
|
||||
|
||||
- Self-hosting documentation and prebuilt images.
|
||||
- A cleaner split between Fluxer.app deployment settings and self-hosted settings.
|
||||
- The Erlang Gateway role split for websocket connections, sessions, guilds, presence, calls, and push notifications.
|
||||
- Rust services for hot paths like users, messages, unfurling, media, and Snowflake IDs.
|
||||
- Better tools for operators and safety work around spam, illegal content, and instance moderation.
|
||||
- The codebase back in a shape where public issues and pull requests can move again.
|
||||
|
||||
And a lot more.
|
||||
|
||||
> [Fluxer on GitHub](https://github.com/fluxerapp/fluxer)
|
||||
>
|
||||
> The main Fluxer repository now has the v2 refactor, public issues, pull requests, self-hosting work, and bounties.
|
||||
>
|
||||
> Source: GitHub / fluxerapp
|
||||
|
||||
Now that the v2 refactor is out, we can keep GitHub in sync with active development, respond to issues, and review pull requests again. Thank you for your patience and kindness as we worked to reach this milestone, and thank you to the dozens of testers who helped get it launched.
|
||||
|
||||
## Mobile app open source
|
||||
|
||||
For the last six weeks, we've been alpha testing the iOS and Android apps. They're built with Flutter, which isn't a typo, and it has made the apps fast and smooth. Early adopters, the Visionaries, have been daily driving them, giving feedback, and finding bugs while @M0N7Y5 and @Elias have been hard at work getting the clients closer to desktop.
|
||||
|
||||
They aren't fully there yet, but we think the native apps are ready for Plutonium subscribers on iOS and for open testing on Android. As of 15 June 2026, you can find the mobile client on GitHub.
|
||||
|
||||
> [Fluxer mobile client on GitHub](https://github.com/fluxerapp/flutter_client)
|
||||
>
|
||||
> The open source Flutter client for iOS and Android.
|
||||
>
|
||||
> Source: GitHub / fluxerapp
|
||||
|
||||
We'll release prebuilt versions on F-Droid, Obtainium, Google Play, and the Apple App Store in the coming weeks. Accrescent will come as soon as they open up again.
|
||||
|
||||
The native client has about 50% of what we'd consider ready for a stable launch. Expect bugs. For now, we'd rather send new non-technical users to the Canary mobile PWA at [web.canary.fluxer.app](https://web.canary.fluxer.app), which has also improved a lot, than have them bounce off a half-finished native app.
|
||||
|
||||
> [Download Fluxer Canary](https://canary.fluxer.app/download)
|
||||
>
|
||||
> The Canary mobile PWA remains the most complete phone experience today, while the native apps continue through alpha testing.
|
||||
>
|
||||
> Source: Fluxer
|
||||
|
||||
If you're on iOS and don't have Plutonium, please hold on a little longer and be careful with prebuilt apps from other sources claiming to be the official Fluxer client.
|
||||
|
||||
## Safety, operations, and bounties
|
||||
|
||||
We've been hard at work behind the scenes on instance moderation and operations. We've dealt with spam attacks, CSAM, extremist content, and other abuse while continuing to improve reliability and uptime.
|
||||
|
||||
Many of those improvements are already part of the Fluxer v2 refactor, and more are coming soon. Check GitHub for the public roadmap, discussion boards, and feature requests.
|
||||
|
||||
> [Fluxer Discussions](https://github.com/fluxerapp/fluxer/discussions)
|
||||
>
|
||||
> Roadmap discussions, feature proposals, and design conversations for Fluxer.
|
||||
>
|
||||
> Source: GitHub / fluxerapp
|
||||
|
||||
Several upcoming features also have bounties on GitHub. The bounties are tagged by priority as low, medium, high, and urgent, with different payments attached. Please read the contribution guidelines before getting started.
|
||||
|
||||
One thing I'm especially excited to keep testing in the open is the new native A/V architecture I've been working on. The goal is screen sharing with audio at 1440p 60fps without audio or video frame drops, built through a lot of optimization and native platform work. That code is on GitHub now, so if you want to help fix bugs before Canary reaches stable, you can.
|
||||
|
||||
## Questions we know people have
|
||||
|
||||
We want to answer some of the obvious ones right away.
|
||||
|
||||
### What about Partnered and Verified Communities?
|
||||
|
||||
We've been working on updated requirements, and the plan is to review applications within the next month. After that, reviews will happen every so often, so expect some time between additions. Keep an eye out for responses or follow-up questions from the team.
|
||||
|
||||
### Why did GitHub lag behind internal development?
|
||||
|
||||
GitHub lagged because we made a call: the public code needed to be useful, not just current. Fluxer needed a cleaner foundation before it made sense to invite people back into issues, pull requests, and self-hosting.
|
||||
|
||||
Most of Fluxer was not rewritten. The v2 refactor was about separating what needed to move, cleaning up infrastructure, and making the codebase easier to run, self-host, and contribute to. Doing that privately made the public repo harder to follow, and I understand why that frustrated people.
|
||||
|
||||
At the same time, we were a small team carrying too much at once: backend, frontend, operations, moderation, mobile, and the v2 refactor. Native screen sharing, audio capture, and platform integration also came with a real learning curve for a mostly solo developer suddenly facing a lot of new technical requirements from a user base that needed more. I'm glad to be close to delivering that when Canary reaches stable.
|
||||
|
||||
Fluxer was also dealing with attacks involving CSAM and gore content across public communities. The new safety systems have put us in a much better place, but getting there was emotionally exhausting. I could not in good conscience push half-finished moderation tools, policy thresholds, or infrastructure changes to GitHub while that work was still tangled together.
|
||||
|
||||
So we finished the v2 foundation first. With the v2 refactor out, the safety work split out, and a roadmap in place, GitHub can be useful again. Issues, pull requests, self-hosting, and migration work can move in the open.
|
||||
|
||||
We're happy to be back there. Seriously, nobody is happier than we are to have this back in the open. Please submit issues and PRs. Pretty please :)
|
||||
|
||||
### Why is federation taking so long?
|
||||
|
||||
We're working on it, and we need your input. Seriously, come talk through the design standard with the Fluxer team and developer community on GitHub and in Fluxer Developers. Federation matters to me and the entire team, and we want to do it right. A lot of questions are still unanswered:
|
||||
|
||||
- What happens when a user keeps returning to one Fluxer instance through another instance?
|
||||
- How does Fluxer prevent illegal content like CSAM from propagating across instances?
|
||||
- In DMs, which instance is responsible for moderation, if any?
|
||||
- How do users and communities migrate between instances?
|
||||
- How should Fluxer handle Plutonium?
|
||||
- Why is Hampus a ghost?
|
||||
|
||||
And dozens more.
|
||||
|
||||
We'd really like to avoid the email problem, where some instances or clients never support newer standards, by being careful with the design upfront. Some split between instances is inevitable, but Fluxer shouldn't start there through rushed implementation.
|
||||
|
||||
We want Fluxer to be the home for your community for years to come, no matter who hosts it. That means putting in the thought and building slowly.
|
||||
|
||||
### I'm worried about Fluxer disappearing!
|
||||
|
||||
Fluxer has a way forward, but we still need the community around it. Costs grow quickly with age and a larger user base, and the best version of Fluxer is built with people using it, testing it, supporting it, and telling us what needs work. Here's how you can help:
|
||||
|
||||
- Subscribe to [Plutonium](/plutonium). Plutonium supports Fluxer while giving you fun perks. It's the best way to contribute to the hosted instance.
|
||||
- [Donate to Fluxer](/donate).
|
||||
- File bug reports.
|
||||
- Talk about feature requests, federation, and other parts of Fluxer in GitHub Discussions and the Fluxer Developers community.
|
||||
- Invite your friends to hang out on Fluxer. We love company.
|
||||
|
||||
And thankfully, even if Fluxer were to close its hosted instance in the future, the point is that you can always run an instance on your own hardware. Fluxer is open source, now and forever.
|
||||
|
||||
### Why should my community use Fluxer over other platforms?
|
||||
|
||||
You should use what gives you joy.
|
||||
|
||||
For us, that's the promise of being able to own your communications and still connect with others. If your community finds joy on other platforms, use them. But give Fluxer a try. You may find that little spark of magic here.
|
||||
|
||||
Communication belongs to people, and people create communities. Building so much at once has been a lot, and honestly, I feel like I need a vacation at this point.
|
||||
|
||||
More than that, though, we're excited to at long last share everything with the developer community and keep building in the open. That's what we wanted all along: for Fluxer to be a home for open, free communities, no matter who hosts them.
|
||||
|
||||
Thank you all for reading, and let's make 2026 the year of ~~Linux~~ Fluxer.
|
||||
@@ -1,234 +0,0 @@
|
||||
---
|
||||
title: "Roadmap 2026"
|
||||
slug: "roadmap-2026"
|
||||
description: "The current 2026 roadmap for Fluxer: canary, mobile, self-hosting, localisation, federation, voice and video, and the backend reliability work behind it."
|
||||
author: "Hampus Kraft"
|
||||
published_at: "2026-01-26T12:49:48Z"
|
||||
updated_at: "2026-05-24T05:00:00Z"
|
||||
feature_image: "/blog/assets/roadmap-2026-feature-image-1280.jpg"
|
||||
feature_image_alt: "Roadmap 2026"
|
||||
source_url: "https://fluxer.app/blog/roadmap-2026"
|
||||
tags:
|
||||
- "News"
|
||||
---
|
||||
|
||||
> [Discord will require a face scan or ID for full access next month](https://www.theverge.com/tech/875309/discord-age-verification-global-roll-out)
|
||||
>
|
||||
> Age verification for all.
|
||||
>
|
||||
> Source: The Verge / Stevie Bonifield
|
||||
|
||||
Fluxer is still in public beta, but it's grown a lot since January. The hosted instance had to grow quickly after Discord's age-verification announcement. The next stretch is about reliability and keeping the app running: fewer client bugs, self-hosting that's easier to set up, stronger moderation tools, fewer incidents, and then the new features people are waiting for. The hosted service pays for that work and tests it under real traffic, and new work should happen in the open, with proper tools for people running their own instances.
|
||||
|
||||
I've also updated the longer post that explains how Fluxer got here, the architecture, and the reasoning behind it:
|
||||
|
||||
> [How I built Fluxer, a Discord-like chat app](/blog/how-i-built-fluxer-a-discord-like-chat-app)
|
||||
>
|
||||
> Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
>
|
||||
> Source: Fluxer Blog / Hampus Kraft
|
||||
|
||||
## #1: Canary to stable, and native mobile
|
||||
|
||||
The canary client is the newest way to use Fluxer. It's past build v0.0.200, compared with v0.0.8 for the original stable desktop client, and includes hundreds of fixes plus big voice and video work: screen sharing with audio, text in voice, better DM calls, a stronger device selector, more mic processing controls, DeepFilterNet3 noise suppression, and LiveKit-backed E2EE for voice and video in testing communities that have it turned on.
|
||||
|
||||
Try it today through the canary desktop build at [canary.fluxer.app/download](https://canary.fluxer.app/download), or in the browser at [web.canary.fluxer.app](https://web.canary.fluxer.app). Canary moves to stable once the last serious bugs are fixed. Voice and video E2EE rolls out more broadly soon, then becomes required as supported clients catch up.
|
||||
|
||||
The launch PWA worked in the browser and as an installable app from day one, and it keeps improving while the native app moves through testing.
|
||||
|
||||
Native mobile is happening at the same time, built with Flutter. As of 15 June 2026, the iOS app is in a limited TestFlight beta with a small group of Plutonium subscribers, since TestFlight slots are capped, and the Android app is available now as an APK from [the open source repo](https://github.com/fluxerapp/flutter_client). Both mobile apps are early alphas, so expect missing features and bugs while they move towards a public release.
|
||||
|
||||
Flutter also gives Fluxer a desktop path later. Mobile comes first, but Flutter can become an alternative to Electron for low-end devices.
|
||||
|
||||
## #2: Self-hosting and the backend cleanup
|
||||
|
||||
Self-hosting work is back in the public repository after the 15 June 2026 sync. The goal is Docker images, operator-focused documentation, and a web setup wizard that walks you through configuring an instance. A typical self-hosted instance only needs the app services, a database, Valkey, and NATS.
|
||||
|
||||
Docs and operator tooling are now public work; the remaining gaps can be fixed as people try running real instances.
|
||||
|
||||
Fluxer.app is much larger than a normal self-hosted instance will ever need to be. It runs the TypeScript API and Worker, the clustered Erlang Gateway, the Rust Media Proxy, NATS, and newer Rust services for users, messages, search, unfurling, and Snowflake IDs. People running their own instances should get the benefit of that work without copying the hosted setup: Fluxer.app stress-tests the hard parts, and instance operators get the simpler shape.
|
||||
|
||||
Gateway clustering is a recent example. Fluxer started with one Gateway node because the original design was built for a much smaller user base. After the growth spike, that became a reliability problem. I rolled out Erlang Gateway clustering, then split the gateway into specialised tiers for websocket connections, sessions, guilds, presence, calls, and push notifications. A problem in one stateful tier is now contained instead of taking down every connected user, and each tier scales on its own.
|
||||
|
||||
> [Scheduled maintenance - Incident details - Fluxer - Status](https://fluxerstatus.com/cmpiwlw5e057vpbi74zh7ohmh)
|
||||
>
|
||||
> Scheduled maintenance window on 24 May 2026 for the Gateway role-split deployment and associated reliability improvements.
|
||||
>
|
||||
> Source: Fluxer Status
|
||||
|
||||
The Rust services are part of the same reliability work. They move hot read paths and ID generation out of the main API where that helps, while the Gateway keeps presence, member lists, and real-time routing in Erlang.
|
||||
|
||||
The Electron desktop path starts with custom backends through in-app account switching in the regular client, so you can point it at your own instance without waiting for full federation.
|
||||
|
||||
The Operator Pass is planned for when the docs and setup guides are solid. It's a $199 or €199 one-time purchase for self-hosters who want to support the open source work and join the Operators community: a smaller place to get help from other self-hosters and the Fluxer team, share ideas, and make feedback heard before it gets buried on GitHub.
|
||||
|
||||
The public repository lagged until 15 June 2026 because the growth spike forced urgent anti-abuse and production work while I kept Fluxer.app stable. The sync separated Fluxer.app-only deployment settings from instance settings other people can use, and moved the remaining operator work into the open.
|
||||
|
||||
## #3: Localisation in the open
|
||||
|
||||
Fluxer already supports 34 locales across the app, email templates, marketing site, and similar places:
|
||||
|
||||
- العربية
|
||||
- Български
|
||||
- 简体中文
|
||||
- 繁體中文
|
||||
- Hrvatski
|
||||
- Čeština
|
||||
- Dansk
|
||||
- Nederlands
|
||||
- English (United Kingdom)
|
||||
- English (United States)
|
||||
- Suomi
|
||||
- Français
|
||||
- Deutsch
|
||||
- Ελληνικά
|
||||
- עברית
|
||||
- हिन्दी
|
||||
- Magyar
|
||||
- Bahasa Indonesia
|
||||
- Italiano
|
||||
- 日本語
|
||||
- 한국어
|
||||
- Lietuvių
|
||||
- Norsk
|
||||
- Polski
|
||||
- Português (Brasil)
|
||||
- Română
|
||||
- Русский
|
||||
- Español (Latinoamérica)
|
||||
- Español (España)
|
||||
- Svenska (Sverige)
|
||||
- ไทย
|
||||
- Türkçe
|
||||
- Українська
|
||||
- Tiếng Việt
|
||||
|
||||
Fluxer shouldn't assume everyone speaks English. It needs to feel usable internationally, whether English is your first language or not.
|
||||
|
||||
Because the app changes quickly and the team is small, the first translation pass is drafted with LLMs. LLM literally means large *language* model, and this is one place the tool fits: it saves humans from starting every locale from a blank page. Native speakers then catch what a model can miss: tone, terminology, awkward phrasing, and cultural details. People have told me the current localisation is already good in many languages, but I still want native speakers involved before treating it as something people can rely on.
|
||||
|
||||
Localisation is moving into a self-hosted Weblate instance so the work can happen in the open. To improve an existing locale or add a new one, email [[email protected]](mailto:[email protected]).
|
||||
|
||||
## #4: Federation and multiple backends
|
||||
|
||||
Federation remains part of the plan, and the order matters. People who run and use Matrix complain about specific things: large federated rooms can be slow and expensive to join, presence and device-list updates can create surprising background load, federation failures can leave rooms or encrypted messages half-working, and moderation gets harder when abuse, media, bans, and bridges cross instance boundaries.
|
||||
|
||||
The next client step is simultaneous connections to multiple backends: connect to more than one self-hosted instance, keep separate identities, and see them together without switching between workspaces. That already helps people who use more than one instance before true federation is ready.
|
||||
|
||||
True federation builds on that base later, with OAuth2-based authentication against remote instances and a clearer model for identity, communities, and data.
|
||||
|
||||
## #5: Threads, forums, and publishing to the web
|
||||
|
||||
> [Discord seeks to solve a problem that it created | TechCrunch](https://techcrunch.com/2025/05/23/discord-seeks-to-solve-a-problem-that-it-created/)
|
||||
>
|
||||
> Conversations on Discord can be hard to follow. Discord SVP Peter Sellis proposes making forum-like features, or using AI summaries.
|
||||
>
|
||||
> Source: TechCrunch / Amanda Silberling
|
||||
|
||||
Fluxer's threads and forums should be useful enough that people don't immediately work around them.
|
||||
|
||||
Forum-style spaces will also have an optional public web mode. When a community chooses to publish something, people can read it without logging in, find it through search engines, archive it, and follow it through RSS and Atom feeds.
|
||||
|
||||
> With LLMs, Sellis said, Discord could take a long, meandering conversation and turn it into "something that could be more sharable and syndicated across the web." However, he said that he and his team hadn't "seen a solution that we feel great about yet."
|
||||
|
||||
Fluxer disagrees with Discord's starting point here. LLMs shouldn't turn people's "meandering conversations" into web content. If something becomes public, it should be because a person or community chose to publish it.
|
||||
|
||||
This feature uses the web itself: public pages with stable URLs, server-rendered posts, clear titles, search indexing, pages that can be archived, RSS and Atom feeds, and links people can share anywhere. People's posts remain their posts.
|
||||
|
||||
Publishing stays opt-in, for communities that benefit from public knowledge: open source projects, developer communities, modding groups, creator communities, research groups, and support forums. Private chat stays private.
|
||||
|
||||
## #6: Discovery for communities, instances, and apps
|
||||
|
||||
Fluxer already has basic in-app community discovery per instance. Once self-hosting and public web publishing become normal, it needs to grow.
|
||||
|
||||
Public communities on Fluxer instances should be findable on the web without logging in. The registry is built from pull requests to a public repository, so listings are easy to inspect, review, and update.
|
||||
|
||||
The same directory can later include Fluxer bots and apps. If someone builds a moderation bot, bridge, game, tool, or integration, there should be a public place to find it without relying on word of mouth. The official discovery list still gets reviewed, but the list itself stays public and easy to check.
|
||||
|
||||
## #7: Slash commands, UI kit, and integrations
|
||||
|
||||
Fluxer needs first-class bots and integrations: slash commands, modals, components, interactions, and then the improvements people ask for once they start building on it.
|
||||
|
||||
## #8: Emoji and sticker packs
|
||||
|
||||
Many Discord users know the habit of joining communities just to use their emojis and stickers globally. Fluxer skips that: people will be able to create emoji and sticker packs that other users can equip directly on their account. Free users get an allowance too, and paid tiers can offer higher limits.
|
||||
|
||||
## #9: E2EE where it makes sense
|
||||
|
||||
Matrix does offer E2EE, but the complexity of the protocol and its client implementations often comes at the expense of what people actually want.
|
||||
|
||||
> [Why We Abandoned Matrix (2024) | Hacker News](https://news.ycombinator.com/item?id=46376201)
|
||||
>
|
||||
> Source: Hacker News
|
||||
|
||||
Most people want a Discord alternative they can use day to day: fast clients, search, profiles and statuses, custom emoji, roles and permissions, voice and video, and moderation tools. That's where Fluxer's priorities lie. Adding E2EE to text messaging adds real complexity, especially around search, moderation, history, recovery, and multi-device sync.
|
||||
|
||||
Optional E2EE still belongs in the roadmap for personal notes, calendar data, DMs, and small groups. E2EE for large communities is out of scope.
|
||||
|
||||
Voice and video are a better fit technically, and that work already runs in canary for testing communities that have it turned on. It rolls out to everyone soon, then becomes required as supported clients catch up.
|
||||
|
||||
## #10: Creator payments
|
||||
|
||||
Fluxer can let fans pay to unlock roles and access to a creator's exclusive community content. That access can be permanent or time-limited, billed as a one-off purchase or a recurring subscription. Creators can also sell event tickets for time-boxed sessions, where a ticket or temporary role unlocks specific text and voice channels for the duration of the event.
|
||||
|
||||
On Fluxer.app, the model is simple: creators bring the thing people want to support, Fluxer handles the community space and payments around it, and Fluxer.app takes a small, clear fee (for example, 5 to 10%) that helps pay for hosted systems and the people keeping both Fluxer.app and the open source project moving. Patreon has been adding Discord-style community features, and Discord has experimented with Patreon-style payments, but those efforts have been mixed. If this works, it keeps the free hosted tier generous, funds work that also improves the self-hosted app, and means the hosted instance relies less on Plutonium.
|
||||
|
||||
> [Patreon is adding a Discord-like chat feature for creators and fans](https://www.theverge.com/2023/9/7/23861171/patreon-community-chats-discord-chatroom-member-profile)
|
||||
>
|
||||
> The Discord integration will still be available.
|
||||
>
|
||||
> Source: The Verge / Mia Sato
|
||||
|
||||
If you'd like to use Fluxer as a combined Discord + Patreon-style app, and you already have a large audience you'd bring over, email [[email protected]](mailto:[email protected]). I'm looking for early creators to test paid community features, and Fluxer can offer a lower fee while the model gets tested.
|
||||
|
||||
## And more!
|
||||
|
||||
Polls and scheduled events, profile connections, a theme marketplace, stage channels, activity sharing, streamer mode, DM folders, popping calls out into their own desktop windows, soundboard clips, community templates, public profile URLs, and better tools for instance operators and safety work. Fluxer will keep adding features.
|
||||
|
||||
GIF search has maintenance work coming up. Fluxer currently relies on Tenor, but Google is shutting down the current Tenor API on 30 June 2026. KLIPY support already exists in the codebase and is the planned replacement. GIFs are proxied through Fluxer either way, so providers don't see your IP address just because you searched for or viewed a GIF through the app.
|
||||
|
||||
You can help shape the roadmap by joining the [Fluxer HQ community](https://fluxer.gg/fluxer-hq), submitting issues in [the GitHub repository](https://github.com/fluxerapp/fluxer), or [emailing me](mailto:[email protected]). You can also support Fluxer directly:
|
||||
|
||||
- [Donate any amount](/donate), as an individual or a business.
|
||||
- [Purchase Plutonium](/plutonium) on the hosted Fluxer.app instance.
|
||||
- Spread the word about Fluxer with friends and on social media.
|
||||
|
||||
## But why?
|
||||
|
||||
Why use Fluxer when Discord is free and works well?
|
||||
|
||||
If Discord does what you need, and you're fine relying on a closed, investor-driven app that has reportedly [filed confidential IPO paperwork](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/), then you may not need Fluxer. Fluxer is for people who want an open, self-hostable alternative.
|
||||
|
||||
> [Discord's IPO could happen in March | TechCrunch](https://techcrunch.com/2026/01/07/discords-ipo-could-happen-in-march/)
|
||||
>
|
||||
> Discord reportedly filed confidential IPO paperwork and has pinned its hopes on a debut in March.
|
||||
>
|
||||
> Source: TechCrunch / Julie Bort
|
||||
|
||||
Fluxer is for people who want a different model: free, open source, self-hostable software, with an optional hosted instance run by an independent European company that helps pay for the open source work. You shouldn't have to give up the basics you like just to leave Discord: Fluxer keeps the familiar shape of modern community chat while making the software open and self-hostable.
|
||||
|
||||
Fluxer can succeed without Discord getting worse. Discord's network effect is hard to beat head-on, so I'm starting where switching already makes sense: technical users and communities that value control and openness, and want software they can run on their own terms. You don't need to be technical for Fluxer to be yours: many people prefer a European-owned instance that has clearer reasons to treat users well.
|
||||
|
||||
Fluxer is free, open source, and self-hostable. The public code should be useful to people who run their own instance, not only to Fluxer.app. No community should have to depend on one company surviving. Fluxer.app should earn money by being a hosted service, then put that money back into hosting, documentation, review time, and development work that self-hosters benefit from too. Plutonium, donations, creator payments, and the optional Operator Pass all fit that model.
|
||||
|
||||
If the hosted free tier limits are too tight, you can run your own instance. Fluxer will keep the software free of feature paywalls, licence key checks, upgrade-for-quota gates, and [SSO tax](https://sso.tax/).
|
||||
|
||||
## Closing thoughts
|
||||
|
||||
The best ways to help are using Fluxer, buying Plutonium if the hosted instance works for you, donating to support the open source work directly, reporting bugs well, and giving feedback in the community.
|
||||
|
||||
Fluxer should stay independent and bootstrapped, with the hosted instance funding the systems and people needed to keep the open source app moving. Self-hosters should benefit from that work too.
|
||||
|
||||
Reach me at [[email protected]](mailto:[email protected]) if you're a content creator, run an open source project, manage a community of any size, or can help with CDN, trust and safety, or moderation work. Fellow independent, bootstrapped, privacy-first alternatives and press inquiries are welcome too.
|
||||
|
||||
Fluxer should become a real alternative to Discord without losing why it was started.
|
||||
|
||||
See you in the Fluxerverse!
|
||||
|
||||
<figure class="blog-media blog-media--gif">
|
||||
<video class="blog-embed-video" autoplay loop muted playsinline preload="metadata" poster="/blog/assets/tenor-delorean-poster.jpg">
|
||||
<source src="/blog/assets/tenor-delorean.webm" type="video/webm" />
|
||||
<source src="/blog/assets/tenor-delorean.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<figcaption>A DeLorean time machine lifts off, heading for new adventures.</figcaption>
|
||||
</figure>
|
||||
@@ -1,25 +0,0 @@
|
||||
You cannot change your date of birth from within the app. If yours needs correcting, our support team can update it for you.
|
||||
|
||||
This is one of the few times we ask for a government-issued ID. Fluxer does not require ID uploads or biometric scans for general access; see [minimum age requirements](/help/minimum-age) for the other case, which is age-related account appeals.
|
||||
|
||||
## What you will need
|
||||
|
||||
- A clear photo or scan of a valid government-issued ID
|
||||
- The correct date of birth
|
||||
- A short explanation of why the change is needed, such as a typo during sign-up
|
||||
|
||||
We only need to see your name and date of birth on the ID. Feel free to cover up the document number, photo, address, and anything else.
|
||||
|
||||
A note on image metadata: photos taken with a phone often carry hidden EXIF data such as GPS location and device identifiers. If you would rather not share that, take a screenshot of the photo (which strips most metadata) or use a metadata-stripping tool before sending.
|
||||
|
||||
## How to request a change
|
||||
|
||||
Email [[email protected]](mailto:[email protected]) from the address linked to your Fluxer account. Include the correct date of birth, a brief reason for the correction, and the redacted ID image.
|
||||
|
||||
## How we handle the ID
|
||||
|
||||
The image is used only to confirm your date of birth. Authorised support staff can access it, every access is logged, and the image is deleted within 60 days after your request is closed, regardless of the outcome. It is never used to train any system, shared with third parties, or attached to your account profile.
|
||||
|
||||
## Things to know
|
||||
|
||||
For security reasons we usually allow only one date of birth correction per account. If the new date of birth changes your access to age-restricted features, your account is updated to match. See [section 7 of our Privacy Policy](/privacy) for the underlying retention rules.
|
||||
@@ -1,21 +0,0 @@
|
||||
You can delete your messages and other content at any time through the Privacy Dashboard, or by contacting our privacy team directly.
|
||||
|
||||
## Delete all messages
|
||||
|
||||
1. Sign in at [web.fluxer.app/login](https://web.fluxer.app/login).
|
||||
2. Open Settings (the cogwheel at the bottom left).
|
||||
3. Go to Privacy Dashboard.
|
||||
4. Select the Data Deletion tab.
|
||||
5. Click Delete all my messages.
|
||||
|
||||
Bulk deletion runs in the background; how long it takes depends on how many messages you have sent. Messages inside a Community or channel that is already pending deletion are skipped during bulk delete; they will be removed when the Community or channel is permanently deleted after its 14-day grace period.
|
||||
|
||||
Deleted messages leave active systems within minutes. They may persist in our encrypted backups for up to about 30 days before being permanently removed. See [section 7.4 of our Privacy Policy](/privacy) and the [data retention article](/help/data-retention) for the full picture.
|
||||
|
||||
## Delete specific data
|
||||
|
||||
Email [[email protected]](mailto:[email protected]) from your account's registered address, with details of what you would like removed.
|
||||
|
||||
## Before you delete
|
||||
|
||||
Deleting a message also deletes its attachments. If you want to keep them, [export your data](/help/data-export) first. You cannot delete messages once your account has been removed, so do this before starting [account deletion](/help/delete-account), or choose the message-deletion option during account deletion. Attachments also expire based on size regardless of deletion; see [how attachment expiry works](/help/attachment-expiry).
|
||||
@@ -1,29 +0,0 @@
|
||||
You can request a complete export of your account data, including every message you have sent and URLs for downloading your attachments.
|
||||
|
||||
## How to request an export
|
||||
|
||||
1. Sign in at [web.fluxer.app/login](https://web.fluxer.app/login).
|
||||
2. Open Settings (the cogwheel at the bottom left).
|
||||
3. Go to Privacy Dashboard.
|
||||
4. Select the Data Export tab.
|
||||
5. Click Request Data Export.
|
||||
|
||||
You can request an export once every 7 days.
|
||||
|
||||
## What is included
|
||||
|
||||
The export is a ZIP archive of machine-readable JSON files covering your account information, per-channel message history, payment history, and security data, along with profile assets if there are any. Attachments are not bundled; the export includes CDN URLs for downloading them while they remain available.
|
||||
|
||||
Messages inside a Community or channel that is already pending deletion are excluded from the export, as are report snapshots and other internal records that are not your personal account data.
|
||||
|
||||
## Receiving your export
|
||||
|
||||
When your export is ready, you will receive an email with a download link to a ZIP file. The link expires after 7 days.
|
||||
|
||||
Treat the link as sensitive: anyone who has it can download the export until it expires. Do not forward the email, paste the link in chat, or share it in a tool that may log or archive URLs. If you think a link has been exposed, request a fresh export. The old link cannot be revoked individually, but it will expire on schedule.
|
||||
|
||||
Once downloaded, the ZIP is yours. Store it somewhere encrypted (your operating system's user folder behind a password, a personal password manager, or full-disk encryption) and delete it when you no longer need it.
|
||||
|
||||
## Before you delete anything
|
||||
|
||||
The export includes URLs for downloading your attachments, but deleting a message also deletes its attachments. Download anything you want to keep before deleting messages or [deleting your account](/help/delete-account). Attachments also expire based on size, so see [how attachment expiry works](/help/attachment-expiry).
|
||||
@@ -1,33 +0,0 @@
|
||||
You can disable or delete your account from your account settings.
|
||||
|
||||
## How to find these options
|
||||
|
||||
1. Sign in at [web.fluxer.app/login](https://web.fluxer.app/login).
|
||||
2. Open Settings (the cogwheel at the bottom left).
|
||||
3. Go to Security & Login.
|
||||
4. Choose Delete account or Disable account.
|
||||
|
||||
## Deleting your account
|
||||
|
||||
Choosing delete schedules your account for permanent removal in 14 days. Signing in at any point during that window cancels the deletion. After 14 days, identifying information is removed from active systems and the rest is anonymised. Encrypted backups roll over on a cycle of up to about 30 days, after which the data is gone from there too. Records we are legally required to keep (such as payment records under Swedish bookkeeping law) are retained for the period the law requires. The full breakdown is in [section 7 of our Privacy Policy](/privacy) and the [data retention article](/help/data-retention).
|
||||
|
||||
### Messages
|
||||
|
||||
Your messages stay on Fluxer unless you remove them first or choose the message-deletion option during account deletion. Other people you talked to can still see past messages to them, though they are no longer linked to your account.
|
||||
|
||||
If you want to clear them out before deleting:
|
||||
|
||||
- Use the message-deletion option in the account deletion flow, if it is shown.
|
||||
- Bulk delete from the Privacy Dashboard. See [requesting data deletion](/help/data-deletion).
|
||||
- Export your data first if you want a copy. See [exporting your account data](/help/data-export).
|
||||
- Email [[email protected]](mailto:[email protected]) from your registered address to request deletion of specific data.
|
||||
|
||||
You cannot delete messages once your account is gone, so do this before the deletion finishes.
|
||||
|
||||
## Disabling your account
|
||||
|
||||
Disabling signs you out of every device. The account stays in our systems exactly as it was; signing in at any time turns it back on. No data is removed.
|
||||
|
||||
## Inactive accounts
|
||||
|
||||
Accounts that go unused for two years may be scheduled for deletion. Before any deletion proceeds, we send advance notice to the registered email address so you can sign in and keep the account active if you want to.
|
||||
@@ -1,71 +0,0 @@
|
||||
You must meet the minimum age requirement for your country to create and use a Fluxer account. In most countries this is 13, but some jurisdictions set a higher minimum.
|
||||
|
||||
## Minimum age by country
|
||||
|
||||
A handful of countries set a minimum age higher than the default of 13. All other countries use 13.
|
||||
|
||||
- Aruba: 16
|
||||
- Austria: 14
|
||||
- Bulgaria: 14
|
||||
- Caribbean Netherlands: 16
|
||||
- Chile: 14
|
||||
- Colombia: 14
|
||||
- Croatia: 16
|
||||
- Curaçao: 16
|
||||
- Cyprus: 14
|
||||
- Czech Republic: 15
|
||||
- France: 15
|
||||
- Germany: 16
|
||||
- Greece: 15
|
||||
- Hungary: 16
|
||||
- Ireland: 16
|
||||
- Italy: 14
|
||||
- Lithuania: 14
|
||||
- Luxembourg: 16
|
||||
- Netherlands: 16
|
||||
- Peru: 14
|
||||
- Poland: 16
|
||||
- Romania: 16
|
||||
- San Marino: 16
|
||||
- Serbia: 15
|
||||
- Sint Maarten: 16
|
||||
- Slovakia: 16
|
||||
- Slovenia: 16
|
||||
- South Korea: 14
|
||||
- Spain: 14
|
||||
- Venezuela: 14
|
||||
- Vietnam: 15
|
||||
|
||||
## Age-restricted content
|
||||
|
||||
Regardless of the minimum age to use Fluxer in your country, you must be 18 or older to access age-restricted content. This covers NSFW channels inside Communities, Communities marked as age-restricted, and media flagged as containing explicit content by our automated classifier (see [section 5.1 of our Privacy Policy](/privacy) for how that classifier works).
|
||||
|
||||
Your basic access is set by the date of birth you provide when you register. In some regions, age-restricted content may also require an extra regional adult verification step. For current region-specific requirements and methods, see our [regional restrictions](/help/regional-restrictions) page.
|
||||
|
||||
## How your age is determined
|
||||
|
||||
When you create an account, you provide your date of birth. That self-declaration is all we ask for: no ID uploads, no biometric scans. We use IP geolocation to determine your approximate country and apply the corresponding minimum age requirement. If your date of birth does not meet the minimum for your detected country, registration is not permitted.
|
||||
|
||||
For details on how we use IP geolocation, see [section 3.2 of our Privacy Policy](/privacy).
|
||||
|
||||
## Reporting underage users
|
||||
|
||||
If you believe an account belongs to someone under the minimum age, you can report it. Our team reviews these reports, and if we have reason to believe the account holder does not meet the requirement, the account may be suspended.
|
||||
|
||||
Reports are confidential. The reported user receives a statement of reasons explaining the action and the rule applied, but we do not share your username, user ID, email, or any other identifying detail with them. See [Reporting violations](/guidelines) in our Community Guidelines for the full position.
|
||||
|
||||
## Appealing a suspension
|
||||
|
||||
If your account is suspended because it is suspected of belonging to someone under the minimum age, you can appeal by contacting [[email protected]](mailto:[email protected]) from the address linked to your account.
|
||||
|
||||
As part of the appeal, you may be asked to submit a government-issued identification document to verify your age. ID requests are limited to two narrow situations: this age appeal, and the rare case of correcting a date of birth on an existing account (see [change your date of birth](/help/change-date-of-birth)). We do not require ID uploads or biometric scans for general access. Any document you submit is used only to verify your age. Authorised staff can access it, each access is logged, and it is deleted within 60 days after the appeal closes, regardless of the outcome.
|
||||
|
||||
## Accounts that do not meet the minimum age
|
||||
|
||||
If Fluxer becomes aware that an account belongs to someone who does not meet the minimum age requirement in their country, we take steps to delete the account and the associated personal data. If you are a parent or legal guardian and believe your child has created an account without your consent or does not meet the minimum age, please contact [[email protected]](mailto:[email protected]) from the child's registered email address, or with enough proof that you are their parent or guardian.
|
||||
|
||||
For more information about how we handle children's data, see [section 11 of our Privacy Policy](/privacy).
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about minimum age requirements, write to [[email protected]](mailto:[email protected]). For privacy-related questions, see our [Privacy Policy](/privacy).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user