initial commit
This commit is contained in:
54
.dockerignore
Normal file
54
.dockerignore
Normal file
@@ -0,0 +1,54 @@
|
||||
# Generated artifacts and caches
|
||||
**/_build
|
||||
**/_checkouts
|
||||
**/_vendor
|
||||
**/build
|
||||
**/coverage
|
||||
**/dist
|
||||
**/generated
|
||||
**/.cache
|
||||
**/.pnpm-store
|
||||
**/target
|
||||
**/certificates
|
||||
**/node_modules
|
||||
|
||||
# Tooling & editor metadata
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.git
|
||||
**/.astro/
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
**/.dev.vars
|
||||
|
||||
# Logs & temporary files
|
||||
**/*.dump
|
||||
**/*.lock
|
||||
**/*.log
|
||||
**/*.tmp
|
||||
**/*.swo
|
||||
**/*.swp
|
||||
**/*~
|
||||
**/log
|
||||
**/logs
|
||||
**/npm-debug.log*
|
||||
**/pnpm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/rebar3.crashdump
|
||||
**/erl_crash.dump
|
||||
|
||||
# Runtime config
|
||||
dev
|
||||
**/.rebar
|
||||
**/.rebar3
|
||||
|
||||
/fluxer_app/src/data/emojis.json
|
||||
/fluxer_app/src/locales/*/messages.js
|
||||
!fluxer_app/dist
|
||||
!fluxer_app/dist/**
|
||||
!fluxer_devops/cassandra/migrations
|
||||
!scripts/cassandra-migrate/Cargo.lock
|
||||
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
max_line_length = 120
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
|
||||
[*.{yml,yaml,Dockerfile}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[justfile]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
* text=auto
|
||||
fluxer_static/** filter=lfs diff=lfs merge=lfs -text
|
||||
613
.github/workflows/build-desktop-canary.yaml
vendored
Normal file
613
.github/workflows/build-desktop-canary.yaml
vendored
Normal file
@@ -0,0 +1,613 @@
|
||||
name: build desktop (canary)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref to build (branch, tag, or commit SHA)
|
||||
required: false
|
||||
default: canary
|
||||
type: string
|
||||
skip_windows:
|
||||
description: Skip Windows builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_macos:
|
||||
description: Skip macOS builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_linux:
|
||||
description: Skip Linux builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_windows_x64:
|
||||
description: Skip Windows x64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_windows_arm64:
|
||||
description: Skip Windows ARM64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_macos_x64:
|
||||
description: Skip macOS x64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_macos_arm64:
|
||||
description: Skip macOS ARM64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_linux_x64:
|
||||
description: Skip Linux x64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_linux_arm64:
|
||||
description: Skip Linux ARM64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: desktop-canary-${{ inputs.ref || 'canary' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CHANNEL: canary
|
||||
SOURCE_REF: ${{ inputs.ref || 'canary' }}
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
name: Resolve build matrix
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Build platform matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
PLATFORMS='[
|
||||
{"platform":"windows","arch":"x64","os":"windows-latest","electron_arch":"x64"},
|
||||
{"platform":"windows","arch":"arm64","os":"windows-11-arm","electron_arch":"arm64"},
|
||||
{"platform":"macos","arch":"x64","os":"macos-15-intel","electron_arch":"x64"},
|
||||
{"platform":"macos","arch":"arm64","os":"macos-15","electron_arch":"arm64"},
|
||||
{"platform":"linux","arch":"x64","os":"ubuntu-24.04","electron_arch":"x64"},
|
||||
{"platform":"linux","arch":"arm64","os":"ubuntu-24.04-arm","electron_arch":"arm64"}
|
||||
]'
|
||||
|
||||
FILTERED="$(echo "$PLATFORMS" | jq -c \
|
||||
--argjson skipWin '${{ inputs.skip_windows }}' \
|
||||
--argjson skipWinX64 '${{ inputs.skip_windows_x64 }}' \
|
||||
--argjson skipWinArm '${{ inputs.skip_windows_arm64 }}' \
|
||||
--argjson skipMac '${{ inputs.skip_macos }}' \
|
||||
--argjson skipMacX64 '${{ inputs.skip_macos_x64 }}' \
|
||||
--argjson skipMacArm '${{ inputs.skip_macos_arm64 }}' \
|
||||
--argjson skipLinux '${{ inputs.skip_linux }}' \
|
||||
--argjson skipLinuxX64 '${{ inputs.skip_linux_x64 }}' \
|
||||
--argjson skipLinuxArm '${{ inputs.skip_linux_arm64 }}' '
|
||||
[.[] | select(
|
||||
(
|
||||
((.platform == "windows") and (
|
||||
$skipWin or
|
||||
((.arch == "x64") and $skipWinX64) or
|
||||
((.arch == "arm64") and $skipWinArm)
|
||||
)) or
|
||||
((.platform == "macos") and (
|
||||
$skipMac or
|
||||
((.arch == "x64") and $skipMacX64) or
|
||||
((.arch == "arm64") and $skipMacArm)
|
||||
)) or
|
||||
((.platform == "linux") and (
|
||||
$skipLinux or
|
||||
((.arch == "x64") and $skipLinuxX64) or
|
||||
((.arch == "arm64") and $skipLinuxArm)
|
||||
))
|
||||
) | not
|
||||
)]
|
||||
')"
|
||||
|
||||
echo "matrix={\"include\":$FILTERED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
version:
|
||||
name: Bump canary version
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.version }}
|
||||
pub_date: ${{ steps.bump.outputs.pub_date }}
|
||||
steps:
|
||||
- name: Calculate next version
|
||||
id: bump
|
||||
run: |
|
||||
VERSION="0.0.${GITHUB_RUN_NUMBER}"
|
||||
PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "pub_date=$PUB_DATE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||
needs: [version, matrix]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||
env:
|
||||
APP_WORKDIR: fluxer_app
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
|
||||
- name: Shorten Windows paths (workspace + temp for Squirrel) and pin pnpm store
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
subst W: "$env:GITHUB_WORKSPACE"
|
||||
"APP_WORKDIR=W:\fluxer_app" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
New-Item -ItemType Directory -Force "C:\t" | Out-Null
|
||||
New-Item -ItemType Directory -Force "C:\sq" | Out-Null
|
||||
New-Item -ItemType Directory -Force "C:\ebcache" | Out-Null
|
||||
"TEMP=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"TMP=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"SQUIRREL_TEMP=C:\sq" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"ELECTRON_BUILDER_CACHE=C:\ebcache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
New-Item -ItemType Directory -Force "C:\pnpm-store" | Out-Null
|
||||
"NPM_CONFIG_STORE_DIR=C:\pnpm-store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"npm_config_store_dir=C:\pnpm-store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
"store-dir=C:\pnpm-store" | Set-Content -Path "W:\.npmrc" -Encoding ascii
|
||||
git config --global core.longpaths true
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.26.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Resolve pnpm store path (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$store = pnpm store path --silent
|
||||
"PNPM_STORE_PATH=$store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
New-Item -ItemType Directory -Force $store | Out-Null
|
||||
|
||||
- name: Resolve pnpm store path (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
store="$(pnpm store path --silent)"
|
||||
echo "PNPM_STORE_PATH=$store" >> "$GITHUB_ENV"
|
||||
mkdir -p "$store"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Python setuptools (Windows ARM64)
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install "setuptools>=69" wheel
|
||||
|
||||
- name: Install Python setuptools (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: brew install python-setuptools
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.platform == 'linux'
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libx11-dev libxtst-dev libxt-dev libxinerama-dev libxkbcommon-dev libxrandr-dev \
|
||||
ruby ruby-dev build-essential rpm \
|
||||
libpixman-1-dev libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Update version
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
run: pnpm version ${{ needs.version.outputs.version }} --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Electron main process
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: canary
|
||||
run: pnpm electron:compile
|
||||
|
||||
- name: Build Electron app (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: canary
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: pnpm exec electron-builder --config electron-builder.canary.yaml --mac --${{ matrix.electron_arch }}
|
||||
|
||||
- name: Build Electron app (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: canary
|
||||
TEMP: C:\t
|
||||
TMP: C:\t
|
||||
SQUIRREL_TEMP: C:\sq
|
||||
ELECTRON_BUILDER_CACHE: C:\ebcache
|
||||
run: pnpm exec electron-builder --config electron-builder.canary.yaml --win --${{ matrix.electron_arch }}
|
||||
|
||||
- name: Analyze Squirrel nupkg for long paths
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
shell: pwsh
|
||||
env:
|
||||
BUILD_VERSION: ${{ needs.version.outputs.version }}
|
||||
MAX_WINDOWS_PATH_LEN: 260
|
||||
PATH_HEADROOM: 10
|
||||
run: |
|
||||
$primaryDir = if ("${{ matrix.arch }}" -eq "arm64") { "dist-electron/squirrel-windows-arm64" } else { "dist-electron/squirrel-windows" }
|
||||
$fallbackDir = if ("${{ matrix.arch }}" -eq "arm64") { "dist-electron/squirrel-windows" } else { "dist-electron/squirrel-windows-arm64" }
|
||||
$dirs = @($primaryDir, $fallbackDir)
|
||||
|
||||
$nupkg = $null
|
||||
foreach ($d in $dirs) {
|
||||
if (Test-Path $d) {
|
||||
$nupkg = Get-ChildItem -Path "$d/*.nupkg" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($nupkg) { break }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $nupkg) {
|
||||
throw "No Squirrel nupkg found in: $($dirs -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host "Analyzing Windows installer $($nupkg.FullName)"
|
||||
$env:NUPKG_PATH = $nupkg.FullName
|
||||
|
||||
$lines = @(
|
||||
'import os'
|
||||
'import zipfile'
|
||||
''
|
||||
'path = os.environ["NUPKG_PATH"]'
|
||||
'build_ver = os.environ["BUILD_VERSION"]'
|
||||
'prefix = os.path.join(os.environ["LOCALAPPDATA"], "fluxer_app", f"app-{build_ver}", "resources", "app.asar.unpacked")'
|
||||
'max_len = int(os.environ.get("MAX_WINDOWS_PATH_LEN", "260"))'
|
||||
'headroom = int(os.environ.get("PATH_HEADROOM", "10"))'
|
||||
'limit = max_len - headroom'
|
||||
''
|
||||
'with zipfile.ZipFile(path) as archive:'
|
||||
' entries = []'
|
||||
' for info in archive.infolist():'
|
||||
' normalized = info.filename.lstrip("/\\\\")'
|
||||
' total_len = len(os.path.join(prefix, normalized)) if normalized else len(prefix)'
|
||||
' entries.append((total_len, info.filename))'
|
||||
''
|
||||
'if not entries:'
|
||||
' raise SystemExit("nupkg archive contains no entries")'
|
||||
''
|
||||
'entries.sort(reverse=True)'
|
||||
'print(f"Assumed install prefix: {prefix} ({len(prefix)} chars). Maximum allowed path length: {limit} (total reserve {max_len}, headroom {headroom}).")'
|
||||
'print("Top 20 longest archived paths (length includes prefix):")'
|
||||
'for length, name in entries[:20]:'
|
||||
' print(f"{length:4d} {name}")'
|
||||
''
|
||||
'longest_len, longest_name = entries[0]'
|
||||
'if longest_len > limit:'
|
||||
' raise SystemExit(f"Longest path {longest_len} for {longest_name} exceeds limit {limit}")'
|
||||
'print(f"Longest archived path {longest_len} is within the limit of {limit}.")'
|
||||
)
|
||||
|
||||
$scriptPath = Join-Path $env:TEMP "nupkg-long-path-check.py"
|
||||
Set-Content -Path $scriptPath -Value $lines -Encoding utf8
|
||||
python $scriptPath
|
||||
|
||||
- name: Build Electron app (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: canary
|
||||
USE_SYSTEM_FPM: true
|
||||
run: pnpm exec electron-builder --config electron-builder.canary.yaml --linux --${{ matrix.electron_arch }}
|
||||
|
||||
- name: Prepare artifacts (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force upload_staging | Out-Null
|
||||
|
||||
$dist = Join-Path $env:APP_WORKDIR "dist-electron"
|
||||
$sqDirName = if ("${{ matrix.arch }}" -eq "arm64") { "squirrel-windows-arm64" } else { "squirrel-windows" }
|
||||
$sqFallbackName = if ($sqDirName -eq "squirrel-windows") { "squirrel-windows-arm64" } else { "squirrel-windows" }
|
||||
|
||||
$sq = Join-Path $dist $sqDirName
|
||||
$sqFallback = Join-Path $dist $sqFallbackName
|
||||
|
||||
$picked = $null
|
||||
if (Test-Path $sq) { $picked = $sq }
|
||||
elseif (Test-Path $sqFallback) { $picked = $sqFallback }
|
||||
|
||||
if ($picked) {
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.exe" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.exe.blockmap" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\RELEASES*" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.nupkg" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.nupkg.blockmap" "upload_staging\"
|
||||
}
|
||||
|
||||
if (Test-Path $dist) {
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.yml" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.zip" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.zip.blockmap" "upload_staging\"
|
||||
}
|
||||
|
||||
if (-not (Get-ChildItem upload_staging -Filter *.exe -ErrorAction SilentlyContinue)) {
|
||||
throw "No installer .exe staged. Squirrel outputs were not copied."
|
||||
}
|
||||
|
||||
Get-ChildItem -Force upload_staging | Format-Table -AutoSize
|
||||
|
||||
- name: Prepare artifacts (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p upload_staging
|
||||
DIST="${{ env.APP_WORKDIR }}/dist-electron"
|
||||
|
||||
cp -f "$DIST"/*.dmg upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.zip upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.zip.blockmap upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.yml upload_staging/ 2>/dev/null || true
|
||||
|
||||
cp -f "$DIST"/*.AppImage upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.deb upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.rpm upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.tar.gz upload_staging/ 2>/dev/null || true
|
||||
|
||||
ls -la upload_staging/
|
||||
|
||||
- name: Normalize updater YAML (arm64)
|
||||
if: matrix.arch == 'arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
cd upload_staging
|
||||
[[ "${{ matrix.platform }}" == "macos" && -f latest-mac.yml && ! -f latest-mac-arm64.yml ]] && mv latest-mac.yml latest-mac-arm64.yml || true
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fluxer-desktop-canary-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: |
|
||||
upload_staging/*.exe
|
||||
upload_staging/*.exe.blockmap
|
||||
upload_staging/*.dmg
|
||||
upload_staging/*.zip
|
||||
upload_staging/*.zip.blockmap
|
||||
upload_staging/*.AppImage
|
||||
upload_staging/*.deb
|
||||
upload_staging/*.rpm
|
||||
upload_staging/*.tar.gz
|
||||
upload_staging/*.yml
|
||||
upload_staging/*.nupkg
|
||||
upload_staging/*.nupkg.blockmap
|
||||
upload_staging/RELEASES*
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
name: Upload to S3 (rclone)
|
||||
needs: [version, build]
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
env:
|
||||
CHANNEL: canary
|
||||
S3_ENDPOINT: https://s3.us-east-va.io.cloud.ovh.us
|
||||
S3_BUCKET: fluxer-downloads
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: fluxer-desktop-canary-*
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rclone >/dev/null 2>&1; then
|
||||
curl -fsSL https://rclone.org/install.sh | sudo bash
|
||||
fi
|
||||
|
||||
- name: Configure rclone (OVH S3)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf <<'RCLONEEOF'
|
||||
[ovh]
|
||||
type = s3
|
||||
provider = Other
|
||||
env_auth = true
|
||||
endpoint = https://s3.us-east-va.io.cloud.ovh.us
|
||||
acl = private
|
||||
RCLONEEOF
|
||||
|
||||
- name: Build S3 payload layout (+ manifest.json)
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PUB_DATE: ${{ needs.version.outputs.pub_date }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p s3_payload
|
||||
|
||||
shopt -s nullglob
|
||||
for dir in artifacts/fluxer-desktop-canary-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
base="$(basename "$dir")"
|
||||
if [[ "$base" =~ ^fluxer-desktop-canary-([a-z]+)-([a-z0-9]+)$ ]]; then
|
||||
platform="${BASH_REMATCH[1]}"
|
||||
arch="${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "Skipping unrecognized artifact dir: $base"
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$platform" in
|
||||
windows) plat="win32" ;;
|
||||
macos) plat="darwin" ;;
|
||||
linux) plat="linux" ;;
|
||||
*)
|
||||
echo "Unknown platform: $platform"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
dest="s3_payload/desktop/${CHANNEL}/${plat}/${arch}"
|
||||
mkdir -p "$dest"
|
||||
cp -av "$dir"/* "$dest/" || true
|
||||
|
||||
if [[ "$plat" == "darwin" ]]; then
|
||||
zip_file=""
|
||||
for z in "$dest"/*.zip; do
|
||||
zip_file="$z"
|
||||
break
|
||||
done
|
||||
|
||||
if [[ -z "$zip_file" ]]; then
|
||||
echo "No .zip found for macOS $arch in $dest (auto-update requires zip artifacts)."
|
||||
else
|
||||
zip_name="$(basename "$zip_file")"
|
||||
url="${PUBLIC_DL_BASE}/desktop/${CHANNEL}/${plat}/${arch}/${zip_name}"
|
||||
|
||||
cat > "$dest/RELEASES.json" <<EOF
|
||||
{
|
||||
"currentRelease": "${VERSION}",
|
||||
"releases": [
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"updateTo": {
|
||||
"version": "${VERSION}",
|
||||
"pub_date": "${PUB_DATE}",
|
||||
"notes": "",
|
||||
"name": "${VERSION}",
|
||||
"url": "${url}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
cp -f "$dest/RELEASES.json" "$dest/releases.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
setup_file=""
|
||||
dmg_file=""
|
||||
zip_file2=""
|
||||
appimage_file=""
|
||||
deb_file=""
|
||||
rpm_file=""
|
||||
targz_file=""
|
||||
|
||||
if [[ "$plat" == "win32" ]]; then
|
||||
setup_file="$(ls -1 "$dest"/*.exe 2>/dev/null | grep -i 'setup' | head -n1 || true)"
|
||||
if [[ -z "$setup_file" ]]; then
|
||||
setup_file="$(ls -1 "$dest"/*.exe 2>/dev/null | head -n1 || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$plat" == "darwin" ]]; then
|
||||
dmg_file="$(ls -1 "$dest"/*.dmg 2>/dev/null | head -n1 || true)"
|
||||
zip_file2="$(ls -1 "$dest"/*.zip 2>/dev/null | head -n1 || true)"
|
||||
fi
|
||||
|
||||
if [[ "$plat" == "linux" ]]; then
|
||||
appimage_file="$(ls -1 "$dest"/*.AppImage 2>/dev/null | head -n1 || true)"
|
||||
deb_file="$(ls -1 "$dest"/*.deb 2>/dev/null | head -n1 || true)"
|
||||
rpm_file="$(ls -1 "$dest"/*.rpm 2>/dev/null | head -n1 || true)"
|
||||
targz_file="$(ls -1 "$dest"/*.tar.gz 2>/dev/null | head -n1 || true)"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg channel "${CHANNEL}" \
|
||||
--arg platform "${plat}" \
|
||||
--arg arch "${arch}" \
|
||||
--arg version "${VERSION}" \
|
||||
--arg pub_date "${PUB_DATE}" \
|
||||
--arg setup "$(basename "${setup_file:-}")" \
|
||||
--arg dmg "$(basename "${dmg_file:-}")" \
|
||||
--arg zip "$(basename "${zip_file2:-}")" \
|
||||
--arg appimage "$(basename "${appimage_file:-}")" \
|
||||
--arg deb "$(basename "${deb_file:-}")" \
|
||||
--arg rpm "$(basename "${rpm_file:-}")" \
|
||||
--arg tar_gz "$(basename "${targz_file:-}")" \
|
||||
'{
|
||||
channel: $channel,
|
||||
platform: $platform,
|
||||
arch: $arch,
|
||||
version: $version,
|
||||
pub_date: $pub_date,
|
||||
files: {
|
||||
setup: $setup,
|
||||
dmg: $dmg,
|
||||
zip: $zip,
|
||||
appimage: $appimage,
|
||||
deb: $deb,
|
||||
rpm: $rpm,
|
||||
tar_gz: $tar_gz
|
||||
}
|
||||
}' > "$dest/manifest.json"
|
||||
done
|
||||
|
||||
echo "Payload tree:"
|
||||
find s3_payload -maxdepth 6 -type f | sort
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rclone copy s3_payload/desktop "ovh:${S3_BUCKET}/desktop" \
|
||||
--transfers 32 \
|
||||
--checkers 16 \
|
||||
--fast-list \
|
||||
--s3-upload-concurrency 8 \
|
||||
--s3-chunk-size 16M \
|
||||
-v
|
||||
|
||||
- name: Build summary
|
||||
run: |
|
||||
{
|
||||
echo "## Desktop Canary Upload Complete"
|
||||
echo ""
|
||||
echo "**Version:** ${{ needs.version.outputs.version }}"
|
||||
echo ""
|
||||
echo "**S3 prefix:** desktop/${CHANNEL}/"
|
||||
echo ""
|
||||
echo "**Redirect endpoint shape:** /dl/desktop/${CHANNEL}/{plat}/{arch}/{format}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
612
.github/workflows/build-desktop.yaml
vendored
Normal file
612
.github/workflows/build-desktop.yaml
vendored
Normal file
@@ -0,0 +1,612 @@
|
||||
name: build desktop
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref to build (branch, tag, or commit SHA)
|
||||
required: false
|
||||
default: stable
|
||||
type: string
|
||||
skip_windows:
|
||||
description: Skip Windows builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_macos:
|
||||
description: Skip macOS builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_linux:
|
||||
description: Skip Linux builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_windows_x64:
|
||||
description: Skip Windows x64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_windows_arm64:
|
||||
description: Skip Windows ARM64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_macos_x64:
|
||||
description: Skip macOS x64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_macos_arm64:
|
||||
description: Skip macOS ARM64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_linux_x64:
|
||||
description: Skip Linux x64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_linux_arm64:
|
||||
description: Skip Linux ARM64 builds
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: desktop-stable-${{ inputs.ref || 'stable' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CHANNEL: stable
|
||||
SOURCE_REF: ${{ inputs.ref || 'stable' }}
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
name: Resolve build matrix
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Build platform matrix
|
||||
id: set-matrix
|
||||
run: |
|
||||
PLATFORMS='[
|
||||
{"platform":"windows","arch":"x64","os":"windows-latest","electron_arch":"x64"},
|
||||
{"platform":"windows","arch":"arm64","os":"windows-11-arm","electron_arch":"arm64"},
|
||||
{"platform":"macos","arch":"x64","os":"macos-15-intel","electron_arch":"x64"},
|
||||
{"platform":"macos","arch":"arm64","os":"macos-15","electron_arch":"arm64"},
|
||||
{"platform":"linux","arch":"x64","os":"ubuntu-24.04","electron_arch":"x64"},
|
||||
{"platform":"linux","arch":"arm64","os":"ubuntu-24.04-arm","electron_arch":"arm64"}
|
||||
]'
|
||||
|
||||
FILTERED="$(echo "$PLATFORMS" | jq -c \
|
||||
--argjson skipWin '${{ inputs.skip_windows }}' \
|
||||
--argjson skipWinX64 '${{ inputs.skip_windows_x64 }}' \
|
||||
--argjson skipWinArm '${{ inputs.skip_windows_arm64 }}' \
|
||||
--argjson skipMac '${{ inputs.skip_macos }}' \
|
||||
--argjson skipMacX64 '${{ inputs.skip_macos_x64 }}' \
|
||||
--argjson skipMacArm '${{ inputs.skip_macos_arm64 }}' \
|
||||
--argjson skipLinux '${{ inputs.skip_linux }}' \
|
||||
--argjson skipLinuxX64 '${{ inputs.skip_linux_x64 }}' \
|
||||
--argjson skipLinuxArm '${{ inputs.skip_linux_arm64 }}' '
|
||||
[.[] | select(
|
||||
(
|
||||
((.platform == "windows") and (
|
||||
$skipWin or
|
||||
((.arch == "x64") and $skipWinX64) or
|
||||
((.arch == "arm64") and $skipWinArm)
|
||||
)) or
|
||||
((.platform == "macos") and (
|
||||
$skipMac or
|
||||
((.arch == "x64") and $skipMacX64) or
|
||||
((.arch == "arm64") and $skipMacArm)
|
||||
)) or
|
||||
((.platform == "linux") and (
|
||||
$skipLinux or
|
||||
((.arch == "x64") and $skipLinuxX64) or
|
||||
((.arch == "arm64") and $skipLinuxArm)
|
||||
))
|
||||
) | not
|
||||
)]
|
||||
')"
|
||||
|
||||
echo "matrix={\"include\":$FILTERED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||
needs: matrix
|
||||
outputs:
|
||||
version: ${{ steps.metadata.outputs.version }}
|
||||
pub_date: ${{ steps.metadata.outputs.pub_date }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
|
||||
env:
|
||||
APP_WORKDIR: fluxer_app
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.SOURCE_REF }}
|
||||
|
||||
- name: Shorten Windows paths (workspace + temp for Squirrel) and pin pnpm store
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
subst W: "$env:GITHUB_WORKSPACE"
|
||||
"APP_WORKDIR=W:\fluxer_app" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
New-Item -ItemType Directory -Force "C:\t" | Out-Null
|
||||
New-Item -ItemType Directory -Force "C:\sq" | Out-Null
|
||||
New-Item -ItemType Directory -Force "C:\ebcache" | Out-Null
|
||||
"TEMP=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"TMP=C:\t" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"SQUIRREL_TEMP=C:\sq" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"ELECTRON_BUILDER_CACHE=C:\ebcache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
New-Item -ItemType Directory -Force "C:\pnpm-store" | Out-Null
|
||||
"NPM_CONFIG_STORE_DIR=C:\pnpm-store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"npm_config_store_dir=C:\pnpm-store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
"store-dir=C:\pnpm-store" | Set-Content -Path "W:\.npmrc" -Encoding ascii
|
||||
git config --global core.longpaths true
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.26.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set build metadata
|
||||
id: metadata
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="0.0.${GITHUB_RUN_NUMBER}"
|
||||
PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "PUB_DATE=${PUB_DATE}" >> "$GITHUB_ENV"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "pub_date=${PUB_DATE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve pnpm store path (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$store = pnpm store path --silent
|
||||
"PNPM_STORE_PATH=$store" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
New-Item -ItemType Directory -Force $store | Out-Null
|
||||
|
||||
- name: Resolve pnpm store path (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
store="$(pnpm store path --silent)"
|
||||
echo "PNPM_STORE_PATH=$store" >> "$GITHUB_ENV"
|
||||
mkdir -p "$store"
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Python setuptools (Windows ARM64)
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install "setuptools>=69" wheel
|
||||
|
||||
- name: Install Python setuptools (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: brew install python-setuptools
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.platform == 'linux'
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libx11-dev libxtst-dev libxt-dev libxinerama-dev libxkbcommon-dev libxrandr-dev \
|
||||
ruby ruby-dev build-essential rpm \
|
||||
libpixman-1-dev libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
|
||||
sudo gem install --no-document fpm
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Update version
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
run: pnpm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Electron main process
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: stable
|
||||
run: pnpm electron:compile
|
||||
|
||||
- name: Build Electron app (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: stable
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: pnpm exec electron-builder --config electron-builder.yaml --mac --${{ matrix.electron_arch }}
|
||||
|
||||
- name: Build Electron app (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: stable
|
||||
TEMP: C:\t
|
||||
TMP: C:\t
|
||||
SQUIRREL_TEMP: C:\sq
|
||||
ELECTRON_BUILDER_CACHE: C:\ebcache
|
||||
run: pnpm exec electron-builder --config electron-builder.yaml --win --${{ matrix.electron_arch }}
|
||||
|
||||
- name: Analyze Squirrel nupkg for long paths
|
||||
if: matrix.platform == 'windows'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
shell: pwsh
|
||||
env:
|
||||
BUILD_VERSION: ${{ steps.metadata.outputs.version }}
|
||||
MAX_WINDOWS_PATH_LEN: 260
|
||||
PATH_HEADROOM: 10
|
||||
run: |
|
||||
$primaryDir = if ("${{ matrix.arch }}" -eq "arm64") { "dist-electron/squirrel-windows-arm64" } else { "dist-electron/squirrel-windows" }
|
||||
$fallbackDir = if ("${{ matrix.arch }}" -eq "arm64") { "dist-electron/squirrel-windows" } else { "dist-electron/squirrel-windows-arm64" }
|
||||
$dirs = @($primaryDir, $fallbackDir)
|
||||
|
||||
$nupkg = $null
|
||||
foreach ($d in $dirs) {
|
||||
if (Test-Path $d) {
|
||||
$nupkg = Get-ChildItem -Path "$d/*.nupkg" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($nupkg) { break }
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $nupkg) {
|
||||
throw "No Squirrel nupkg found in: $($dirs -join ', ')"
|
||||
}
|
||||
|
||||
Write-Host "Analyzing Windows installer $($nupkg.FullName)"
|
||||
$env:NUPKG_PATH = $nupkg.FullName
|
||||
|
||||
$lines = @(
|
||||
'import os'
|
||||
'import zipfile'
|
||||
''
|
||||
'path = os.environ["NUPKG_PATH"]'
|
||||
'build_ver = os.environ["BUILD_VERSION"]'
|
||||
'prefix = os.path.join(os.environ["LOCALAPPDATA"], "fluxer_app", f"app-{build_ver}", "resources", "app.asar.unpacked")'
|
||||
'max_len = int(os.environ.get("MAX_WINDOWS_PATH_LEN", "260"))'
|
||||
'headroom = int(os.environ.get("PATH_HEADROOM", "10"))'
|
||||
'limit = max_len - headroom'
|
||||
''
|
||||
'with zipfile.ZipFile(path) as archive:'
|
||||
' entries = []'
|
||||
' for info in archive.infolist():'
|
||||
' normalized = info.filename.lstrip("/\\\\")'
|
||||
' total_len = len(os.path.join(prefix, normalized)) if normalized else len(prefix)'
|
||||
' entries.append((total_len, info.filename))'
|
||||
''
|
||||
'if not entries:'
|
||||
' raise SystemExit("nupkg archive contains no entries")'
|
||||
''
|
||||
'entries.sort(reverse=True)'
|
||||
'print(f"Assumed install prefix: {prefix} ({len(prefix)} chars). Maximum allowed path length: {limit} (total reserve {max_len}, headroom {headroom}).")'
|
||||
'print("Top 20 longest archived paths (length includes prefix):")'
|
||||
'for length, name in entries[:20]:'
|
||||
' print(f"{length:4d} {name}")'
|
||||
''
|
||||
'longest_len, longest_name = entries[0]'
|
||||
'if longest_len > limit:'
|
||||
' raise SystemExit(f"Longest path {longest_len} for {longest_name} exceeds limit {limit}")'
|
||||
'print(f"Longest archived path {longest_len} is within the limit of {limit}.")'
|
||||
)
|
||||
|
||||
$scriptPath = Join-Path $env:TEMP "nupkg-long-path-check.py"
|
||||
Set-Content -Path $scriptPath -Value $lines -Encoding utf8
|
||||
python $scriptPath
|
||||
|
||||
- name: Build Electron app (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
working-directory: ${{ env.APP_WORKDIR }}
|
||||
env:
|
||||
BUILD_CHANNEL: stable
|
||||
USE_SYSTEM_FPM: true
|
||||
run: pnpm exec electron-builder --config electron-builder.yaml --linux --${{ matrix.electron_arch }}
|
||||
|
||||
- name: Prepare artifacts (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force upload_staging | Out-Null
|
||||
|
||||
$dist = Join-Path $env:APP_WORKDIR "dist-electron"
|
||||
$sqDirName = if ("${{ matrix.arch }}" -eq "arm64") { "squirrel-windows-arm64" } else { "squirrel-windows" }
|
||||
$sqFallbackName = if ($sqDirName -eq "squirrel-windows") { "squirrel-windows-arm64" } else { "squirrel-windows" }
|
||||
|
||||
$sq = Join-Path $dist $sqDirName
|
||||
$sqFallback = Join-Path $dist $sqFallbackName
|
||||
|
||||
$picked = $null
|
||||
if (Test-Path $sq) { $picked = $sq }
|
||||
elseif (Test-Path $sqFallback) { $picked = $sqFallback }
|
||||
|
||||
if ($picked) {
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.exe" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.exe.blockmap" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\RELEASES*" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.nupkg" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$picked\*.nupkg.blockmap" "upload_staging\"
|
||||
}
|
||||
|
||||
if (Test-Path $dist) {
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.yml" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.zip" "upload_staging\"
|
||||
Copy-Item -Force -ErrorAction SilentlyContinue "$dist\*.zip.blockmap" "upload_staging\"
|
||||
}
|
||||
|
||||
if (-not (Get-ChildItem upload_staging -Filter *.exe -ErrorAction SilentlyContinue)) {
|
||||
throw "No installer .exe staged. Squirrel outputs were not copied."
|
||||
}
|
||||
|
||||
Get-ChildItem -Force upload_staging | Format-Table -AutoSize
|
||||
|
||||
- name: Prepare artifacts (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p upload_staging
|
||||
DIST="${{ env.APP_WORKDIR }}/dist-electron"
|
||||
|
||||
cp -f "$DIST"/*.dmg upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.zip upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.zip.blockmap upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.yml upload_staging/ 2>/dev/null || true
|
||||
|
||||
cp -f "$DIST"/*.AppImage upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.deb upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.rpm upload_staging/ 2>/dev/null || true
|
||||
cp -f "$DIST"/*.tar.gz upload_staging/ 2>/dev/null || true
|
||||
|
||||
ls -la upload_staging/
|
||||
|
||||
- name: Normalize updater YAML (arm64)
|
||||
if: matrix.arch == 'arm64'
|
||||
shell: bash
|
||||
run: |
|
||||
cd upload_staging
|
||||
[[ "${{ matrix.platform }}" == "macos" && -f latest-mac.yml && ! -f latest-mac-arm64.yml ]] && mv latest-mac.yml latest-mac-arm64.yml || true
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fluxer-desktop-stable-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: |
|
||||
upload_staging/*.exe
|
||||
upload_staging/*.exe.blockmap
|
||||
upload_staging/*.dmg
|
||||
upload_staging/*.zip
|
||||
upload_staging/*.zip.blockmap
|
||||
upload_staging/*.AppImage
|
||||
upload_staging/*.deb
|
||||
upload_staging/*.rpm
|
||||
upload_staging/*.tar.gz
|
||||
upload_staging/*.yml
|
||||
upload_staging/*.nupkg
|
||||
upload_staging/*.nupkg.blockmap
|
||||
upload_staging/RELEASES*
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
name: Upload to S3 (rclone)
|
||||
needs: build
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
env:
|
||||
CHANNEL: stable
|
||||
S3_ENDPOINT: https://s3.us-east-va.io.cloud.ovh.us
|
||||
S3_BUCKET: fluxer-downloads
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: fluxer-desktop-stable-*
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rclone >/dev/null 2>&1; then
|
||||
curl -fsSL https://rclone.org/install.sh | sudo bash
|
||||
fi
|
||||
|
||||
- name: Configure rclone (OVH S3)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf <<'RCLONEEOF'
|
||||
[ovh]
|
||||
type = s3
|
||||
provider = Other
|
||||
env_auth = true
|
||||
endpoint = https://s3.us-east-va.io.cloud.ovh.us
|
||||
acl = private
|
||||
RCLONEEOF
|
||||
|
||||
- name: Build S3 payload layout (+ manifest.json)
|
||||
env:
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
PUB_DATE: ${{ needs.build.outputs.pub_date }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p s3_payload
|
||||
|
||||
shopt -s nullglob
|
||||
for dir in artifacts/fluxer-desktop-stable-*; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
base="$(basename "$dir")"
|
||||
if [[ "$base" =~ ^fluxer-desktop-stable-([a-z]+)-([a-z0-9]+)$ ]]; then
|
||||
platform="${BASH_REMATCH[1]}"
|
||||
arch="${BASH_REMATCH[2]}"
|
||||
else
|
||||
echo "Skipping unrecognized artifact dir: $base"
|
||||
continue
|
||||
fi
|
||||
|
||||
case "$platform" in
|
||||
windows) plat="win32" ;;
|
||||
macos) plat="darwin" ;;
|
||||
linux) plat="linux" ;;
|
||||
*)
|
||||
echo "Unknown platform: $platform"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
dest="s3_payload/desktop/${CHANNEL}/${plat}/${arch}"
|
||||
mkdir -p "$dest"
|
||||
cp -av "$dir"/* "$dest/" || true
|
||||
|
||||
if [[ "$plat" == "darwin" ]]; then
|
||||
zip_file=""
|
||||
for z in "$dest"/*.zip; do
|
||||
zip_file="$z"
|
||||
break
|
||||
done
|
||||
|
||||
if [[ -z "$zip_file" ]]; then
|
||||
echo "No .zip found for macOS $arch in $dest (auto-update requires zip artifacts)."
|
||||
else
|
||||
zip_name="$(basename "$zip_file")"
|
||||
url="${PUBLIC_DL_BASE}/desktop/${CHANNEL}/${plat}/${arch}/${zip_name}"
|
||||
|
||||
cat > "$dest/RELEASES.json" <<EOF
|
||||
{
|
||||
"currentRelease": "${VERSION}",
|
||||
"releases": [
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"updateTo": {
|
||||
"version": "${VERSION}",
|
||||
"pub_date": "${PUB_DATE}",
|
||||
"notes": "",
|
||||
"name": "${VERSION}",
|
||||
"url": "${url}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
cp -f "$dest/RELEASES.json" "$dest/releases.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
setup_file=""
|
||||
dmg_file=""
|
||||
zip_file2=""
|
||||
appimage_file=""
|
||||
deb_file=""
|
||||
rpm_file=""
|
||||
targz_file=""
|
||||
|
||||
if [[ "$plat" == "win32" ]]; then
|
||||
setup_file="$(ls -1 "$dest"/*.exe 2>/dev/null | grep -i 'setup' | head -n1 || true)"
|
||||
if [[ -z "$setup_file" ]]; then
|
||||
setup_file="$(ls -1 "$dest"/*.exe 2>/dev/null | head -n1 || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$plat" == "darwin" ]]; then
|
||||
dmg_file="$(ls -1 "$dest"/*.dmg 2>/dev/null | head -n1 || true)"
|
||||
zip_file2="$(ls -1 "$dest"/*.zip 2>/dev/null | head -n1 || true)"
|
||||
fi
|
||||
|
||||
if [[ "$plat" == "linux" ]]; then
|
||||
appimage_file="$(ls -1 "$dest"/*.AppImage 2>/dev/null | head -n1 || true)"
|
||||
deb_file="$(ls -1 "$dest"/*.deb 2>/dev/null | head -n1 || true)"
|
||||
rpm_file="$(ls -1 "$dest"/*.rpm 2>/dev/null | head -n1 || true)"
|
||||
targz_file="$(ls -1 "$dest"/*.tar.gz 2>/dev/null | head -n1 || true)"
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg channel "${CHANNEL}" \
|
||||
--arg platform "${plat}" \
|
||||
--arg arch "${arch}" \
|
||||
--arg version "${VERSION}" \
|
||||
--arg pub_date "${PUB_DATE}" \
|
||||
--arg setup "$(basename "${setup_file:-}")" \
|
||||
--arg dmg "$(basename "${dmg_file:-}")" \
|
||||
--arg zip "$(basename "${zip_file2:-}")" \
|
||||
--arg appimage "$(basename "${appimage_file:-}")" \
|
||||
--arg deb "$(basename "${deb_file:-}")" \
|
||||
--arg rpm "$(basename "${rpm_file:-}")" \
|
||||
--arg tar_gz "$(basename "${targz_file:-}")" \
|
||||
'{
|
||||
channel: $channel,
|
||||
platform: $platform,
|
||||
arch: $arch,
|
||||
version: $version,
|
||||
pub_date: $pub_date,
|
||||
files: {
|
||||
setup: $setup,
|
||||
dmg: $dmg,
|
||||
zip: $zip,
|
||||
appimage: $appimage,
|
||||
deb: $deb,
|
||||
rpm: $rpm,
|
||||
tar_gz: $tar_gz
|
||||
}
|
||||
}' > "$dest/manifest.json"
|
||||
done
|
||||
|
||||
echo "Payload tree:"
|
||||
find s3_payload -maxdepth 6 -type f | sort
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rclone copy s3_payload/desktop "ovh:${S3_BUCKET}/desktop" \
|
||||
--transfers 32 \
|
||||
--checkers 16 \
|
||||
--fast-list \
|
||||
--s3-upload-concurrency 8 \
|
||||
--s3-chunk-size 16M \
|
||||
-v
|
||||
|
||||
- name: Build summary
|
||||
run: |
|
||||
{
|
||||
echo "## Desktop Stable Upload Complete"
|
||||
echo ""
|
||||
echo "**Version:** ${{ steps.metadata.outputs.version }}"
|
||||
echo ""
|
||||
echo "**S3 prefix:** desktop/${CHANNEL}/"
|
||||
echo ""
|
||||
echo "**Redirect endpoint shape:** /dl/desktop/${CHANNEL}/{plat}/{arch}/{format}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
141
.github/workflows/deploy-admin-canary.yaml
vendored
Normal file
141
.github/workflows/deploy-admin-canary.yaml
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
name: deploy admin (canary)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- fluxer_admin/**/*
|
||||
- .github/workflows/deploy-admin-canary.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-admin-canary
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app (canary)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_admin
|
||||
file: fluxer_admin/Dockerfile
|
||||
tags: fluxer-admin-canary:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-admin-canary
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-admin-canary
|
||||
build-args: |
|
||||
BUILD_TIMESTAMP=${{ github.run_id }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-admin-canary:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-admin-canary
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-admin-canary
|
||||
cd /opt/fluxer-admin-canary
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- FLUXER_API_PUBLIC_ENDPOINT=https://api.canary.fluxer.app
|
||||
- FLUXER_APP_ENDPOINT=https://web.canary.fluxer.app
|
||||
- FLUXER_MEDIA_ENDPOINT=https://fluxerusercontent.com
|
||||
- FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
- FLUXER_ADMIN_ENDPOINT=https://admin.canary.fluxer.app
|
||||
- FLUXER_PATH_ADMIN=/
|
||||
- APP_MODE=admin
|
||||
- FLUXER_ADMIN_PORT=8080
|
||||
- ADMIN_OAUTH2_REDIRECT_URI=https://admin.canary.fluxer.app/oauth2_callback
|
||||
- ADMIN_OAUTH2_CLIENT_ID=1440355698178071552
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=admin.canary.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-admin-canary
|
||||
docker service update --image ${IMAGE_TAG} fluxer-admin-canary_app
|
||||
EOF
|
||||
141
.github/workflows/deploy-admin.yaml
vendored
Normal file
141
.github/workflows/deploy-admin.yaml
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
name: deploy admin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_admin/**/*
|
||||
- .github/workflows/deploy-admin.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-admin
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_admin
|
||||
file: fluxer_admin/Dockerfile
|
||||
tags: fluxer-admin:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-admin
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-admin
|
||||
build-args: |
|
||||
BUILD_TIMESTAMP=${{ github.run_id }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-admin:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-admin
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-admin
|
||||
cd /opt/fluxer-admin
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- FLUXER_API_PUBLIC_ENDPOINT=https://api.fluxer.app
|
||||
- FLUXER_APP_ENDPOINT=https://web.fluxer.app
|
||||
- FLUXER_MEDIA_ENDPOINT=https://fluxerusercontent.com
|
||||
- FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
- FLUXER_ADMIN_ENDPOINT=https://admin.fluxer.app
|
||||
- FLUXER_PATH_ADMIN=/
|
||||
- APP_MODE=admin
|
||||
- FLUXER_ADMIN_PORT=8080
|
||||
- ADMIN_OAUTH2_REDIRECT_URI=https://admin.fluxer.app/oauth2_callback
|
||||
- ADMIN_OAUTH2_CLIENT_ID=1440355698178071552
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=admin.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-admin
|
||||
docker service update --image ${IMAGE_TAG} fluxer-admin_app
|
||||
EOF
|
||||
201
.github/workflows/deploy-api-canary.yaml
vendored
Normal file
201
.github/workflows/deploy-api-canary.yaml
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
name: deploy api (canary)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- fluxer_api/**/*
|
||||
- .github/workflows/deploy-api-canary.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-api-canary
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app (canary)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_api
|
||||
file: fluxer_api/Dockerfile
|
||||
tags: fluxer-api-canary:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-api-canary
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-api-canary
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-api-canary:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-api-canary
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-api-canary
|
||||
cd /opt/fluxer-api-canary
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
command: ['npm', 'run', 'start']
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- FLUXER_API_PORT=8080
|
||||
- SENTRY_DSN=https://bb16e8b823b82d788db49a666b3b4b90@o4510149383094272.ingest.us.sentry.io/4510205804019712
|
||||
- CASSANDRA_HOSTS=cassandra
|
||||
- CASSANDRA_KEYSPACE=fluxer
|
||||
- CASSANDRA_LOCAL_DC=dc1
|
||||
- FLUXER_GATEWAY_RPC_HOST=fluxer-gateway_app
|
||||
- FLUXER_GATEWAY_RPC_PORT=8081
|
||||
- FLUXER_MEDIA_PROXY_HOST=fluxer-media-proxy_app
|
||||
- FLUXER_MEDIA_PROXY_PORT=8080
|
||||
- FLUXER_API_CLIENT_ENDPOINT=https://web.canary.fluxer.app/api
|
||||
- FLUXER_APP_ENDPOINT=https://web.canary.fluxer.app
|
||||
- FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
- FLUXER_MEDIA_ENDPOINT=https://fluxerusercontent.com
|
||||
- FLUXER_INVITE_ENDPOINT=https://fluxer.gg
|
||||
- FLUXER_GIFT_ENDPOINT=https://fluxer.gift
|
||||
- AWS_S3_ENDPOINT=https://s3.us-east-va.io.cloud.ovh.us
|
||||
- AWS_S3_BUCKET_CDN=fluxer
|
||||
- AWS_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
- AWS_S3_BUCKET_REPORTS=fluxer-reports
|
||||
- AWS_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
- AWS_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
- SENDGRID_FROM_EMAIL=noreply@fluxer.app
|
||||
- SENDGRID_FROM_NAME=Fluxer
|
||||
- SENDGRID_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoeqQS37o9s8ZcLBJUtT4hghAmI5RqsvcQ0OvsUn3XPfl7GkjxljufyxuL8+m1mCHP2IA1jdYT3kJQoQYXP6ZpQ==
|
||||
- FLUXER_API_PUBLIC_ENDPOINT=https://api.canary.fluxer.app
|
||||
- FLUXER_GATEWAY_ENDPOINT=wss://gateway.fluxer.app
|
||||
- FLUXER_MARKETING_ENDPOINT=https://canary.fluxer.app
|
||||
- FLUXER_PATH_MARKETING=/
|
||||
- FLUXER_ADMIN_ENDPOINT=https://admin.canary.fluxer.app
|
||||
- FLUXER_PATH_ADMIN=/
|
||||
- ADMIN_OAUTH2_CLIENT_ID=1440355698178071552
|
||||
- ADMIN_OAUTH2_REDIRECT_URI=https://admin.canary.fluxer.app/oauth2_callback
|
||||
- ADMIN_OAUTH2_AUTO_CREATE=false
|
||||
- PASSKEYS_ENABLED=true
|
||||
- PASSKEY_RP_NAME=Fluxer
|
||||
- PASSKEY_RP_ID=fluxer.app
|
||||
- PASSKEY_ALLOWED_ORIGINS=https://web.fluxer.app,https://web.canary.fluxer.app
|
||||
- CAPTCHA_ENABLED=true
|
||||
- CAPTCHA_PRIMARY_PROVIDER=turnstile
|
||||
- HCAPTCHA_SITE_KEY=9cbad400-df84-4e0c-bda6-e65000be78aa
|
||||
- TURNSTILE_SITE_KEY=0x4AAAAAAB_lAoDdTWznNHMq
|
||||
- EMAIL_ENABLED=true
|
||||
- SMS_ENABLED=true
|
||||
- VOICE_ENABLED=true
|
||||
- SEARCH_ENABLED=true
|
||||
- MEILISEARCH_URL=http://meilisearch:7700
|
||||
- STRIPE_ENABLED=true
|
||||
- STRIPE_PRICE_ID_MONTHLY_USD=price_1SJHZzFPC94Os7FdzBgvz0go
|
||||
- STRIPE_PRICE_ID_YEARLY_USD=price_1SJHabFPC94Os7FdhSOWVfcr
|
||||
- STRIPE_PRICE_ID_VISIONARY_USD=price_1SJHGTFPC94Os7FdWTyqvJZ8
|
||||
- STRIPE_PRICE_ID_MONTHLY_EUR=price_1SJHaFFPC94Os7FdmcrGicXa
|
||||
- STRIPE_PRICE_ID_YEARLY_EUR=price_1SJHarFPC94Os7Fddbyzr5I8
|
||||
- STRIPE_PRICE_ID_VISIONARY_EUR=price_1SJHGnFPC94Os7FdZn23KkYB
|
||||
- STRIPE_PRICE_ID_GIFT_VISIONARY_USD=price_1SKhWqFPC94Os7FdxRmQrg3k
|
||||
- STRIPE_PRICE_ID_GIFT_VISIONARY_EUR=price_1SKhXrFPC94Os7FdcepLrJqr
|
||||
- STRIPE_PRICE_ID_GIFT_1_MONTH_USD=price_1SJHHKFPC94Os7FdGwUs1EQg
|
||||
- STRIPE_PRICE_ID_GIFT_1_YEAR_USD=price_1SJHHrFPC94Os7FdWrQN5tKl
|
||||
- STRIPE_PRICE_ID_GIFT_1_MONTH_EUR=price_1SJHHaFPC94Os7FdwwpwhliW
|
||||
- STRIPE_PRICE_ID_GIFT_1_YEAR_EUR=price_1SJHI5FPC94Os7Fd3DpLxb0D
|
||||
- FLUXER_VISIONARIES_GUILD_ID=1428504839258075143
|
||||
- FLUXER_OPERATORS_GUILD_ID=1434192442151473226
|
||||
- CLOUDFLARE_PURGE_ENABLED=true
|
||||
- CLAMAV_ENABLED=true
|
||||
- CLAMAV_HOST=clamav
|
||||
- CLAMAV_PORT=3310
|
||||
- GEOIP_HOST=fluxer-geoip_app:8080
|
||||
- GEOIP_PROVIDER=maxmind
|
||||
- MAXMIND_DB_PATH=/data/GeoLite2-City.mmdb
|
||||
- VAPID_PUBLIC_KEY=BEIwQxIwfj6m90tLYAR0AU_GJWU4kw8J_zJcHQG55NCUWSyRy-dzMOgvxk8yEDwdVyJZa6xUL4fmwngijq8T2pY
|
||||
volumes:
|
||||
- /opt/geoip/GeoLite2-City.mmdb:/data/GeoLite2-City.mmdb:ro
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=api.canary.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- 'caddy.header.Expect-Ct="max-age=86400, report-uri=\"https://o4510149383094272.ingest.us.sentry.io/api/4510205804019712/security/?sentry_key=bb16e8b823b82d788db49a666b3b4b90\""'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-api-canary
|
||||
docker service update --image ${IMAGE_TAG} fluxer-api-canary_app
|
||||
EOF
|
||||
187
.github/workflows/deploy-api-worker.yaml
vendored
Normal file
187
.github/workflows/deploy-api-worker.yaml
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
name: deploy api-worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_api/**/*
|
||||
- .github/workflows/deploy-api-worker.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-api-worker
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy worker
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_api
|
||||
file: fluxer_api/Dockerfile
|
||||
tags: fluxer-api-worker:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-api-worker
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-api-worker
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-api-worker:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-api-worker
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-api-worker
|
||||
cd /opt/fluxer-api-worker
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
worker:
|
||||
image: ${IMAGE_TAG}
|
||||
command: ['npm', 'run', 'start:worker']
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- FLUXER_API_PORT=8080
|
||||
- SENTRY_DSN=https://bb16e8b823b82d788db49a666b3b4b90@o4510149383094272.ingest.us.sentry.io/4510205804019712
|
||||
- CASSANDRA_HOSTS=cassandra
|
||||
- CASSANDRA_KEYSPACE=fluxer
|
||||
- CASSANDRA_LOCAL_DC=dc1
|
||||
- FLUXER_GATEWAY_RPC_HOST=fluxer-gateway_app
|
||||
- FLUXER_GATEWAY_RPC_PORT=8081
|
||||
- FLUXER_MEDIA_PROXY_HOST=fluxer-media-proxy_app
|
||||
- FLUXER_MEDIA_PROXY_PORT=8080
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- FLUXER_API_CLIENT_ENDPOINT=https://web.fluxer.app/api
|
||||
- FLUXER_APP_ENDPOINT=https://web.fluxer.app
|
||||
- FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
- FLUXER_MEDIA_ENDPOINT=https://fluxerusercontent.com
|
||||
- FLUXER_INVITE_ENDPOINT=https://fluxer.gg
|
||||
- FLUXER_GIFT_ENDPOINT=https://fluxer.gift
|
||||
- AWS_S3_ENDPOINT=https://s3.us-east-va.io.cloud.ovh.us
|
||||
- AWS_S3_BUCKET_CDN=fluxer
|
||||
- AWS_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
- AWS_S3_BUCKET_REPORTS=fluxer-reports
|
||||
- AWS_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
- AWS_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
- SENDGRID_FROM_EMAIL=noreply@fluxer.app
|
||||
- SENDGRID_FROM_NAME=Fluxer
|
||||
- SENDGRID_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoeqQS37o9s8ZcLBJUtT4hghAmI5RqsvcQ0OvsUn3XPfl7GkjxljufyxuL8+m1mCHP2IA1jdYT3kJQoQYXP6ZpQ==
|
||||
- FLUXER_API_PUBLIC_ENDPOINT=https://api.fluxer.app
|
||||
- FLUXER_GATEWAY_ENDPOINT=wss://gateway.fluxer.app
|
||||
- FLUXER_MARKETING_ENDPOINT=https://fluxer.app
|
||||
- FLUXER_PATH_MARKETING=/
|
||||
- FLUXER_ADMIN_ENDPOINT=https://admin.fluxer.app
|
||||
- FLUXER_PATH_ADMIN=/
|
||||
- ADMIN_OAUTH2_CLIENT_ID=1440355698178071552
|
||||
- ADMIN_OAUTH2_REDIRECT_URI=https://admin.fluxer.app/oauth2_callback
|
||||
- ADMIN_OAUTH2_AUTO_CREATE=false
|
||||
- PASSKEYS_ENABLED=true
|
||||
- PASSKEY_RP_NAME=Fluxer
|
||||
- PASSKEY_RP_ID=fluxer.app
|
||||
- PASSKEY_ALLOWED_ORIGINS=https://web.fluxer.app,https://web.canary.fluxer.app
|
||||
- CAPTCHA_ENABLED=true
|
||||
- CAPTCHA_PRIMARY_PROVIDER=turnstile
|
||||
- HCAPTCHA_SITE_KEY=9cbad400-df84-4e0c-bda6-e65000be78aa
|
||||
- TURNSTILE_SITE_KEY=0x4AAAAAAB_lAoDdTWznNHMq
|
||||
- EMAIL_ENABLED=true
|
||||
- SMS_ENABLED=true
|
||||
- VOICE_ENABLED=true
|
||||
- SEARCH_ENABLED=true
|
||||
- MEILISEARCH_URL=http://meilisearch:7700
|
||||
- STRIPE_ENABLED=true
|
||||
- STRIPE_PRICE_ID_MONTHLY_USD=price_1SJHZzFPC94Os7FdzBgvz0go
|
||||
- STRIPE_PRICE_ID_YEARLY_USD=price_1SJHabFPC94Os7FdhSOWVfcr
|
||||
- STRIPE_PRICE_ID_VISIONARY_USD=price_1SJHGTFPC94Os7FdWTyqvJZ8
|
||||
- STRIPE_PRICE_ID_MONTHLY_EUR=price_1SJHaFFPC94Os7FdmcrGicXa
|
||||
- STRIPE_PRICE_ID_YEARLY_EUR=price_1SJHarFPC94Os7Fddbyzr5I8
|
||||
- STRIPE_PRICE_ID_VISIONARY_EUR=price_1SJHGnFPC94Os7FdZn23KkYB
|
||||
- STRIPE_PRICE_ID_GIFT_VISIONARY_USD=price_1SKhWqFPC94Os7FdxRmQrg3k
|
||||
- STRIPE_PRICE_ID_GIFT_VISIONARY_EUR=price_1SKhXrFPC94Os7FdcepLrJqr
|
||||
- STRIPE_PRICE_ID_GIFT_1_MONTH_USD=price_1SJHHKFPC94Os7FdGwUs1EQg
|
||||
- STRIPE_PRICE_ID_GIFT_1_YEAR_USD=price_1SJHHrFPC94Os7FdWrQN5tKl
|
||||
- STRIPE_PRICE_ID_GIFT_1_MONTH_EUR=price_1SJHHaFPC94Os7FdwwpwhliW
|
||||
- STRIPE_PRICE_ID_GIFT_1_YEAR_EUR=price_1SJHI5FPC94Os7Fd3DpLxb0D
|
||||
- FLUXER_VISIONARIES_GUILD_ID=1428504839258075143
|
||||
- FLUXER_OPERATORS_GUILD_ID=1434192442151473226
|
||||
- CLOUDFLARE_PURGE_ENABLED=true
|
||||
- CLAMAV_ENABLED=true
|
||||
- CLAMAV_HOST=clamav
|
||||
- CLAMAV_PORT=3310
|
||||
- GEOIP_HOST=fluxer-geoip_app:8080
|
||||
- GEOIP_PROVIDER=maxmind
|
||||
- MAXMIND_DB_PATH=/data/GeoLite2-City.mmdb
|
||||
- VAPID_PUBLIC_KEY=BEIwQxIwfj6m90tLYAR0AU_GJWU4kw8J_zJcHQG55NCUWSyRy-dzMOgvxk8yEDwdVyJZa6xUL4fmwngijq8T2pY
|
||||
volumes:
|
||||
- /opt/geoip/GeoLite2-City.mmdb:/data/GeoLite2-City.mmdb:ro
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
networks:
|
||||
- fluxer-shared
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-api-worker
|
||||
docker service update --image ${IMAGE_TAG} fluxer-api-worker_worker
|
||||
EOF
|
||||
202
.github/workflows/deploy-api.yaml
vendored
Normal file
202
.github/workflows/deploy-api.yaml
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
name: deploy api
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_api/**/*
|
||||
- .github/workflows/deploy-api.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-api
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_api
|
||||
file: fluxer_api/Dockerfile
|
||||
tags: fluxer-api:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-api
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-api
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-api:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-api
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-api
|
||||
cd /opt/fluxer-api
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
command: ['npm', 'run', 'start']
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- FLUXER_API_PORT=8080
|
||||
- SENTRY_DSN=https://bb16e8b823b82d788db49a666b3b4b90@o4510149383094272.ingest.us.sentry.io/4510205804019712
|
||||
- CASSANDRA_HOSTS=cassandra
|
||||
- CASSANDRA_KEYSPACE=fluxer
|
||||
- CASSANDRA_LOCAL_DC=dc1
|
||||
- FLUXER_GATEWAY_RPC_HOST=fluxer-gateway_app
|
||||
- FLUXER_GATEWAY_RPC_PORT=8081
|
||||
- FLUXER_MEDIA_PROXY_HOST=fluxer-media-proxy_app
|
||||
- FLUXER_MEDIA_PROXY_PORT=8080
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- FLUXER_API_CLIENT_ENDPOINT=https://web.fluxer.app/api
|
||||
- FLUXER_APP_ENDPOINT=https://web.fluxer.app
|
||||
- FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
- FLUXER_MEDIA_ENDPOINT=https://fluxerusercontent.com
|
||||
- FLUXER_INVITE_ENDPOINT=https://fluxer.gg
|
||||
- FLUXER_GIFT_ENDPOINT=https://fluxer.gift
|
||||
- AWS_S3_ENDPOINT=https://s3.us-east-va.io.cloud.ovh.us
|
||||
- AWS_S3_BUCKET_CDN=fluxer
|
||||
- AWS_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
- AWS_S3_BUCKET_REPORTS=fluxer-reports
|
||||
- AWS_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
- AWS_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
- SENDGRID_FROM_EMAIL=noreply@fluxer.app
|
||||
- SENDGRID_FROM_NAME=Fluxer
|
||||
- SENDGRID_WEBHOOK_PUBLIC_KEY=MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoeqQS37o9s8ZcLBJUtT4hghAmI5RqsvcQ0OvsUn3XPfl7GkjxljufyxuL8+m1mCHP2IA1jdYT3kJQoQYXP6ZpQ==
|
||||
- FLUXER_API_PUBLIC_ENDPOINT=https://api.fluxer.app
|
||||
- FLUXER_GATEWAY_ENDPOINT=wss://gateway.fluxer.app
|
||||
- FLUXER_MARKETING_ENDPOINT=https://fluxer.app
|
||||
- FLUXER_PATH_MARKETING=/
|
||||
- FLUXER_ADMIN_ENDPOINT=https://admin.fluxer.app
|
||||
- FLUXER_PATH_ADMIN=/
|
||||
- ADMIN_OAUTH2_CLIENT_ID=1440355698178071552
|
||||
- ADMIN_OAUTH2_REDIRECT_URI=https://admin.fluxer.app/oauth2_callback
|
||||
- ADMIN_OAUTH2_AUTO_CREATE=false
|
||||
- PASSKEYS_ENABLED=true
|
||||
- PASSKEY_RP_NAME=Fluxer
|
||||
- PASSKEY_RP_ID=fluxer.app
|
||||
- PASSKEY_ALLOWED_ORIGINS=https://web.fluxer.app,https://web.canary.fluxer.app
|
||||
- CAPTCHA_ENABLED=true
|
||||
- CAPTCHA_PRIMARY_PROVIDER=turnstile
|
||||
- HCAPTCHA_SITE_KEY=9cbad400-df84-4e0c-bda6-e65000be78aa
|
||||
- TURNSTILE_SITE_KEY=0x4AAAAAAB_lAoDdTWznNHMq
|
||||
- EMAIL_ENABLED=true
|
||||
- SMS_ENABLED=true
|
||||
- VOICE_ENABLED=true
|
||||
- SEARCH_ENABLED=true
|
||||
- MEILISEARCH_URL=http://meilisearch:7700
|
||||
- STRIPE_ENABLED=true
|
||||
- STRIPE_PRICE_ID_MONTHLY_USD=price_1SJHZzFPC94Os7FdzBgvz0go
|
||||
- STRIPE_PRICE_ID_YEARLY_USD=price_1SJHabFPC94Os7FdhSOWVfcr
|
||||
- STRIPE_PRICE_ID_VISIONARY_USD=price_1SJHGTFPC94Os7FdWTyqvJZ8
|
||||
- STRIPE_PRICE_ID_MONTHLY_EUR=price_1SJHaFFPC94Os7FdmcrGicXa
|
||||
- STRIPE_PRICE_ID_YEARLY_EUR=price_1SJHarFPC94Os7Fddbyzr5I8
|
||||
- STRIPE_PRICE_ID_VISIONARY_EUR=price_1SJHGnFPC94Os7FdZn23KkYB
|
||||
- STRIPE_PRICE_ID_GIFT_VISIONARY_USD=price_1SKhWqFPC94Os7FdxRmQrg3k
|
||||
- STRIPE_PRICE_ID_GIFT_VISIONARY_EUR=price_1SKhXrFPC94Os7FdcepLrJqr
|
||||
- STRIPE_PRICE_ID_GIFT_1_MONTH_USD=price_1SJHHKFPC94Os7FdGwUs1EQg
|
||||
- STRIPE_PRICE_ID_GIFT_1_YEAR_USD=price_1SJHHrFPC94Os7FdWrQN5tKl
|
||||
- STRIPE_PRICE_ID_GIFT_1_MONTH_EUR=price_1SJHHaFPC94Os7FdwwpwhliW
|
||||
- STRIPE_PRICE_ID_GIFT_1_YEAR_EUR=price_1SJHI5FPC94Os7Fd3DpLxb0D
|
||||
- FLUXER_VISIONARIES_GUILD_ID=1428504839258075143
|
||||
- FLUXER_OPERATORS_GUILD_ID=1434192442151473226
|
||||
- CLOUDFLARE_PURGE_ENABLED=true
|
||||
- CLAMAV_ENABLED=true
|
||||
- CLAMAV_HOST=clamav
|
||||
- CLAMAV_PORT=3310
|
||||
- GEOIP_HOST=fluxer-geoip_app:8080
|
||||
- GEOIP_PROVIDER=maxmind
|
||||
- MAXMIND_DB_PATH=/data/GeoLite2-City.mmdb
|
||||
- VAPID_PUBLIC_KEY=BEIwQxIwfj6m90tLYAR0AU_GJWU4kw8J_zJcHQG55NCUWSyRy-dzMOgvxk8yEDwdVyJZa6xUL4fmwngijq8T2pY
|
||||
volumes:
|
||||
- /opt/geoip/GeoLite2-City.mmdb:/data/GeoLite2-City.mmdb:ro
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=api.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- 'caddy.header.Expect-Ct="max-age=86400, report-uri=\"https://o4510149383094272.ingest.us.sentry.io/api/4510205804019712/security/?sentry_key=bb16e8b823b82d788db49a666b3b4b90\""'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-api
|
||||
docker service update --image ${IMAGE_TAG} fluxer-api_app
|
||||
EOF
|
||||
318
.github/workflows/deploy-app-canary.yaml
vendored
Normal file
318
.github/workflows/deploy-app-canary.yaml
vendored
Normal file
@@ -0,0 +1,318 @@
|
||||
name: deploy app (canary)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- fluxer_app/**/*
|
||||
- .github/workflows/deploy-app-canary.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-app-canary
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
SERVICE_NAME: fluxer-app-canary
|
||||
IMAGE_NAME: fluxer-app-canary
|
||||
COMPOSE_STACK: fluxer-app-canary
|
||||
DOCKERFILE: fluxer_app/proxy/Dockerfile
|
||||
SENTRY_PROXY_PATH: /error-reporting-proxy
|
||||
DEPLOY_BRANCH: ${{ github.event_name == 'workflow_dispatch' && 'canary' || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app (canary)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && 'refs/heads/canary' || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.26.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: fluxer_app/pnpm-lock.yaml
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25.5'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: fluxer_app
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Lingui i18n tasks
|
||||
working-directory: fluxer_app
|
||||
run: pnpm lingui:extract && pnpm lingui:compile --strict
|
||||
|
||||
- name: Record deploy commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha=$(git rev-parse HEAD)
|
||||
echo "Deploying commit ${sha}"
|
||||
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
fluxer_app/crates/gif_wasm/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('fluxer_app/crates/gif_wasm/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v wasm-pack >/dev/null 2>&1; then
|
||||
cargo install wasm-pack --version 0.13.1
|
||||
fi
|
||||
|
||||
- name: Generate wasm artifacts
|
||||
working-directory: fluxer_app
|
||||
run: pnpm wasm:codegen
|
||||
|
||||
- name: Build application
|
||||
working-directory: fluxer_app
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: https://web.canary.fluxer.app/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://api.canary.fluxer.app
|
||||
PUBLIC_API_VERSION: 1
|
||||
PUBLIC_PROJECT_ENV: canary
|
||||
PUBLIC_SENTRY_PROJECT_ID: 4510205815291904
|
||||
PUBLIC_SENTRY_PUBLIC_KEY: 59ced0e2666ab83dd1ddb056cdd22d1b
|
||||
PUBLIC_SENTRY_DSN: https://59ced0e2666ab83dd1ddb056cdd22d1b@sentry.web.canary.fluxer.app/4510205815291904
|
||||
PUBLIC_SENTRY_PROXY_PATH: ${{ env.SENTRY_PROXY_PATH }}
|
||||
PUBLIC_BUILD_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PUBLIC_BUILD_SHA=$(git rev-parse --short HEAD)
|
||||
export PUBLIC_BUILD_TIMESTAMP=$(date +%s)
|
||||
pnpm build
|
||||
cat > dist/version.json << EOF
|
||||
{
|
||||
"sha": "$PUBLIC_BUILD_SHA",
|
||||
"buildNumber": $PUBLIC_BUILD_NUMBER,
|
||||
"timestamp": $PUBLIC_BUILD_TIMESTAMP,
|
||||
"env": "canary"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rclone >/dev/null 2>&1; then
|
||||
curl -fsSL https://rclone.org/install.sh | sudo bash
|
||||
fi
|
||||
|
||||
- name: Upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf << RCLONEEOF
|
||||
[ovh]
|
||||
type = s3
|
||||
provider = Other
|
||||
env_auth = true
|
||||
endpoint = https://s3.us-east-va.io.cloud.ovh.us
|
||||
acl = public-read
|
||||
RCLONEEOF
|
||||
|
||||
rclone copy fluxer_app/dist/assets ovh:fluxer-static/assets \
|
||||
--transfers 32 \
|
||||
--checkers 16 \
|
||||
--size-only \
|
||||
--fast-list \
|
||||
--s3-upload-concurrency 8 \
|
||||
--s3-chunk-size 16M \
|
||||
-v
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ env.DEPLOY_SHA }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=${{ env.SERVICE_NAME }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.SERVICE_NAME }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: ${{ env.IMAGE_NAME }}:${{ env.DEPLOY_SHA }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
SENTRY_DSN: https://59ced0e2666ab83dd1ddb056cdd22d1b@o4510149383094272.ingest.us.sentry.io/4510205815291904
|
||||
SENTRY_PROXY_PATH: ${{ env.SENTRY_PROXY_PATH }}
|
||||
SENTRY_REPORT_HOST: https://sentry.web.canary.fluxer.app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} \
|
||||
"IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK} SENTRY_DSN=${SENTRY_DSN} SENTRY_PROXY_PATH=${SENTRY_PROXY_PATH} SENTRY_REPORT_HOST=${SENTRY_REPORT_HOST}" bash << 'EOF'
|
||||
set -euo pipefail
|
||||
sudo mkdir -p /opt/${SERVICE_NAME}
|
||||
sudo chown -R ${USER}:${USER} /opt/${SERVICE_NAME}
|
||||
cd /opt/${SERVICE_NAME}
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=web.canary.fluxer.app'
|
||||
- 'caddy.handle_path_0=/api*'
|
||||
- 'caddy.handle_path_0.reverse_proxy=http://fluxer-api-canary_app:8080'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- "caddy.header.Expect-Ct=max-age=86400, report-uri=\"${SENTRY_REPORT_HOST}/api/4510205815291904/security/?sentry_key=59ced0e2666ab83dd1ddb056cdd22d1b\""
|
||||
- 'caddy.header.Cache-Control="no-store, no-cache, must-revalidate"'
|
||||
- 'caddy.header.Pragma=no-cache'
|
||||
- 'caddy.header.Expires=0'
|
||||
environment:
|
||||
- PORT=8080
|
||||
- RELEASE_CHANNEL=canary
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
- SENTRY_PROXY_PATH=${SENTRY_PROXY_PATH}
|
||||
- SENTRY_REPORT_HOST=${SENTRY_REPORT_HOST}
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
sentry:
|
||||
image: ${IMAGE_TAG}
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=sentry.web.canary.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- "caddy.header.Expect-Ct=max-age=86400, report-uri=\"${SENTRY_REPORT_HOST}/api/4510205815291904/security/?sentry_key=59ced0e2666ab83dd1ddb056cdd22d1b\""
|
||||
- 'caddy.header.Cache-Control="no-store, no-cache, must-revalidate"'
|
||||
- 'caddy.header.Pragma=no-cache'
|
||||
- 'caddy.header.Expires=0'
|
||||
environment:
|
||||
- PORT=8080
|
||||
- RELEASE_CHANNEL=canary
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
- SENTRY_PROXY_PATH=/
|
||||
- SENTRY_REPORT_HOST=${SENTRY_REPORT_HOST}
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml ${COMPOSE_STACK}
|
||||
docker service update --image ${IMAGE_TAG} fluxer-app-canary_app
|
||||
docker service update --image ${IMAGE_TAG} fluxer-app-canary_sentry
|
||||
EOF
|
||||
316
.github/workflows/deploy-app.yaml
vendored
Normal file
316
.github/workflows/deploy-app.yaml
vendored
Normal file
@@ -0,0 +1,316 @@
|
||||
name: deploy app
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_app/**/*
|
||||
- .github/workflows/deploy-app.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-app-stable
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
SERVICE_NAME: fluxer-app-stable
|
||||
IMAGE_NAME: fluxer-app-stable
|
||||
COMPOSE_STACK: fluxer-app-stable
|
||||
DOCKERFILE: fluxer_app/proxy/Dockerfile
|
||||
SENTRY_PROXY_PATH: /error-reporting-proxy
|
||||
DEPLOY_BRANCH: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app (stable)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && 'refs/heads/main' || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.26.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: fluxer_app/pnpm-lock.yaml
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25.5'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: fluxer_app
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Lingui i18n tasks
|
||||
working-directory: fluxer_app
|
||||
run: pnpm lingui:extract && pnpm lingui:compile --strict
|
||||
|
||||
- name: Record deploy commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha=$(git rev-parse HEAD)
|
||||
echo "Deploying commit ${sha}"
|
||||
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
fluxer_app/crates/gif_wasm/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('fluxer_app/crates/gif_wasm/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v wasm-pack >/dev/null 2>&1; then
|
||||
cargo install wasm-pack --version 0.13.1
|
||||
fi
|
||||
|
||||
- name: Generate wasm artifacts
|
||||
working-directory: fluxer_app
|
||||
run: pnpm wasm:codegen
|
||||
|
||||
- name: Build application
|
||||
working-directory: fluxer_app
|
||||
env:
|
||||
NODE_ENV: production
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: https://web.fluxer.app/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://api.fluxer.app
|
||||
PUBLIC_API_VERSION: 1
|
||||
PUBLIC_PROJECT_ENV: stable
|
||||
PUBLIC_SENTRY_PROJECT_ID: 4510205815291904
|
||||
PUBLIC_SENTRY_PUBLIC_KEY: 59ced0e2666ab83dd1ddb056cdd22d1b
|
||||
PUBLIC_SENTRY_DSN: https://59ced0e2666ab83dd1ddb056cdd22d1b@sentry.web.fluxer.app/4510205815291904
|
||||
PUBLIC_SENTRY_PROXY_PATH: ${{ env.SENTRY_PROXY_PATH }}
|
||||
PUBLIC_BUILD_NUMBER: ${{ github.run_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PUBLIC_BUILD_SHA=$(git rev-parse --short HEAD)
|
||||
export PUBLIC_BUILD_TIMESTAMP=$(date +%s)
|
||||
pnpm build
|
||||
cat > dist/version.json << EOF
|
||||
{
|
||||
"sha": "$PUBLIC_BUILD_SHA",
|
||||
"buildNumber": $PUBLIC_BUILD_NUMBER,
|
||||
"timestamp": $PUBLIC_BUILD_TIMESTAMP,
|
||||
"env": "stable"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rclone >/dev/null 2>&1; then
|
||||
curl -fsSL https://rclone.org/install.sh | sudo bash
|
||||
fi
|
||||
|
||||
- name: Upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf << RCLONEEOF
|
||||
[ovh]
|
||||
type = s3
|
||||
provider = Other
|
||||
env_auth = true
|
||||
endpoint = https://s3.us-east-va.io.cloud.ovh.us
|
||||
acl = public-read
|
||||
RCLONEEOF
|
||||
|
||||
rclone copy fluxer_app/dist/assets ovh:fluxer-static/assets \
|
||||
--transfers 32 \
|
||||
--checkers 16 \
|
||||
--size-only \
|
||||
--fast-list \
|
||||
--s3-upload-concurrency 8 \
|
||||
--s3-chunk-size 16M \
|
||||
-v
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ env.DEPLOY_SHA }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=${{ env.SERVICE_NAME }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.SERVICE_NAME }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: ${{ env.IMAGE_NAME }}:${{ env.DEPLOY_SHA }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
SENTRY_DSN: https://59ced0e2666ab83dd1ddb056cdd22d1b@o4510149383094272.ingest.us.sentry.io/4510205815291904
|
||||
SENTRY_PROXY_PATH: ${{ env.SENTRY_PROXY_PATH }}
|
||||
SENTRY_REPORT_HOST: https://sentry.web.fluxer.app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} \
|
||||
"IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK} SENTRY_DSN=${SENTRY_DSN} SENTRY_PROXY_PATH=${SENTRY_PROXY_PATH} SENTRY_REPORT_HOST=${SENTRY_REPORT_HOST}" bash << 'EOF'
|
||||
set -euo pipefail
|
||||
sudo mkdir -p /opt/${SERVICE_NAME}
|
||||
sudo chown -R ${USER}:${USER} /opt/${SERVICE_NAME}
|
||||
cd /opt/${SERVICE_NAME}
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=web.fluxer.app'
|
||||
- 'caddy.handle_path_0=/api*'
|
||||
- 'caddy.handle_path_0.reverse_proxy=http://fluxer-api_app:8080'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- "caddy.header.Expect-Ct=max-age=86400, report-uri=\"${SENTRY_REPORT_HOST}/api/4510205815291904/security/?sentry_key=59ced0e2666ab83dd1ddb056cdd22d1b\""
|
||||
- 'caddy.header.Cache-Control="no-store, no-cache, must-revalidate"'
|
||||
- 'caddy.header.Pragma=no-cache'
|
||||
- 'caddy.header.Expires=0'
|
||||
environment:
|
||||
- PORT=8080
|
||||
- RELEASE_CHANNEL=stable
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
- SENTRY_PROXY_PATH=${SENTRY_PROXY_PATH}
|
||||
- SENTRY_REPORT_HOST=${SENTRY_REPORT_HOST}
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
sentry:
|
||||
image: ${IMAGE_TAG}
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=sentry.web.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- "caddy.header.Expect-Ct=max-age=86400, report-uri=\"${SENTRY_REPORT_HOST}/api/4510205815291904/security/?sentry_key=59ced0e2666ab83dd1ddb056cdd22d1b\""
|
||||
- 'caddy.header.Cache-Control="no-store, no-cache, must-revalidate"'
|
||||
- 'caddy.header.Pragma=no-cache'
|
||||
- 'caddy.header.Expires=0'
|
||||
environment:
|
||||
- PORT=8080
|
||||
- RELEASE_CHANNEL=stable
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
- SENTRY_PROXY_PATH=/
|
||||
- SENTRY_REPORT_HOST=${SENTRY_REPORT_HOST}
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml ${COMPOSE_STACK}
|
||||
docker service update --image ${IMAGE_TAG} fluxer-app-stable_app
|
||||
docker service update --image ${IMAGE_TAG} fluxer-app-stable_sentry
|
||||
EOF
|
||||
129
.github/workflows/deploy-docs-canary.yaml
vendored
Normal file
129
.github/workflows/deploy-docs-canary.yaml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: deploy docs (canary)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- fluxer_docs/**/*
|
||||
- .github/workflows/deploy-docs-canary.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-docs-canary
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app (canary)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_docs
|
||||
file: fluxer_docs/Dockerfile
|
||||
tags: fluxer-docs-canary:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-docs-canary
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-docs-canary
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-docs-canary:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-docs-canary
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-docs-canary
|
||||
cd /opt/fluxer-docs-canary
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=docs.canary.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 3000}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-docs-canary
|
||||
docker service update --image ${IMAGE_TAG} fluxer-docs-canary_app
|
||||
EOF
|
||||
128
.github/workflows/deploy-docs.yaml
vendored
Normal file
128
.github/workflows/deploy-docs.yaml
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
name: deploy docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_docs/**/*
|
||||
- .github/workflows/deploy-docs.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-docs
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_docs
|
||||
file: fluxer_docs/Dockerfile
|
||||
tags: fluxer-docs:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-docs
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-docs
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-docs:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-docs
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-docs
|
||||
cd /opt/fluxer-docs
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=docs.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 3000}}'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:3000']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-docs
|
||||
docker service update --image ${IMAGE_TAG} fluxer-docs_app
|
||||
EOF
|
||||
278
.github/workflows/deploy-gateway.yaml
vendored
Normal file
278
.github/workflows/deploy-gateway.yaml
vendored
Normal file
@@ -0,0 +1,278 @@
|
||||
name: deploy gateway
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
- main
|
||||
paths:
|
||||
- 'fluxer_gateway/**'
|
||||
|
||||
concurrency:
|
||||
group: deploy-gateway
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy (hot patch)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: fluxer_gateway
|
||||
|
||||
- name: Set up Erlang
|
||||
uses: erlef/setup-beam@v1
|
||||
with:
|
||||
otp-version: "28"
|
||||
rebar3-version: "3.24.0"
|
||||
|
||||
- name: Compile
|
||||
working-directory: fluxer_gateway
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rebar3 as prod compile
|
||||
|
||||
- name: Set up SSH
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
GATEWAY_ADMIN_SECRET: ${{ secrets.GATEWAY_ADMIN_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER_ID="$(ssh "${SERVER}" "docker ps -q --filter label=com.docker.swarm.service.name=fluxer-gateway_app | head -1")"
|
||||
if [ -z "${CONTAINER_ID}" ]; then
|
||||
echo "::error::No running container found for service fluxer-gateway_app"
|
||||
ssh "${SERVER}" "docker ps --filter 'name=fluxer-gateway_app' --format '{{.ID}} {{.Names}} {{.Status}}'" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Container: ${CONTAINER_ID}"
|
||||
|
||||
LOCAL_MD5_LINES="$(
|
||||
erl -noshell -eval '
|
||||
Files = filelib:wildcard("fluxer_gateway/_build/prod/lib/fluxer_gateway/ebin/*.beam"),
|
||||
lists:foreach(
|
||||
fun(F) ->
|
||||
{ok, {M, Md5}} = beam_lib:md5(F),
|
||||
Hex = binary:encode_hex(Md5, lowercase),
|
||||
io:format("~s ~s ~s~n", [atom_to_list(M), binary_to_list(Hex), F])
|
||||
end,
|
||||
Files
|
||||
),
|
||||
halt().'
|
||||
)"
|
||||
|
||||
REMOTE_MD5_LINES="$(
|
||||
ssh "${SERVER}" "docker exec ${CONTAINER_ID} /opt/fluxer_gateway/bin/fluxer_gateway eval '
|
||||
Mods = hot_reload:get_loaded_modules(),
|
||||
lists:foreach(
|
||||
fun(M) ->
|
||||
case hot_reload:get_module_info(M) of
|
||||
{ok, Info} ->
|
||||
V = maps:get(loaded_md5, Info),
|
||||
S = case V of
|
||||
null -> \"null\";
|
||||
B when is_binary(B) -> binary_to_list(B)
|
||||
end,
|
||||
io:format(\"~s ~s~n\", [atom_to_list(M), S]);
|
||||
_ ->
|
||||
ok
|
||||
end
|
||||
end,
|
||||
Mods
|
||||
),
|
||||
ok.
|
||||
' " | tr -d '\r'
|
||||
)"
|
||||
|
||||
LOCAL_MD5_FILE="$(mktemp)"
|
||||
REMOTE_MD5_FILE="$(mktemp)"
|
||||
CHANGED_FILE_LIST="$(mktemp)"
|
||||
CHANGED_MAIN_LIST="$(mktemp)"
|
||||
CHANGED_SELF_LIST="$(mktemp)"
|
||||
RELOAD_RESULT_MAIN="$(mktemp)"
|
||||
RELOAD_RESULT_SELF="$(mktemp)"
|
||||
trap 'rm -f "${LOCAL_MD5_FILE}" "${REMOTE_MD5_FILE}" "${CHANGED_FILE_LIST}" "${CHANGED_MAIN_LIST}" "${CHANGED_SELF_LIST}" "${RELOAD_RESULT_MAIN}" "${RELOAD_RESULT_SELF}"' EXIT
|
||||
|
||||
printf '%s' "${LOCAL_MD5_LINES}" > "${LOCAL_MD5_FILE}"
|
||||
printf '%s' "${REMOTE_MD5_LINES}" > "${REMOTE_MD5_FILE}"
|
||||
|
||||
python3 - <<'PY' "${LOCAL_MD5_FILE}" "${REMOTE_MD5_FILE}" "${CHANGED_FILE_LIST}"
|
||||
import sys
|
||||
|
||||
local_path, remote_path, out_path = sys.argv[1:4]
|
||||
|
||||
remote = {}
|
||||
with open(remote_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
mod, md5 = parts
|
||||
remote[mod] = md5.strip()
|
||||
|
||||
changed_paths = []
|
||||
with open(local_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(" ", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
mod, md5, path = parts
|
||||
r = remote.get(mod)
|
||||
if r is None or r == "null" or r != md5:
|
||||
changed_paths.append(path)
|
||||
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
for p in changed_paths:
|
||||
f.write(p + "\n")
|
||||
PY
|
||||
|
||||
mapfile -t CHANGED_FILES < "${CHANGED_FILE_LIST}"
|
||||
|
||||
if [ "${#CHANGED_FILES[@]}" -eq 0 ]; then
|
||||
echo "No BEAM changes detected, nothing to hot-reload."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changed modules count: ${#CHANGED_FILES[@]}"
|
||||
|
||||
while IFS= read -r p; do
|
||||
[ -n "${p}" ] || continue
|
||||
m="$(basename "${p}")"
|
||||
m="${m%.beam}"
|
||||
if [ "${m}" = "hot_reload" ] || [ "${m}" = "hot_reload_handler" ]; then
|
||||
printf '%s\n' "${p}" >> "${CHANGED_SELF_LIST}"
|
||||
else
|
||||
printf '%s\n' "${p}" >> "${CHANGED_MAIN_LIST}"
|
||||
fi
|
||||
done < "${CHANGED_FILE_LIST}"
|
||||
|
||||
build_json() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys, json, base64, os
|
||||
list_path = sys.argv[1]
|
||||
beams = []
|
||||
with open(list_path, "r", encoding="utf-8") as f:
|
||||
for path in f:
|
||||
path = path.strip()
|
||||
if not path:
|
||||
continue
|
||||
mod = os.path.basename(path)
|
||||
if not mod.endswith(".beam"):
|
||||
continue
|
||||
mod = mod[:-5]
|
||||
with open(path, "rb") as bf:
|
||||
b = bf.read()
|
||||
beams.append({"module": mod, "beam_b64": base64.b64encode(b).decode("ascii")})
|
||||
print(json.dumps({"beams": beams, "purge": "soft"}, separators=(",", ":")))
|
||||
PY
|
||||
}
|
||||
|
||||
strict_verify() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
print("::error::Empty reload response")
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception as e:
|
||||
print(f"::error::Invalid JSON reload response: {e}")
|
||||
raise SystemExit(1)
|
||||
results = data.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
print("::error::Reload response missing results array")
|
||||
raise SystemExit(1)
|
||||
bad = [
|
||||
r for r in results
|
||||
if r.get("status") != "ok"
|
||||
or r.get("verified") is not True
|
||||
or r.get("purged_old_code") is not True
|
||||
or (r.get("lingering_count") or 0) != 0
|
||||
]
|
||||
if bad:
|
||||
print("::error::Hot reload verification failed")
|
||||
print(json.dumps(bad, indent=2))
|
||||
raise SystemExit(1)
|
||||
print(f"Verified {len(results)} modules")
|
||||
'
|
||||
}
|
||||
|
||||
self_verify() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
raw = sys.stdin.read()
|
||||
if not raw.strip():
|
||||
print("::error::Empty reload response")
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception as e:
|
||||
print(f"::error::Invalid JSON reload response: {e}")
|
||||
raise SystemExit(1)
|
||||
results = data.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
print("::error::Reload response missing results array")
|
||||
raise SystemExit(1)
|
||||
bad = [
|
||||
r for r in results
|
||||
if r.get("status") != "ok"
|
||||
or r.get("verified") is not True
|
||||
]
|
||||
if bad:
|
||||
print("::error::Hot reload verification failed")
|
||||
print(json.dumps(bad, indent=2))
|
||||
raise SystemExit(1)
|
||||
warns = [
|
||||
r for r in results
|
||||
if r.get("purged_old_code") is not True
|
||||
or (r.get("lingering_count") or 0) != 0
|
||||
]
|
||||
if warns:
|
||||
print("::warning::Self-reload modules may linger until request completes")
|
||||
print(json.dumps(warns, indent=2))
|
||||
print(f"Verified {len(results)} self modules")
|
||||
'
|
||||
}
|
||||
|
||||
if [ -s "${CHANGED_MAIN_LIST}" ]; then
|
||||
if ! build_json "${CHANGED_MAIN_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -fsS -X POST -H 'Authorization: Bearer ${GATEWAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:8081/_admin/reload" | tee "${RELOAD_RESULT_MAIN}" | strict_verify; then
|
||||
echo "::group::Hot reload response (main)"
|
||||
cat "${RELOAD_RESULT_MAIN}" || true
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -s "${CHANGED_SELF_LIST}" ]; then
|
||||
if ! build_json "${CHANGED_SELF_LIST}" | ssh "${SERVER}" "docker exec -i ${CONTAINER_ID} curl -fsS -X POST -H 'Authorization: Bearer ${GATEWAY_ADMIN_SECRET}' -H 'Content-Type: application/json' --data @- http://localhost:8081/_admin/reload" | tee "${RELOAD_RESULT_SELF}" | self_verify; then
|
||||
echo "::group::Hot reload response (self)"
|
||||
cat "${RELOAD_RESULT_SELF}" || true
|
||||
echo "::endgroup::"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
117
.github/workflows/deploy-geoip.yaml
vendored
Normal file
117
.github/workflows/deploy-geoip.yaml
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
name: deploy geoip
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_geoip/**/*
|
||||
- .github/workflows/deploy-geoip.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-geoip
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy geoip
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_geoip
|
||||
file: fluxer_geoip/Dockerfile
|
||||
tags: fluxer-geoip:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-geoip
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-geoip
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-geoip:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} bash << EOF
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-geoip
|
||||
sudo chown -R \${USER}:\${USER} /opt/fluxer-geoip
|
||||
cd /opt/fluxer-geoip
|
||||
|
||||
cat > compose.yaml << 'COMPOSEEOF'
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
volumes:
|
||||
- /etc/fluxer/ipinfo_lite.mmdb:/data/ipinfo_lite.mmdb:ro
|
||||
deploy:
|
||||
replicas: 3
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
environment:
|
||||
- FLUXER_GEOIP_PORT=8080
|
||||
- GEOIP_DB_PATH=/data/ipinfo_lite.mmdb
|
||||
- GEOIP_CACHE_TTL=10m
|
||||
- GEOIP_CACHE_SIZE=20000
|
||||
networks:
|
||||
- fluxer-shared
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-geoip
|
||||
docker service update --image ${IMAGE_TAG} fluxer-geoip_app
|
||||
EOF
|
||||
148
.github/workflows/deploy-marketing-canary.yaml
vendored
Normal file
148
.github/workflows/deploy-marketing-canary.yaml
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
name: deploy marketing (canary)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- fluxer_marketing/**/*
|
||||
- .github/workflows/deploy-marketing-canary.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-marketing-canary
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app (canary)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && 'refs/heads/canary' || github.ref }}
|
||||
|
||||
- name: Record deploy commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha=$(git rev-parse HEAD)
|
||||
echo "Deploying commit ${sha}"
|
||||
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_marketing
|
||||
file: fluxer_marketing/Dockerfile
|
||||
tags: fluxer-marketing-canary:${{ env.DEPLOY_SHA }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-marketing-canary
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-marketing-canary
|
||||
build-args: |
|
||||
BUILD_TIMESTAMP=${{ github.run_id }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-marketing-canary:${{ env.DEPLOY_SHA }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-marketing-canary
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-marketing-canary
|
||||
cd /opt/fluxer-marketing-canary
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- FLUXER_API_PUBLIC_ENDPOINT=https://api.canary.fluxer.app
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- FLUXER_API_HOST=fluxer-api-canary_app:8080
|
||||
- FLUXER_APP_ENDPOINT=https://web.canary.fluxer.app
|
||||
- FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
- FLUXER_MARKETING_ENDPOINT=https://canary.fluxer.app
|
||||
- FLUXER_MARKETING_PORT=8080
|
||||
- FLUXER_PATH_MARKETING=/
|
||||
- GEOIP_HOST=fluxer-geoip_app:8080
|
||||
- RELEASE_CHANNEL=canary
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=canary.fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- 'caddy.@channels.path=/channels /channels/*'
|
||||
- 'caddy.redir=@channels https://web.canary.fluxer.app{uri}'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-marketing-canary
|
||||
docker service update --image ${IMAGE_TAG} fluxer-marketing-canary_app
|
||||
EOF
|
||||
165
.github/workflows/deploy-marketing.yaml
vendored
Normal file
165
.github/workflows/deploy-marketing.yaml
vendored
Normal file
@@ -0,0 +1,165 @@
|
||||
name: deploy marketing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_marketing/**/*
|
||||
- .github/workflows/deploy-marketing.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-marketing
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && 'refs/heads/main' || github.ref }}
|
||||
|
||||
- name: Record deploy commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha=$(git rev-parse HEAD)
|
||||
echo "Deploying commit ${sha}"
|
||||
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_marketing
|
||||
file: fluxer_marketing/Dockerfile
|
||||
tags: fluxer-marketing:${{ env.DEPLOY_SHA }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-marketing
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-marketing
|
||||
build-args: |
|
||||
BUILD_TIMESTAMP=${{ github.run_id }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-marketing:${{ env.DEPLOY_SHA }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG}" bash << 'EOF'
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-marketing
|
||||
sudo chown -R ${USER}:${USER} /opt/fluxer-marketing
|
||||
cd /opt/fluxer-marketing
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- FLUXER_API_PUBLIC_ENDPOINT=https://api.fluxer.app
|
||||
- FLUXER_API_HOST=fluxer-api_app:8080
|
||||
- FLUXER_APP_ENDPOINT=https://web.fluxer.app
|
||||
- FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
- FLUXER_MARKETING_ENDPOINT=https://fluxer.app
|
||||
- FLUXER_MARKETING_PORT=8080
|
||||
- FLUXER_PATH_MARKETING=/
|
||||
- GEOIP_HOST=fluxer-geoip_app:8080
|
||||
- RELEASE_CHANNEL=stable
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=fluxer.app'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- 'caddy.redir_0=/channels/* https://web.fluxer.app{uri}'
|
||||
- 'caddy.redir_1=/channels https://web.fluxer.app{uri}'
|
||||
- 'caddy.redir_2=/delete-my-account https://fluxer.app/help/articles/1445724566704881664 302'
|
||||
- 'caddy.redir_3=/delete-my-data https://fluxer.app/help/articles/1445730947679911936 302'
|
||||
- 'caddy.redir_4=/export-my-data https://fluxer.app/help/articles/1445731738851475456 302'
|
||||
- 'caddy.redir_5=/bugs https://fluxer.app/help/articles/1447264362996695040 302'
|
||||
- 'caddy_1=www.fluxer.app'
|
||||
- 'caddy_1.redir=https://fluxer.app{uri}'
|
||||
- 'caddy_3=fluxer.gg'
|
||||
- 'caddy_3.redir=https://web.fluxer.app/invite{uri}'
|
||||
- 'caddy_4=fluxer.gift'
|
||||
- 'caddy_4.redir=https://web.fluxer.app/gift{uri}'
|
||||
- 'caddy_5=fluxerapp.com'
|
||||
- 'caddy_5.redir=https://fluxer.app{uri}'
|
||||
- 'caddy_6=www.fluxerapp.com'
|
||||
- 'caddy_6.redir=https://fluxer.app{uri}'
|
||||
- 'caddy_7=fluxer.dev'
|
||||
- 'caddy_7.redir=https://docs.fluxer.app{uri}'
|
||||
- 'caddy_8=www.fluxer.dev'
|
||||
- 'caddy_8.redir=https://docs.fluxer.app{uri}'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml fluxer-marketing
|
||||
docker service update --image ${IMAGE_TAG} fluxer-marketing_app
|
||||
EOF
|
||||
144
.github/workflows/deploy-media-proxy.yaml
vendored
Normal file
144
.github/workflows/deploy-media-proxy.yaml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
name: deploy media-proxy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_media_proxy/**/*
|
||||
- .github/workflows/deploy-media-proxy.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-media-proxy
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVICE_NAME: fluxer-media-proxy
|
||||
IMAGE_NAME: fluxer-media-proxy
|
||||
CONTEXT_DIR: fluxer_media_proxy
|
||||
COMPOSE_STACK: fluxer-media-proxy
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy media proxy
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ env.CONTEXT_DIR }}
|
||||
file: ${{ env.CONTEXT_DIR }}/Dockerfile
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=${{ env.SERVICE_NAME }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.SERVICE_NAME }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK}" bash << 'EOF'
|
||||
set -euo pipefail
|
||||
sudo mkdir -p /opt/${SERVICE_NAME}
|
||||
sudo chown -R ${USER}:${USER} /opt/${SERVICE_NAME}
|
||||
cd /opt/${SERVICE_NAME}
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
command: ['pnpm', 'start']
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- FLUXER_MEDIA_PROXY_PORT=8080
|
||||
- FLUXER_MEDIA_PROXY_REQUIRE_CLOUDFLARE=true
|
||||
- SENTRY_DSN=https://2670068cd12b6a62f3a30a7f0055f0f1@o4510149383094272.ingest.us.sentry.io/4510205811556352
|
||||
- AWS_S3_ENDPOINT=https://s3.us-east-va.io.cloud.ovh.us
|
||||
- AWS_S3_BUCKET_CDN=fluxer
|
||||
- AWS_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=http://fluxerusercontent.com'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- 'caddy.header.Expect-Ct="max-age=86400, report-uri=\"https://o4510149383094272.ingest.us.sentry.io/api/4510205811556352/security/?sentry_key=2670068cd12b6a62f3a30a7f0055f0f1\""'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml ${COMPOSE_STACK}
|
||||
docker service update --image ${IMAGE_TAG} fluxer-media-proxy_app
|
||||
EOF
|
||||
125
.github/workflows/deploy-metrics.yaml
vendored
Normal file
125
.github/workflows/deploy-metrics.yaml
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
name: deploy metrics
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_metrics/**/*
|
||||
- .github/workflows/deploy-metrics.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-metrics
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy metrics
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_metrics
|
||||
file: fluxer_metrics/Dockerfile
|
||||
tags: fluxer-metrics:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-metrics
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-metrics
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-metrics:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} bash << EOF
|
||||
set -e
|
||||
sudo mkdir -p /opt/fluxer-metrics
|
||||
sudo chown -R \${USER}:\${USER} /opt/fluxer-metrics
|
||||
cd /opt/fluxer-metrics
|
||||
|
||||
cat > compose.yaml << 'COMPOSEEOF'
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- METRICS_PORT=8080
|
||||
- CLICKHOUSE_URL=http://clickhouse:8123
|
||||
- CLICKHOUSE_DATABASE=fluxer_metrics
|
||||
- CLICKHOUSE_USER=fluxer
|
||||
- FLUXER_ADMIN_ENDPOINT=https://admin.fluxer.app
|
||||
- ANOMALY_DETECTION_ENABLED=true
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml --with-registry-auth fluxer-metrics
|
||||
docker service update --image ${IMAGE_TAG} fluxer-metrics_app
|
||||
EOF
|
||||
144
.github/workflows/deploy-static-proxy.yaml
vendored
Normal file
144
.github/workflows/deploy-static-proxy.yaml
vendored
Normal file
@@ -0,0 +1,144 @@
|
||||
name: deploy static-proxy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_media_proxy/**/*
|
||||
- .github/workflows/deploy-static-proxy.yaml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-static-proxy
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVICE_NAME: fluxer-static-proxy
|
||||
IMAGE_NAME: fluxer-static-proxy
|
||||
CONTEXT_DIR: fluxer_media_proxy
|
||||
COMPOSE_STACK: fluxer-static-proxy
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy static proxy
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ env.CONTEXT_DIR }}
|
||||
file: ${{ env.CONTEXT_DIR }}/Dockerfile
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=${{ env.SERVICE_NAME }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.SERVICE_NAME }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK}" bash << 'EOF'
|
||||
set -euo pipefail
|
||||
sudo mkdir -p /opt/${SERVICE_NAME}
|
||||
sudo chown -R ${USER}:${USER} /opt/${SERVICE_NAME}
|
||||
cd /opt/${SERVICE_NAME}
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
command: ['pnpm', 'start']
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- FLUXER_MEDIA_PROXY_PORT=8080
|
||||
- FLUXER_MEDIA_PROXY_STATIC_MODE=true
|
||||
- FLUXER_MEDIA_PROXY_REQUIRE_CLOUDFLARE=true
|
||||
- AWS_S3_ENDPOINT=https://s3.us-east-va.io.cloud.ovh.us
|
||||
- AWS_S3_BUCKET_CDN=fluxer
|
||||
- AWS_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
- AWS_S3_BUCKET_STATIC=fluxer-static
|
||||
deploy:
|
||||
replicas: 2
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy=http://fluxerstatic.com'
|
||||
- 'caddy.reverse_proxy={{upstreams 8080}}'
|
||||
- 'caddy.header.X-Robots-Tag="noindex, nofollow, nosnippet, noimageindex"'
|
||||
- 'caddy.header.Strict-Transport-Security="max-age=31536000; includeSubDomains; preload"'
|
||||
- 'caddy.header.X-Xss-Protection="1; mode=block"'
|
||||
- 'caddy.header.X-Content-Type-Options=nosniff'
|
||||
- 'caddy.header.Referrer-Policy=strict-origin-when-cross-origin'
|
||||
- 'caddy.header.X-Frame-Options=DENY'
|
||||
- 'caddy.header.Expect-Ct="max-age=86400, report-uri=\"https://o4510149383094272.ingest.us.sentry.io/api/4510205811556352/security/?sentry_key=2670068cd12b6a62f3a30a7f0055f0f1\""'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml ${COMPOSE_STACK}
|
||||
docker service update --image ${IMAGE_TAG} fluxer-static-proxy_app
|
||||
EOF
|
||||
125
.github/workflows/migrate-cassandra.yaml
vendored
Normal file
125
.github/workflows/migrate-cassandra.yaml
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
name: migrate cassandra
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
paths:
|
||||
- fluxer_devops/cassandra/migrations/**/*.cql
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: migrate-cassandra-prod
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
migrate:
|
||||
name: Run database migrations
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
scripts/cassandra-migrate/target/
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('scripts/cassandra-migrate/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Build migration tool
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd scripts/cassandra-migrate
|
||||
cargo build --release
|
||||
|
||||
- name: Validate migrations
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./scripts/cassandra-migrate/target/release/cassandra-migrate check
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Set up SSH tunnel for Cassandra
|
||||
run: |
|
||||
set -euo pipefail
|
||||
nohup ssh -N -o ConnectTimeout=30 -o ServerAliveInterval=10 -o ServerAliveCountMax=30 -o ExitOnForwardFailure=yes -L 9042:localhost:9042 ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} > /tmp/ssh-tunnel.log 2>&1 &
|
||||
SSH_TUNNEL_PID=$!
|
||||
printf 'SSH_TUNNEL_PID=%s\n' "$SSH_TUNNEL_PID" >> "$GITHUB_ENV"
|
||||
|
||||
for i in {1..30}; do
|
||||
if timeout 1 bash -c "echo > /dev/tcp/localhost/9042" 2>/dev/null; then
|
||||
echo "SSH tunnel established"
|
||||
break
|
||||
elif command -v ss >/dev/null 2>&1 && ss -tln | grep -q ":9042 "; then
|
||||
echo "SSH tunnel established"
|
||||
break
|
||||
elif command -v netstat >/dev/null 2>&1 && netstat -tln | grep -q ":9042 "; then
|
||||
echo "SSH tunnel established"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
cat /tmp/ssh-tunnel.log || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
ps -p $SSH_TUNNEL_PID > /dev/null || exit 1
|
||||
|
||||
- name: Test Cassandra connection
|
||||
env:
|
||||
CASSANDRA_USERNAME: ${{ secrets.CASSANDRA_USERNAME }}
|
||||
CASSANDRA_PASSWORD: ${{ secrets.CASSANDRA_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./scripts/cassandra-migrate/target/release/cassandra-migrate \
|
||||
--host localhost \
|
||||
--port 9042 \
|
||||
--username "${CASSANDRA_USERNAME}" \
|
||||
--password "${CASSANDRA_PASSWORD}" \
|
||||
test
|
||||
|
||||
- name: Run migrations
|
||||
env:
|
||||
CASSANDRA_USERNAME: ${{ secrets.CASSANDRA_USERNAME }}
|
||||
CASSANDRA_PASSWORD: ${{ secrets.CASSANDRA_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./scripts/cassandra-migrate/target/release/cassandra-migrate \
|
||||
--host localhost \
|
||||
--port 9042 \
|
||||
--username "${CASSANDRA_USERNAME}" \
|
||||
--password "${CASSANDRA_PASSWORD}" \
|
||||
up
|
||||
|
||||
- name: Close SSH tunnel
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${SSH_TUNNEL_PID:-}" ]; then
|
||||
kill "$SSH_TUNNEL_PID" 2>/dev/null || true
|
||||
fi
|
||||
pkill -f "ssh.*9042:localhost:9042" || true
|
||||
rm -f /tmp/ssh-tunnel.log || true
|
||||
143
.github/workflows/restart-gateway.yaml
vendored
Normal file
143
.github/workflows/restart-gateway.yaml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
name: restart gateway
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirmation:
|
||||
description: 'this will cause service interruption for all users. type RESTART to confirm.'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: restart-gateway
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SERVICE_NAME: fluxer-gateway
|
||||
IMAGE_NAME: fluxer-gateway
|
||||
CONTEXT_DIR: fluxer_gateway
|
||||
COMPOSE_STACK: fluxer-gateway
|
||||
|
||||
jobs:
|
||||
restart:
|
||||
name: Restart gateway
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Validate confirmation
|
||||
if: ${{ github.event.inputs.confirmation != 'RESTART' }}
|
||||
run: |
|
||||
echo "::error::Confirmation failed. You must type 'RESTART' to proceed with a full restart."
|
||||
echo "::error::For regular updates, use deploy-gateway.yaml instead."
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ${{ env.CONTEXT_DIR }}
|
||||
file: ${{ env.CONTEXT_DIR }}/Dockerfile
|
||||
tags: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=${{ env.SERVICE_NAME }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.SERVICE_NAME }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: ${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh ${IMAGE_TAG} ${SERVER}
|
||||
|
||||
ssh ${SERVER} "IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK}" bash << 'EOF'
|
||||
set -euo pipefail
|
||||
sudo mkdir -p /opt/${SERVICE_NAME}
|
||||
sudo chown -R ${USER}:${USER} /opt/${SERVICE_NAME}
|
||||
cd /opt/${SERVICE_NAME}
|
||||
|
||||
cat > compose.yaml << COMPOSEEOF
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
hostname: "{{.Node.Hostname}}-{{.Task.Slot}}"
|
||||
env_file:
|
||||
- /etc/fluxer/fluxer.env
|
||||
environment:
|
||||
- API_HOST=fluxer-api_app:8080
|
||||
- API_CANARY_HOST=fluxer-api-canary_app:8080
|
||||
- RELEASE_NODE=fluxer_gateway@{{.Node.Hostname}}-{{.Task.Slot}}
|
||||
- LOGGER_LEVEL=info
|
||||
- VAPID_PUBLIC_KEY=BEIwQxIwfj6m90tLYAR0AU_GJWU4kw8J_zJcHQG55NCUWSyRy-dzMOgvxk8yEDwdVyJZa6xUL4fmwngijq8T2pY
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
- MEDIA_PROXY_ENDPOINT=https://fluxerusercontent.com
|
||||
deploy:
|
||||
replicas: 1
|
||||
endpoint_mode: dnsrr
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
labels:
|
||||
- 'caddy_gw=gateway.fluxer.app'
|
||||
- 'caddy_gw.reverse_proxy={{upstreams 8080}}'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy -c compose.yaml ${COMPOSE_STACK}
|
||||
docker service update --image ${IMAGE_TAG} fluxer-gateway_app
|
||||
EOF
|
||||
59
.github/workflows/sync-static.yaml
vendored
Normal file
59
.github/workflows/sync-static.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: sync static-bucket
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'fluxer_static/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: sync-fluxer-static
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
RCLONE_REMOTE: ovh
|
||||
RCLONE_BUCKET: fluxer-static
|
||||
RCLONE_SOURCE: fluxer_static
|
||||
RCLONE_ENDPOINT: https://s3.us-east-va.io.cloud.ovh.us
|
||||
RCLONE_REGION: us-east-1
|
||||
RCLONE_SOURCE_DIR: fluxer_static
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
lfs: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v rclone >/dev/null 2>&1; then
|
||||
curl -fsSL https://rclone.org/install.sh | sudo bash
|
||||
fi
|
||||
|
||||
- name: Push repo contents to bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf << RCLONEEOF
|
||||
[ovh]
|
||||
type = s3
|
||||
provider = Other
|
||||
env_auth = true
|
||||
endpoint = $RCLONE_ENDPOINT
|
||||
acl = private
|
||||
RCLONEEOF
|
||||
|
||||
mkdir -p "$RCLONE_SOURCE_DIR"
|
||||
rclone sync "$RCLONE_SOURCE" "$RCLONE_REMOTE:$RCLONE_BUCKET" --create-empty-src-dirs --exclude "assets/**"
|
||||
306
.github/workflows/test-cassandra-backup.yaml
vendored
Normal file
306
.github/workflows/test-cassandra-backup.yaml
vendored
Normal file
@@ -0,0 +1,306 @@
|
||||
name: test cassandra-backup
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *'
|
||||
|
||||
concurrency:
|
||||
group: test-cassandra-backup
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-backup:
|
||||
name: Test latest Cassandra backup
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
|
||||
env:
|
||||
CASSANDRA_IMAGE: cassandra:5.0.6
|
||||
CASS_CONTAINER: cass-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
UTIL_CONTAINER: cass-util-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
CASS_VOLUME: cassandra-data-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
BACKUP_VOLUME: cassandra-backup-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
MAX_HEAP_SIZE: 2G
|
||||
HEAP_NEWSIZE: 512M
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set temp paths
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${RUNNER_TEMP:?RUNNER_TEMP is not set}"
|
||||
echo "WORKDIR=$RUNNER_TEMP/cassandra-restore-test" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pre-clean
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker rm -f "${CASS_CONTAINER}" "${UTIL_CONTAINER}" 2>/dev/null || true
|
||||
docker volume rm "${CASS_VOLUME}" 2>/dev/null || true
|
||||
docker volume rm "${BACKUP_VOLUME}" 2>/dev/null || true
|
||||
rm -rf "${WORKDIR}" 2>/dev/null || true
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y --no-install-recommends rclone age ca-certificates
|
||||
|
||||
- name: Find latest backup, validate freshness, download, decrypt, extract into Docker volume
|
||||
env:
|
||||
B2_KEY_ID: ${{ secrets.B2_KEY_ID }}
|
||||
B2_APPLICATION_KEY: ${{ secrets.B2_APPLICATION_KEY }}
|
||||
AGE_PRIVATE_KEY: ${{ secrets.CASSANDRA_AGE_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
rm -rf "$WORKDIR"
|
||||
mkdir -p "$WORKDIR"
|
||||
|
||||
export RCLONE_CONFIG_B2S3_TYPE=s3
|
||||
export RCLONE_CONFIG_B2S3_PROVIDER=Other
|
||||
export RCLONE_CONFIG_B2S3_ACCESS_KEY_ID="${B2_KEY_ID}"
|
||||
export RCLONE_CONFIG_B2S3_SECRET_ACCESS_KEY="${B2_APPLICATION_KEY}"
|
||||
export RCLONE_CONFIG_B2S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com"
|
||||
export RCLONE_CONFIG_B2S3_REGION="eu-central-003"
|
||||
export RCLONE_CONFIG_B2S3_FORCE_PATH_STYLE=true
|
||||
|
||||
LATEST_BACKUP="$(
|
||||
rclone lsf "B2S3:fluxer" --recursive --files-only --fast-list \
|
||||
| grep -E '(^|/)cassandra-backup-[0-9]{8}-[0-9]{6}\.tar\.age$' \
|
||||
| sort -r \
|
||||
| head -n 1
|
||||
)"
|
||||
|
||||
if [ -z "${LATEST_BACKUP}" ]; then
|
||||
echo "Error: No backup found in bucket"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "LATEST_BACKUP=${LATEST_BACKUP}" >> "$GITHUB_ENV"
|
||||
|
||||
base="$(basename "${LATEST_BACKUP}")"
|
||||
ts="${base#cassandra-backup-}"
|
||||
ts="${ts%.tar.age}"
|
||||
|
||||
if ! [[ "$ts" =~ ^[0-9]{8}-[0-9]{6}$ ]]; then
|
||||
echo "Error: Could not extract timestamp from backup filename: ${base}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_EPOCH="$(date -u -d "${ts:0:8} ${ts:9:2}:${ts:11:2}:${ts:13:2}" +%s)"
|
||||
CURRENT_EPOCH="$(date -u +%s)"
|
||||
AGE_HOURS=$(( (CURRENT_EPOCH - BACKUP_EPOCH) / 3600 ))
|
||||
|
||||
echo "Backup age: ${AGE_HOURS} hours"
|
||||
if [ "${AGE_HOURS}" -ge 3 ]; then
|
||||
echo "Error: Latest backup is ${AGE_HOURS} hours old (threshold: 3 hours)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rclone copyto "B2S3:fluxer/${LATEST_BACKUP}" "${WORKDIR}/backup.tar.age" --fast-list
|
||||
|
||||
umask 077
|
||||
printf '%s' "${AGE_PRIVATE_KEY}" > "${WORKDIR}/age.key"
|
||||
|
||||
docker volume create "${BACKUP_VOLUME}"
|
||||
|
||||
age -d -i "${WORKDIR}/age.key" "${WORKDIR}/backup.tar.age" \
|
||||
| docker run --rm -i \
|
||||
-v "${BACKUP_VOLUME}:/backup" \
|
||||
--entrypoint bash \
|
||||
"${CASSANDRA_IMAGE}" -lc '
|
||||
set -euo pipefail
|
||||
rm -rf /backup/*
|
||||
mkdir -p /backup/_tmp
|
||||
tar -C /backup/_tmp -xf -
|
||||
|
||||
top="$(find /backup/_tmp -maxdepth 1 -mindepth 1 -type d -name "cassandra-backup-*" | head -n 1 || true)"
|
||||
|
||||
if [ -n "$top" ] && [ -f "$top/schema.cql" ]; then
|
||||
cp -a "$top"/. /backup/
|
||||
elif [ -f /backup/_tmp/schema.cql ]; then
|
||||
cp -a /backup/_tmp/. /backup/
|
||||
else
|
||||
echo "Error: schema.cql not found after extraction"
|
||||
find /backup/_tmp -maxdepth 3 -type f -print | sed -n "1,80p" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf /backup/_tmp
|
||||
'
|
||||
|
||||
docker run --rm \
|
||||
-v "${BACKUP_VOLUME}:/backup:ro" \
|
||||
--entrypoint bash \
|
||||
"${CASSANDRA_IMAGE}" -lc '
|
||||
set -euo pipefail
|
||||
test -f /backup/schema.cql
|
||||
echo "Extracted backup layout (top 3 levels):"
|
||||
find /backup -maxdepth 3 -type d -print | sed -n "1,200p" || true
|
||||
echo "Sample SSTables (*Data.db):"
|
||||
find /backup -type f -name "*Data.db" | sed -n "1,30p" || true
|
||||
'
|
||||
|
||||
- name: Create data volume
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker volume create "${CASS_VOLUME}"
|
||||
|
||||
- name: Restore keyspaces into volume and promote snapshot SSTables
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm \
|
||||
--name "${UTIL_CONTAINER}" \
|
||||
-v "${CASS_VOLUME}:/var/lib/cassandra" \
|
||||
-v "${BACKUP_VOLUME}:/backup:ro" \
|
||||
--entrypoint bash \
|
||||
"${CASSANDRA_IMAGE}" -lc '
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
BASE=/var/lib/cassandra
|
||||
DATA_DIR="$BASE/data"
|
||||
mkdir -p "$DATA_DIR" "$BASE/commitlog" "$BASE/hints" "$BASE/saved_caches"
|
||||
|
||||
ROOT=/backup
|
||||
if [ -d "$ROOT/cassandra_data" ]; then ROOT="$ROOT/cassandra_data"; fi
|
||||
if [ -d "$ROOT/data" ]; then ROOT="$ROOT/data"; fi
|
||||
|
||||
echo "Using backup ROOT=$ROOT"
|
||||
echo "Restoring into DATA_DIR=$DATA_DIR"
|
||||
|
||||
restored=0
|
||||
for keyspace_dir in "$ROOT"/*/; do
|
||||
[ -d "$keyspace_dir" ] || continue
|
||||
ks="$(basename "$keyspace_dir")"
|
||||
|
||||
if [ "$ks" = "system_schema" ] || ! [[ "$ks" =~ ^system ]]; then
|
||||
echo "Restoring keyspace: $ks"
|
||||
rm -rf "$DATA_DIR/$ks"
|
||||
cp -a "$keyspace_dir" "$DATA_DIR/"
|
||||
restored=$((restored + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$restored" -le 0 ]; then
|
||||
echo "Error: No keyspaces restored from backup root: $ROOT"
|
||||
echo "Debug: listing $ROOT:"
|
||||
ls -la "$ROOT" || true
|
||||
find "$ROOT" -maxdepth 2 -type d -print | sed -n "1,100p" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
promoted=0
|
||||
for ks_dir in "$DATA_DIR"/*/; do
|
||||
[ -d "$ks_dir" ] || continue
|
||||
ks="$(basename "$ks_dir")"
|
||||
|
||||
if [ "$ks" != "system_schema" ] && [[ "$ks" =~ ^system ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
for table_dir in "$ks_dir"*/; do
|
||||
[ -d "$table_dir" ] || continue
|
||||
|
||||
snap_root="$table_dir/snapshots"
|
||||
[ -d "$snap_root" ] || continue
|
||||
|
||||
latest_snap="$(ls -1d "$snap_root"/*/ 2>/dev/null | sort -r | head -n 1 || true)"
|
||||
[ -n "$latest_snap" ] || continue
|
||||
|
||||
files=( "$latest_snap"* )
|
||||
if [ "${#files[@]}" -gt 0 ]; then
|
||||
cp -av "${files[@]}" "$table_dir"
|
||||
promoted=$((promoted + $(ls -1 "$latest_snap"/*Data.db 2>/dev/null | wc -l || true)))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
chown -R cassandra:cassandra "$BASE"
|
||||
|
||||
echo "Promoted Data.db files: $promoted"
|
||||
if [ "$promoted" -le 0 ]; then
|
||||
echo "Error: No *Data.db files were promoted out of snapshots"
|
||||
echo "Debug: first snapshot dirs found:"
|
||||
find "$DATA_DIR" -type d -path "*/snapshots/*" | sed -n "1,50p" || true
|
||||
exit 1
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Start Cassandra
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run -d \
|
||||
--name "${CASS_CONTAINER}" \
|
||||
-v "${CASS_VOLUME}:/var/lib/cassandra" \
|
||||
-e MAX_HEAP_SIZE="${MAX_HEAP_SIZE}" \
|
||||
-e HEAP_NEWSIZE="${HEAP_NEWSIZE}" \
|
||||
-e JVM_OPTS="-Dcassandra.disable_mlock=true" \
|
||||
"${CASSANDRA_IMAGE}"
|
||||
|
||||
for i in $(seq 1 150); do
|
||||
status="$(docker inspect -f '{{.State.Status}}' "${CASS_CONTAINER}" 2>/dev/null || true)"
|
||||
if [ "${status}" != "running" ]; then
|
||||
docker inspect "${CASS_CONTAINER}" --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Error={{.State.Error}}' || true
|
||||
docker logs --tail 300 "${CASS_CONTAINER}" || true
|
||||
exit 1
|
||||
fi
|
||||
if docker exec "${CASS_CONTAINER}" cqlsh -e "SELECT now() FROM system.local;" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
docker exec "${CASS_CONTAINER}" cqlsh -e "SELECT now() FROM system.local;" >/dev/null 2>&1
|
||||
|
||||
- name: Verify data
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
USER_COUNT=""
|
||||
for i in $(seq 1 20); do
|
||||
USER_COUNT="$(
|
||||
docker exec "${CASS_CONTAINER}" cqlsh -e "SELECT COUNT(*) FROM fluxer.users;" 2>/dev/null \
|
||||
| awk "/^[[:space:]]*[0-9]+[[:space:]]*$/ {print \$1; exit}" || true
|
||||
)"
|
||||
if [ -n "${USER_COUNT}" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -n "${USER_COUNT}" ] && [ "${USER_COUNT}" -gt 0 ] 2>/dev/null; then
|
||||
echo "Backup restore verification passed"
|
||||
else
|
||||
echo "Backup restore verification failed"
|
||||
docker logs --tail 300 "${CASS_CONTAINER}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker rm -f "${CASS_CONTAINER}" 2>/dev/null || true
|
||||
docker volume rm "${CASS_VOLUME}" 2>/dev/null || true
|
||||
docker volume rm "${BACKUP_VOLUME}" 2>/dev/null || true
|
||||
rm -rf "${WORKDIR}" 2>/dev/null || true
|
||||
|
||||
- name: Report status
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LATEST_BACKUP_NAME="${LATEST_BACKUP:-unknown}"
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
echo "Backup ${LATEST_BACKUP_NAME} is valid and restorable"
|
||||
else
|
||||
echo "Backup ${LATEST_BACKUP_NAME} test failed"
|
||||
fi
|
||||
81
.github/workflows/update-geoip-db.yaml
vendored
Normal file
81
.github/workflows/update-geoip-db.yaml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: update geoip-db
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-geoip-db
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh-db:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Refresh MMDB on server & roll restart
|
||||
env:
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ssh "${SERVER}" bash << EOSSH
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y curl
|
||||
fi
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y golang-go
|
||||
fi
|
||||
|
||||
export PATH="\$PATH:\$(go env GOPATH)/bin"
|
||||
if ! command -v mmdbverify >/dev/null 2>&1; then
|
||||
GOBIN="\$(go env GOPATH)/bin" go install github.com/maxmind/mmdbverify@latest
|
||||
fi
|
||||
|
||||
TMPDIR="\$(mktemp -d)"
|
||||
trap 'rm -rf "\$TMPDIR"' EXIT
|
||||
|
||||
DEST_DIR="/etc/fluxer"
|
||||
DEST_DB="\${DEST_DIR}/ipinfo_lite.mmdb"
|
||||
|
||||
mkdir -p "\$DEST_DIR"
|
||||
|
||||
curl -fsSL -o "\$TMPDIR/ipinfo_lite.mmdb" \
|
||||
"https://ipinfo.io/data/ipinfo_lite.mmdb?token=${IPINFO_TOKEN}"
|
||||
|
||||
[ -s "\$TMPDIR/ipinfo_lite.mmdb" ]
|
||||
|
||||
mmdbverify -file "\$TMPDIR/ipinfo_lite.mmdb"
|
||||
|
||||
install -m 0644 "\$TMPDIR/ipinfo_lite.mmdb" "\$DEST_DB.tmp"
|
||||
mv -f "\$DEST_DB.tmp" "\$DEST_DB"
|
||||
|
||||
docker service update --force fluxer-geoip_app
|
||||
EOSSH
|
||||
73
.github/workflows/update-word-lists.yaml
vendored
Normal file
73
.github/workflows/update-word-lists.yaml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: update word-lists
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 1 * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-word-lists:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: canary
|
||||
|
||||
- name: Download latest word lists
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/words/scales.txt -o /tmp/scales.txt
|
||||
curl -fsSL https://raw.githubusercontent.com/tailscale/tailscale/refs/heads/main/words/tails.txt -o /tmp/tails.txt
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Compare the downloaded files with the existing ones
|
||||
if ! diff -q /tmp/scales.txt fluxer_api/src/words/scales.txt > /dev/null 2>&1 || \
|
||||
! diff -q /tmp/tails.txt fluxer_api/src/words/tails.txt > /dev/null 2>&1; then
|
||||
printf 'changes_detected=true\n' >> "$GITHUB_OUTPUT"
|
||||
echo "Changes detected in word lists"
|
||||
else
|
||||
printf 'changes_detected=false\n' >> "$GITHUB_OUTPUT"
|
||||
echo "No changes detected in word lists"
|
||||
fi
|
||||
|
||||
- name: Update word lists
|
||||
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp /tmp/scales.txt fluxer_api/src/words/scales.txt
|
||||
cp /tmp/tails.txt fluxer_api/src/words/tails.txt
|
||||
|
||||
- name: Create pull request for updated word lists
|
||||
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: word-lists-update-${{ github.run_id }}
|
||||
base: canary
|
||||
title: "chore: update word lists from Tailscale upstream"
|
||||
body: |
|
||||
Automated update of scales.txt and tails.txt from the Tailscale repository.
|
||||
|
||||
These files are used to generate connection IDs for voice connections.
|
||||
|
||||
Source:
|
||||
- https://github.com/tailscale/tailscale/blob/main/words/scales.txt
|
||||
- https://github.com/tailscale/tailscale/blob/main/words/tails.txt
|
||||
commit-message: "chore: update word lists from Tailscale upstream"
|
||||
files: |
|
||||
fluxer_api/src/words/scales.txt
|
||||
fluxer_api/src/words/tails.txt
|
||||
labels: automation
|
||||
|
||||
- name: No changes detected
|
||||
if: steps.check_changes.outputs.changes_detected == 'false'
|
||||
run: echo "Word lists are already up to date."
|
||||
93
.gitignore
vendored
Normal file
93
.gitignore
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
# Build artifacts
|
||||
**/_build
|
||||
**/_checkouts
|
||||
**/_vendor
|
||||
**/.astro/
|
||||
**/coverage
|
||||
**/dist
|
||||
**/generated
|
||||
**/target
|
||||
**/ebin
|
||||
**/certificates
|
||||
/fluxer_admin/build
|
||||
/fluxer_marketing/build
|
||||
|
||||
# Caches & editor metadata
|
||||
**/.cache
|
||||
**/.*cache
|
||||
**/.pnpm-store
|
||||
**/.swc
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.idea
|
||||
**/.vscode
|
||||
|
||||
# Environment and credentials
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
**/.dev.vars
|
||||
**/.erlang.cookie
|
||||
**/.eunit
|
||||
**/.rebar
|
||||
**/.rebar3
|
||||
**/fluxer.env
|
||||
**/secrets.env
|
||||
/dev/fluxer.env
|
||||
|
||||
# Logs, temporary files, and binaries
|
||||
**/*.beam
|
||||
**/*.dump
|
||||
**/*.iml
|
||||
**/*.log
|
||||
**/*.o
|
||||
**/*.plt
|
||||
**/*.swo
|
||||
**/*.swp
|
||||
**/*.tmp
|
||||
**/*~
|
||||
**/log
|
||||
**/logs
|
||||
**/npm-debug.log*
|
||||
**/pnpm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/rebar3.crashdump
|
||||
**/erl_crash.dump
|
||||
|
||||
## Dependencies
|
||||
**/node_modules
|
||||
|
||||
# Framework & tooling buckets
|
||||
**/.next
|
||||
**/.next/cache
|
||||
**/.vercel
|
||||
**/out
|
||||
**/.pnp
|
||||
**/.pnp.js
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Source files we never want tracked
|
||||
**/.source
|
||||
**/*.source
|
||||
|
||||
# Project-specific artifacts
|
||||
/fluxer_admin/priv/static/app.css
|
||||
/fluxer_app/src/assets/emoji-sprites/
|
||||
/fluxer_app/src/locales/*/messages.js
|
||||
/fluxer_app/src/locales/*/messages.mjs
|
||||
/fluxer_gateway/config/sys.config
|
||||
/fluxer_gateway/config/vm.args
|
||||
/fluxer_marketing/priv/static/app.css
|
||||
/fluxer_marketing/priv/locales
|
||||
geoip_data
|
||||
livekit.yaml
|
||||
fluxer.yaml
|
||||
|
||||
# Generated CSS type definitions
|
||||
**/*.css.d.ts
|
||||
|
||||
# Generated UI components
|
||||
/fluxer_app/src/components/uikit/AvatarStatusGeometry.ts
|
||||
/fluxer_app/src/components/uikit/SVGMasks.tsx
|
||||
85
.ignore
Normal file
85
.ignore
Normal file
@@ -0,0 +1,85 @@
|
||||
# Build artifact directories
|
||||
**/_build
|
||||
**/_checkouts
|
||||
**/_vendor
|
||||
**/.astro/
|
||||
**/build
|
||||
!fluxer_app/scripts/build
|
||||
**/coverage
|
||||
**/dist
|
||||
fluxer_app/dist/
|
||||
**/generated
|
||||
**/target
|
||||
**/ebin
|
||||
**/certificates
|
||||
|
||||
# Dependency directories
|
||||
**/node_modules
|
||||
**/.pnpm-store
|
||||
|
||||
# Framework & tooling buckets
|
||||
**/.next
|
||||
**/.next/cache
|
||||
**/.vercel
|
||||
**/out
|
||||
**/.pnp
|
||||
**/.pnp.js
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Cache directories
|
||||
**/.cache
|
||||
**/.*cache
|
||||
**/.swc
|
||||
|
||||
# Logs and temporary files
|
||||
**/*.beam
|
||||
**/*.dump
|
||||
**/*.iml
|
||||
**/*.log
|
||||
**/*.o
|
||||
**/*.plt
|
||||
**/*.swo
|
||||
**/*.swp
|
||||
**/*.tmp
|
||||
**/*~
|
||||
**/*.lock
|
||||
**/log
|
||||
**/logs
|
||||
**/npm-debug.log*
|
||||
**/pnpm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/rebar3.crashdump
|
||||
**/erl_crash.dump
|
||||
|
||||
# Source files we never want tracked
|
||||
**/.source
|
||||
**/*.source
|
||||
|
||||
# Environment files
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
**/.dev.vars
|
||||
**/.erlang.cookie
|
||||
**/.eunit
|
||||
**/.rebar
|
||||
**/.rebar3
|
||||
**/fluxer.env
|
||||
**/secrets.env
|
||||
/dev/fluxer.env
|
||||
|
||||
# Project-specific artifacts
|
||||
/fluxer_app/src/assets/emoji-sprites/
|
||||
/fluxer_app/src/locales/*/messages.js
|
||||
/fluxer_admin/priv/static/app.css
|
||||
/fluxer_marketing/priv/static/app.css
|
||||
app.css
|
||||
fluxer_static
|
||||
geoip_data
|
||||
livekit.yaml
|
||||
fluxer.yaml
|
||||
|
||||
# Generated CSS type definitions
|
||||
**/*.css.d.ts
|
||||
2
.lfsconfig
Normal file
2
.lfsconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
[lfs]
|
||||
url = https://github.com/fluxerapp/fluxer.git/info/lfs
|
||||
25
.prettierignore
Normal file
25
.prettierignore
Normal file
@@ -0,0 +1,25 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/.cache
|
||||
**/.pnpm-store
|
||||
**/.swc
|
||||
fluxer_app/dist
|
||||
fluxer_app/src/assets/emoji-sprites
|
||||
fluxer_app/src/locales/*/messages.js
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxcore/**
|
||||
fluxer_app/proxy/assets
|
||||
fluxer_gateway/_build
|
||||
fluxer_marketing/build
|
||||
fluxer_docs/.next
|
||||
fluxer_docs/.next/cache
|
||||
fluxer_docs/out
|
||||
fluxer_docs/.vercel
|
||||
fluxer_docs/.cache
|
||||
fluxer_docs/coverage
|
||||
fluxer_static/**
|
||||
dev/geoip_data
|
||||
dev/livekit.yaml
|
||||
dev/fluxer.yaml
|
||||
*.log
|
||||
**/*.css.d.ts
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
6
README.md
Normal file
6
README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Fluxer
|
||||
|
||||
Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.
|
||||
|
||||
> [!NOTE]
|
||||
> Docs are coming very soon! With your help and [donations](https://fluxer.app/donate), the self-hosting and documentation story will get a lot better.
|
||||
139
biome.json
Normal file
139
biome.json
Normal file
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 120,
|
||||
"lineEnding": "lf",
|
||||
"bracketSpacing": false
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": false,
|
||||
"bracketSameLine": false
|
||||
},
|
||||
"globals": ["React"]
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 120
|
||||
},
|
||||
"parser": {
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": true
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 120,
|
||||
"quoteStyle": "single"
|
||||
},
|
||||
"parser": {
|
||||
"cssModules": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noImportantStyles": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"noInvalidUseBeforeDeclaration": "off",
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noArrayIndexKey": "off",
|
||||
"noAssignInExpressions": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noDoubleEquals": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"ignoreNull": true
|
||||
}
|
||||
},
|
||||
"noVar": "error",
|
||||
"useAdjacentOverloadSignatures": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"style": {
|
||||
"useConsistentArrayType": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"syntax": "generic"
|
||||
}
|
||||
},
|
||||
"useConst": "error",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": true,
|
||||
"useAriaPropsForRole": "error",
|
||||
"useValidAriaRole": "error",
|
||||
"useValidAriaValues": "error",
|
||||
"useValidAriaProps": "error",
|
||||
"useAltText": "error",
|
||||
"useAnchorContent": "error",
|
||||
"useButtonType": "error",
|
||||
"useKeyWithClickEvents": "error",
|
||||
"useKeyWithMouseEvents": "error",
|
||||
"useSemanticElements": "off",
|
||||
"noAriaUnsupportedElements": "error",
|
||||
"noNoninteractiveElementToInteractiveRole": "error",
|
||||
"noNoninteractiveTabindex": "error",
|
||||
"noRedundantAlt": "error",
|
||||
"noRedundantRoles": "error",
|
||||
"noInteractiveElementToNoninteractiveRole": "error",
|
||||
"noAutofocus": "warn",
|
||||
"noAccessKey": "warn",
|
||||
"useAriaActivedescendantWithTabindex": "error",
|
||||
"noSvgWithoutTitle": "warn"
|
||||
},
|
||||
"nursery": {
|
||||
"useSortedClasses": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"assist": {"actions": {"source": {"organizeImports": "on"}}},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/.git",
|
||||
"!**/app.css",
|
||||
"!**/build",
|
||||
"fluxer_app/scripts/build",
|
||||
"!**/dist",
|
||||
"!**/fluxer_app/src/data/emojis.json",
|
||||
"!**/fluxer_app/src/locales/*/messages.js",
|
||||
"!**/fluxer_app/src/env.d.ts",
|
||||
"!**/node_modules",
|
||||
"!**/tailwind.css",
|
||||
"!**/*.html",
|
||||
"!**/*.module.css.d.ts",
|
||||
"!**/fluxer_app/src/components/uikit/SVGMasks.tsx",
|
||||
"!fluxer_static"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
}
|
||||
}
|
||||
169
dev/.env.example
Normal file
169
dev/.env.example
Normal file
@@ -0,0 +1,169 @@
|
||||
NODE_ENV=development
|
||||
|
||||
FLUXER_API_PUBLIC_ENDPOINT=http://127.0.0.1:8088/api
|
||||
FLUXER_API_CLIENT_ENDPOINT=
|
||||
FLUXER_APP_ENDPOINT=http://localhost:8088
|
||||
FLUXER_GATEWAY_ENDPOINT=ws://127.0.0.1:8088/gateway
|
||||
FLUXER_MEDIA_ENDPOINT=http://127.0.0.1:8088/media
|
||||
FLUXER_CDN_ENDPOINT=https://fluxerstatic.com
|
||||
FLUXER_MARKETING_ENDPOINT=http://127.0.0.1:8088
|
||||
FLUXER_ADMIN_ENDPOINT=http://127.0.0.1:8088
|
||||
FLUXER_INVITE_ENDPOINT=http://fluxer.gg
|
||||
FLUXER_GIFT_ENDPOINT=http://fluxer.gift
|
||||
FLUXER_API_HOST=api:8080
|
||||
|
||||
FLUXER_API_PORT=8080
|
||||
FLUXER_GATEWAY_WS_PORT=8080
|
||||
FLUXER_GATEWAY_RPC_PORT=8081
|
||||
FLUXER_MEDIA_PROXY_PORT=8080
|
||||
FLUXER_ADMIN_PORT=8080
|
||||
FLUXER_MARKETING_PORT=8080
|
||||
FLUXER_GEOIP_PORT=8080
|
||||
GEOIP_HOST=geoip:8080
|
||||
|
||||
FLUXER_PATH_GATEWAY=/gateway
|
||||
FLUXER_PATH_ADMIN=/admin
|
||||
FLUXER_PATH_MARKETING=/marketing
|
||||
|
||||
API_HOST=api:8080
|
||||
FLUXER_GATEWAY_RPC_HOST=
|
||||
FLUXER_GATEWAY_PUSH_ENABLED=false
|
||||
FLUXER_GATEWAY_PUSH_USER_GUILD_SETTINGS_CACHE_MB=1024
|
||||
FLUXER_GATEWAY_PUSH_SUBSCRIPTIONS_CACHE_MB=1024
|
||||
FLUXER_GATEWAY_PUSH_BLOCKED_IDS_CACHE_MB=1024
|
||||
FLUXER_GATEWAY_IDENTIFY_RATE_LIMIT_ENABLED=false
|
||||
|
||||
FLUXER_MEDIA_PROXY_HOST=
|
||||
MEDIA_PROXY_ENDPOINT=
|
||||
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_EMAIL=support@fluxer.app
|
||||
|
||||
SUDO_MODE_SECRET=
|
||||
PASSKEYS_ENABLED=true
|
||||
PASSKEY_RP_NAME=Fluxer
|
||||
PASSKEY_RP_ID=127.0.0.1
|
||||
PASSKEY_ALLOWED_ORIGINS=http://127.0.0.1:8088,http://localhost:8088
|
||||
|
||||
ADMIN_OAUTH2_CLIENT_ID=
|
||||
ADMIN_OAUTH2_CLIENT_SECRET=
|
||||
ADMIN_OAUTH2_AUTO_CREATE=false
|
||||
ADMIN_OAUTH2_REDIRECT_URI=http://127.0.0.1:8088/admin/oauth2_callback
|
||||
|
||||
RELEASE_CHANNEL=stable
|
||||
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/fluxer
|
||||
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
CASSANDRA_HOSTS=cassandra
|
||||
CASSANDRA_KEYSPACE=fluxer
|
||||
CASSANDRA_LOCAL_DC=datacenter1
|
||||
CASSANDRA_USERNAME=cassandra
|
||||
CASSANDRA_PASSWORD=cassandra
|
||||
|
||||
AWS_S3_ENDPOINT=http://minio:9000
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
|
||||
AWS_S3_BUCKET_CDN=fluxer
|
||||
AWS_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
AWS_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
AWS_S3_BUCKET_REPORTS=fluxer-reports
|
||||
AWS_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
|
||||
R2_S3_ENDPOINT=http://minio:9000
|
||||
R2_ACCESS_KEY_ID=minioadmin
|
||||
R2_SECRET_ACCESS_KEY=minioadmin
|
||||
|
||||
METRICS_MODE=noop
|
||||
|
||||
CLICKHOUSE_URL=http://clickhouse:8123
|
||||
CLICKHOUSE_DATABASE=fluxer_metrics
|
||||
CLICKHOUSE_USER=fluxer
|
||||
CLICKHOUSE_PASSWORD=fluxer_dev
|
||||
|
||||
ANOMALY_DETECTION_ENABLED=true
|
||||
ANOMALY_WINDOW_SIZE=100
|
||||
ANOMALY_ZSCORE_THRESHOLD=3.0
|
||||
ANOMALY_CHECK_INTERVAL_SECS=60
|
||||
ANOMALY_COOLDOWN_SECS=300
|
||||
ANOMALY_ERROR_RATE_THRESHOLD=0.05
|
||||
ALERT_WEBHOOK_URL=
|
||||
|
||||
EMAIL_ENABLED=false
|
||||
SENDGRID_FROM_EMAIL=noreply@fluxer.app
|
||||
SENDGRID_FROM_NAME=Fluxer
|
||||
SENDGRID_API_KEY=
|
||||
SENDGRID_WEBHOOK_PUBLIC_KEY=
|
||||
|
||||
SMS_ENABLED=false
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_VERIFY_SERVICE_SID=
|
||||
|
||||
CAPTCHA_ENABLED=true
|
||||
CAPTCHA_PRIMARY_PROVIDER=turnstile
|
||||
|
||||
HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
|
||||
HCAPTCHA_PUBLIC_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
|
||||
HCAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000
|
||||
|
||||
TURNSTILE_SITE_KEY=1x00000000000000000000AA
|
||||
TURNSTILE_PUBLIC_SITE_KEY=1x00000000000000000000AA
|
||||
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
|
||||
|
||||
SEARCH_ENABLED=true
|
||||
MEILISEARCH_URL=http://meilisearch:7700
|
||||
MEILISEARCH_API_KEY=masterKey
|
||||
|
||||
STRIPE_ENABLED=false
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
STRIPE_PRICE_ID_MONTHLY_USD=
|
||||
STRIPE_PRICE_ID_MONTHLY_EUR=
|
||||
STRIPE_PRICE_ID_YEARLY_USD=
|
||||
STRIPE_PRICE_ID_YEARLY_EUR=
|
||||
STRIPE_PRICE_ID_VISIONARY_USD=
|
||||
STRIPE_PRICE_ID_VISIONARY_EUR=
|
||||
STRIPE_PRICE_ID_GIFT_VISIONARY_USD=
|
||||
STRIPE_PRICE_ID_GIFT_VISIONARY_EUR=
|
||||
STRIPE_PRICE_ID_GIFT_1_MONTH_USD=
|
||||
STRIPE_PRICE_ID_GIFT_1_MONTH_EUR=
|
||||
STRIPE_PRICE_ID_GIFT_1_YEAR_USD=
|
||||
STRIPE_PRICE_ID_GIFT_1_YEAR_EUR=
|
||||
|
||||
CLOUDFLARE_PURGE_ENABLED=false
|
||||
CLOUDFLARE_ZONE_ID=
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
CLOUDFLARE_TUNNEL_TOKEN=
|
||||
|
||||
VOICE_ENABLED=true
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
LIVEKIT_WEBHOOK_URL=http://api:8080/webhooks/livekit
|
||||
LIVEKIT_AUTO_CREATE_DUMMY_DATA=true
|
||||
|
||||
CLAMAV_ENABLED=false
|
||||
CLAMAV_HOST=clamav
|
||||
CLAMAV_PORT=3310
|
||||
|
||||
TENOR_API_KEY=
|
||||
YOUTUBE_API_KEY=
|
||||
IPINFO_TOKEN=
|
||||
|
||||
SECRET_KEY_BASE=
|
||||
GATEWAY_RPC_SECRET=
|
||||
GATEWAY_ADMIN_SECRET=
|
||||
ERLANG_COOKIE=fluxer_dev_cookie
|
||||
MEDIA_PROXY_SECRET_KEY=
|
||||
|
||||
SELF_HOSTED=false
|
||||
AUTO_JOIN_INVITE_CODE=
|
||||
FLUXER_VISIONARIES_GUILD_ID=
|
||||
FLUXER_OPERATORS_GUILD_ID=
|
||||
|
||||
GIT_SHA=dev
|
||||
BUILD_TIMESTAMP=
|
||||
66
dev/Caddyfile.dev
Normal file
66
dev/Caddyfile.dev
Normal file
@@ -0,0 +1,66 @@
|
||||
:8088 {
|
||||
encode zstd gzip
|
||||
|
||||
@api path /api/*
|
||||
handle @api {
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
}
|
||||
|
||||
@media path /media/*
|
||||
handle @media {
|
||||
handle_path /media/* {
|
||||
reverse_proxy media:8080
|
||||
}
|
||||
}
|
||||
|
||||
@s3 path /s3/*
|
||||
handle @s3 {
|
||||
handle_path /s3/* {
|
||||
reverse_proxy minio:9000
|
||||
}
|
||||
}
|
||||
|
||||
@admin path /admin /admin/*
|
||||
handle @admin {
|
||||
uri strip_prefix /admin
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
@geoip path /geoip/*
|
||||
handle @geoip {
|
||||
handle_path /geoip/* {
|
||||
reverse_proxy geoip:8080
|
||||
}
|
||||
}
|
||||
|
||||
@marketing path /marketing /marketing/*
|
||||
handle @marketing {
|
||||
uri strip_prefix /marketing
|
||||
reverse_proxy marketing:8080
|
||||
}
|
||||
|
||||
@gateway path /gateway /gateway/*
|
||||
handle @gateway {
|
||||
uri strip_prefix /gateway
|
||||
reverse_proxy gateway:8080
|
||||
}
|
||||
|
||||
@livekit path /livekit /livekit/*
|
||||
handle @livekit {
|
||||
handle_path /livekit/* {
|
||||
reverse_proxy livekit:7880
|
||||
}
|
||||
}
|
||||
|
||||
@metrics path /metrics /metrics/*
|
||||
handle @metrics {
|
||||
uri strip_prefix /metrics
|
||||
reverse_proxy metrics:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy host.docker.internal:3000
|
||||
}
|
||||
}
|
||||
160
dev/compose.data.yaml
Normal file
160
dev/compose.data.yaml
Normal file
@@ -0,0 +1,160 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: fluxer
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
cassandra:
|
||||
image: scylladb/scylla:latest
|
||||
command: --smp 1 --memory 512M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0
|
||||
ports:
|
||||
- '9042:9042'
|
||||
volumes:
|
||||
- scylla_data:/var/lib/scylla
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'cqlsh -e "describe cluster"']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:latest
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: valkey-server --save 60 1 --loglevel warning
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', 'mc', 'ready', 'local']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-setup:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set minio http://minio:9000 minioadmin minioadmin;
|
||||
mc mb --ignore-existing minio/fluxer-metrics;
|
||||
mc mb --ignore-existing minio/fluxer-uploads;
|
||||
exit 0;
|
||||
"
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: 'no'
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:latest
|
||||
volumes:
|
||||
- clamav_data:/var/lib/clamav
|
||||
environment:
|
||||
CLAMAV_NO_FRESHCLAMD: 'false'
|
||||
CLAMAV_NO_CLAMD: 'false'
|
||||
CLAMAV_NO_MILTERD: 'true'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', '/usr/local/bin/clamdcheck.sh']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 300s
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.25.0
|
||||
volumes:
|
||||
- meilisearch_data:/meili_data
|
||||
environment:
|
||||
MEILI_ENV: development
|
||||
MEILI_MASTER_KEY: masterKey
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /etc/livekit.yaml --dev
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- '7880:7880'
|
||||
- '7882:7882/udp'
|
||||
- '7999:7999/udp'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24.8
|
||||
hostname: clickhouse
|
||||
profiles:
|
||||
- clickhouse
|
||||
environment:
|
||||
- CLICKHOUSE_DB=fluxer_metrics
|
||||
- CLICKHOUSE_USER=fluxer
|
||||
- CLICKHOUSE_PASSWORD=fluxer_dev
|
||||
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
|
||||
volumes:
|
||||
- clickhouse_data:/var/lib/clickhouse
|
||||
- clickhouse_logs:/var/log/clickhouse-server
|
||||
networks:
|
||||
- fluxer-shared
|
||||
ports:
|
||||
- '8123:8123'
|
||||
- '9000:9000'
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', 'clickhouse-client', '--query', 'SELECT 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
name: fluxer-shared
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
scylla_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
clamav_data:
|
||||
meilisearch_data:
|
||||
clickhouse_data:
|
||||
clickhouse_logs:
|
||||
398
dev/compose.yaml
Normal file
398
dev/compose.yaml
Normal file
@@ -0,0 +1,398 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- '8088:8088'
|
||||
volumes:
|
||||
- ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
|
||||
- ../fluxer_app/dist:/app/dist:ro
|
||||
networks:
|
||||
- fluxer-shared
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
restart: on-failure
|
||||
|
||||
cloudflared:
|
||||
image: cloudflare/cloudflared:latest
|
||||
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
api:
|
||||
image: node:24-bookworm-slim
|
||||
working_dir: /workspace
|
||||
command: bash -lc "corepack enable pnpm && CI=true pnpm install && npx tsx watch --clear-screen=false src/App.ts"
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- CI=true
|
||||
- VAPID_PUBLIC_KEY=BJHAPp7Xg4oeN_D6-EVu0D-bDyPDwFFJiLn7CzkUjUvaG_F-keQGpA_-RiNugCosTPhhdvdrn4mEOh-_1Bt35V8
|
||||
- FLUXER_METRICS_HOST=metrics:8080
|
||||
volumes:
|
||||
- ../fluxer_api:/workspace
|
||||
- api_node_modules:/workspace/node_modules
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
worker:
|
||||
image: node:24-bookworm-slim
|
||||
working_dir: /workspace
|
||||
command: bash -lc "corepack enable pnpm && CI=true pnpm install && npm run dev:worker"
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- CI=true
|
||||
- FLUXER_METRICS_HOST=metrics:8080
|
||||
volumes:
|
||||
- ../fluxer_api:/workspace
|
||||
- api_node_modules:/workspace/node_modules
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
- cassandra
|
||||
|
||||
media:
|
||||
build:
|
||||
context: ../fluxer_media_proxy
|
||||
dockerfile: Dockerfile
|
||||
target: build
|
||||
working_dir: /workspace
|
||||
command: >
|
||||
bash -lc "
|
||||
corepack enable pnpm &&
|
||||
CI=true pnpm install &&
|
||||
pnpm dev
|
||||
"
|
||||
user: root
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- CI=true
|
||||
- NODE_ENV=development
|
||||
- FLUXER_METRICS_HOST=metrics:8080
|
||||
volumes:
|
||||
- ../fluxer_media_proxy:/workspace
|
||||
- media_node_modules:/workspace/node_modules
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
admin:
|
||||
build:
|
||||
context: ../fluxer_admin
|
||||
dockerfile: Dockerfile.dev
|
||||
working_dir: /workspace
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- PORT=8080
|
||||
- APP_MODE=admin
|
||||
- FLUXER_METRICS_HOST=metrics:8080
|
||||
volumes:
|
||||
- admin_build:/workspace/build
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ../fluxer_admin/src
|
||||
- action: rebuild
|
||||
path: ../fluxer_admin/tailwind.css
|
||||
|
||||
marketing:
|
||||
build:
|
||||
context: ../fluxer_marketing
|
||||
dockerfile: Dockerfile.dev
|
||||
working_dir: /workspace
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- PORT=8080
|
||||
- FLUXER_METRICS_HOST=metrics:8080
|
||||
volumes:
|
||||
- marketing_build:/workspace/build
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: ../fluxer_marketing/src
|
||||
- action: rebuild
|
||||
path: ../fluxer_marketing/tailwind.css
|
||||
|
||||
docs:
|
||||
image: node:24-bookworm-slim
|
||||
working_dir: /workspace
|
||||
command: bash -lc "corepack enable pnpm && CI=true pnpm install && pnpm dev"
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- CI=true
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- ../fluxer_docs:/workspace
|
||||
- docs_node_modules:/workspace/node_modules
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
geoip:
|
||||
image: golang:1.25.5
|
||||
working_dir: /workspace
|
||||
command: bash -c "mkdir -p /data && if [ ! -f /data/ipinfo_lite.mmdb ] && [ -n \"$$IPINFO_TOKEN\" ]; then echo 'Downloading GeoIP database...'; curl -fsSL -o /data/ipinfo_lite.mmdb \"https://ipinfo.io/data/ipinfo_lite.mmdb?token=$$IPINFO_TOKEN\" && echo 'GeoIP database downloaded'; fi && go run ."
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ../fluxer_geoip:/workspace
|
||||
- ./geoip_data:/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
gateway:
|
||||
image: erlang:28-slim
|
||||
working_dir: /workspace
|
||||
command: bash -c "apt-get update && apt-get install -y --no-install-recommends build-essential linux-libc-dev curl ca-certificates gettext-base git && curl -fsSL https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o /usr/local/bin/rebar3 && chmod +x /usr/local/bin/rebar3 && rebar3 compile && exec ./docker-entrypoint.sh"
|
||||
hostname: gateway
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- RELEASE_NODE=fluxer_gateway@gateway
|
||||
- LOGGER_LEVEL=debug
|
||||
- CLUSTER_NAME=fluxer_gateway
|
||||
- CLUSTER_DISCOVERY_DNS=gateway
|
||||
- NODE_COOKIE=fluxer_dev_cookie
|
||||
- VAPID_PUBLIC_KEY=BJHAPp7Xg4oeN_D6-EVu0D-bDyPDwFFJiLn7CzkUjUvaG_F-keQGpA_-RiNugCosTPhhdvdrn4mEOh-_1Bt35V8
|
||||
- VAPID_PRIVATE_KEY=Ze8J4aSmwV5B77zz9NzTU_IdyFyR1hMiKaYF2G61Y-E
|
||||
- VAPID_EMAIL=support@fluxer.app
|
||||
- FLUXER_METRICS_HOST=metrics:8080
|
||||
volumes:
|
||||
- ../fluxer_gateway:/workspace
|
||||
- gateway_build:/workspace/_build
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: fluxer
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
cassandra:
|
||||
image: scylladb/scylla:latest
|
||||
command: --smp 1 --memory 512M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0
|
||||
ports:
|
||||
- '9042:9042'
|
||||
volumes:
|
||||
- scylla_data:/var/lib/scylla
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'cqlsh -e "describe cluster"']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:latest
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: valkey-server --save 60 1 --loglevel warning
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', 'mc', 'ready', 'local']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-setup:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set minio http://minio:9000 minioadmin minioadmin;
|
||||
mc mb --ignore-existing minio/fluxer-metrics;
|
||||
mc mb --ignore-existing minio/fluxer-uploads;
|
||||
exit 0;
|
||||
"
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: 'no'
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:latest
|
||||
volumes:
|
||||
- clamav_data:/var/lib/clamav
|
||||
environment:
|
||||
CLAMAV_NO_FRESHCLAMD: 'false'
|
||||
CLAMAV_NO_CLAMD: 'false'
|
||||
CLAMAV_NO_MILTERD: 'true'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', '/usr/local/bin/clamdcheck.sh']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 300s
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.25.0
|
||||
volumes:
|
||||
- meilisearch_data:/meili_data
|
||||
environment:
|
||||
MEILI_ENV: development
|
||||
MEILI_MASTER_KEY: masterKey
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24.8
|
||||
hostname: clickhouse
|
||||
profiles:
|
||||
- clickhouse
|
||||
environment:
|
||||
- CLICKHOUSE_DB=fluxer_metrics
|
||||
- CLICKHOUSE_USER=fluxer
|
||||
- CLICKHOUSE_PASSWORD=fluxer_dev
|
||||
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
|
||||
volumes:
|
||||
- clickhouse_data:/var/lib/clickhouse
|
||||
- clickhouse_logs:/var/log/clickhouse-server
|
||||
networks:
|
||||
- fluxer-shared
|
||||
ports:
|
||||
- '8123:8123'
|
||||
- '9000:9000'
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', 'clickhouse-client', '--query', 'SELECT 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
|
||||
metrics:
|
||||
build:
|
||||
context: ../fluxer_metrics
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- ./.env
|
||||
environment:
|
||||
- METRICS_PORT=8080
|
||||
- METRICS_MODE=${METRICS_MODE:-noop}
|
||||
- CLICKHOUSE_URL=http://clickhouse:8123
|
||||
- CLICKHOUSE_DATABASE=fluxer_metrics
|
||||
- CLICKHOUSE_USER=fluxer
|
||||
- CLICKHOUSE_PASSWORD=fluxer_dev
|
||||
- ANOMALY_DETECTION_ENABLED=true
|
||||
- FLUXER_ADMIN_ENDPOINT=${FLUXER_ADMIN_ENDPOINT:-}
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
metrics-clickhouse:
|
||||
extends:
|
||||
service: metrics
|
||||
profiles:
|
||||
- clickhouse
|
||||
environment:
|
||||
- METRICS_MODE=clickhouse
|
||||
depends_on:
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
|
||||
cassandra-migrate:
|
||||
image: debian:bookworm-slim
|
||||
command:
|
||||
[
|
||||
'bash',
|
||||
'-lc',
|
||||
'apt-get update && apt-get install -y dnsutils && sleep 30 && /cassandra-migrate --host cassandra --username cassandra --password cassandra up',
|
||||
]
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- ../scripts/cassandra-migrate/target/release/cassandra-migrate:/cassandra-migrate
|
||||
- ../fluxer_devops/cassandra/migrations:/workspace/fluxer_devops/cassandra/migrations
|
||||
networks:
|
||||
- fluxer-shared
|
||||
depends_on:
|
||||
cassandra:
|
||||
condition: service_healthy
|
||||
restart: 'no'
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /etc/livekit.yaml --dev
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- '7880:7880'
|
||||
- '7882:7882/udp'
|
||||
- '7999:7999/udp'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
name: fluxer-shared
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
scylla_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
clamav_data:
|
||||
meilisearch_data:
|
||||
clickhouse_data:
|
||||
clickhouse_logs:
|
||||
api_node_modules:
|
||||
media_node_modules:
|
||||
admin_build:
|
||||
marketing_build:
|
||||
gateway_build:
|
||||
docs_node_modules:
|
||||
34
dev/main.go
Normal file
34
dev/main.go
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"fluxer.dev/dev/pkg/commands"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := commands.NewRootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
305
dev/pkg/commands/commands.go
Normal file
305
dev/pkg/commands/commands.go
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"fluxer.dev/dev/pkg/integrations"
|
||||
"fluxer.dev/dev/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultComposeFile = "dev/compose.yaml"
|
||||
defaultEnvFile = "dev/.env"
|
||||
)
|
||||
|
||||
// NewRootCmd creates the root command
|
||||
func NewRootCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "devctl",
|
||||
Short: "Fluxer development control tool",
|
||||
Long: "Docker Compose wrapper and development utilities for Fluxer.",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
NewUpCmd(),
|
||||
NewDownCmd(),
|
||||
NewRestartCmd(),
|
||||
NewLogsCmd(),
|
||||
NewPsCmd(),
|
||||
NewExecCmd(),
|
||||
NewShellCmd(),
|
||||
|
||||
NewLivekitSyncCmd(),
|
||||
NewGeoIPDownloadCmd(),
|
||||
NewEnsureNetworkCmd(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewUpCmd starts services
|
||||
func NewUpCmd() *cobra.Command {
|
||||
var detach bool
|
||||
var build bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "up [services...]",
|
||||
Short: "Start services",
|
||||
Long: "Start all or specific services using docker compose",
|
||||
RunE: func(cmd *cobra.Command, services []string) error {
|
||||
args := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "up"}
|
||||
if detach {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
if build {
|
||||
args = append(args, "--build")
|
||||
}
|
||||
args = append(args, services...)
|
||||
return runDockerCompose(args...)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&detach, "detach", "d", true, "Run in background")
|
||||
cmd.Flags().BoolVar(&build, "build", false, "Build images before starting")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewDownCmd stops and removes containers
|
||||
func NewDownCmd() *cobra.Command {
|
||||
var volumes bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "down",
|
||||
Short: "Stop and remove containers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dcArgs := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "down"}
|
||||
if volumes {
|
||||
dcArgs = append(dcArgs, "-v")
|
||||
}
|
||||
return runDockerCompose(dcArgs...)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&volumes, "volumes", "v", false, "Remove volumes")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewRestartCmd restarts services
|
||||
func NewRestartCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "restart [services...]",
|
||||
Short: "Restart services",
|
||||
RunE: func(cmd *cobra.Command, services []string) error {
|
||||
args := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "restart"}
|
||||
args = append(args, services...)
|
||||
return runDockerCompose(args...)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewLogsCmd shows service logs
|
||||
func NewLogsCmd() *cobra.Command {
|
||||
var follow bool
|
||||
var tail string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [services...]",
|
||||
Short: "Show service logs",
|
||||
RunE: func(cmd *cobra.Command, services []string) error {
|
||||
args := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "logs"}
|
||||
if follow {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
if tail != "" {
|
||||
args = append(args, "--tail", tail)
|
||||
}
|
||||
args = append(args, services...)
|
||||
return runDockerCompose(args...)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&follow, "follow", "f", true, "Follow log output")
|
||||
cmd.Flags().StringVarP(&tail, "tail", "n", "100", "Number of lines to show from the end")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewPsCmd lists containers
|
||||
func NewPsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: "List containers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDockerCompose("--env-file", defaultEnvFile, "-f", defaultComposeFile, "ps")
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewExecCmd executes a command in a running container
|
||||
func NewExecCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "exec SERVICE COMMAND...",
|
||||
Short: "Execute a command in a running container",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dcArgs := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "exec"}
|
||||
dcArgs = append(dcArgs, args...)
|
||||
return runDockerCompose(dcArgs...)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewShellCmd opens a shell in a container
|
||||
func NewShellCmd() *cobra.Command {
|
||||
var shell string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "sh SERVICE",
|
||||
Short: "Open a shell in a container",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
service := args[0]
|
||||
return runDockerCompose("--env-file", defaultEnvFile, "-f", defaultComposeFile, "exec", service, shell)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&shell, "shell", "sh", "Shell to use (sh, bash, etc.)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewLivekitSyncCmd syncs LiveKit configuration
|
||||
func NewLivekitSyncCmd() *cobra.Command {
|
||||
var envPath string
|
||||
var outputPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "livekit-sync",
|
||||
Short: "Generate LiveKit configuration from environment variables",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
env, err := utils.ParseEnvFile(envPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read env file: %w", err)
|
||||
}
|
||||
|
||||
written, err := integrations.WriteLivekitFileFromEnv(outputPath, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !written {
|
||||
fmt.Println("⚠️ Voice/LiveKit is disabled - no config generated")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("✅ LiveKit config written to %s\n", outputPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&envPath, "env", "e", defaultEnvFile, "Environment file path")
|
||||
cmd.Flags().StringVarP(&outputPath, "output", "o", "dev/livekit.yaml", "Output path")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewGeoIPDownloadCmd downloads GeoIP database
|
||||
func NewGeoIPDownloadCmd() *cobra.Command {
|
||||
var token string
|
||||
var envPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "geoip-download",
|
||||
Short: "Download GeoIP database from IPInfo",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return integrations.DownloadGeoIP(token, envPath)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&token, "token", "", "IPInfo API token")
|
||||
cmd.Flags().StringVarP(&envPath, "env", "e", defaultEnvFile, "Env file to read token from")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewEnsureNetworkCmd ensures the Docker network exists
|
||||
func NewEnsureNetworkCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ensure-network",
|
||||
Short: "Ensure the fluxer-shared Docker network exists",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return ensureNetwork()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runDockerCompose runs a docker compose command
|
||||
func runDockerCompose(args ...string) error {
|
||||
cmd := exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ensureNetwork ensures the fluxer-shared network exists
|
||||
func ensureNetwork() error {
|
||||
checkCmd := exec.Command("docker", "network", "ls", "--format", "{{.Name}}")
|
||||
output, err := checkCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list networks: %w", err)
|
||||
}
|
||||
|
||||
networks := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, net := range networks {
|
||||
if net == "fluxer-shared" {
|
||||
fmt.Println("✅ fluxer-shared network already exists")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Creating fluxer-shared network...")
|
||||
createCmd := exec.Command("docker", "network", "create", "fluxer-shared")
|
||||
createCmd.Stdout = os.Stdout
|
||||
createCmd.Stderr = os.Stderr
|
||||
if err := createCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to create network: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ fluxer-shared network created")
|
||||
return nil
|
||||
}
|
||||
95
dev/pkg/integrations/geoip.go
Normal file
95
dev/pkg/integrations/geoip.go
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fluxer.dev/dev/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultGeoIPDir = "dev/geoip"
|
||||
DefaultGeoIPFile = "country_asn.mmdb"
|
||||
)
|
||||
|
||||
// DownloadGeoIP downloads the GeoIP database from IPInfo
|
||||
func DownloadGeoIP(tokenFlag, envPath string) error {
|
||||
token := strings.TrimSpace(tokenFlag)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(os.Getenv("IPINFO_TOKEN"))
|
||||
}
|
||||
|
||||
if token == "" && envPath != "" {
|
||||
env, err := utils.ParseEnvFile(envPath)
|
||||
if err == nil {
|
||||
token = strings.TrimSpace(env["IPINFO_TOKEN"])
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return errors.New("IPInfo token required; provide via --token, IPINFO_TOKEN env var, or the config/env")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(DefaultGeoIPDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
outPath := filepath.Join(DefaultGeoIPDir, DefaultGeoIPFile)
|
||||
u := fmt.Sprintf("https://ipinfo.io/data/free/country_asn.mmdb?token=%s", url.QueryEscape(token))
|
||||
|
||||
fmt.Printf("Downloading GeoIP database to %s...\n", outPath)
|
||||
|
||||
resp, err := http.Get(u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download GeoIP db: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("unexpected response (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errors.New("downloaded GeoIP file is empty; check your IPInfo token")
|
||||
}
|
||||
|
||||
fmt.Printf("✅ GeoIP database downloaded (%d bytes).\n", n)
|
||||
fmt.Println()
|
||||
fmt.Println("If you're running a GeoIP service container, restart it so it picks up the new database.")
|
||||
return nil
|
||||
}
|
||||
82
dev/pkg/integrations/livekit.go
Normal file
82
dev/pkg/integrations/livekit.go
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteLivekitFileFromEnv writes LiveKit configuration from environment variables
|
||||
func WriteLivekitFileFromEnv(path string, env map[string]string) (bool, error) {
|
||||
voiceEnabled := strings.ToLower(strings.TrimSpace(env["VOICE_ENABLED"])) == "true"
|
||||
if !voiceEnabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(env["LIVEKIT_API_KEY"])
|
||||
apiSecret := strings.TrimSpace(env["LIVEKIT_API_SECRET"])
|
||||
webhookURL := strings.TrimSpace(env["LIVEKIT_WEBHOOK_URL"])
|
||||
|
||||
if apiKey == "" || apiSecret == "" || webhookURL == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
redisURL := strings.TrimSpace(env["REDIS_URL"])
|
||||
redisAddr := strings.TrimPrefix(redisURL, "redis://")
|
||||
if redisAddr == "" {
|
||||
redisAddr = "redis:6379"
|
||||
}
|
||||
|
||||
yaml := fmt.Sprintf(`port: 7880
|
||||
|
||||
redis:
|
||||
address: "%s"
|
||||
db: 0
|
||||
|
||||
keys:
|
||||
"%s": "%s"
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
|
||||
webhook:
|
||||
api_key: "%s"
|
||||
urls:
|
||||
- "%s"
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
max_participants: 100
|
||||
empty_timeout: 300
|
||||
|
||||
development: true
|
||||
`, redisAddr, apiKey, apiSecret, apiKey, webhookURL)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
154
dev/pkg/utils/helpers.go
Normal file
154
dev/pkg/utils/helpers.go
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileExists checks if a file exists at the given path
|
||||
func FileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// BoolString converts a boolean to a string ("true" or "false")
|
||||
func BoolString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// FirstNonZeroInt returns the first non-zero integer from the provided values,
|
||||
// or the default value if all are zero
|
||||
func FirstNonZeroInt(values ...int) int {
|
||||
for _, v := range values {
|
||||
if v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DefaultString returns the value if non-empty, otherwise returns the default
|
||||
func DefaultString(value, defaultValue string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// RandomString generates a random alphanumeric string of the given length
|
||||
func RandomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// RandomBase32 generates a random base32-encoded string (without padding)
|
||||
func RandomBase32(byteLength int) string {
|
||||
b := make([]byte, byteLength)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return strings.TrimRight(base32.StdEncoding.EncodeToString(b), "=")
|
||||
}
|
||||
|
||||
// GenerateSnowflake generates a snowflake ID
|
||||
// Format: timestamp (42 bits) + worker ID (10 bits) + sequence (12 bits)
|
||||
func GenerateSnowflake() string {
|
||||
const fluxerEpoch = 1420070400000
|
||||
timestamp := time.Now().UnixMilli() - fluxerEpoch
|
||||
workerID := int64(0)
|
||||
sequence := int64(0)
|
||||
snowflake := (timestamp << 22) | (workerID << 12) | sequence
|
||||
return fmt.Sprintf("%d", snowflake)
|
||||
}
|
||||
|
||||
// ValidateURL validates that a string is a valid URL
|
||||
func ValidateURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if parsedURL.Scheme == "" {
|
||||
return fmt.Errorf("URL must have a scheme (http:// or https://)")
|
||||
}
|
||||
if parsedURL.Host == "" {
|
||||
return fmt.Errorf("URL must have a host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseEnvFile parses a .env file and returns a map of key-value pairs
|
||||
func ParseEnvFile(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
env := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
return env, scanner.Err()
|
||||
}
|
||||
154
dev/setup.sh
Executable file
154
dev/setup.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (C) 2026 Fluxer Contributors
|
||||
#
|
||||
# This file is part of Fluxer.
|
||||
#
|
||||
# Fluxer is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Fluxer is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
check_command() {
|
||||
if command -v "$1" &> /dev/null; then
|
||||
echo -e "${GREEN}[OK]${NC} $1 is installed"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}[MISSING]${NC} $1 is not installed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_node_version() {
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_VERSION" -ge 20 ]; then
|
||||
echo -e "${GREEN}[OK]${NC} Node.js $(node -v) is installed"
|
||||
return 0
|
||||
else
|
||||
echo -e "${YELLOW}[WARN]${NC} Node.js $(node -v) is installed, but v20+ is recommended"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}[MISSING]${NC} Node.js is not installed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Fluxer Development Setup ==="
|
||||
echo ""
|
||||
|
||||
echo "Checking prerequisites..."
|
||||
echo ""
|
||||
|
||||
MISSING=0
|
||||
|
||||
check_node_version || MISSING=1
|
||||
check_command pnpm || MISSING=1
|
||||
check_command docker || MISSING=1
|
||||
check_command rustc || echo -e "${YELLOW}[OPTIONAL]${NC} Rust is not installed (needed for fluxer_app WASM modules)"
|
||||
check_command wasm-pack || echo -e "${YELLOW}[OPTIONAL]${NC} wasm-pack is not installed (needed for fluxer_app WASM modules)"
|
||||
check_command go || echo -e "${YELLOW}[OPTIONAL]${NC} Go is not installed (needed for fluxer_geoip)"
|
||||
|
||||
echo ""
|
||||
|
||||
if [ "$MISSING" -eq 1 ]; then
|
||||
echo -e "${RED}Some required dependencies are missing. Please install them before continuing.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating Docker network if needed..."
|
||||
if docker network inspect fluxer-shared &> /dev/null; then
|
||||
echo -e "${GREEN}[OK]${NC} Docker network 'fluxer-shared' already exists"
|
||||
else
|
||||
docker network create fluxer-shared
|
||||
echo -e "${GREEN}[OK]${NC} Created Docker network 'fluxer-shared'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
||||
echo "Creating .env from .env.example..."
|
||||
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
|
||||
echo -e "${GREEN}[OK]${NC} Created .env file"
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} .env file already exists"
|
||||
fi
|
||||
|
||||
mkdir -p "$SCRIPT_DIR/geoip_data"
|
||||
if [ ! -f "$SCRIPT_DIR/geoip_data/ipinfo_lite.mmdb" ]; then
|
||||
echo -e "${YELLOW}[INFO]${NC} GeoIP database not found."
|
||||
echo " Set IPINFO_TOKEN in .env and run the geoip service to download it,"
|
||||
echo " or manually download ipinfo_lite.mmdb to dev/geoip_data/"
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} GeoIP database exists"
|
||||
fi
|
||||
|
||||
if [ ! -f "$SCRIPT_DIR/livekit.yaml" ]; then
|
||||
echo "Creating default livekit.yaml..."
|
||||
cat > "$SCRIPT_DIR/livekit.yaml" << 'EOF'
|
||||
port: 7880
|
||||
|
||||
redis:
|
||||
address: 'redis:6379'
|
||||
db: 0
|
||||
|
||||
keys:
|
||||
'e1dG953yAoJPIsK1dzfTWAKMNE9gmnPL': 'rCtIICXHtAwSAJ4glb11jARcXCCgMTGvvTKLIlpD0pEoANLgjCNPD1Ysm8uWhQTB'
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
|
||||
webhook:
|
||||
api_key: 'e1dG953yAoJPIsK1dzfTWAKMNE9gmnPL'
|
||||
urls:
|
||||
- 'http://api:8080/webhooks/livekit'
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
max_participants: 100
|
||||
empty_timeout: 300
|
||||
|
||||
development: true
|
||||
EOF
|
||||
echo -e "${GREEN}[OK]${NC} Created livekit.yaml"
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} livekit.yaml already exists"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo ""
|
||||
echo "1. Start data stores:"
|
||||
echo " docker compose -f compose.data.yaml up -d"
|
||||
echo ""
|
||||
echo "2. Start app services:"
|
||||
echo " docker compose up -d api worker media gateway admin marketing docs geoip metrics caddy"
|
||||
echo ""
|
||||
echo "3. Run the frontend on your host machine:"
|
||||
echo " cd ../fluxer_app && pnpm install && pnpm dev"
|
||||
echo ""
|
||||
echo "4. Access the app at: http://localhost:8088"
|
||||
echo ""
|
||||
echo "Optional: Start Cloudflare tunnel:"
|
||||
echo " docker compose up -d cloudflared"
|
||||
echo ""
|
||||
48
fluxer_admin/Dockerfile
Normal file
48
fluxer_admin/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
ARG BUILD_TIMESTAMP=0
|
||||
FROM erlang:27.1.1.0-alpine AS builder
|
||||
|
||||
COPY --from=ghcr.io/gleam-lang/gleam:nightly-erlang /bin/gleam /bin/gleam
|
||||
|
||||
RUN apk add --no-cache git curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY gleam.toml manifest.toml ./
|
||||
COPY src ./src
|
||||
COPY priv ./priv
|
||||
COPY tailwind.css ./
|
||||
|
||||
RUN gleam deps download
|
||||
RUN gleam export erlang-shipment
|
||||
|
||||
ARG TAILWIND_VERSION=v4.1.17
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
TAILWIND_ARCH="x64"; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then \
|
||||
TAILWIND_ARCH="arm64"; \
|
||||
else \
|
||||
TAILWIND_ARCH="x64"; \
|
||||
fi && \
|
||||
echo "Downloading Tailwind CSS $TAILWIND_VERSION for Alpine Linux: linux-$TAILWIND_ARCH-musl" && \
|
||||
curl -sSLf -o /tmp/tailwindcss "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${TAILWIND_ARCH}-musl" && \
|
||||
chmod +x /tmp/tailwindcss && \
|
||||
/tmp/tailwindcss -i ./tailwind.css -o ./priv/static/app.css --minify
|
||||
|
||||
FROM erlang:27.1.1.0-alpine
|
||||
|
||||
ARG BUILD_TIMESTAMP
|
||||
|
||||
RUN apk add --no-cache openssl ncurses-libs curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/build/erlang-shipment /app
|
||||
COPY --from=builder /app/priv ./priv
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV PORT=8080
|
||||
ENV BUILD_TIMESTAMP=${BUILD_TIMESTAMP}
|
||||
|
||||
CMD ["/app/entrypoint.sh", "run"]
|
||||
21
fluxer_admin/Dockerfile.dev
Normal file
21
fluxer_admin/Dockerfile.dev
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM ghcr.io/gleam-lang/gleam:v1.13.0-erlang-alpine
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Download gleam dependencies
|
||||
COPY gleam.toml manifest.toml* ./
|
||||
RUN gleam deps download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Download and setup tailwindcss, then build CSS
|
||||
RUN mkdir -p build/bin && \
|
||||
curl -sLo build/bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.17/tailwindcss-linux-x64-musl && \
|
||||
chmod +x build/bin/tailwindcss && \
|
||||
build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css
|
||||
|
||||
CMD ["gleam", "run"]
|
||||
21
fluxer_admin/gleam.toml
Normal file
21
fluxer_admin/gleam.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
name = "fluxer_admin"
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
gleam_stdlib = ">= 0.63.2 and < 1.0.0"
|
||||
gleam_http = ">= 4.2.0 and < 5.0.0"
|
||||
gleam_erlang = ">= 1.0.0 and < 2.0.0"
|
||||
gleam_json = ">= 3.0.0 and < 4.0.0"
|
||||
gleam_httpc = ">= 5.0.0 and < 6.0.0"
|
||||
wisp = ">= 2.0.0 and < 3.0.0"
|
||||
mist = ">= 5.0.0 and < 6.0.0"
|
||||
lustre = ">= 5.3.0 and < 6.0.0"
|
||||
dot_env = ">= 1.2.0 and < 2.0.0"
|
||||
birl = ">= 1.8.0 and < 2.0.0"
|
||||
logging = ">= 1.3.0 and < 2.0.0"
|
||||
gleam_crypto = ">= 1.5.1 and < 2.0.0"
|
||||
envoy = ">= 1.0.2 and < 2.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
gleeunit = ">= 1.6.1 and < 2.0.0"
|
||||
glailglind = ">= 2.2.0 and < 3.0.0"
|
||||
34
fluxer_admin/justfile
Normal file
34
fluxer_admin/justfile
Normal file
@@ -0,0 +1,34 @@
|
||||
default:
|
||||
@just --list
|
||||
|
||||
build:
|
||||
gleam build
|
||||
|
||||
run:
|
||||
just css && gleam run
|
||||
|
||||
test:
|
||||
gleam test
|
||||
|
||||
css:
|
||||
./build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css
|
||||
|
||||
css-watch:
|
||||
./build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css --watch
|
||||
|
||||
clean:
|
||||
rm -rf build/
|
||||
rm -rf priv/static/app.css
|
||||
|
||||
deps:
|
||||
gleam deps download
|
||||
|
||||
format:
|
||||
gleam format
|
||||
|
||||
check: format build test
|
||||
|
||||
install-tailwind:
|
||||
gleam run -m tailwind/install
|
||||
|
||||
setup: deps install-tailwind css
|
||||
55
fluxer_admin/manifest.toml
Normal file
55
fluxer_admin/manifest.toml
Normal file
@@ -0,0 +1,55 @@
|
||||
# This file was generated by Gleam
|
||||
# You typically do not need to edit this file
|
||||
|
||||
packages = [
|
||||
{ name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" },
|
||||
{ name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
|
||||
{ name = "dot_env", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "simplifile"], otp_app = "dot_env", source = "hex", outer_checksum = "F2B4815F1B5AF8F20A6EADBB393E715C4C35203EBD5BE8200F766EA83A0B18DE" },
|
||||
{ name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
|
||||
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
|
||||
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
|
||||
{ name = "glailglind", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_httpc", "gleam_stdlib", "shellout", "simplifile", "tom"], otp_app = "glailglind", source = "hex", outer_checksum = "B0306F2C0A03A5A03633FC2BDF2D52B1E76FCAED656FB3F5EBCB7C31770E2524" },
|
||||
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
|
||||
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
|
||||
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
|
||||
{ name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },
|
||||
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
|
||||
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
|
||||
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
|
||||
{ name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
|
||||
{ name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" },
|
||||
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
|
||||
{ name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
|
||||
{ name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" },
|
||||
{ name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
|
||||
{ name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
|
||||
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
|
||||
{ name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
|
||||
{ name = "lustre", version = "5.3.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "5CBB5DD2849D8316A2101792FC35AEB58CE4B151451044A9C2A2A70A2F7FCEB8" },
|
||||
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
|
||||
{ name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" },
|
||||
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
|
||||
{ name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" },
|
||||
{ name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" },
|
||||
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
|
||||
{ name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
|
||||
{ name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" },
|
||||
{ name = "wisp", version = "2.1.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "362BDDD11BF48EB38CDE51A73BC7D1B89581B395CA998E3F23F11EC026151C54" },
|
||||
]
|
||||
|
||||
[requirements]
|
||||
birl = { version = ">= 1.8.0 and < 2.0.0" }
|
||||
dot_env = { version = ">= 1.2.0 and < 2.0.0" }
|
||||
glailglind = { version = ">= 2.2.0 and < 3.0.0" }
|
||||
gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" }
|
||||
gleam_http = { version = ">= 4.2.0 and < 5.0.0" }
|
||||
gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" }
|
||||
gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
|
||||
gleam_stdlib = { version = ">= 0.63.2 and < 1.0.0" }
|
||||
gleeunit = { version = ">= 1.6.1 and < 2.0.0" }
|
||||
logging = { version = ">= 1.3.0 and < 2.0.0" }
|
||||
lustre = { version = ">= 5.3.0 and < 6.0.0" }
|
||||
mist = { version = ">= 5.0.0 and < 6.0.0" }
|
||||
wisp = { version = ">= 2.0.0 and < 3.0.0" }
|
||||
gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" }
|
||||
envoy = { version = ">= 1.0.2 and < 2.0.0" }
|
||||
0
fluxer_admin/priv/static/.gitkeep
Normal file
0
fluxer_admin/priv/static/.gitkeep
Normal file
72
fluxer_admin/src/fluxer_admin.gleam
Normal file
72
fluxer_admin/src/fluxer_admin.gleam
Normal file
@@ -0,0 +1,72 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/config
|
||||
import fluxer_admin/middleware/cache_middleware
|
||||
import fluxer_admin/router
|
||||
import fluxer_admin/web.{type Context, Context, normalize_base_path}
|
||||
import gleam/erlang/process
|
||||
import mist
|
||||
import wisp
|
||||
import wisp/wisp_mist
|
||||
|
||||
pub fn main() {
|
||||
wisp.configure_logger()
|
||||
|
||||
let assert Ok(cfg) = config.load_config()
|
||||
|
||||
let base_path = normalize_base_path(cfg.base_path)
|
||||
|
||||
let ctx =
|
||||
Context(
|
||||
api_endpoint: cfg.api_endpoint,
|
||||
oauth_client_id: cfg.oauth_client_id,
|
||||
oauth_client_secret: cfg.oauth_client_secret,
|
||||
oauth_redirect_uri: cfg.oauth_redirect_uri,
|
||||
secret_key_base: cfg.secret_key_base,
|
||||
static_directory: "priv/static",
|
||||
media_endpoint: cfg.media_endpoint,
|
||||
cdn_endpoint: cfg.cdn_endpoint,
|
||||
asset_version: cfg.build_timestamp,
|
||||
base_path: base_path,
|
||||
app_endpoint: cfg.admin_endpoint,
|
||||
web_app_endpoint: cfg.web_app_endpoint,
|
||||
metrics_endpoint: cfg.metrics_endpoint,
|
||||
)
|
||||
|
||||
let assert Ok(_) =
|
||||
wisp_mist.handler(handle_request(_, ctx), cfg.secret_key_base)
|
||||
|> mist.new
|
||||
|> mist.bind("0.0.0.0")
|
||||
|> mist.port(cfg.port)
|
||||
|> mist.start
|
||||
|
||||
process.sleep_forever()
|
||||
}
|
||||
|
||||
fn handle_request(req: wisp.Request, ctx: Context) -> wisp.Response {
|
||||
let static_dir = ctx.static_directory
|
||||
|
||||
case wisp.path_segments(req) {
|
||||
["static", ..] -> {
|
||||
use <- wisp.serve_static(req, under: "/static", from: static_dir)
|
||||
router.handle_request(req, ctx)
|
||||
}
|
||||
_ -> router.handle_request(req, ctx)
|
||||
}
|
||||
|> cache_middleware.add_cache_headers
|
||||
}
|
||||
24
fluxer_admin/src/fluxer_admin/acl.gleam
Normal file
24
fluxer_admin/src/fluxer_admin/acl.gleam
Normal file
@@ -0,0 +1,24 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/constants
|
||||
import gleam/list
|
||||
|
||||
pub fn has_permission(admin_acls: List(String), required_acl: String) -> Bool {
|
||||
list.contains(admin_acls, required_acl)
|
||||
|| list.contains(admin_acls, constants.acl_wildcard)
|
||||
}
|
||||
264
fluxer_admin/src/fluxer_admin/api/archives.gleam
Normal file
264
fluxer_admin/src/fluxer_admin/api/archives.gleam
Normal file
@@ -0,0 +1,264 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
|
||||
pub type Archive {
|
||||
Archive(
|
||||
archive_id: String,
|
||||
subject_type: String,
|
||||
subject_id: String,
|
||||
requested_by: String,
|
||||
requested_at: String,
|
||||
started_at: Option(String),
|
||||
completed_at: Option(String),
|
||||
failed_at: Option(String),
|
||||
file_size: Option(String),
|
||||
progress_percent: Int,
|
||||
progress_step: Option(String),
|
||||
error_message: Option(String),
|
||||
download_url_expires_at: Option(String),
|
||||
expires_at: Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListArchivesResponse {
|
||||
ListArchivesResponse(archives: List(Archive))
|
||||
}
|
||||
|
||||
pub fn trigger_user_archive(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
user_id: String,
|
||||
audit_log_reason: Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/archives/user",
|
||||
[#("user_id", json.string(user_id))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn trigger_guild_archive(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
guild_id: String,
|
||||
audit_log_reason: Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/archives/guild",
|
||||
[#("guild_id", json.string(guild_id))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
fn archive_decoder() {
|
||||
use archive_id <- decode.field("archive_id", decode.string)
|
||||
use subject_type <- decode.field("subject_type", decode.string)
|
||||
use subject_id <- decode.field("subject_id", decode.string)
|
||||
use requested_by <- decode.field("requested_by", decode.string)
|
||||
use requested_at <- decode.field("requested_at", decode.string)
|
||||
use started_at <- decode.optional_field(
|
||||
"started_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use completed_at <- decode.optional_field(
|
||||
"completed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use failed_at <- decode.optional_field(
|
||||
"failed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use file_size <- decode.optional_field(
|
||||
"file_size",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use progress_percent <- decode.field("progress_percent", decode.int)
|
||||
use progress_step <- decode.optional_field(
|
||||
"progress_step",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use error_message <- decode.optional_field(
|
||||
"error_message",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use download_url_expires_at <- decode.optional_field(
|
||||
"download_url_expires_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use expires_at <- decode.optional_field(
|
||||
"expires_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(Archive(
|
||||
archive_id: archive_id,
|
||||
subject_type: subject_type,
|
||||
subject_id: subject_id,
|
||||
requested_by: requested_by,
|
||||
requested_at: requested_at,
|
||||
started_at: started_at,
|
||||
completed_at: completed_at,
|
||||
failed_at: failed_at,
|
||||
file_size: file_size,
|
||||
progress_percent: progress_percent,
|
||||
progress_step: progress_step,
|
||||
error_message: error_message,
|
||||
download_url_expires_at: download_url_expires_at,
|
||||
expires_at: expires_at,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn list_archives(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
subject_type: String,
|
||||
subject_id: Option(String),
|
||||
include_expired: Bool,
|
||||
) -> Result(ListArchivesResponse, ApiError) {
|
||||
let fields = [
|
||||
#("subject_type", json.string(subject_type)),
|
||||
#("include_expired", json.bool(include_expired)),
|
||||
]
|
||||
let fields = case subject_id {
|
||||
option.Some(id) -> fields |> list.append([#("subject_id", json.string(id))])
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let url = ctx.api_endpoint <> "/admin/archives/list"
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use archives <- decode.field("archives", decode.list(archive_decoder()))
|
||||
decode.success(ListArchivesResponse(archives: archives))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_archive_download_url(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
subject_type: String,
|
||||
subject_id: String,
|
||||
archive_id: String,
|
||||
) -> Result(#(String, String), ApiError) {
|
||||
let url =
|
||||
ctx.api_endpoint
|
||||
<> "/admin/archives/"
|
||||
<> subject_type
|
||||
<> "/"
|
||||
<> subject_id
|
||||
<> "/"
|
||||
<> archive_id
|
||||
<> "/download"
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use download_url <- decode.field("downloadUrl", decode.string)
|
||||
use expires_at <- decode.field("expiresAt", decode.string)
|
||||
decode.success(#(download_url, expires_at))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
128
fluxer_admin/src/fluxer_admin/api/assets.gleam
Normal file
128
fluxer_admin/src/fluxer_admin/api/assets.gleam
Normal file
@@ -0,0 +1,128 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type AssetPurgeResult {
|
||||
AssetPurgeResult(
|
||||
id: String,
|
||||
asset_type: String,
|
||||
found_in_db: Bool,
|
||||
guild_id: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type AssetPurgeError {
|
||||
AssetPurgeError(id: String, error: String)
|
||||
}
|
||||
|
||||
pub type AssetPurgeResponse {
|
||||
AssetPurgeResponse(
|
||||
processed: List(AssetPurgeResult),
|
||||
errors: List(AssetPurgeError),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn purge_assets(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(AssetPurgeResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/assets/purge"
|
||||
let body =
|
||||
json.object([#("ids", json.array(ids, json.string))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let result_decoder = {
|
||||
use processed <- decode.field(
|
||||
"processed",
|
||||
decode.list({
|
||||
use id <- decode.field("id", decode.string)
|
||||
use asset_type <- decode.field("asset_type", decode.string)
|
||||
use found_in_db <- decode.field("found_in_db", decode.bool)
|
||||
use guild_id <- decode.field(
|
||||
"guild_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(AssetPurgeResult(
|
||||
id: id,
|
||||
asset_type: asset_type,
|
||||
found_in_db: found_in_db,
|
||||
guild_id: guild_id,
|
||||
))
|
||||
}),
|
||||
)
|
||||
use errors <- decode.field(
|
||||
"errors",
|
||||
decode.list({
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(AssetPurgeError(id: id, error: error))
|
||||
}),
|
||||
)
|
||||
decode.success(AssetPurgeResponse(processed: processed, errors: errors))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, result_decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
169
fluxer_admin/src/fluxer_admin/api/audit.gleam
Normal file
169
fluxer_admin/src/fluxer_admin/api/audit.gleam
Normal file
@@ -0,0 +1,169 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dict
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type AuditLog {
|
||||
AuditLog(
|
||||
log_id: String,
|
||||
admin_user_id: String,
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
action: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
metadata: List(#(String, String)),
|
||||
created_at: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListAuditLogsResponse {
|
||||
ListAuditLogsResponse(logs: List(AuditLog), total: Int)
|
||||
}
|
||||
|
||||
pub fn search_audit_logs(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
query: option.Option(String),
|
||||
admin_user_id_filter: option.Option(String),
|
||||
target_type: option.Option(String),
|
||||
target_id: option.Option(String),
|
||||
action: option.Option(String),
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(ListAuditLogsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/audit-logs/search"
|
||||
|
||||
let mut_fields = [#("limit", json.int(limit)), #("offset", json.int(offset))]
|
||||
|
||||
let mut_fields = case query {
|
||||
option.Some(q) if q != "" -> [#("query", json.string(q)), ..mut_fields]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case admin_user_id_filter {
|
||||
option.Some(id) if id != "" -> [
|
||||
#("admin_user_id", json.string(id)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case target_type {
|
||||
option.Some(tt) if tt != "" -> [
|
||||
#("target_type", json.string(tt)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case target_id {
|
||||
option.Some(tid) if tid != "" -> [
|
||||
#("target_id", json.string(tid)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case action {
|
||||
option.Some(act) if act != "" -> [
|
||||
#("action", json.string(act)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
|
||||
let body = json.object(mut_fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let audit_log_decoder = {
|
||||
use log_id <- decode.field("log_id", decode.string)
|
||||
use admin_user_id <- decode.field("admin_user_id", decode.string)
|
||||
use target_type_val <- decode.field("target_type", decode.string)
|
||||
use target_id_val <- decode.field("target_id", decode.string)
|
||||
use action <- decode.field("action", decode.string)
|
||||
use audit_log_reason <- decode.field(
|
||||
"audit_log_reason",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use metadata <- decode.field(
|
||||
"metadata",
|
||||
decode.dict(decode.string, decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.string)
|
||||
|
||||
let metadata_list =
|
||||
metadata
|
||||
|> dict.to_list
|
||||
|
||||
decode.success(AuditLog(
|
||||
log_id: log_id,
|
||||
admin_user_id: admin_user_id,
|
||||
target_type: target_type_val,
|
||||
target_id: target_id_val,
|
||||
action: action,
|
||||
audit_log_reason: audit_log_reason,
|
||||
metadata: metadata_list,
|
||||
created_at: created_at,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use logs <- decode.field("logs", decode.list(audit_log_decoder))
|
||||
use total <- decode.field("total", decode.int)
|
||||
decode.success(ListAuditLogsResponse(logs: logs, total: total))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
249
fluxer_admin/src/fluxer_admin/api/bans.gleam
Normal file
249
fluxer_admin/src/fluxer_admin/api/bans.gleam
Normal file
@@ -0,0 +1,249 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_simple, admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type CheckBanResponse {
|
||||
CheckBanResponse(banned: Bool)
|
||||
}
|
||||
|
||||
pub fn ban_email(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
email: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/bans/email/add",
|
||||
[#("email", json.string(email))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn unban_email(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
email: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/bans/email/remove",
|
||||
[#("email", json.string(email))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn check_email_ban(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
email: String,
|
||||
) -> Result(CheckBanResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bans/email/check"
|
||||
let body = json.object([#("email", json.string(email))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use banned <- decode.field("banned", decode.bool)
|
||||
decode.success(CheckBanResponse(banned: banned))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ban_ip(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ip: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/ip/add", [
|
||||
#("ip", json.string(ip)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn unban_ip(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ip: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/ip/remove", [
|
||||
#("ip", json.string(ip)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn check_ip_ban(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ip: String,
|
||||
) -> Result(CheckBanResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bans/ip/check"
|
||||
let body = json.object([#("ip", json.string(ip))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use banned <- decode.field("banned", decode.bool)
|
||||
decode.success(CheckBanResponse(banned: banned))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ban_phone(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
phone: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/phone/add", [
|
||||
#("phone", json.string(phone)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn unban_phone(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
phone: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/phone/remove", [
|
||||
#("phone", json.string(phone)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn check_phone_ban(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
phone: String,
|
||||
) -> Result(CheckBanResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bans/phone/check"
|
||||
let body = json.object([#("phone", json.string(phone))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use banned <- decode.field("banned", decode.bool)
|
||||
decode.success(CheckBanResponse(banned: banned))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
332
fluxer_admin/src/fluxer_admin/api/bulk.gleam
Normal file
332
fluxer_admin/src/fluxer_admin/api/bulk.gleam
Normal file
@@ -0,0 +1,332 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type BulkOperationError {
|
||||
BulkOperationError(id: String, error: String)
|
||||
}
|
||||
|
||||
pub type BulkOperationResponse {
|
||||
BulkOperationResponse(
|
||||
successful: List(String),
|
||||
failed: List(BulkOperationError),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bulk_update_user_flags(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
add_flags: List(String),
|
||||
remove_flags: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/bulk-update-flags"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
#("add_flags", json.array(add_flags, json.string)),
|
||||
#("remove_flags", json.array(remove_flags, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_update_guild_features(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_ids: List(String),
|
||||
add_features: List(String),
|
||||
remove_features: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/bulk-update-features"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_ids", json.array(guild_ids, json.string)),
|
||||
#("add_features", json.array(add_features, json.string)),
|
||||
#("remove_features", json.array(remove_features, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_add_guild_members(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
user_ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bulk/add-guild-members"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_schedule_user_deletion(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
reason_code: Int,
|
||||
public_reason: option.Option(String),
|
||||
days_until_deletion: Int,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bulk/schedule-user-deletion"
|
||||
let fields = [
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
#("reason_code", json.int(reason_code)),
|
||||
#("days_until_deletion", json.int(days_until_deletion)),
|
||||
]
|
||||
let fields = case public_reason {
|
||||
option.Some(r) -> [#("public_reason", json.string(r)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
124
fluxer_admin/src/fluxer_admin/api/codes.gleam
Normal file
124
fluxer_admin/src/fluxer_admin/api/codes.gleam
Normal file
@@ -0,0 +1,124 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
|
||||
fn parse_codes(body: String) -> Result(List(String), ApiError) {
|
||||
let decoder = {
|
||||
use codes <- decode.field("codes", decode.list(decode.string))
|
||||
decode.success(codes)
|
||||
}
|
||||
|
||||
case json.parse(body, decoder) {
|
||||
Ok(codes) -> Ok(codes)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_beta_codes(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
count: Int,
|
||||
) -> Result(List(String), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/codes/beta"
|
||||
let body = json.object([#("count", json.int(count))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) ->
|
||||
case resp.status {
|
||||
200 -> parse_codes(resp.body)
|
||||
401 -> Error(Unauthorized)
|
||||
403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
404 -> Error(NotFound)
|
||||
_ -> Error(ServerError)
|
||||
}
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_gift_codes(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
count: Int,
|
||||
product_type: String,
|
||||
) -> Result(List(String), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/codes/gift"
|
||||
let body =
|
||||
json.object([
|
||||
#("count", json.int(count)),
|
||||
#("product_type", json.string(product_type)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) ->
|
||||
case resp.status {
|
||||
200 -> parse_codes(resp.body)
|
||||
401 -> Error(Unauthorized)
|
||||
403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
404 -> Error(NotFound)
|
||||
_ -> Error(ServerError)
|
||||
}
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
239
fluxer_admin/src/fluxer_admin/api/common.gleam
Normal file
239
fluxer_admin/src/fluxer_admin/api/common.gleam
Normal file
@@ -0,0 +1,239 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type UserLookupResult {
|
||||
UserLookupResult(
|
||||
id: String,
|
||||
username: String,
|
||||
discriminator: Int,
|
||||
global_name: option.Option(String),
|
||||
bot: Bool,
|
||||
system: Bool,
|
||||
flags: String,
|
||||
avatar: option.Option(String),
|
||||
banner: option.Option(String),
|
||||
bio: option.Option(String),
|
||||
pronouns: option.Option(String),
|
||||
accent_color: option.Option(Int),
|
||||
email: option.Option(String),
|
||||
email_verified: Bool,
|
||||
email_bounced: Bool,
|
||||
phone: option.Option(String),
|
||||
date_of_birth: option.Option(String),
|
||||
locale: option.Option(String),
|
||||
premium_type: option.Option(Int),
|
||||
premium_since: option.Option(String),
|
||||
premium_until: option.Option(String),
|
||||
suspicious_activity_flags: Int,
|
||||
temp_banned_until: option.Option(String),
|
||||
pending_deletion_at: option.Option(String),
|
||||
pending_bulk_message_deletion_at: option.Option(String),
|
||||
deletion_reason_code: option.Option(Int),
|
||||
deletion_public_reason: option.Option(String),
|
||||
acls: List(String),
|
||||
has_totp: Bool,
|
||||
authenticator_types: List(Int),
|
||||
last_active_at: option.Option(String),
|
||||
last_active_ip: option.Option(String),
|
||||
last_active_ip_reverse: option.Option(String),
|
||||
last_active_location: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type ApiError {
|
||||
Unauthorized
|
||||
Forbidden(message: String)
|
||||
NotFound
|
||||
ServerError
|
||||
NetworkError
|
||||
}
|
||||
|
||||
pub fn admin_post_simple(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
path: String,
|
||||
fields: List(#(String, json.Json)),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(ctx, session, path, fields, option.None)
|
||||
}
|
||||
|
||||
pub fn admin_post_with_audit(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
path: String,
|
||||
fields: List(#(String, json.Json)),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let url = ctx.api_endpoint <> path
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> Ok(Nil)
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_lookup_decoder() {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use username <- decode.field("username", decode.string)
|
||||
use discriminator <- decode.field("discriminator", decode.int)
|
||||
use global_name <- decode.field("global_name", decode.optional(decode.string))
|
||||
use bot <- decode.field("bot", decode.bool)
|
||||
use system <- decode.field("system", decode.bool)
|
||||
use flags <- decode.field("flags", decode.string)
|
||||
use avatar <- decode.field("avatar", decode.optional(decode.string))
|
||||
use banner <- decode.field("banner", decode.optional(decode.string))
|
||||
use bio <- decode.field("bio", decode.optional(decode.string))
|
||||
use pronouns <- decode.field("pronouns", decode.optional(decode.string))
|
||||
use accent_color <- decode.field("accent_color", decode.optional(decode.int))
|
||||
use email <- decode.field("email", decode.optional(decode.string))
|
||||
use email_verified <- decode.field("email_verified", decode.bool)
|
||||
use email_bounced <- decode.field("email_bounced", decode.bool)
|
||||
use phone <- decode.field("phone", decode.optional(decode.string))
|
||||
use date_of_birth <- decode.field(
|
||||
"date_of_birth",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use locale <- decode.field("locale", decode.optional(decode.string))
|
||||
use premium_type <- decode.field("premium_type", decode.optional(decode.int))
|
||||
use premium_since <- decode.field(
|
||||
"premium_since",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use premium_until <- decode.field(
|
||||
"premium_until",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use suspicious_activity_flags <- decode.field(
|
||||
"suspicious_activity_flags",
|
||||
decode.int,
|
||||
)
|
||||
use temp_banned_until <- decode.field(
|
||||
"temp_banned_until",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use pending_deletion_at <- decode.field(
|
||||
"pending_deletion_at",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use pending_bulk_message_deletion_at <- decode.field(
|
||||
"pending_bulk_message_deletion_at",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use deletion_reason_code <- decode.field(
|
||||
"deletion_reason_code",
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use deletion_public_reason <- decode.field(
|
||||
"deletion_public_reason",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use acls <- decode.field("acls", decode.list(decode.string))
|
||||
use has_totp <- decode.field("has_totp", decode.bool)
|
||||
use authenticator_types <- decode.field(
|
||||
"authenticator_types",
|
||||
decode.list(decode.int),
|
||||
)
|
||||
use last_active_at <- decode.field(
|
||||
"last_active_at",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use last_active_ip <- decode.field(
|
||||
"last_active_ip",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use last_active_ip_reverse <- decode.field(
|
||||
"last_active_ip_reverse",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use last_active_location <- decode.field(
|
||||
"last_active_location",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(UserLookupResult(
|
||||
id: id,
|
||||
username: username,
|
||||
discriminator: discriminator,
|
||||
global_name: global_name,
|
||||
bot: bot,
|
||||
system: system,
|
||||
flags: flags,
|
||||
avatar: avatar,
|
||||
banner: banner,
|
||||
bio: bio,
|
||||
pronouns: pronouns,
|
||||
accent_color: accent_color,
|
||||
email: email,
|
||||
email_verified: email_verified,
|
||||
email_bounced: email_bounced,
|
||||
phone: phone,
|
||||
date_of_birth: date_of_birth,
|
||||
locale: locale,
|
||||
premium_type: premium_type,
|
||||
premium_since: premium_since,
|
||||
premium_until: premium_until,
|
||||
suspicious_activity_flags: suspicious_activity_flags,
|
||||
temp_banned_until: temp_banned_until,
|
||||
pending_deletion_at: pending_deletion_at,
|
||||
pending_bulk_message_deletion_at: pending_bulk_message_deletion_at,
|
||||
deletion_reason_code: deletion_reason_code,
|
||||
deletion_public_reason: deletion_public_reason,
|
||||
acls: acls,
|
||||
has_totp: has_totp,
|
||||
authenticator_types: authenticator_types,
|
||||
last_active_at: last_active_at,
|
||||
last_active_ip: last_active_ip,
|
||||
last_active_ip_reverse: last_active_ip_reverse,
|
||||
last_active_location: last_active_location,
|
||||
))
|
||||
}
|
||||
109
fluxer_admin/src/fluxer_admin/api/feature_flags.gleam
Normal file
109
fluxer_admin/src/fluxer_admin/api/feature_flags.gleam
Normal file
@@ -0,0 +1,109 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dict
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
pub type FeatureFlagConfig {
|
||||
FeatureFlagConfig(guild_ids: List(String))
|
||||
}
|
||||
|
||||
pub fn get_feature_flags(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
) -> Result(List(#(String, FeatureFlagConfig)), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/feature-flags/get"
|
||||
let body = json.object([]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use feature_flags <- decode.field(
|
||||
"feature_flags",
|
||||
decode.dict(decode.string, decode.list(decode.string)),
|
||||
)
|
||||
decode.success(feature_flags)
|
||||
}
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(flags_dict) -> {
|
||||
let entries =
|
||||
dict.to_list(flags_dict)
|
||||
|> list.map(fn(entry) {
|
||||
let #(flag, guild_ids) = entry
|
||||
#(flag, FeatureFlagConfig(guild_ids:))
|
||||
})
|
||||
Ok(entries)
|
||||
}
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_feature_flag(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
flag_id: String,
|
||||
guild_ids: List(String),
|
||||
) -> Result(FeatureFlagConfig, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/feature-flags/update"
|
||||
let guild_ids_str = string.join(guild_ids, ",")
|
||||
let body =
|
||||
json.object([
|
||||
#("flag", json.string(flag_id)),
|
||||
#("guild_ids", json.string(guild_ids_str)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> Ok(FeatureFlagConfig(guild_ids:))
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
182
fluxer_admin/src/fluxer_admin/api/guild_assets.gleam
Normal file
182
fluxer_admin/src/fluxer_admin/api/guild_assets.gleam
Normal file
@@ -0,0 +1,182 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
|
||||
pub type GuildEmojiAsset {
|
||||
GuildEmojiAsset(
|
||||
id: String,
|
||||
name: String,
|
||||
animated: Bool,
|
||||
creator_id: String,
|
||||
media_url: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildEmojisResponse {
|
||||
ListGuildEmojisResponse(guild_id: String, emojis: List(GuildEmojiAsset))
|
||||
}
|
||||
|
||||
pub type GuildStickerAsset {
|
||||
GuildStickerAsset(
|
||||
id: String,
|
||||
name: String,
|
||||
format_type: Int,
|
||||
creator_id: String,
|
||||
media_url: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildStickersResponse {
|
||||
ListGuildStickersResponse(guild_id: String, stickers: List(GuildStickerAsset))
|
||||
}
|
||||
|
||||
pub fn list_guild_emojis(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(ListGuildEmojisResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/" <> guild_id <> "/emojis"
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let emoji_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use animated <- decode.field("animated", decode.bool)
|
||||
use creator_id <- decode.field("creator_id", decode.string)
|
||||
use media_url <- decode.field("media_url", decode.string)
|
||||
decode.success(GuildEmojiAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
animated: animated,
|
||||
creator_id: creator_id,
|
||||
media_url: media_url,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guild_id <- decode.field("guild_id", decode.string)
|
||||
use emojis <- decode.field("emojis", decode.list(emoji_decoder))
|
||||
decode.success(ListGuildEmojisResponse(
|
||||
guild_id: guild_id,
|
||||
emojis: emojis,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_guild_stickers(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(ListGuildStickersResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/" <> guild_id <> "/stickers"
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let sticker_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use format_type <- decode.field("format_type", decode.int)
|
||||
use creator_id <- decode.field("creator_id", decode.string)
|
||||
use media_url <- decode.field("media_url", decode.string)
|
||||
decode.success(GuildStickerAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
format_type: format_type,
|
||||
creator_id: creator_id,
|
||||
media_url: media_url,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guild_id <- decode.field("guild_id", decode.string)
|
||||
use stickers <- decode.field("stickers", decode.list(sticker_decoder))
|
||||
decode.success(ListGuildStickersResponse(
|
||||
guild_id: guild_id,
|
||||
stickers: stickers,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
529
fluxer_admin/src/fluxer_admin/api/guilds.gleam
Normal file
529
fluxer_admin/src/fluxer_admin/api/guilds.gleam
Normal file
@@ -0,0 +1,529 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_simple,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type GuildChannel {
|
||||
GuildChannel(
|
||||
id: String,
|
||||
name: String,
|
||||
type_: Int,
|
||||
position: Int,
|
||||
parent_id: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildRole {
|
||||
GuildRole(
|
||||
id: String,
|
||||
name: String,
|
||||
color: Int,
|
||||
position: Int,
|
||||
permissions: String,
|
||||
hoist: Bool,
|
||||
mentionable: Bool,
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildMember {
|
||||
GuildMember(
|
||||
user: GuildMemberUser,
|
||||
nick: option.Option(String),
|
||||
avatar: option.Option(String),
|
||||
roles: List(String),
|
||||
joined_at: String,
|
||||
premium_since: option.Option(String),
|
||||
deaf: Bool,
|
||||
mute: Bool,
|
||||
flags: Int,
|
||||
pending: Bool,
|
||||
communication_disabled_until: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildMemberUser {
|
||||
GuildMemberUser(
|
||||
id: String,
|
||||
username: String,
|
||||
discriminator: String,
|
||||
avatar: option.Option(String),
|
||||
bot: Bool,
|
||||
system: Bool,
|
||||
public_flags: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildMembersResponse {
|
||||
ListGuildMembersResponse(
|
||||
members: List(GuildMember),
|
||||
total: Int,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildLookupResult {
|
||||
GuildLookupResult(
|
||||
id: String,
|
||||
owner_id: String,
|
||||
name: String,
|
||||
vanity_url_code: option.Option(String),
|
||||
icon: option.Option(String),
|
||||
banner: option.Option(String),
|
||||
splash: option.Option(String),
|
||||
features: List(String),
|
||||
verification_level: Int,
|
||||
mfa_level: Int,
|
||||
nsfw_level: Int,
|
||||
explicit_content_filter: Int,
|
||||
default_message_notifications: Int,
|
||||
afk_channel_id: option.Option(String),
|
||||
afk_timeout: Int,
|
||||
system_channel_id: option.Option(String),
|
||||
system_channel_flags: Int,
|
||||
rules_channel_id: option.Option(String),
|
||||
disabled_operations: Int,
|
||||
member_count: Int,
|
||||
channels: List(GuildChannel),
|
||||
roles: List(GuildRole),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildSearchResult {
|
||||
GuildSearchResult(
|
||||
id: String,
|
||||
owner_id: String,
|
||||
name: String,
|
||||
features: List(String),
|
||||
icon: option.Option(String),
|
||||
banner: option.Option(String),
|
||||
member_count: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type SearchGuildsResponse {
|
||||
SearchGuildsResponse(guilds: List(GuildSearchResult), total: Int)
|
||||
}
|
||||
|
||||
pub fn lookup_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(option.Option(GuildLookupResult), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/lookup"
|
||||
let body =
|
||||
json.object([#("guild_id", json.string(guild_id))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let channel_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use type_ <- decode.field("type", decode.int)
|
||||
use position <- decode.field("position", decode.int)
|
||||
use parent_id <- decode.field(
|
||||
"parent_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(GuildChannel(
|
||||
id: id,
|
||||
name: name,
|
||||
type_: type_,
|
||||
position: position,
|
||||
parent_id: parent_id,
|
||||
))
|
||||
}
|
||||
|
||||
let role_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use color <- decode.field("color", decode.int)
|
||||
use position <- decode.field("position", decode.int)
|
||||
use permissions <- decode.field("permissions", decode.string)
|
||||
use hoist <- decode.field("hoist", decode.bool)
|
||||
use mentionable <- decode.field("mentionable", decode.bool)
|
||||
decode.success(GuildRole(
|
||||
id: id,
|
||||
name: name,
|
||||
color: color,
|
||||
position: position,
|
||||
permissions: permissions,
|
||||
hoist: hoist,
|
||||
mentionable: mentionable,
|
||||
))
|
||||
}
|
||||
|
||||
let guild_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use owner_id <- decode.field("owner_id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use vanity_url_code <- decode.field(
|
||||
"vanity_url_code",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use icon <- decode.field("icon", decode.optional(decode.string))
|
||||
use banner <- decode.field("banner", decode.optional(decode.string))
|
||||
use splash <- decode.field("splash", decode.optional(decode.string))
|
||||
use features <- decode.field("features", decode.list(decode.string))
|
||||
use verification_level <- decode.field("verification_level", decode.int)
|
||||
use mfa_level <- decode.field("mfa_level", decode.int)
|
||||
use nsfw_level <- decode.field("nsfw_level", decode.int)
|
||||
use explicit_content_filter <- decode.field(
|
||||
"explicit_content_filter",
|
||||
decode.int,
|
||||
)
|
||||
use default_message_notifications <- decode.field(
|
||||
"default_message_notifications",
|
||||
decode.int,
|
||||
)
|
||||
use afk_channel_id <- decode.field(
|
||||
"afk_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use afk_timeout <- decode.field("afk_timeout", decode.int)
|
||||
use system_channel_id <- decode.field(
|
||||
"system_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use system_channel_flags <- decode.field(
|
||||
"system_channel_flags",
|
||||
decode.int,
|
||||
)
|
||||
use rules_channel_id <- decode.field(
|
||||
"rules_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use disabled_operations <- decode.field(
|
||||
"disabled_operations",
|
||||
decode.int,
|
||||
)
|
||||
use member_count <- decode.field("member_count", decode.int)
|
||||
use channels <- decode.field("channels", decode.list(channel_decoder))
|
||||
use roles <- decode.field("roles", decode.list(role_decoder))
|
||||
decode.success(GuildLookupResult(
|
||||
id: id,
|
||||
owner_id: owner_id,
|
||||
name: name,
|
||||
vanity_url_code: vanity_url_code,
|
||||
icon: icon,
|
||||
banner: banner,
|
||||
splash: splash,
|
||||
features: features,
|
||||
verification_level: verification_level,
|
||||
mfa_level: mfa_level,
|
||||
nsfw_level: nsfw_level,
|
||||
explicit_content_filter: explicit_content_filter,
|
||||
default_message_notifications: default_message_notifications,
|
||||
afk_channel_id: afk_channel_id,
|
||||
afk_timeout: afk_timeout,
|
||||
system_channel_id: system_channel_id,
|
||||
system_channel_flags: system_channel_flags,
|
||||
rules_channel_id: rules_channel_id,
|
||||
disabled_operations: disabled_operations,
|
||||
member_count: member_count,
|
||||
channels: channels,
|
||||
roles: roles,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guild <- decode.field("guild", decode.optional(guild_decoder))
|
||||
decode.success(guild)
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_guild_fields(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
fields: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/clear-fields", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("fields", json.array(fields, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn update_guild_features(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
add_features: List(String),
|
||||
remove_features: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-features", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("add_features", json.array(add_features, json.string)),
|
||||
#("remove_features", json.array(remove_features, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn update_guild_settings(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
verification_level: option.Option(Int),
|
||||
mfa_level: option.Option(Int),
|
||||
nsfw_level: option.Option(Int),
|
||||
explicit_content_filter: option.Option(Int),
|
||||
default_message_notifications: option.Option(Int),
|
||||
disabled_operations: option.Option(Int),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let mut_fields = [#("guild_id", json.string(guild_id))]
|
||||
let mut_fields = case verification_level {
|
||||
option.Some(vl) -> [#("verification_level", json.int(vl)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case mfa_level {
|
||||
option.Some(ml) -> [#("mfa_level", json.int(ml)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case nsfw_level {
|
||||
option.Some(nl) -> [#("nsfw_level", json.int(nl)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case explicit_content_filter {
|
||||
option.Some(ecf) -> [
|
||||
#("explicit_content_filter", json.int(ecf)),
|
||||
..mut_fields
|
||||
]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case default_message_notifications {
|
||||
option.Some(dmn) -> [
|
||||
#("default_message_notifications", json.int(dmn)),
|
||||
..mut_fields
|
||||
]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case disabled_operations {
|
||||
option.Some(dops) -> [
|
||||
#("disabled_operations", json.int(dops)),
|
||||
..mut_fields
|
||||
]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-settings", mut_fields)
|
||||
}
|
||||
|
||||
pub fn update_guild_name(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
name: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-name", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("name", json.string(name)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn update_guild_vanity(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
vanity_url_code: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = [#("guild_id", json.string(guild_id))]
|
||||
let fields = case vanity_url_code {
|
||||
option.Some(code) -> [#("vanity_url_code", json.string(code)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-vanity", fields)
|
||||
}
|
||||
|
||||
pub fn transfer_guild_ownership(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
new_owner_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/transfer-ownership", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("new_owner_id", json.string(new_owner_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn reload_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/reload", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn shutdown_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/shutdown", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn delete_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/delete", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn force_add_user_to_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/force-add-user", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn search_guilds(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
query: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(SearchGuildsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/search"
|
||||
let body =
|
||||
json.object([
|
||||
#("query", json.string(query)),
|
||||
#("limit", json.int(limit)),
|
||||
#("offset", json.int(offset)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let guild_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use owner_id <- decode.optional_field("owner_id", "", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use features <- decode.field("features", decode.list(decode.string))
|
||||
use icon <- decode.optional_field(
|
||||
"icon",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use banner <- decode.optional_field(
|
||||
"banner",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use member_count <- decode.optional_field("member_count", 0, decode.int)
|
||||
decode.success(GuildSearchResult(
|
||||
id: id,
|
||||
owner_id: owner_id,
|
||||
name: name,
|
||||
features: features,
|
||||
icon: icon,
|
||||
banner: banner,
|
||||
member_count: member_count,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guilds <- decode.field("guilds", decode.list(guild_decoder))
|
||||
use total <- decode.field("total", decode.int)
|
||||
decode.success(SearchGuildsResponse(guilds: guilds, total: total))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
191
fluxer_admin/src/fluxer_admin/api/guilds_members.gleam
Normal file
191
fluxer_admin/src/fluxer_admin/api/guilds_members.gleam
Normal file
@@ -0,0 +1,191 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type GuildMember {
|
||||
GuildMember(
|
||||
user: GuildMemberUser,
|
||||
nick: option.Option(String),
|
||||
avatar: option.Option(String),
|
||||
roles: List(String),
|
||||
joined_at: String,
|
||||
premium_since: option.Option(String),
|
||||
deaf: Bool,
|
||||
mute: Bool,
|
||||
flags: Int,
|
||||
pending: Bool,
|
||||
communication_disabled_until: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildMemberUser {
|
||||
GuildMemberUser(
|
||||
id: String,
|
||||
username: String,
|
||||
discriminator: String,
|
||||
avatar: option.Option(String),
|
||||
bot: Bool,
|
||||
system: Bool,
|
||||
public_flags: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildMembersResponse {
|
||||
ListGuildMembersResponse(
|
||||
members: List(GuildMember),
|
||||
total: Int,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_guild_members(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(ListGuildMembersResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/list-members"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("limit", json.int(limit)),
|
||||
#("offset", json.int(offset)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let user_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use username <- decode.field("username", decode.string)
|
||||
use discriminator <- decode.field("discriminator", decode.string)
|
||||
use avatar <- decode.field("avatar", decode.optional(decode.string))
|
||||
use bot <- decode.optional_field("bot", False, decode.bool)
|
||||
use system <- decode.optional_field("system", False, decode.bool)
|
||||
use public_flags <- decode.optional_field("public_flags", 0, decode.int)
|
||||
decode.success(GuildMemberUser(
|
||||
id: id,
|
||||
username: username,
|
||||
discriminator: discriminator,
|
||||
avatar: avatar,
|
||||
bot: bot,
|
||||
system: system,
|
||||
public_flags: public_flags,
|
||||
))
|
||||
}
|
||||
|
||||
let member_decoder = {
|
||||
use user <- decode.field("user", user_decoder)
|
||||
use nick <- decode.optional_field(
|
||||
"nick",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use avatar <- decode.optional_field(
|
||||
"avatar",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use roles <- decode.field("roles", decode.list(decode.string))
|
||||
use joined_at <- decode.field("joined_at", decode.string)
|
||||
use premium_since <- decode.optional_field(
|
||||
"premium_since",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use deaf <- decode.optional_field("deaf", False, decode.bool)
|
||||
use mute <- decode.optional_field("mute", False, decode.bool)
|
||||
use flags <- decode.optional_field("flags", 0, decode.int)
|
||||
use pending <- decode.optional_field("pending", False, decode.bool)
|
||||
use communication_disabled_until <- decode.optional_field(
|
||||
"communication_disabled_until",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(GuildMember(
|
||||
user: user,
|
||||
nick: nick,
|
||||
avatar: avatar,
|
||||
roles: roles,
|
||||
joined_at: joined_at,
|
||||
premium_since: premium_since,
|
||||
deaf: deaf,
|
||||
mute: mute,
|
||||
flags: flags,
|
||||
pending: pending,
|
||||
communication_disabled_until: communication_disabled_until,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use members <- decode.field("members", decode.list(member_decoder))
|
||||
use total <- decode.field("total", decode.int)
|
||||
use limit <- decode.field("limit", decode.int)
|
||||
use offset <- decode.field("offset", decode.int)
|
||||
decode.success(ListGuildMembersResponse(
|
||||
members: members,
|
||||
total: total,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
172
fluxer_admin/src/fluxer_admin/api/instance_config.gleam
Normal file
172
fluxer_admin/src/fluxer_admin/api/instance_config.gleam
Normal file
@@ -0,0 +1,172 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type InstanceConfig {
|
||||
InstanceConfig(
|
||||
manual_review_enabled: Bool,
|
||||
manual_review_schedule_enabled: Bool,
|
||||
manual_review_schedule_start_hour_utc: Int,
|
||||
manual_review_schedule_end_hour_utc: Int,
|
||||
manual_review_active_now: Bool,
|
||||
registration_alerts_webhook_url: String,
|
||||
system_alerts_webhook_url: String,
|
||||
)
|
||||
}
|
||||
|
||||
fn instance_config_decoder() {
|
||||
use manual_review_enabled <- decode.field(
|
||||
"manual_review_enabled",
|
||||
decode.bool,
|
||||
)
|
||||
use manual_review_schedule_enabled <- decode.field(
|
||||
"manual_review_schedule_enabled",
|
||||
decode.bool,
|
||||
)
|
||||
use manual_review_schedule_start_hour_utc <- decode.field(
|
||||
"manual_review_schedule_start_hour_utc",
|
||||
decode.int,
|
||||
)
|
||||
use manual_review_schedule_end_hour_utc <- decode.field(
|
||||
"manual_review_schedule_end_hour_utc",
|
||||
decode.int,
|
||||
)
|
||||
use manual_review_active_now <- decode.field(
|
||||
"manual_review_active_now",
|
||||
decode.bool,
|
||||
)
|
||||
use registration_alerts_webhook_url <- decode.field(
|
||||
"registration_alerts_webhook_url",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use system_alerts_webhook_url <- decode.field(
|
||||
"system_alerts_webhook_url",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(InstanceConfig(
|
||||
manual_review_enabled:,
|
||||
manual_review_schedule_enabled:,
|
||||
manual_review_schedule_start_hour_utc:,
|
||||
manual_review_schedule_end_hour_utc:,
|
||||
manual_review_active_now:,
|
||||
registration_alerts_webhook_url: option.unwrap(
|
||||
registration_alerts_webhook_url,
|
||||
"",
|
||||
),
|
||||
system_alerts_webhook_url: option.unwrap(system_alerts_webhook_url, ""),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_instance_config(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
) -> Result(InstanceConfig, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/instance-config/get"
|
||||
let body = json.object([]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
case json.parse(resp.body, instance_config_decoder()) {
|
||||
Ok(config) -> Ok(config)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_instance_config(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
manual_review_enabled: Bool,
|
||||
manual_review_schedule_enabled: Bool,
|
||||
manual_review_schedule_start_hour_utc: Int,
|
||||
manual_review_schedule_end_hour_utc: Int,
|
||||
registration_alerts_webhook_url: String,
|
||||
system_alerts_webhook_url: String,
|
||||
) -> Result(InstanceConfig, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/instance-config/update"
|
||||
let registration_webhook_json = case registration_alerts_webhook_url {
|
||||
"" -> json.null()
|
||||
url -> json.string(url)
|
||||
}
|
||||
let system_webhook_json = case system_alerts_webhook_url {
|
||||
"" -> json.null()
|
||||
url -> json.string(url)
|
||||
}
|
||||
let body =
|
||||
json.object([
|
||||
#("manual_review_enabled", json.bool(manual_review_enabled)),
|
||||
#(
|
||||
"manual_review_schedule_enabled",
|
||||
json.bool(manual_review_schedule_enabled),
|
||||
),
|
||||
#(
|
||||
"manual_review_schedule_start_hour_utc",
|
||||
json.int(manual_review_schedule_start_hour_utc),
|
||||
),
|
||||
#(
|
||||
"manual_review_schedule_end_hour_utc",
|
||||
json.int(manual_review_schedule_end_hour_utc),
|
||||
),
|
||||
#("registration_alerts_webhook_url", registration_webhook_json),
|
||||
#("system_alerts_webhook_url", system_webhook_json),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
case json.parse(resp.body, instance_config_decoder()) {
|
||||
Ok(config) -> Ok(config)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
508
fluxer_admin/src/fluxer_admin/api/messages.gleam
Normal file
508
fluxer_admin/src/fluxer_admin/api/messages.gleam
Normal file
@@ -0,0 +1,508 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type MessageAttachment {
|
||||
MessageAttachment(filename: String, url: String)
|
||||
}
|
||||
|
||||
pub type Message {
|
||||
Message(
|
||||
id: String,
|
||||
channel_id: String,
|
||||
author_id: String,
|
||||
author_username: String,
|
||||
content: String,
|
||||
timestamp: String,
|
||||
attachments: List(MessageAttachment),
|
||||
)
|
||||
}
|
||||
|
||||
pub type LookupMessageResponse {
|
||||
LookupMessageResponse(messages: List(Message), message_id: String)
|
||||
}
|
||||
|
||||
pub type MessageShredResponse {
|
||||
MessageShredResponse(job_id: String, requested: option.Option(Int))
|
||||
}
|
||||
|
||||
pub type DeleteAllUserMessagesResponse {
|
||||
DeleteAllUserMessagesResponse(
|
||||
dry_run: Bool,
|
||||
channel_count: Int,
|
||||
message_count: Int,
|
||||
job_id: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type MessageShredStatus {
|
||||
MessageShredStatus(
|
||||
status: String,
|
||||
requested: option.Option(Int),
|
||||
total: option.Option(Int),
|
||||
processed: option.Option(Int),
|
||||
skipped: option.Option(Int),
|
||||
started_at: option.Option(String),
|
||||
completed_at: option.Option(String),
|
||||
failed_at: option.Option(String),
|
||||
error: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_message(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = [
|
||||
#("channel_id", json.string(channel_id)),
|
||||
#("message_id", json.string(message_id)),
|
||||
]
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/messages/delete",
|
||||
fields,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn lookup_message(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
context_limit: Int,
|
||||
) -> Result(LookupMessageResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/lookup"
|
||||
let body =
|
||||
json.object([
|
||||
#("channel_id", json.string(channel_id)),
|
||||
#("message_id", json.string(message_id)),
|
||||
#("context_limit", json.int(context_limit)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let attachment_decoder = {
|
||||
use filename <- decode.field("filename", decode.string)
|
||||
use url <- decode.field("url", decode.string)
|
||||
decode.success(MessageAttachment(filename: filename, url: url))
|
||||
}
|
||||
|
||||
let message_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use channel_id <- decode.field("channel_id", decode.string)
|
||||
use author_id <- decode.field("author_id", decode.string)
|
||||
use author_username <- decode.field("author_username", decode.string)
|
||||
use content <- decode.field("content", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.string)
|
||||
use attachments <- decode.optional_field(
|
||||
"attachments",
|
||||
[],
|
||||
decode.list(attachment_decoder),
|
||||
)
|
||||
decode.success(Message(
|
||||
id: id,
|
||||
channel_id: channel_id,
|
||||
author_id: author_id,
|
||||
author_username: author_username,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
attachments: attachments,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use messages <- decode.field("messages", decode.list(message_decoder))
|
||||
use message_id <- decode.field("message_id", decode.string)
|
||||
decode.success(LookupMessageResponse(
|
||||
messages: messages,
|
||||
message_id: message_id,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_message_shred(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
entries: json.Json,
|
||||
) -> Result(MessageShredResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/shred"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_id", json.string(user_id)),
|
||||
#("entries", entries),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use job_id <- decode.field("job_id", decode.string)
|
||||
use requested <- decode.optional_field(
|
||||
"requested",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
decode.success(MessageShredResponse(
|
||||
job_id: job_id,
|
||||
requested: requested,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_all_user_messages(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
dry_run: Bool,
|
||||
) -> Result(DeleteAllUserMessagesResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/delete-all"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_id", json.string(user_id)),
|
||||
#("dry_run", json.bool(dry_run)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use dry_run <- decode.field("dry_run", decode.bool)
|
||||
use channel_count <- decode.field("channel_count", decode.int)
|
||||
use message_count <- decode.field("message_count", decode.int)
|
||||
use job_id <- decode.optional_field(
|
||||
"job_id",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(DeleteAllUserMessagesResponse(
|
||||
dry_run: dry_run,
|
||||
channel_count: channel_count,
|
||||
message_count: message_count,
|
||||
job_id: job_id,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_message_shred_status(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
job_id: String,
|
||||
) -> Result(MessageShredStatus, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/shred-status"
|
||||
let body =
|
||||
json.object([#("job_id", json.string(job_id))])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use status <- decode.field("status", decode.string)
|
||||
use requested <- decode.optional_field(
|
||||
"requested",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use total <- decode.optional_field(
|
||||
"total",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use processed <- decode.optional_field(
|
||||
"processed",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use skipped <- decode.optional_field(
|
||||
"skipped",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use started_at <- decode.optional_field(
|
||||
"started_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use completed_at <- decode.optional_field(
|
||||
"completed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use failed_at <- decode.optional_field(
|
||||
"failed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use error <- decode.optional_field(
|
||||
"error",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(MessageShredStatus(
|
||||
status: status,
|
||||
requested: requested,
|
||||
total: total,
|
||||
processed: processed,
|
||||
skipped: skipped,
|
||||
started_at: started_at,
|
||||
completed_at: completed_at,
|
||||
failed_at: failed_at,
|
||||
error: error,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup_message_by_attachment(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
channel_id: String,
|
||||
attachment_id: String,
|
||||
filename: String,
|
||||
context_limit: Int,
|
||||
) -> Result(LookupMessageResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/lookup-by-attachment"
|
||||
let body =
|
||||
json.object([
|
||||
#("channel_id", json.string(channel_id)),
|
||||
#("attachment_id", json.string(attachment_id)),
|
||||
#("filename", json.string(filename)),
|
||||
#("context_limit", json.int(context_limit)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let attachment_decoder = {
|
||||
use filename <- decode.field("filename", decode.string)
|
||||
use url <- decode.field("url", decode.string)
|
||||
decode.success(MessageAttachment(filename: filename, url: url))
|
||||
}
|
||||
|
||||
let message_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use channel_id <- decode.field("channel_id", decode.string)
|
||||
use author_id <- decode.field("author_id", decode.string)
|
||||
use author_username <- decode.field("author_username", decode.string)
|
||||
use content <- decode.field("content", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.string)
|
||||
use attachments <- decode.optional_field(
|
||||
"attachments",
|
||||
[],
|
||||
decode.list(attachment_decoder),
|
||||
)
|
||||
decode.success(Message(
|
||||
id: id,
|
||||
channel_id: channel_id,
|
||||
author_id: author_id,
|
||||
author_username: author_username,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
attachments: attachments,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use messages <- decode.field("messages", decode.list(message_decoder))
|
||||
use message_id <- decode.field("message_id", decode.string)
|
||||
decode.success(LookupMessageResponse(
|
||||
messages: messages,
|
||||
message_id: message_id,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
264
fluxer_admin/src/fluxer_admin/api/metrics.gleam
Normal file
264
fluxer_admin/src/fluxer_admin/api/metrics.gleam
Normal file
@@ -0,0 +1,264 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, NetworkError, NotFound, ServerError,
|
||||
}
|
||||
import fluxer_admin/web.{type Context}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/int
|
||||
import gleam/json
|
||||
import gleam/option.{type Option, None, Some}
|
||||
|
||||
pub type DataPoint {
|
||||
DataPoint(timestamp: Int, value: Float)
|
||||
}
|
||||
|
||||
pub type QueryResponse {
|
||||
QueryResponse(metric: String, data: List(DataPoint))
|
||||
}
|
||||
|
||||
pub type TopEntry {
|
||||
TopEntry(label: String, value: Float)
|
||||
}
|
||||
|
||||
pub type AggregateResponse {
|
||||
AggregateResponse(
|
||||
metric: String,
|
||||
total: Float,
|
||||
breakdown: option.Option(List(TopEntry)),
|
||||
)
|
||||
}
|
||||
|
||||
pub type TopQueryResponse {
|
||||
TopQueryResponse(metric: String, entries: List(TopEntry))
|
||||
}
|
||||
|
||||
pub type CrashEvent {
|
||||
CrashEvent(
|
||||
id: String,
|
||||
timestamp: Int,
|
||||
guild_id: String,
|
||||
stacktrace: String,
|
||||
notified: Bool,
|
||||
)
|
||||
}
|
||||
|
||||
pub type CrashesResponse {
|
||||
CrashesResponse(crashes: List(CrashEvent))
|
||||
}
|
||||
|
||||
pub fn query_metrics(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
start: Option(String),
|
||||
end: Option(String),
|
||||
) -> Result(QueryResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let query_params = case start, end {
|
||||
Some(s), Some(e) ->
|
||||
"?metric=" <> metric <> "&start=" <> s <> "&end=" <> e
|
||||
Some(s), None -> "?metric=" <> metric <> "&start=" <> s
|
||||
None, Some(e) -> "?metric=" <> metric <> "&end=" <> e
|
||||
None, None -> "?metric=" <> metric
|
||||
}
|
||||
let url = endpoint <> "/query" <> query_params
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let data_point_decoder = {
|
||||
use timestamp <- decode.field("timestamp", decode.int)
|
||||
use value <- decode.field("value", decode.float)
|
||||
decode.success(DataPoint(timestamp: timestamp, value: value))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use metric_name <- decode.field("metric", decode.string)
|
||||
use data <- decode.field("data", decode.list(data_point_decoder))
|
||||
decode.success(QueryResponse(metric: metric_name, data: data))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_aggregate(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
) -> Result(AggregateResponse, ApiError) {
|
||||
query_aggregate_grouped(ctx, metric, option.None)
|
||||
}
|
||||
|
||||
fn top_entry_decoder() -> decode.Decoder(TopEntry) {
|
||||
{
|
||||
use label <- decode.field("label", decode.string)
|
||||
use value <- decode.field("value", decode.float)
|
||||
decode.success(TopEntry(label: label, value: value))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_aggregate_grouped(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
group_by: option.Option(String),
|
||||
) -> Result(AggregateResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let query_params = case group_by {
|
||||
option.Some(group) -> "?metric=" <> metric <> "&group_by=" <> group
|
||||
option.None -> "?metric=" <> metric
|
||||
}
|
||||
let url = endpoint <> "/query/aggregate" <> query_params
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use metric_name <- decode.field("metric", decode.string)
|
||||
use total <- decode.field("total", decode.float)
|
||||
use breakdown <- decode.optional_field(
|
||||
"breakdown",
|
||||
option.None,
|
||||
decode.list(top_entry_decoder()) |> decode.map(option.Some),
|
||||
)
|
||||
decode.success(AggregateResponse(
|
||||
metric: metric_name,
|
||||
total: total,
|
||||
breakdown: breakdown,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_top(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
limit: Int,
|
||||
) -> Result(TopQueryResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let url =
|
||||
endpoint
|
||||
<> "/query/top?metric="
|
||||
<> metric
|
||||
<> "&limit="
|
||||
<> int.to_string(limit)
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use metric_name <- decode.field("metric", decode.string)
|
||||
use entries <- decode.field(
|
||||
"entries",
|
||||
decode.list(top_entry_decoder()),
|
||||
)
|
||||
decode.success(TopQueryResponse(
|
||||
metric: metric_name,
|
||||
entries: entries,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_crashes(
|
||||
ctx: Context,
|
||||
limit: Int,
|
||||
) -> Result(CrashesResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let url = endpoint <> "/query/crashes?limit=" <> int.to_string(limit)
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let crash_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.int)
|
||||
use guild_id <- decode.field("guild_id", decode.string)
|
||||
use stacktrace <- decode.field("stacktrace", decode.string)
|
||||
use notified <- decode.field("notified", decode.bool)
|
||||
decode.success(CrashEvent(
|
||||
id: id,
|
||||
timestamp: timestamp,
|
||||
guild_id: guild_id,
|
||||
stacktrace: stacktrace,
|
||||
notified: notified,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use crashes <- decode.field("crashes", decode.list(crash_decoder))
|
||||
decode.success(CrashesResponse(crashes: crashes))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1010
fluxer_admin/src/fluxer_admin/api/oauth.gleam
Normal file
1010
fluxer_admin/src/fluxer_admin/api/oauth.gleam
Normal file
File diff suppressed because it is too large
Load Diff
705
fluxer_admin/src/fluxer_admin/api/reports.gleam
Normal file
705
fluxer_admin/src/fluxer_admin/api/reports.gleam
Normal file
@@ -0,0 +1,705 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/api/messages.{type Message, Message, MessageAttachment}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type Report {
|
||||
Report(
|
||||
report_id: String,
|
||||
reporter_id: option.Option(String),
|
||||
reporter_tag: option.Option(String),
|
||||
reporter_username: option.Option(String),
|
||||
reporter_discriminator: option.Option(String),
|
||||
reporter_email: option.Option(String),
|
||||
reporter_full_legal_name: option.Option(String),
|
||||
reporter_country_of_residence: option.Option(String),
|
||||
reported_at: String,
|
||||
status: Int,
|
||||
report_type: Int,
|
||||
category: String,
|
||||
additional_info: option.Option(String),
|
||||
reported_user_id: option.Option(String),
|
||||
reported_user_tag: option.Option(String),
|
||||
reported_user_username: option.Option(String),
|
||||
reported_user_discriminator: option.Option(String),
|
||||
reported_user_avatar_hash: option.Option(String),
|
||||
reported_guild_id: option.Option(String),
|
||||
reported_guild_name: option.Option(String),
|
||||
reported_guild_icon_hash: option.Option(String),
|
||||
reported_message_id: option.Option(String),
|
||||
reported_channel_id: option.Option(String),
|
||||
reported_channel_name: option.Option(String),
|
||||
reported_guild_invite_code: option.Option(String),
|
||||
resolved_at: option.Option(String),
|
||||
resolved_by_admin_id: option.Option(String),
|
||||
public_comment: option.Option(String),
|
||||
message_context: List(Message),
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListReportsResponse {
|
||||
ListReportsResponse(reports: List(Report))
|
||||
}
|
||||
|
||||
pub type SearchReportResult {
|
||||
SearchReportResult(
|
||||
report_id: String,
|
||||
reporter_id: option.Option(String),
|
||||
reporter_tag: option.Option(String),
|
||||
reporter_username: option.Option(String),
|
||||
reporter_discriminator: option.Option(String),
|
||||
reporter_email: option.Option(String),
|
||||
reporter_full_legal_name: option.Option(String),
|
||||
reporter_country_of_residence: option.Option(String),
|
||||
reported_at: String,
|
||||
status: Int,
|
||||
report_type: Int,
|
||||
category: String,
|
||||
additional_info: option.Option(String),
|
||||
reported_user_id: option.Option(String),
|
||||
reported_user_tag: option.Option(String),
|
||||
reported_user_username: option.Option(String),
|
||||
reported_user_discriminator: option.Option(String),
|
||||
reported_user_avatar_hash: option.Option(String),
|
||||
reported_guild_id: option.Option(String),
|
||||
reported_guild_name: option.Option(String),
|
||||
reported_guild_invite_code: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type SearchReportsResponse {
|
||||
SearchReportsResponse(
|
||||
reports: List(SearchReportResult),
|
||||
total: Int,
|
||||
offset: Int,
|
||||
limit: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_reports(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
status: Int,
|
||||
limit: Int,
|
||||
offset: option.Option(Int),
|
||||
) -> Result(ListReportsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/reports/list"
|
||||
|
||||
let mut_fields = [#("status", json.int(status)), #("limit", json.int(limit))]
|
||||
let mut_fields = case offset {
|
||||
option.Some(o) -> [#("offset", json.int(o)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
|
||||
let body = json.object(mut_fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let report_decoder = {
|
||||
use report_id <- decode.field("report_id", decode.string)
|
||||
use reporter_id <- decode.field(
|
||||
"reporter_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_tag <- decode.field(
|
||||
"reporter_tag",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_username <- decode.field(
|
||||
"reporter_username",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_discriminator <- decode.field(
|
||||
"reporter_discriminator",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_email <- decode.field(
|
||||
"reporter_email",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_full_legal_name <- decode.field(
|
||||
"reporter_full_legal_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_country_of_residence <- decode.field(
|
||||
"reporter_country_of_residence",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_at <- decode.field("reported_at", decode.string)
|
||||
use status_val <- decode.field("status", decode.int)
|
||||
use report_type <- decode.field("report_type", decode.int)
|
||||
use category <- decode.field("category", decode.string)
|
||||
use additional_info <- decode.field(
|
||||
"additional_info",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_id <- decode.field(
|
||||
"reported_user_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_tag <- decode.field(
|
||||
"reported_user_tag",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_username <- decode.field(
|
||||
"reported_user_username",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_discriminator <- decode.field(
|
||||
"reported_user_discriminator",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_avatar_hash <- decode.field(
|
||||
"reported_user_avatar_hash",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_id <- decode.field(
|
||||
"reported_guild_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_name <- decode.field(
|
||||
"reported_guild_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_icon_hash <- decode.field(
|
||||
"reported_guild_icon_hash",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_invite_code <- decode.field(
|
||||
"reported_guild_invite_code",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_message_id <- decode.field(
|
||||
"reported_message_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_channel_id <- decode.field(
|
||||
"reported_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_channel_name <- decode.field(
|
||||
"reported_channel_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use resolved_at <- decode.field(
|
||||
"resolved_at",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use resolved_by_admin_id <- decode.field(
|
||||
"resolved_by_admin_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use public_comment <- decode.field(
|
||||
"public_comment",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
|
||||
decode.success(
|
||||
Report(
|
||||
report_id: report_id,
|
||||
reporter_id: reporter_id,
|
||||
reporter_tag: reporter_tag,
|
||||
reporter_username: reporter_username,
|
||||
reporter_discriminator: reporter_discriminator,
|
||||
reporter_email: reporter_email,
|
||||
reporter_full_legal_name: reporter_full_legal_name,
|
||||
reporter_country_of_residence: reporter_country_of_residence,
|
||||
reported_at: reported_at,
|
||||
status: status_val,
|
||||
report_type: report_type,
|
||||
category: category,
|
||||
additional_info: additional_info,
|
||||
reported_user_id: reported_user_id,
|
||||
reported_user_tag: reported_user_tag,
|
||||
reported_user_username: reported_user_username,
|
||||
reported_user_discriminator: reported_user_discriminator,
|
||||
reported_user_avatar_hash: reported_user_avatar_hash,
|
||||
reported_guild_id: reported_guild_id,
|
||||
reported_guild_name: reported_guild_name,
|
||||
reported_guild_icon_hash: reported_guild_icon_hash,
|
||||
reported_message_id: reported_message_id,
|
||||
reported_channel_id: reported_channel_id,
|
||||
reported_channel_name: reported_channel_name,
|
||||
reported_guild_invite_code: reported_guild_invite_code,
|
||||
resolved_at: resolved_at,
|
||||
resolved_by_admin_id: resolved_by_admin_id,
|
||||
public_comment: public_comment,
|
||||
message_context: [],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use reports <- decode.field("reports", decode.list(report_decoder))
|
||||
decode.success(ListReportsResponse(reports: reports))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_report(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
report_id: String,
|
||||
public_comment: option.Option(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = [#("report_id", json.string(report_id))]
|
||||
let fields = case public_comment {
|
||||
option.Some(comment) -> [
|
||||
#("public_comment", json.string(comment)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/reports/resolve",
|
||||
fields,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn search_reports(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
query: option.Option(String),
|
||||
status_filter: option.Option(Int),
|
||||
type_filter: option.Option(Int),
|
||||
category_filter: option.Option(String),
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(SearchReportsResponse, ApiError) {
|
||||
let mut_fields = [#("limit", json.int(limit)), #("offset", json.int(offset))]
|
||||
|
||||
let mut_fields = case query {
|
||||
option.Some(q) if q != "" -> [#("query", json.string(q)), ..mut_fields]
|
||||
_ -> mut_fields
|
||||
}
|
||||
|
||||
let mut_fields = case status_filter {
|
||||
option.Some(s) -> [#("status", json.int(s)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
|
||||
let mut_fields = case type_filter {
|
||||
option.Some(t) -> [#("report_type", json.int(t)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
|
||||
let mut_fields = case category_filter {
|
||||
option.Some(c) if c != "" -> [#("category", json.string(c)), ..mut_fields]
|
||||
_ -> mut_fields
|
||||
}
|
||||
|
||||
let url = ctx.api_endpoint <> "/admin/reports/search"
|
||||
let body = json.object(mut_fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let report_decoder = {
|
||||
use report_id <- decode.field("report_id", decode.string)
|
||||
use reporter_id <- decode.field(
|
||||
"reporter_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_tag <- decode.field(
|
||||
"reporter_tag",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_username <- decode.field(
|
||||
"reporter_username",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_discriminator <- decode.field(
|
||||
"reporter_discriminator",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_email <- decode.field(
|
||||
"reporter_email",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_full_legal_name <- decode.field(
|
||||
"reporter_full_legal_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_country_of_residence <- decode.field(
|
||||
"reporter_country_of_residence",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_at <- decode.field("reported_at", decode.string)
|
||||
use status_val <- decode.field("status", decode.int)
|
||||
use report_type <- decode.field("report_type", decode.int)
|
||||
use category <- decode.field("category", decode.string)
|
||||
use additional_info <- decode.field(
|
||||
"additional_info",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_id <- decode.field(
|
||||
"reported_user_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_tag <- decode.field(
|
||||
"reported_user_tag",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_username <- decode.field(
|
||||
"reported_user_username",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_discriminator <- decode.field(
|
||||
"reported_user_discriminator",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_avatar_hash <- decode.field(
|
||||
"reported_user_avatar_hash",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_id <- decode.field(
|
||||
"reported_guild_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_name <- decode.field(
|
||||
"reported_guild_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_invite_code <- decode.field(
|
||||
"reported_guild_invite_code",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(SearchReportResult(
|
||||
report_id: report_id,
|
||||
reporter_id: reporter_id,
|
||||
reporter_tag: reporter_tag,
|
||||
reporter_username: reporter_username,
|
||||
reporter_discriminator: reporter_discriminator,
|
||||
reporter_email: reporter_email,
|
||||
reporter_full_legal_name: reporter_full_legal_name,
|
||||
reporter_country_of_residence: reporter_country_of_residence,
|
||||
reported_at: reported_at,
|
||||
status: status_val,
|
||||
report_type: report_type,
|
||||
category: category,
|
||||
additional_info: additional_info,
|
||||
reported_user_id: reported_user_id,
|
||||
reported_user_tag: reported_user_tag,
|
||||
reported_user_username: reported_user_username,
|
||||
reported_user_discriminator: reported_user_discriminator,
|
||||
reported_user_avatar_hash: reported_user_avatar_hash,
|
||||
reported_guild_id: reported_guild_id,
|
||||
reported_guild_name: reported_guild_name,
|
||||
reported_guild_invite_code: reported_guild_invite_code,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use reports <- decode.field("reports", decode.list(report_decoder))
|
||||
use total <- decode.field("total", decode.int)
|
||||
use offset_val <- decode.field("offset", decode.int)
|
||||
use limit_val <- decode.field("limit", decode.int)
|
||||
decode.success(SearchReportsResponse(
|
||||
reports: reports,
|
||||
total: total,
|
||||
offset: offset_val,
|
||||
limit: limit_val,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_report_detail(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
report_id: String,
|
||||
) -> Result(Report, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/reports/" <> report_id
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let attachment_decoder = {
|
||||
use filename <- decode.field("filename", decode.string)
|
||||
use url <- decode.field("url", decode.string)
|
||||
decode.success(MessageAttachment(filename: filename, url: url))
|
||||
}
|
||||
|
||||
let context_message_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use channel_id <- decode.field(
|
||||
"channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use author_id <- decode.field("author_id", decode.string)
|
||||
use author_username <- decode.field("author_username", decode.string)
|
||||
use content <- decode.field("content", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.string)
|
||||
use attachments <- decode.optional_field(
|
||||
"attachments",
|
||||
[],
|
||||
decode.list(attachment_decoder),
|
||||
)
|
||||
decode.success(Message(
|
||||
id: id,
|
||||
channel_id: option.unwrap(channel_id, ""),
|
||||
author_id: author_id,
|
||||
author_username: author_username,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
attachments: attachments,
|
||||
))
|
||||
}
|
||||
|
||||
let report_decoder = {
|
||||
use report_id <- decode.field("report_id", decode.string)
|
||||
use reporter_id <- decode.field(
|
||||
"reporter_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_tag <- decode.field(
|
||||
"reporter_tag",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_username <- decode.field(
|
||||
"reporter_username",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_discriminator <- decode.field(
|
||||
"reporter_discriminator",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_email <- decode.field(
|
||||
"reporter_email",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_full_legal_name <- decode.field(
|
||||
"reporter_full_legal_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reporter_country_of_residence <- decode.field(
|
||||
"reporter_country_of_residence",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_at <- decode.field("reported_at", decode.string)
|
||||
use status_val <- decode.field("status", decode.int)
|
||||
use report_type <- decode.field("report_type", decode.int)
|
||||
use category <- decode.field("category", decode.string)
|
||||
use additional_info <- decode.field(
|
||||
"additional_info",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_id <- decode.field(
|
||||
"reported_user_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_tag <- decode.field(
|
||||
"reported_user_tag",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_username <- decode.field(
|
||||
"reported_user_username",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_discriminator <- decode.field(
|
||||
"reported_user_discriminator",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_user_avatar_hash <- decode.field(
|
||||
"reported_user_avatar_hash",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_id <- decode.field(
|
||||
"reported_guild_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_name <- decode.field(
|
||||
"reported_guild_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_icon_hash <- decode.field(
|
||||
"reported_guild_icon_hash",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_invite_code <- decode.field(
|
||||
"reported_guild_invite_code",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_message_id <- decode.field(
|
||||
"reported_message_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_channel_id <- decode.field(
|
||||
"reported_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_channel_name <- decode.field(
|
||||
"reported_channel_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use resolved_at <- decode.field(
|
||||
"resolved_at",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use resolved_by_admin_id <- decode.field(
|
||||
"resolved_by_admin_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use public_comment <- decode.field(
|
||||
"public_comment",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use message_context <- decode.optional_field(
|
||||
"message_context",
|
||||
[],
|
||||
decode.list(context_message_decoder),
|
||||
)
|
||||
decode.success(Report(
|
||||
report_id: report_id,
|
||||
reporter_id: reporter_id,
|
||||
reporter_tag: reporter_tag,
|
||||
reporter_username: reporter_username,
|
||||
reporter_discriminator: reporter_discriminator,
|
||||
reporter_email: reporter_email,
|
||||
reporter_full_legal_name: reporter_full_legal_name,
|
||||
reporter_country_of_residence: reporter_country_of_residence,
|
||||
reported_at: reported_at,
|
||||
status: status_val,
|
||||
report_type: report_type,
|
||||
category: category,
|
||||
additional_info: additional_info,
|
||||
reported_user_id: reported_user_id,
|
||||
reported_user_tag: reported_user_tag,
|
||||
reported_user_username: reported_user_username,
|
||||
reported_user_discriminator: reported_user_discriminator,
|
||||
reported_user_avatar_hash: reported_user_avatar_hash,
|
||||
reported_guild_id: reported_guild_id,
|
||||
reported_guild_name: reported_guild_name,
|
||||
reported_guild_icon_hash: reported_guild_icon_hash,
|
||||
reported_message_id: reported_message_id,
|
||||
reported_channel_id: reported_channel_id,
|
||||
reported_channel_name: reported_channel_name,
|
||||
reported_guild_invite_code: reported_guild_invite_code,
|
||||
resolved_at: resolved_at,
|
||||
resolved_by_admin_id: resolved_by_admin_id,
|
||||
public_comment: public_comment,
|
||||
message_context: message_context,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, report_decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
202
fluxer_admin/src/fluxer_admin/api/search.gleam
Normal file
202
fluxer_admin/src/fluxer_admin/api/search.gleam
Normal file
@@ -0,0 +1,202 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type RefreshSearchIndexResponse {
|
||||
RefreshSearchIndexResponse(job_id: String)
|
||||
}
|
||||
|
||||
pub type IndexRefreshStatus {
|
||||
IndexRefreshStatus(
|
||||
status: String,
|
||||
total: option.Option(Int),
|
||||
indexed: option.Option(Int),
|
||||
started_at: option.Option(String),
|
||||
completed_at: option.Option(String),
|
||||
error: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn refresh_search_index(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
index_type: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(RefreshSearchIndexResponse, ApiError) {
|
||||
refresh_search_index_with_guild(
|
||||
ctx,
|
||||
session,
|
||||
index_type,
|
||||
option.None,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn refresh_search_index_with_guild(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
index_type: String,
|
||||
guild_id: option.Option(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(RefreshSearchIndexResponse, ApiError) {
|
||||
let fields = case guild_id {
|
||||
option.Some(id) -> [
|
||||
#("index_type", json.string(index_type)),
|
||||
#("guild_id", json.string(id)),
|
||||
]
|
||||
option.None -> [#("index_type", json.string(index_type))]
|
||||
}
|
||||
let url = ctx.api_endpoint <> "/admin/search/refresh-index"
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use job_id <- decode.field("job_id", decode.string)
|
||||
decode.success(RefreshSearchIndexResponse(job_id: job_id))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_index_refresh_status(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
job_id: String,
|
||||
) -> Result(IndexRefreshStatus, ApiError) {
|
||||
let fields = [#("job_id", json.string(job_id))]
|
||||
let url = ctx.api_endpoint <> "/admin/search/refresh-status"
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use status <- decode.field("status", decode.string)
|
||||
use total <- decode.optional_field(
|
||||
"total",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use indexed <- decode.optional_field(
|
||||
"indexed",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use started_at <- decode.optional_field(
|
||||
"started_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use completed_at <- decode.optional_field(
|
||||
"completed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use error <- decode.optional_field(
|
||||
"error",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(IndexRefreshStatus(
|
||||
status: status,
|
||||
total: total,
|
||||
indexed: indexed,
|
||||
started_at: started_at,
|
||||
completed_at: completed_at,
|
||||
error: error,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
247
fluxer_admin/src/fluxer_admin/api/system.gleam
Normal file
247
fluxer_admin/src/fluxer_admin/api/system.gleam
Normal file
@@ -0,0 +1,247 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/int
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type ProcessMemoryStats {
|
||||
ProcessMemoryStats(
|
||||
guild_id: option.Option(String),
|
||||
guild_name: String,
|
||||
guild_icon: option.Option(String),
|
||||
memory_mb: Float,
|
||||
member_count: Int,
|
||||
session_count: Int,
|
||||
presence_count: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ProcessMemoryStatsResponse {
|
||||
ProcessMemoryStatsResponse(guilds: List(ProcessMemoryStats))
|
||||
}
|
||||
|
||||
pub fn get_guild_memory_stats(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
limit: Int,
|
||||
) -> Result(ProcessMemoryStatsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/gateway/memory-stats"
|
||||
let body = json.object([#("limit", json.int(limit))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let guild_decoder = {
|
||||
use guild_id <- decode.field("guild_id", decode.optional(decode.string))
|
||||
use guild_name <- decode.field("guild_name", decode.string)
|
||||
use guild_icon <- decode.field(
|
||||
"guild_icon",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use memory <- decode.field("memory", decode.int)
|
||||
use member_count <- decode.field("member_count", decode.int)
|
||||
use session_count <- decode.field("session_count", decode.int)
|
||||
use presence_count <- decode.field("presence_count", decode.int)
|
||||
|
||||
let memory_mb = int.to_float(memory) /. 1_024_000.0
|
||||
|
||||
decode.success(ProcessMemoryStats(
|
||||
guild_id: guild_id,
|
||||
guild_name: guild_name,
|
||||
guild_icon: guild_icon,
|
||||
memory_mb: memory_mb,
|
||||
member_count: member_count,
|
||||
session_count: session_count,
|
||||
presence_count: presence_count,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guilds <- decode.field("guilds", decode.list(guild_decoder))
|
||||
decode.success(ProcessMemoryStatsResponse(guilds: guilds))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reload_all_guilds(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
guild_ids: List(String),
|
||||
) -> Result(Int, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/gateway/reload-all"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_ids", json.array(guild_ids, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use count <- decode.field("count", decode.int)
|
||||
decode.success(count)
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(count) -> Ok(count)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub type NodeStats {
|
||||
NodeStats(
|
||||
status: String,
|
||||
sessions: Int,
|
||||
guilds: Int,
|
||||
presences: Int,
|
||||
calls: Int,
|
||||
memory_total: Int,
|
||||
memory_processes: Int,
|
||||
memory_system: Int,
|
||||
process_count: Int,
|
||||
process_limit: Int,
|
||||
uptime_seconds: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_node_stats(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
) -> Result(NodeStats, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/gateway/stats"
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use status <- decode.field("status", decode.string)
|
||||
use sessions <- decode.field("sessions", decode.int)
|
||||
use guilds <- decode.field("guilds", decode.int)
|
||||
use presences <- decode.field("presences", decode.int)
|
||||
use calls <- decode.field("calls", decode.int)
|
||||
use memory <- decode.field("memory", {
|
||||
use total <- decode.field("total", decode.int)
|
||||
use processes <- decode.field("processes", decode.int)
|
||||
use system <- decode.field("system", decode.int)
|
||||
decode.success(#(total, processes, system))
|
||||
})
|
||||
use process_count <- decode.field("process_count", decode.int)
|
||||
use process_limit <- decode.field("process_limit", decode.int)
|
||||
use uptime_seconds <- decode.field("uptime_seconds", decode.int)
|
||||
|
||||
let #(mem_total, mem_proc, mem_sys) = memory
|
||||
|
||||
decode.success(NodeStats(
|
||||
status: status,
|
||||
sessions: sessions,
|
||||
guilds: guilds,
|
||||
presences: presences,
|
||||
calls: calls,
|
||||
memory_total: mem_total,
|
||||
memory_processes: mem_proc,
|
||||
memory_system: mem_sys,
|
||||
process_count: process_count,
|
||||
process_limit: process_limit,
|
||||
uptime_seconds: uptime_seconds,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Forbidden"))
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
700
fluxer_admin/src/fluxer_admin/api/users.gleam
Normal file
700
fluxer_admin/src/fluxer_admin/api/users.gleam
Normal file
@@ -0,0 +1,700 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, type UserLookupResult, Forbidden, NetworkError, NotFound,
|
||||
ServerError, Unauthorized, admin_post_simple, admin_post_with_audit,
|
||||
user_lookup_decoder,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option.{type Option}
|
||||
|
||||
pub type ContactChangeLogEntry {
|
||||
ContactChangeLogEntry(
|
||||
event_id: String,
|
||||
field: String,
|
||||
old_value: Option(String),
|
||||
new_value: Option(String),
|
||||
reason: String,
|
||||
actor_user_id: Option(String),
|
||||
event_at: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListUserChangeLogResponse {
|
||||
ListUserChangeLogResponse(
|
||||
entries: List(ContactChangeLogEntry),
|
||||
next_page_token: Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type UserSession {
|
||||
UserSession(
|
||||
session_id_hash: String,
|
||||
created_at: String,
|
||||
approx_last_used_at: String,
|
||||
client_ip: String,
|
||||
client_os: String,
|
||||
client_platform: String,
|
||||
client_location: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListUserSessionsResponse {
|
||||
ListUserSessionsResponse(sessions: List(UserSession))
|
||||
}
|
||||
|
||||
pub type SearchUsersResponse {
|
||||
SearchUsersResponse(users: List(UserLookupResult), total: Int)
|
||||
}
|
||||
|
||||
pub type UserGuild {
|
||||
UserGuild(
|
||||
id: String,
|
||||
owner_id: String,
|
||||
name: String,
|
||||
features: List(String),
|
||||
icon: option.Option(String),
|
||||
banner: option.Option(String),
|
||||
member_count: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListUserGuildsResponse {
|
||||
ListUserGuildsResponse(guilds: List(UserGuild))
|
||||
}
|
||||
|
||||
pub fn list_user_guilds(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(ListUserGuildsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/list-guilds"
|
||||
let body = json.object([#("user_id", json.string(user_id))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let guild_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use owner_id <- decode.optional_field("owner_id", "", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use features <- decode.field("features", decode.list(decode.string))
|
||||
use icon <- decode.optional_field(
|
||||
"icon",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use banner <- decode.optional_field(
|
||||
"banner",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use member_count <- decode.optional_field("member_count", 0, decode.int)
|
||||
decode.success(UserGuild(
|
||||
id: id,
|
||||
owner_id: owner_id,
|
||||
name: name,
|
||||
features: features,
|
||||
icon: icon,
|
||||
banner: banner,
|
||||
member_count: member_count,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guilds <- decode.field("guilds", decode.list(guild_decoder))
|
||||
decode.success(ListUserGuildsResponse(guilds: guilds))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_user_change_log(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(ListUserChangeLogResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/change-log"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_id", json.string(user_id)),
|
||||
#("limit", json.int(50)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let entry_decoder = {
|
||||
use event_id <- decode.field("event_id", decode.string)
|
||||
use field <- decode.field("field", decode.string)
|
||||
use old_value <- decode.field("old_value", decode.optional(decode.string))
|
||||
use new_value <- decode.field("new_value", decode.optional(decode.string))
|
||||
use reason <- decode.field("reason", decode.string)
|
||||
use actor_user_id <- decode.field(
|
||||
"actor_user_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use event_at <- decode.field("event_at", decode.string)
|
||||
decode.success(ContactChangeLogEntry(
|
||||
event_id: event_id,
|
||||
field: field,
|
||||
old_value: old_value,
|
||||
new_value: new_value,
|
||||
reason: reason,
|
||||
actor_user_id: actor_user_id,
|
||||
event_at: event_at,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use entries <- decode.field("entries", decode.list(entry_decoder))
|
||||
use next_page_token <- decode.field(
|
||||
"next_page_token",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(ListUserChangeLogResponse(
|
||||
entries: entries,
|
||||
next_page_token: next_page_token,
|
||||
))
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 ->
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Missing permission"))
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup_user(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
query: String,
|
||||
) -> Result(Option(UserLookupResult), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/lookup"
|
||||
let body = json.object([#("query", json.string(query))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use user <- decode.field("user", decode.optional(user_lookup_decoder()))
|
||||
decode.success(user)
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_user_flags(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
add_flags: List(String),
|
||||
remove_flags: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/update-flags"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_id", json.string(user_id)),
|
||||
#("add_flags", json.array(add_flags, json.string)),
|
||||
#("remove_flags", json.array(remove_flags, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> Ok(Nil)
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disable_mfa(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/disable-mfa", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn verify_email(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/verify-email", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn unlink_phone(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/unlink-phone", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn terminate_sessions(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/terminate-sessions", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn temp_ban_user(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
duration_hours: Int,
|
||||
reason: option.Option(String),
|
||||
private_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("duration_hours", json.int(duration_hours)),
|
||||
]
|
||||
let fields = case reason {
|
||||
option.Some(r) -> [#("reason", json.string(r)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/users/temp-ban",
|
||||
fields,
|
||||
private_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn unban_user(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/unban", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn schedule_deletion(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
reason_code: Int,
|
||||
public_reason: option.Option(String),
|
||||
days_until_deletion: Int,
|
||||
private_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("reason_code", json.int(reason_code)),
|
||||
#("days_until_deletion", json.int(days_until_deletion)),
|
||||
]
|
||||
let fields = case public_reason {
|
||||
option.Some(r) -> [#("public_reason", json.string(r)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/users/schedule-deletion",
|
||||
fields,
|
||||
private_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn cancel_deletion(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/cancel-deletion", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn cancel_bulk_message_deletion(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/cancel-bulk-message-deletion", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn change_email(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
email: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/change-email", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("email", json.string(email)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn send_password_reset(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/send-password-reset", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn update_suspicious_activity_flags(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
flags: Int,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/users/update-suspicious-activity-flags",
|
||||
[#("user_id", json.string(user_id)), #("flags", json.int(flags))],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_current_admin(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
) -> Result(Option(UserLookupResult), ApiError) {
|
||||
lookup_user(ctx, session, session.user_id)
|
||||
}
|
||||
|
||||
pub fn set_user_acls(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
acls: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/set-acls", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("acls", json.array(acls, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn clear_user_fields(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
fields: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/clear-fields", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("fields", json.array(fields, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn set_bot_status(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
bot: Bool,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/set-bot-status", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("bot", json.bool(bot)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn set_system_status(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
system: Bool,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/set-system-status", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("system", json.bool(system)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn change_username(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
username: String,
|
||||
discriminator: Option(Int),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = case discriminator {
|
||||
option.Some(disc) -> [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("username", json.string(username)),
|
||||
#("discriminator", json.int(disc)),
|
||||
]
|
||||
option.None -> [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("username", json.string(username)),
|
||||
]
|
||||
}
|
||||
admin_post_simple(ctx, session, "/admin/users/change-username", fields)
|
||||
}
|
||||
|
||||
pub fn change_dob(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
date_of_birth: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/users/change-dob", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("date_of_birth", json.string(date_of_birth)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn list_user_sessions(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(ListUserSessionsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/list-sessions"
|
||||
let body = json.object([#("user_id", json.string(user_id))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let session_decoder = {
|
||||
use session_id_hash <- decode.field("session_id_hash", decode.string)
|
||||
use created_at <- decode.field("created_at", decode.string)
|
||||
use approx_last_used_at <- decode.field(
|
||||
"approx_last_used_at",
|
||||
decode.string,
|
||||
)
|
||||
use client_ip <- decode.field("client_ip", decode.string)
|
||||
use client_os <- decode.field("client_os", decode.string)
|
||||
use client_platform <- decode.field("client_platform", decode.string)
|
||||
use client_location <- decode.field("client_location", decode.string)
|
||||
decode.success(UserSession(
|
||||
session_id_hash: session_id_hash,
|
||||
created_at: created_at,
|
||||
approx_last_used_at: approx_last_used_at,
|
||||
client_ip: client_ip,
|
||||
client_os: client_os,
|
||||
client_platform: client_platform,
|
||||
client_location: client_location,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use sessions <- decode.field("sessions", decode.list(session_decoder))
|
||||
decode.success(ListUserSessionsResponse(sessions: sessions))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_users(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
query: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(SearchUsersResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/search"
|
||||
let body =
|
||||
json.object([
|
||||
#("query", json.string(query)),
|
||||
#("limit", json.int(limit)),
|
||||
#("offset", json.int(offset)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use users <- decode.field("users", decode.list(user_lookup_decoder()))
|
||||
use total <- decode.field("total", decode.int)
|
||||
decode.success(SearchUsersResponse(users: users, total: total))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
160
fluxer_admin/src/fluxer_admin/api/verifications.gleam
Normal file
160
fluxer_admin/src/fluxer_admin/api/verifications.gleam
Normal file
@@ -0,0 +1,160 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, type UserLookupResult, Forbidden, NetworkError, NotFound,
|
||||
ServerError, Unauthorized, admin_post_simple, user_lookup_decoder,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
|
||||
pub type PendingVerificationMetadata {
|
||||
PendingVerificationMetadata(key: String, value: String)
|
||||
}
|
||||
|
||||
pub type PendingVerification {
|
||||
PendingVerification(
|
||||
user_id: String,
|
||||
created_at: String,
|
||||
user: UserLookupResult,
|
||||
metadata: List(PendingVerificationMetadata),
|
||||
)
|
||||
}
|
||||
|
||||
pub type PendingVerificationsResponse {
|
||||
PendingVerificationsResponse(pending_verifications: List(PendingVerification))
|
||||
}
|
||||
|
||||
pub fn list_pending_verifications(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
limit: Int,
|
||||
) -> Result(PendingVerificationsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/pending-verifications/list"
|
||||
let body = json.object([#("limit", json.int(limit))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let pending_verification_metadata_decoder = {
|
||||
use key <- decode.field("key", decode.string)
|
||||
use value <- decode.field("value", decode.string)
|
||||
decode.success(PendingVerificationMetadata(key: key, value: value))
|
||||
}
|
||||
|
||||
let pending_verification_decoder = {
|
||||
use user_id <- decode.field("user_id", decode.string)
|
||||
use created_at <- decode.field("created_at", decode.string)
|
||||
use user <- decode.field("user", user_lookup_decoder())
|
||||
use metadata <- decode.field(
|
||||
"metadata",
|
||||
decode.list(pending_verification_metadata_decoder),
|
||||
)
|
||||
decode.success(PendingVerification(
|
||||
user_id: user_id,
|
||||
created_at: created_at,
|
||||
user: user,
|
||||
metadata: metadata,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use pending_verifications <- decode.field(
|
||||
"pending_verifications",
|
||||
decode.list(pending_verification_decoder),
|
||||
)
|
||||
decode.success(PendingVerificationsResponse(
|
||||
pending_verifications: pending_verifications,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn approve_registration(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/approve", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn reject_registration(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/reject", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn bulk_approve_registrations(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/bulk-approve", [
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn bulk_reject_registrations(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/bulk-reject", [
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
])
|
||||
}
|
||||
567
fluxer_admin/src/fluxer_admin/api/voice.gleam
Normal file
567
fluxer_admin/src/fluxer_admin/api/voice.gleam
Normal file
@@ -0,0 +1,567 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type VoiceRegion {
|
||||
VoiceRegion(
|
||||
id: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
latitude: Float,
|
||||
longitude: Float,
|
||||
is_default: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
allowed_user_ids: List(String),
|
||||
created_at: option.Option(String),
|
||||
updated_at: option.Option(String),
|
||||
servers: option.Option(List(VoiceServer)),
|
||||
)
|
||||
}
|
||||
|
||||
pub type VoiceServer {
|
||||
VoiceServer(
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
endpoint: String,
|
||||
is_active: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
allowed_user_ids: List(String),
|
||||
created_at: option.Option(String),
|
||||
updated_at: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListVoiceRegionsResponse {
|
||||
ListVoiceRegionsResponse(regions: List(VoiceRegion))
|
||||
}
|
||||
|
||||
pub type GetVoiceRegionResponse {
|
||||
GetVoiceRegionResponse(region: option.Option(VoiceRegion))
|
||||
}
|
||||
|
||||
pub type ListVoiceServersResponse {
|
||||
ListVoiceServersResponse(servers: List(VoiceServer))
|
||||
}
|
||||
|
||||
pub type GetVoiceServerResponse {
|
||||
GetVoiceServerResponse(server: option.Option(VoiceServer))
|
||||
}
|
||||
|
||||
fn voice_region_decoder() {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use emoji <- decode.field("emoji", decode.string)
|
||||
use latitude <- decode.field("latitude", decode.float)
|
||||
use longitude <- decode.field("longitude", decode.float)
|
||||
use is_default <- decode.field("is_default", decode.bool)
|
||||
use vip_only <- decode.field("vip_only", decode.bool)
|
||||
use required_guild_features <- decode.field(
|
||||
"required_guild_features",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_guild_ids <- decode.field(
|
||||
"allowed_guild_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_user_ids <- decode.field(
|
||||
"allowed_user_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.optional(decode.string))
|
||||
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
|
||||
|
||||
decode.success(VoiceRegion(
|
||||
id: id,
|
||||
name: name,
|
||||
emoji: emoji,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
is_default: is_default,
|
||||
vip_only: vip_only,
|
||||
required_guild_features: required_guild_features,
|
||||
allowed_guild_ids: allowed_guild_ids,
|
||||
allowed_user_ids: allowed_user_ids,
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
servers: option.None,
|
||||
))
|
||||
}
|
||||
|
||||
fn voice_region_with_servers_decoder() {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use emoji <- decode.field("emoji", decode.string)
|
||||
use latitude <- decode.field("latitude", decode.float)
|
||||
use longitude <- decode.field("longitude", decode.float)
|
||||
use is_default <- decode.field("is_default", decode.bool)
|
||||
use vip_only <- decode.field("vip_only", decode.bool)
|
||||
use required_guild_features <- decode.field(
|
||||
"required_guild_features",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_guild_ids <- decode.field(
|
||||
"allowed_guild_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_user_ids <- decode.field(
|
||||
"allowed_user_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.optional(decode.string))
|
||||
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
|
||||
use servers <- decode.field("servers", decode.list(voice_server_decoder()))
|
||||
|
||||
decode.success(VoiceRegion(
|
||||
id: id,
|
||||
name: name,
|
||||
emoji: emoji,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
is_default: is_default,
|
||||
vip_only: vip_only,
|
||||
required_guild_features: required_guild_features,
|
||||
allowed_guild_ids: allowed_guild_ids,
|
||||
allowed_user_ids: allowed_user_ids,
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
servers: option.Some(servers),
|
||||
))
|
||||
}
|
||||
|
||||
fn voice_server_decoder() {
|
||||
use region_id <- decode.field("region_id", decode.string)
|
||||
use server_id <- decode.field("server_id", decode.string)
|
||||
use endpoint <- decode.field("endpoint", decode.string)
|
||||
use is_active <- decode.field("is_active", decode.bool)
|
||||
use vip_only <- decode.field("vip_only", decode.bool)
|
||||
use required_guild_features <- decode.field(
|
||||
"required_guild_features",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_guild_ids <- decode.field(
|
||||
"allowed_guild_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_user_ids <- decode.field(
|
||||
"allowed_user_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.optional(decode.string))
|
||||
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
|
||||
|
||||
decode.success(VoiceServer(
|
||||
region_id: region_id,
|
||||
server_id: server_id,
|
||||
endpoint: endpoint,
|
||||
is_active: is_active,
|
||||
vip_only: vip_only,
|
||||
required_guild_features: required_guild_features,
|
||||
allowed_guild_ids: allowed_guild_ids,
|
||||
allowed_user_ids: allowed_user_ids,
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn list_voice_regions(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
include_servers: Bool,
|
||||
) -> Result(ListVoiceRegionsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/voice/regions/list"
|
||||
let body =
|
||||
json.object([#("include_servers", json.bool(include_servers))])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder_fn = case include_servers {
|
||||
True -> voice_region_with_servers_decoder
|
||||
False -> voice_region_decoder
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use regions <- decode.field("regions", decode.list(decoder_fn()))
|
||||
decode.success(ListVoiceRegionsResponse(regions: regions))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
include_servers: Bool,
|
||||
) -> Result(GetVoiceRegionResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/voice/regions/get"
|
||||
let body =
|
||||
json.object([
|
||||
#("id", json.string(region_id)),
|
||||
#("include_servers", json.bool(include_servers)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder_fn = case include_servers {
|
||||
True -> voice_region_with_servers_decoder
|
||||
False -> voice_region_decoder
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use region <- decode.field("region", decode.optional(decoder_fn()))
|
||||
decode.success(GetVoiceRegionResponse(region: region))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
id: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
latitude: Float,
|
||||
longitude: Float,
|
||||
is_default: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/regions/create",
|
||||
[
|
||||
#("id", json.string(id)),
|
||||
#("name", json.string(name)),
|
||||
#("emoji", json.string(emoji)),
|
||||
#("latitude", json.float(latitude)),
|
||||
#("longitude", json.float(longitude)),
|
||||
#("is_default", json.bool(is_default)),
|
||||
#("vip_only", json.bool(vip_only)),
|
||||
#(
|
||||
"required_guild_features",
|
||||
json.array(required_guild_features, json.string),
|
||||
),
|
||||
#("allowed_guild_ids", json.array(allowed_guild_ids, json.string)),
|
||||
],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
id: String,
|
||||
name: option.Option(String),
|
||||
emoji: option.Option(String),
|
||||
latitude: option.Option(Float),
|
||||
longitude: option.Option(Float),
|
||||
is_default: option.Option(Bool),
|
||||
vip_only: option.Option(Bool),
|
||||
required_guild_features: option.Option(List(String)),
|
||||
allowed_guild_ids: option.Option(List(String)),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let base_fields = [#("id", json.string(id))]
|
||||
|
||||
let fields = case name {
|
||||
option.Some(n) -> [#("name", json.string(n)), ..base_fields]
|
||||
option.None -> base_fields
|
||||
}
|
||||
|
||||
let fields = case emoji {
|
||||
option.Some(e) -> [#("emoji", json.string(e)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case latitude {
|
||||
option.Some(lat) -> [#("latitude", json.float(lat)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case longitude {
|
||||
option.Some(lng) -> [#("longitude", json.float(lng)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case is_default {
|
||||
option.Some(d) -> [#("is_default", json.bool(d)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case vip_only {
|
||||
option.Some(v) -> [#("vip_only", json.bool(v)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case required_guild_features {
|
||||
option.Some(features) -> [
|
||||
#("required_guild_features", json.array(features, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case allowed_guild_ids {
|
||||
option.Some(ids) -> [
|
||||
#("allowed_guild_ids", json.array(ids, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/regions/update",
|
||||
fields,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
id: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/regions/delete",
|
||||
[#("id", json.string(id))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_voice_servers(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
) -> Result(ListVoiceServersResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/voice/servers/list"
|
||||
let body =
|
||||
json.object([#("region_id", json.string(region_id))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use servers <- decode.field(
|
||||
"servers",
|
||||
decode.list(voice_server_decoder()),
|
||||
)
|
||||
decode.success(ListVoiceServersResponse(servers: servers))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_voice_server(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
endpoint: String,
|
||||
api_key: String,
|
||||
api_secret: String,
|
||||
is_active: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/servers/create",
|
||||
[
|
||||
#("region_id", json.string(region_id)),
|
||||
#("server_id", json.string(server_id)),
|
||||
#("endpoint", json.string(endpoint)),
|
||||
#("api_key", json.string(api_key)),
|
||||
#("api_secret", json.string(api_secret)),
|
||||
#("is_active", json.bool(is_active)),
|
||||
#("vip_only", json.bool(vip_only)),
|
||||
#(
|
||||
"required_guild_features",
|
||||
json.array(required_guild_features, json.string),
|
||||
),
|
||||
#("allowed_guild_ids", json.array(allowed_guild_ids, json.string)),
|
||||
],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_voice_server(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
endpoint: option.Option(String),
|
||||
api_key: option.Option(String),
|
||||
api_secret: option.Option(String),
|
||||
is_active: option.Option(Bool),
|
||||
vip_only: option.Option(Bool),
|
||||
required_guild_features: option.Option(List(String)),
|
||||
allowed_guild_ids: option.Option(List(String)),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let base_fields = [
|
||||
#("region_id", json.string(region_id)),
|
||||
#("server_id", json.string(server_id)),
|
||||
]
|
||||
|
||||
let fields = case endpoint {
|
||||
option.Some(e) -> [#("endpoint", json.string(e)), ..base_fields]
|
||||
option.None -> base_fields
|
||||
}
|
||||
|
||||
let fields = case api_key {
|
||||
option.Some(k) -> [#("api_key", json.string(k)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case api_secret {
|
||||
option.Some(s) -> [#("api_secret", json.string(s)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case is_active {
|
||||
option.Some(a) -> [#("is_active", json.bool(a)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case vip_only {
|
||||
option.Some(v) -> [#("vip_only", json.bool(v)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case required_guild_features {
|
||||
option.Some(features) -> [
|
||||
#("required_guild_features", json.array(features, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case allowed_guild_ids {
|
||||
option.Some(ids) -> [
|
||||
#("allowed_guild_ids", json.array(ids, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/servers/update",
|
||||
fields,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_voice_server(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/servers/delete",
|
||||
[
|
||||
#("region_id", json.string(region_id)),
|
||||
#("server_id", json.string(server_id)),
|
||||
],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
138
fluxer_admin/src/fluxer_admin/avatar.gleam
Normal file
138
fluxer_admin/src/fluxer_admin/avatar.gleam
Normal file
@@ -0,0 +1,138 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import gleam/string
|
||||
|
||||
pub fn get_user_avatar_url(
|
||||
media_endpoint: String,
|
||||
cdn_endpoint: String,
|
||||
user_id: String,
|
||||
avatar: Option(String),
|
||||
animated: Bool,
|
||||
asset_version: String,
|
||||
) -> String {
|
||||
case avatar {
|
||||
option.Some(hash) -> {
|
||||
let is_animated = string.starts_with(hash, "a_")
|
||||
let actual_hash = case is_animated {
|
||||
True -> string.drop_start(hash, 2)
|
||||
False -> hash
|
||||
}
|
||||
let should_animate = is_animated && animated
|
||||
let format = case should_animate {
|
||||
True -> "gif"
|
||||
False -> "webp"
|
||||
}
|
||||
media_endpoint
|
||||
<> "/avatars/"
|
||||
<> user_id
|
||||
<> "/"
|
||||
<> actual_hash
|
||||
<> "."
|
||||
<> format
|
||||
<> "?size=160"
|
||||
}
|
||||
option.None -> get_default_avatar(cdn_endpoint, user_id, asset_version)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_default_avatar(
|
||||
cdn_endpoint: String,
|
||||
user_id: String,
|
||||
asset_version: String,
|
||||
) -> String {
|
||||
let id = do_parse_bigint(user_id)
|
||||
let index = do_rem(id, 6)
|
||||
cdn_endpoint
|
||||
<> "/avatars/"
|
||||
<> int.to_string(index)
|
||||
<> ".png"
|
||||
|> web.cache_busted_with_version(asset_version)
|
||||
}
|
||||
|
||||
@external(erlang, "erlang", "binary_to_integer")
|
||||
fn do_parse_bigint(id: String) -> Int
|
||||
|
||||
@external(erlang, "erlang", "rem")
|
||||
fn do_rem(a: Int, b: Int) -> Int
|
||||
|
||||
pub fn get_guild_icon_url(
|
||||
media_proxy_endpoint: String,
|
||||
guild_id: String,
|
||||
icon: Option(String),
|
||||
animated: Bool,
|
||||
) -> Option(String) {
|
||||
case icon {
|
||||
option.Some(hash) -> {
|
||||
let is_animated = string.starts_with(hash, "a_")
|
||||
let actual_hash = case is_animated {
|
||||
True -> string.drop_start(hash, 2)
|
||||
False -> hash
|
||||
}
|
||||
let should_animate = is_animated && animated
|
||||
let format = case should_animate {
|
||||
True -> "gif"
|
||||
False -> "webp"
|
||||
}
|
||||
option.Some(
|
||||
media_proxy_endpoint
|
||||
<> "/icons/"
|
||||
<> guild_id
|
||||
<> "/"
|
||||
<> actual_hash
|
||||
<> "."
|
||||
<> format
|
||||
<> "?size=160",
|
||||
)
|
||||
}
|
||||
option.None -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_initials_from_name(name: String) -> String {
|
||||
name
|
||||
|> string.to_graphemes
|
||||
|> do_get_initials(True, [])
|
||||
|> list.reverse
|
||||
|> string.join("")
|
||||
|> string.uppercase
|
||||
}
|
||||
|
||||
fn do_get_initials(
|
||||
chars: List(String),
|
||||
is_start: Bool,
|
||||
acc: List(String),
|
||||
) -> List(String) {
|
||||
case chars {
|
||||
[] -> acc
|
||||
[char, ..rest] -> {
|
||||
case char {
|
||||
" " -> do_get_initials(rest, True, acc)
|
||||
_ -> {
|
||||
case is_start {
|
||||
True -> do_get_initials(rest, False, [char, ..acc])
|
||||
False -> do_get_initials(rest, False, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
fluxer_admin/src/fluxer_admin/badge.gleam
Normal file
73
fluxer_admin/src/fluxer_admin/badge.gleam
Normal file
@@ -0,0 +1,73 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
|
||||
pub type Badge {
|
||||
Badge(name: String, icon: String)
|
||||
}
|
||||
|
||||
const flag_staff = 1
|
||||
|
||||
const flag_ctp_member = 2
|
||||
|
||||
const flag_partner = 4
|
||||
|
||||
const flag_bug_hunter = 8
|
||||
|
||||
pub fn get_user_badges(cdn_endpoint: String, flags: String) -> List(Badge) {
|
||||
case int.parse(flags) {
|
||||
Ok(flags_int) -> {
|
||||
[]
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_staff,
|
||||
Badge("Staff", cdn_endpoint <> "/badges/staff.svg"),
|
||||
)
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_ctp_member,
|
||||
Badge("CTP Member", cdn_endpoint <> "/badges/ctp.svg"),
|
||||
)
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_partner,
|
||||
Badge("Partner", cdn_endpoint <> "/badges/partner.svg"),
|
||||
)
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_bug_hunter,
|
||||
Badge("Bug Hunter", cdn_endpoint <> "/badges/bug-hunter.svg"),
|
||||
)
|
||||
|> list.reverse
|
||||
}
|
||||
Error(_) -> []
|
||||
}
|
||||
}
|
||||
|
||||
fn add_badge_if_has_flag(
|
||||
badges: List(Badge),
|
||||
flags: Int,
|
||||
flag: Int,
|
||||
badge: Badge,
|
||||
) -> List(Badge) {
|
||||
case int.bitwise_and(flags, flag) == flag {
|
||||
True -> [badge, ..badges]
|
||||
False -> badges
|
||||
}
|
||||
}
|
||||
61
fluxer_admin/src/fluxer_admin/components/date_time.gleam
Normal file
61
fluxer_admin/src/fluxer_admin/components/date_time.gleam
Normal file
@@ -0,0 +1,61 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/string
|
||||
|
||||
pub fn format_timestamp(timestamp: String) -> String {
|
||||
case string.split(timestamp, "T") {
|
||||
[date_part, time_part] -> {
|
||||
let time_clean = case string.split(time_part, ".") {
|
||||
[hms, _] -> hms
|
||||
_ -> time_part
|
||||
}
|
||||
let time_clean = string.replace(time_clean, "Z", "")
|
||||
|
||||
case string.split(time_clean, ":") {
|
||||
[hour, minute, _] -> date_part <> " " <> hour <> ":" <> minute
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_date(timestamp: String) -> String {
|
||||
case string.split(timestamp, "T") {
|
||||
[date_part, _] -> date_part
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_time(timestamp: String) -> String {
|
||||
case string.split(timestamp, "T") {
|
||||
[_, time_part] -> {
|
||||
let time_clean = case string.split(time_part, ".") {
|
||||
[hms, _] -> hms
|
||||
_ -> time_part
|
||||
}
|
||||
let time_clean = string.replace(time_clean, "Z", "")
|
||||
|
||||
case string.split(time_clean, ":") {
|
||||
[hour, minute, _] -> hour <> ":" <> minute
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn render() {
|
||||
let script =
|
||||
"(function(){const configs=[{selectId:'user-deletion-reason',inputId:'user-deletion-days'},{selectId:'bulk-deletion-reason',inputId:'bulk-deletion-days'}];const userReason='1';const userMin=14;const defaultMin=60;const update=(select,input)=>{const min=select.value===userReason?userMin:defaultMin;input.min=min.toString();const current=parseInt(input.value,10);if(isNaN(current)||current<min){input.value=min.toString();}};configs.forEach(({selectId,inputId})=>{const select=document.getElementById(selectId);const input=document.getElementById(inputId);if(!select||!input){return;}select.addEventListener('change',()=>update(select,input));update(select,input);});})();"
|
||||
|
||||
h.script([a.attribute("defer", "defer")], script)
|
||||
}
|
||||
108
fluxer_admin/src/fluxer_admin/components/errors.gleam
Normal file
108
fluxer_admin/src/fluxer_admin/components/errors.gleam
Normal file
@@ -0,0 +1,108 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/option
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn error_view(error: common.ApiError) {
|
||||
h.div(
|
||||
[a.class("bg-red-50 border border-red-200 rounded-lg p-6 text-center")],
|
||||
[
|
||||
h.p([a.class("text-red-800 text-sm font-medium mb-2")], [
|
||||
element.text("Error"),
|
||||
]),
|
||||
h.p([a.class("text-red-600")], [
|
||||
element.text(case error {
|
||||
common.Unauthorized -> "Unauthorized"
|
||||
common.Forbidden(msg) -> "Forbidden - " <> msg
|
||||
common.NotFound -> "Not found"
|
||||
common.ServerError -> "Server error"
|
||||
common.NetworkError -> "Network error"
|
||||
}),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn api_error_view(
|
||||
ctx: Context,
|
||||
err: common.ApiError,
|
||||
back_url: option.Option(String),
|
||||
back_label: option.Option(String),
|
||||
) {
|
||||
let #(title, message) = case err {
|
||||
common.Unauthorized -> #(
|
||||
"Authentication Required",
|
||||
"Your session has expired. Please log in again.",
|
||||
)
|
||||
common.Forbidden(msg) -> #("Permission Denied", msg)
|
||||
common.NotFound -> #("Not Found", "The requested resource was not found.")
|
||||
common.ServerError -> #(
|
||||
"Server Error",
|
||||
"An internal server error occurred. Please try again later.",
|
||||
)
|
||||
common.NetworkError -> #(
|
||||
"Network Error",
|
||||
"Could not connect to the API. Please try again later.",
|
||||
)
|
||||
}
|
||||
|
||||
h.div([a.class("max-w-4xl mx-auto")], [
|
||||
h.div([a.class("bg-red-50 border border-red-200 rounded-lg p-8")], [
|
||||
h.div([a.class("flex items-start gap-4")], [
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.span([a.class("text-red-600 text-base font-semibold")], [
|
||||
element.text("!"),
|
||||
]),
|
||||
],
|
||||
),
|
||||
h.div([a.class("flex-1")], [
|
||||
h.h2([a.class("text-base font-semibold text-red-900 mb-2")], [
|
||||
element.text(title),
|
||||
]),
|
||||
h.p([a.class("text-red-700 mb-6")], [element.text(message)]),
|
||||
case back_url {
|
||||
option.Some(url) ->
|
||||
h.a(
|
||||
[
|
||||
href(ctx, url),
|
||||
a.class(
|
||||
"inline-flex items-center gap-2 px-4 py-2 bg-red-900 text-white rounded-lg text-sm font-medium hover:bg-red-800 transition-colors",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.span([a.class("text-lg")], [element.text("←")]),
|
||||
element.text(option.unwrap(back_label, "Go Back")),
|
||||
],
|
||||
)
|
||||
option.None -> element.none()
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
152
fluxer_admin/src/fluxer_admin/components/flash.gleam
Normal file
152
fluxer_admin/src/fluxer_admin/components/flash.gleam
Normal file
@@ -0,0 +1,152 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context}
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import gleam/string
|
||||
import gleam/uri
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
import wisp.{type Request, type Response}
|
||||
|
||||
pub type Flash {
|
||||
Flash(message: String, flash_type: FlashType)
|
||||
}
|
||||
|
||||
pub type FlashType {
|
||||
Success
|
||||
Error
|
||||
Info
|
||||
Warning
|
||||
}
|
||||
|
||||
pub fn flash_type_to_string(flash_type: FlashType) -> String {
|
||||
case flash_type {
|
||||
Success -> "success"
|
||||
Error -> "error"
|
||||
Info -> "info"
|
||||
Warning -> "warning"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_flash_type(type_str: String) -> FlashType {
|
||||
case type_str {
|
||||
"success" -> Success
|
||||
"error" -> Error
|
||||
"warning" -> Warning
|
||||
"info" | _ -> Info
|
||||
}
|
||||
}
|
||||
|
||||
fn flash_classes(flash_type: FlashType) -> String {
|
||||
case flash_type {
|
||||
Success ->
|
||||
"bg-green-50 border border-green-200 rounded-lg p-4 text-green-800"
|
||||
Error -> "bg-red-50 border border-red-200 rounded-lg p-4 text-red-800"
|
||||
Warning ->
|
||||
"bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800"
|
||||
Info -> "bg-blue-50 border border-blue-200 rounded-lg p-4 text-blue-800"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flash_view(
|
||||
message: Option(String),
|
||||
flash_type: Option(FlashType),
|
||||
) -> element.Element(t) {
|
||||
case message {
|
||||
option.Some(msg) -> {
|
||||
let type_ = option.unwrap(flash_type, Info)
|
||||
h.div([a.class(flash_classes(type_))], [element.text(msg)])
|
||||
}
|
||||
option.None -> element.none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect_url(
|
||||
path: String,
|
||||
message: String,
|
||||
flash_type: FlashType,
|
||||
) -> String {
|
||||
let encoded_message = uri.percent_encode(message)
|
||||
let type_param = flash_type_to_string(flash_type)
|
||||
|
||||
case string.contains(path, "?") {
|
||||
True -> path <> "&flash=" <> encoded_message <> "&flash_type=" <> type_param
|
||||
False ->
|
||||
path <> "?flash=" <> encoded_message <> "&flash_type=" <> type_param
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect_with_success(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Success)))
|
||||
}
|
||||
|
||||
pub fn redirect_with_error(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Error)))
|
||||
}
|
||||
|
||||
pub fn redirect_with_info(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Info)))
|
||||
}
|
||||
|
||||
pub fn redirect_with_warning(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Warning)))
|
||||
}
|
||||
|
||||
pub fn from_request(req: Request) -> Option(Flash) {
|
||||
let query = wisp.get_query(req)
|
||||
|
||||
let flash_msg = list.key_find(query, "flash") |> option.from_result
|
||||
let flash_type_str = list.key_find(query, "flash_type") |> option.from_result
|
||||
|
||||
case flash_msg {
|
||||
option.Some(msg) -> {
|
||||
let type_ = case flash_type_str {
|
||||
option.Some(type_str) -> parse_flash_type(type_str)
|
||||
option.None -> Info
|
||||
}
|
||||
option.Some(Flash(msg, type_))
|
||||
}
|
||||
option.None -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(flash: Option(Flash)) -> element.Element(t) {
|
||||
case flash {
|
||||
option.Some(Flash(msg, type_)) ->
|
||||
h.div([a.class(flash_classes(type_))], [element.text(msg)])
|
||||
option.None -> element.none()
|
||||
}
|
||||
}
|
||||
106
fluxer_admin/src/fluxer_admin/components/helpers.gleam
Normal file
106
fluxer_admin/src/fluxer_admin/components/helpers.gleam
Normal file
@@ -0,0 +1,106 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn compact_info(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
|
||||
h.span([a.class("text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn compact_info_mono(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
|
||||
h.span([a.class("text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn compact_info_with_element(label: String, value: element.Element(a)) {
|
||||
h.div([], [
|
||||
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
|
||||
h.span([a.class("text-neutral-900")], [value]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn form_field(
|
||||
label: String,
|
||||
name: String,
|
||||
type_: String,
|
||||
placeholder: String,
|
||||
required: Bool,
|
||||
help: String,
|
||||
) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text(label),
|
||||
]),
|
||||
h.input([
|
||||
a.type_(type_),
|
||||
a.name(name),
|
||||
a.placeholder(placeholder),
|
||||
a.required(required),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||
),
|
||||
case type_ == "number" {
|
||||
True -> a.attribute("step", "any")
|
||||
False -> a.class("")
|
||||
},
|
||||
]),
|
||||
h.p([a.class("text-xs text-neutral-500")], [element.text(help)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn form_field_with_value(
|
||||
label: String,
|
||||
name: String,
|
||||
type_: String,
|
||||
value: String,
|
||||
required: Bool,
|
||||
help: String,
|
||||
) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text(label),
|
||||
]),
|
||||
h.input([
|
||||
a.type_(type_),
|
||||
a.name(name),
|
||||
a.value(value),
|
||||
a.required(required),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||
),
|
||||
case type_ == "number" {
|
||||
True -> a.attribute("step", "any")
|
||||
False -> a.class("")
|
||||
},
|
||||
]),
|
||||
h.p([a.class("text-xs text-neutral-500")], [element.text(help)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn info_item(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.p([a.class("text-xs text-neutral-600")], [element.text(label)]),
|
||||
h.p([a.class("text-sm text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
157
fluxer_admin/src/fluxer_admin/components/icons.gleam
Normal file
157
fluxer_admin/src/fluxer_admin/components/icons.gleam
Normal file
@@ -0,0 +1,157 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
|
||||
pub fn paperclip_icon(color: String) {
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class("w-3 h-3 inline-block " <> color),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"rect",
|
||||
[
|
||||
a.attribute("width", "256"),
|
||||
a.attribute("height", "256"),
|
||||
a.attribute("fill", "none"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"path",
|
||||
[
|
||||
a.attribute(
|
||||
"d",
|
||||
"M108.71,197.23l-5.11,5.11a46.63,46.63,0,0,1-66-.05h0a46.63,46.63,0,0,1,.06-65.89L72.4,101.66a46.62,46.62,0,0,1,65.94,0h0A46.34,46.34,0,0,1,150.78,124",
|
||||
),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"path",
|
||||
[
|
||||
a.attribute(
|
||||
"d",
|
||||
"M147.29,58.77l5.11-5.11a46.62,46.62,0,0,1,65.94,0h0a46.62,46.62,0,0,1,0,65.94L193.94,144,183.6,154.34a46.63,46.63,0,0,1-66-.05h0A46.46,46.46,0,0,1,105.22,132",
|
||||
),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn checkmark_icon(color: String) {
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class("w-4 h-4 inline-block " <> color),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"rect",
|
||||
[
|
||||
a.attribute("width", "256"),
|
||||
a.attribute("height", "256"),
|
||||
a.attribute("fill", "none"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"polyline",
|
||||
[
|
||||
a.attribute("points", "40 144 96 200 224 72"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn x_icon(color: String) {
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class("w-4 h-4 inline-block " <> color),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"rect",
|
||||
[
|
||||
a.attribute("width", "256"),
|
||||
a.attribute("height", "256"),
|
||||
a.attribute("fill", "none"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"line",
|
||||
[
|
||||
a.attribute("x1", "200"),
|
||||
a.attribute("y1", "56"),
|
||||
a.attribute("x2", "56"),
|
||||
a.attribute("y2", "200"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"line",
|
||||
[
|
||||
a.attribute("x1", "200"),
|
||||
a.attribute("y1", "200"),
|
||||
a.attribute("x2", "56"),
|
||||
a.attribute("y2", "56"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
46
fluxer_admin/src/fluxer_admin/components/icons_meta.gleam
Normal file
46
fluxer_admin/src/fluxer_admin/components/icons_meta.gleam
Normal file
@@ -0,0 +1,46 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element.{type Element}
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn build_icon_links(cdn_endpoint: String) -> List(Element(t)) {
|
||||
[
|
||||
h.link([
|
||||
a.rel("icon"),
|
||||
a.attribute("type", "image/x-icon"),
|
||||
a.href(cdn_endpoint <> "/web/favicon.ico"),
|
||||
]),
|
||||
h.link([
|
||||
a.rel("apple-touch-icon"),
|
||||
a.href(cdn_endpoint <> "/web/apple-touch-icon.png"),
|
||||
]),
|
||||
h.link([
|
||||
a.rel("icon"),
|
||||
a.attribute("type", "image/png"),
|
||||
a.attribute("sizes", "32x32"),
|
||||
a.href(cdn_endpoint <> "/web/favicon-32x32.png"),
|
||||
]),
|
||||
h.link([
|
||||
a.rel("icon"),
|
||||
a.attribute("type", "image/png"),
|
||||
a.attribute("sizes", "16x16"),
|
||||
a.href(cdn_endpoint <> "/web/favicon-16x16.png"),
|
||||
]),
|
||||
]
|
||||
}
|
||||
392
fluxer_admin/src/fluxer_admin/components/layout.gleam
Normal file
392
fluxer_admin/src/fluxer_admin/components/layout.gleam
Normal file
@@ -0,0 +1,392 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{type UserLookupResult}
|
||||
import fluxer_admin/avatar
|
||||
import fluxer_admin/components/flash
|
||||
import fluxer_admin/components/icons_meta
|
||||
import fluxer_admin/user
|
||||
import fluxer_admin/web.{type Context, type Session, cache_busted_asset, href}
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn build_head(title: String, ctx: Context) -> element.Element(a) {
|
||||
build_head_with_refresh(title, ctx, False)
|
||||
}
|
||||
|
||||
pub fn build_head_with_refresh(
|
||||
title: String,
|
||||
ctx: Context,
|
||||
auto_refresh: Bool,
|
||||
) -> element.Element(a) {
|
||||
let refresh_meta = case auto_refresh {
|
||||
True -> [
|
||||
h.meta([a.attribute("http-equiv", "refresh"), a.attribute("content", "3")]),
|
||||
]
|
||||
False -> []
|
||||
}
|
||||
|
||||
h.head([], [
|
||||
h.meta([a.attribute("charset", "UTF-8")]),
|
||||
h.meta([
|
||||
a.attribute("name", "viewport"),
|
||||
a.attribute("content", "width=device-width, initial-scale=1.0"),
|
||||
]),
|
||||
..list.append(
|
||||
refresh_meta,
|
||||
list.append(
|
||||
[
|
||||
h.title([], title <> " ~ Fluxer Admin"),
|
||||
h.link([
|
||||
a.rel("stylesheet"),
|
||||
a.href(cache_busted_asset(ctx, "/static/app.css")),
|
||||
]),
|
||||
],
|
||||
icons_meta.build_icon_links(ctx.cdn_endpoint),
|
||||
),
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
pub fn page(
|
||||
title: String,
|
||||
active_page: String,
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
current_admin: Option(UserLookupResult),
|
||||
flash_data: Option(flash.Flash),
|
||||
content: element.Element(a),
|
||||
) {
|
||||
page_with_refresh(
|
||||
title,
|
||||
active_page,
|
||||
ctx,
|
||||
session,
|
||||
current_admin,
|
||||
flash_data,
|
||||
content,
|
||||
False,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn page_with_refresh(
|
||||
title: String,
|
||||
active_page: String,
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
current_admin: Option(UserLookupResult),
|
||||
flash_data: Option(flash.Flash),
|
||||
content: element.Element(a),
|
||||
auto_refresh: Bool,
|
||||
) {
|
||||
h.html(
|
||||
[a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)],
|
||||
[
|
||||
build_head_with_refresh(title, ctx, auto_refresh),
|
||||
h.body([a.class("min-h-screen bg-neutral-50 flex")], [
|
||||
sidebar(ctx, active_page),
|
||||
h.div([a.class("ml-64 flex-1 flex flex-col")], [
|
||||
header(ctx, session, current_admin),
|
||||
h.main([a.class("flex-1 p-8")], [
|
||||
h.div([a.class("max-w-7xl mx-auto")], [
|
||||
case flash_data {
|
||||
option.Some(_) ->
|
||||
h.div([a.class("mb-6")], [flash.view(flash_data)])
|
||||
option.None -> element.none()
|
||||
},
|
||||
content,
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn sidebar(ctx: Context, active_page: String) {
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"w-64 bg-neutral-900 text-white flex flex-col h-screen fixed left-0 top-0",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.div([a.class("p-6 border-b border-neutral-800")], [
|
||||
h.a([href(ctx, "/users")], [
|
||||
h.h1([a.class("text-base font-semibold")], [
|
||||
element.text("Fluxer Admin"),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
h.nav(
|
||||
[
|
||||
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
|
||||
],
|
||||
admin_sidebar(ctx, active_page),
|
||||
),
|
||||
h.script(
|
||||
[a.attribute("defer", "defer")],
|
||||
"(function(){var el=document.querySelector('[data-active]');if(el)el.scrollIntoView({block:'nearest'});})();",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn admin_sidebar(ctx: Context, active_page: String) -> List(element.Element(a)) {
|
||||
[
|
||||
sidebar_section("Lookup", [
|
||||
sidebar_item(ctx, "Users", "/users", active_page == "users"),
|
||||
sidebar_item(ctx, "Guilds", "/guilds", active_page == "guilds"),
|
||||
]),
|
||||
sidebar_section("Moderation", [
|
||||
sidebar_item(ctx, "Reports", "/reports", active_page == "reports"),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Pending Verifications",
|
||||
"/pending-verifications",
|
||||
active_page == "pending-verifications",
|
||||
),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Bulk Actions",
|
||||
"/bulk-actions",
|
||||
active_page == "bulk-actions",
|
||||
),
|
||||
]),
|
||||
sidebar_section("Bans", [
|
||||
sidebar_item(ctx, "IP Bans", "/ip-bans", active_page == "ip-bans"),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Email Bans",
|
||||
"/email-bans",
|
||||
active_page == "email-bans",
|
||||
),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Phone Bans",
|
||||
"/phone-bans",
|
||||
active_page == "phone-bans",
|
||||
),
|
||||
]),
|
||||
sidebar_section("Content", [
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Message Tools",
|
||||
"/messages",
|
||||
active_page == "message-tools",
|
||||
),
|
||||
sidebar_item(ctx, "Archives", "/archives", active_page == "archives"),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Asset Purge",
|
||||
"/asset-purge",
|
||||
active_page == "asset-purge",
|
||||
),
|
||||
]),
|
||||
sidebar_section("Metrics", [
|
||||
sidebar_item(ctx, "Overview", "/metrics", active_page == "metrics"),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Messaging & API",
|
||||
"/messages-metrics",
|
||||
active_page == "messages-metrics",
|
||||
),
|
||||
]),
|
||||
sidebar_section("Observability", [
|
||||
sidebar_item(ctx, "Gateway", "/gateway", active_page == "gateway"),
|
||||
sidebar_item(ctx, "Jobs", "/jobs", active_page == "jobs"),
|
||||
sidebar_item(ctx, "Storage", "/storage", active_page == "storage"),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Audit Logs",
|
||||
"/audit-logs",
|
||||
active_page == "audit-logs",
|
||||
),
|
||||
]),
|
||||
sidebar_section("Platform", [
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Search Index",
|
||||
"/search-index",
|
||||
active_page == "search-index",
|
||||
),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Voice Regions",
|
||||
"/voice-regions",
|
||||
active_page == "voice-regions",
|
||||
),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Voice Servers",
|
||||
"/voice-servers",
|
||||
active_page == "voice-servers",
|
||||
),
|
||||
]),
|
||||
sidebar_section("Configuration", [
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Instance Config",
|
||||
"/instance-config",
|
||||
active_page == "instance-config",
|
||||
),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Feature Flags",
|
||||
"/feature-flags",
|
||||
active_page == "feature-flags",
|
||||
),
|
||||
]),
|
||||
sidebar_section("Codes", [
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Beta Codes",
|
||||
"/beta-codes",
|
||||
active_page == "beta-codes",
|
||||
),
|
||||
sidebar_item(
|
||||
ctx,
|
||||
"Gift Codes",
|
||||
"/gift-codes",
|
||||
active_page == "gift-codes",
|
||||
),
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
fn sidebar_section(title: String, items: List(element.Element(a))) {
|
||||
h.div([a.class("mb-4")], [
|
||||
h.div([a.class("text-neutral-400 text-xs uppercase mb-2")], [
|
||||
element.text(title),
|
||||
]),
|
||||
h.div([a.class("space-y-1")], items),
|
||||
])
|
||||
}
|
||||
|
||||
fn sidebar_item(ctx: Context, title: String, path: String, active: Bool) {
|
||||
let classes = case active {
|
||||
True ->
|
||||
"block px-3 py-2 rounded bg-neutral-800 text-white text-sm transition-colors"
|
||||
False ->
|
||||
"block px-3 py-2 rounded text-neutral-300 hover:bg-neutral-800 hover:text-white text-sm transition-colors"
|
||||
}
|
||||
|
||||
let attrs = case active {
|
||||
True -> [href(ctx, path), a.class(classes), a.attribute("data-active", "")]
|
||||
False -> [href(ctx, path), a.class(classes)]
|
||||
}
|
||||
|
||||
h.a(attrs, [element.text(title)])
|
||||
}
|
||||
|
||||
fn header(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
current_admin: Option(UserLookupResult),
|
||||
) {
|
||||
h.header(
|
||||
[
|
||||
a.class(
|
||||
"bg-white border-b border-neutral-200 px-8 py-4 flex items-center justify-between",
|
||||
),
|
||||
],
|
||||
[
|
||||
render_user_info(ctx, session, current_admin),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/logout"),
|
||||
a.class(
|
||||
"px-4 py-2 text-sm font-medium text-neutral-700 hover:text-neutral-900 border border-neutral-300 rounded hover:border-neutral-400 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("Logout")],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn render_user_info(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
current_admin: Option(UserLookupResult),
|
||||
) {
|
||||
case current_admin {
|
||||
option.Some(admin_user) -> {
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/users/" <> session.user_id),
|
||||
a.class("flex items-center gap-3 hover:opacity-80 transition-opacity"),
|
||||
],
|
||||
[
|
||||
render_avatar(
|
||||
ctx,
|
||||
admin_user.id,
|
||||
admin_user.avatar,
|
||||
admin_user.username,
|
||||
),
|
||||
h.div([a.class("flex flex-col")], [
|
||||
h.div([a.class("text-sm text-neutral-900")], [
|
||||
element.text(
|
||||
admin_user.username
|
||||
<> "#"
|
||||
<> user.format_discriminator(admin_user.discriminator),
|
||||
),
|
||||
]),
|
||||
h.div([a.class("text-xs text-neutral-500")], [
|
||||
element.text("Admin"),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
option.None -> {
|
||||
h.div([a.class("text-sm text-neutral-600")], [
|
||||
element.text("Logged in as: "),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/users/" <> session.user_id),
|
||||
a.class("text-blue-600 hover:text-blue-800 hover:underline"),
|
||||
],
|
||||
[element.text(session.user_id)],
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_avatar(
|
||||
ctx: Context,
|
||||
user_id: String,
|
||||
avatar: Option(String),
|
||||
username: String,
|
||||
) {
|
||||
h.img([
|
||||
a.src(avatar.get_user_avatar_url(
|
||||
ctx.media_endpoint,
|
||||
ctx.cdn_endpoint,
|
||||
user_id,
|
||||
avatar,
|
||||
True,
|
||||
ctx.asset_version,
|
||||
)),
|
||||
a.alt(username <> "'s avatar"),
|
||||
a.class("w-10 h-10 rounded-full"),
|
||||
])
|
||||
}
|
||||
180
fluxer_admin/src/fluxer_admin/components/message_list.gleam
Normal file
180
fluxer_admin/src/fluxer_admin/components/message_list.gleam
Normal file
@@ -0,0 +1,180 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/messages
|
||||
import fluxer_admin/components/icons
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn render(
|
||||
ctx: Context,
|
||||
messages: List(messages.Message),
|
||||
include_delete_button: Bool,
|
||||
) {
|
||||
h.div([a.class("space-y-1")], {
|
||||
list.map(messages, fn(message) {
|
||||
render_message_row(ctx, message, include_delete_button)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_message_row(
|
||||
ctx: Context,
|
||||
message: messages.Message,
|
||||
include_delete_button: Bool,
|
||||
) {
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"group flex items-start gap-3 px-4 py-2 hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
a.attribute("data-message-id", message.id),
|
||||
],
|
||||
[
|
||||
h.div([a.class("flex-shrink-0 pt-0.5")], [
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/users/" <> message.author_id),
|
||||
a.class("text-xs text-neutral-900 hover:underline cursor-pointer"),
|
||||
a.title(message.author_id),
|
||||
],
|
||||
[element.text(message.author_username)],
|
||||
),
|
||||
h.div([a.class("text-xs text-neutral-500")], [
|
||||
element.text(message.timestamp),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex-1 min-w-0 message-content")], [
|
||||
h.div(
|
||||
[a.class("text-sm text-neutral-900 whitespace-pre-wrap break-words")],
|
||||
[element.text(message.content)],
|
||||
),
|
||||
case list.is_empty(message.attachments) {
|
||||
True -> element.none()
|
||||
False ->
|
||||
h.div([a.class("mt-2 space-y-1")], {
|
||||
list.map(message.attachments, fn(att) {
|
||||
h.div([a.class("text-xs flex items-center gap-1")], [
|
||||
icons.paperclip_icon("text-neutral-500"),
|
||||
h.a(
|
||||
[
|
||||
a.href(att.url),
|
||||
a.target("_blank"),
|
||||
a.class("text-blue-600 hover:underline"),
|
||||
],
|
||||
[element.text(att.filename)],
|
||||
),
|
||||
])
|
||||
})
|
||||
})
|
||||
},
|
||||
h.div([a.class("text-xs text-neutral-400 mt-1")], [
|
||||
element.text("ID: " <> message.id),
|
||||
]),
|
||||
]),
|
||||
case include_delete_button && !string.is_empty(message.channel_id) {
|
||||
True ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.button(
|
||||
[
|
||||
a.type_("button"),
|
||||
a.class(
|
||||
"px-2 py-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors",
|
||||
),
|
||||
a.title("Delete message"),
|
||||
a.attribute(
|
||||
"onclick",
|
||||
"deleteMessage('"
|
||||
<> message.channel_id
|
||||
<> "', '"
|
||||
<> message.id
|
||||
<> "', this)",
|
||||
),
|
||||
],
|
||||
[element.text("Delete")],
|
||||
),
|
||||
],
|
||||
)
|
||||
False -> element.none()
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn deletion_script() {
|
||||
"<script>
|
||||
function deleteMessage(channelId, messageId, button) {
|
||||
if (!confirm('Are you sure you want to delete this message?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('channel_id', channelId);
|
||||
formData.append('message_id', messageId);
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Deleting...';
|
||||
|
||||
const basePath = document.documentElement.dataset.basePath || '';
|
||||
fetch(basePath + '/messages?action=delete', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
const messageRow = button.closest('[data-message-id]');
|
||||
if (messageRow) {
|
||||
messageRow.style.opacity = '0.5';
|
||||
messageRow.style.pointerEvents = 'none';
|
||||
const messageContent = messageRow.querySelector('.message-content');
|
||||
if (messageContent) {
|
||||
messageContent.style.textDecoration = 'line-through';
|
||||
}
|
||||
}
|
||||
const buttonContainer = button.parentElement;
|
||||
const deletedBadge = document.createElement('span');
|
||||
deletedBadge.className = 'px-2 py-1 bg-red-100 text-red-800 text-xs rounded opacity-100';
|
||||
deletedBadge.textContent = 'DELETED';
|
||||
button.replaceWith(deletedBadge);
|
||||
if (buttonContainer) {
|
||||
buttonContainer.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Delete';
|
||||
alert('Failed to delete message');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
button.disabled = false;
|
||||
button.textContent = 'Delete';
|
||||
alert('Error deleting message');
|
||||
});
|
||||
}
|
||||
</script>"
|
||||
}
|
||||
93
fluxer_admin/src/fluxer_admin/components/pagination.gleam
Normal file
93
fluxer_admin/src/fluxer_admin/components/pagination.gleam
Normal file
@@ -0,0 +1,93 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/int
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn pagination(
|
||||
ctx: Context,
|
||||
total: Int,
|
||||
limit: Int,
|
||||
current_page: Int,
|
||||
build_url_fn: fn(Int) -> String,
|
||||
) -> element.Element(a) {
|
||||
let total_pages = { total + limit - 1 } / limit
|
||||
let has_previous = current_page > 0
|
||||
let has_next = current_page < total_pages - 1
|
||||
|
||||
h.div([a.class("mt-6 flex justify-center gap-3 items-center")], [
|
||||
case has_previous {
|
||||
True -> {
|
||||
let prev_url = build_url_fn(current_page - 1)
|
||||
|
||||
h.a(
|
||||
[
|
||||
href(ctx, prev_url),
|
||||
a.class(
|
||||
"px-6 py-2 bg-white text-neutral-900 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("← Previous")],
|
||||
)
|
||||
}
|
||||
False ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"px-6 py-2 bg-neutral-100 text-neutral-400 border border-neutral-200 rounded-lg text-sm font-medium cursor-not-allowed",
|
||||
),
|
||||
],
|
||||
[element.text("← Previous")],
|
||||
)
|
||||
},
|
||||
h.span([a.class("text-sm text-neutral-600")], [
|
||||
element.text(
|
||||
"Page "
|
||||
<> int.to_string(current_page + 1)
|
||||
<> " of "
|
||||
<> int.to_string(total_pages),
|
||||
),
|
||||
]),
|
||||
case has_next {
|
||||
True -> {
|
||||
let next_url = build_url_fn(current_page + 1)
|
||||
|
||||
h.a(
|
||||
[
|
||||
href(ctx, next_url),
|
||||
a.class(
|
||||
"px-6 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors no-underline",
|
||||
),
|
||||
],
|
||||
[element.text("Next →")],
|
||||
)
|
||||
}
|
||||
False ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"px-6 py-2 bg-neutral-100 text-neutral-400 rounded-lg text-sm font-medium cursor-not-allowed",
|
||||
),
|
||||
],
|
||||
[element.text("Next →")],
|
||||
)
|
||||
},
|
||||
])
|
||||
}
|
||||
419
fluxer_admin/src/fluxer_admin/components/review_deck.gleam
Normal file
419
fluxer_admin/src/fluxer_admin/components/review_deck.gleam
Normal file
@@ -0,0 +1,419 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/components/review_keyboard
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn styles() -> element.Element(a) {
|
||||
let css =
|
||||
"
|
||||
[data-review-deck] { position: relative; }
|
||||
[data-review-card] { will-change: transform, opacity; touch-action: pan-y; }
|
||||
[data-review-card][hidden] { display: none !important; }
|
||||
|
||||
.review-card-enter { animation: reviewEnter 120ms ease-out; }
|
||||
@keyframes reviewEnter { from { opacity: .6; transform: translateY(6px) scale(.995);} to { opacity: 1; transform: translateY(0) scale(1);} }
|
||||
|
||||
.review-card-leave-left { animation: reviewLeaveLeft 180ms ease-in forwards; }
|
||||
.review-card-leave-right { animation: reviewLeaveRight 180ms ease-in forwards; }
|
||||
@keyframes reviewLeaveLeft { to { opacity: 0; transform: translateX(-120%) rotate(-10deg);} }
|
||||
@keyframes reviewLeaveRight { to { opacity: 0; transform: translateX(120%) rotate(10deg);} }
|
||||
|
||||
.review-toast { position: fixed; left: 16px; right: 16px; bottom: 16px; z-index: 80; }
|
||||
.review-toast-inner { max-width: 720px; margin: 0 auto; }
|
||||
|
||||
.review-hintbar { position: sticky; bottom: 0; z-index: 10; }
|
||||
.review-kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
|
||||
"
|
||||
h.style([a.type_("text/css")], css)
|
||||
}
|
||||
|
||||
pub fn script_tags() -> List(element.Element(a)) {
|
||||
[
|
||||
review_keyboard.script_tag(),
|
||||
h.script([a.attribute("defer", "defer")], script()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn script() -> String {
|
||||
"
|
||||
(function () {
|
||||
function qs(el, sel) { return el.querySelector(sel); }
|
||||
function qsa(el, sel) { return Array.prototype.slice.call(el.querySelectorAll(sel)); }
|
||||
|
||||
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
||||
|
||||
function showToast(message) {
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'review-toast';
|
||||
toast.innerHTML =
|
||||
'<div class=\"review-toast-inner\">' +
|
||||
'<div class=\"bg-red-50 border border-red-200 text-red-800 rounded-xl px-4 py-3 shadow-lg\">' +
|
||||
'<div class=\"text-sm font-semibold\">Action failed</div>' +
|
||||
'<div class=\"text-sm mt-1\" style=\"word-break: break-word;\">' + (message || 'Unknown error') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function () { toast.remove(); }, 4200);
|
||||
}
|
||||
|
||||
function parseHTML(html) {
|
||||
var parser = new DOMParser();
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
}
|
||||
|
||||
function asURL(url) {
|
||||
try { return new URL(url, window.location.origin); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function enhanceDeck(deck) {
|
||||
var cards = qsa(deck, '[data-review-card]');
|
||||
var idx = 0;
|
||||
|
||||
var fragmentBase = deck.getAttribute('data-fragment-base') || '';
|
||||
var nextPage = parseInt(deck.getAttribute('data-next-page') || '0', 10);
|
||||
var canPaginate = deck.getAttribute('data-can-paginate') === 'true';
|
||||
var prefetchWhenRemaining = parseInt(deck.getAttribute('data-prefetch-when-remaining') || '6', 10);
|
||||
var prefetchInFlight = false;
|
||||
|
||||
var emptyUrl = deck.getAttribute('data-empty-url') || '';
|
||||
|
||||
function currentCard() { return cards[idx] || null; }
|
||||
function remainingCount() { return Math.max(0, cards.length - idx); }
|
||||
|
||||
function setHiddenAllExcept(active) {
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var c = cards[i];
|
||||
c.hidden = (c !== active);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrlFor(card) {
|
||||
var directUrl = card && card.getAttribute('data-direct-url');
|
||||
if (!directUrl) return;
|
||||
try {
|
||||
history.replaceState({ review: true, directUrl: directUrl }, '', directUrl);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function focusCard(card) {
|
||||
if (!card) return;
|
||||
requestAnimationFrame(function () {
|
||||
try { card.focus({ preventScroll: true }); } catch (_) { try { card.focus(); } catch (_) {} }
|
||||
});
|
||||
}
|
||||
|
||||
function ensureActiveCard() {
|
||||
var card = currentCard();
|
||||
if (!card) {
|
||||
if (emptyUrl) {
|
||||
try { history.replaceState({}, '', emptyUrl); } catch (_) {}
|
||||
}
|
||||
deck.dispatchEvent(new CustomEvent('review:empty'));
|
||||
return;
|
||||
}
|
||||
setHiddenAllExcept(card);
|
||||
card.classList.remove('review-card-leave-left', 'review-card-leave-right');
|
||||
card.classList.add('review-card-enter');
|
||||
setTimeout(function () { card.classList.remove('review-card-enter'); }, 160);
|
||||
updateUrlFor(card);
|
||||
focusCard(card);
|
||||
maybePrefetchDetails(card);
|
||||
maybePrefetchMore();
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
var el = qs(deck, '[data-review-progress]');
|
||||
if (!el) return;
|
||||
el.textContent = remainingCount().toString() + ' remaining';
|
||||
}
|
||||
|
||||
async function backgroundSubmit(form) {
|
||||
var actionUrl = asURL(form.action);
|
||||
if (!actionUrl) throw new Error('Invalid action URL');
|
||||
actionUrl.searchParams.set('background', '1');
|
||||
|
||||
var fd = new FormData(form);
|
||||
var body = new URLSearchParams();
|
||||
fd.forEach(function (v, k) { body.append(k, v); });
|
||||
|
||||
var resp = await fetch(actionUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (resp.status === 204) return;
|
||||
var text = '';
|
||||
try { text = await resp.text(); } catch (_) {}
|
||||
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
||||
}
|
||||
|
||||
function advance() {
|
||||
idx = idx + 1;
|
||||
ensureActiveCard();
|
||||
}
|
||||
|
||||
function animateAndAdvance(card, dir) {
|
||||
card.classList.remove('review-card-enter');
|
||||
card.classList.add(dir === 'left' ? 'review-card-leave-left' : 'review-card-leave-right');
|
||||
setTimeout(function () { advance(); }, 190);
|
||||
}
|
||||
|
||||
async function act(dir) {
|
||||
var card = currentCard();
|
||||
if (!card) return;
|
||||
|
||||
if (dir === 'left' && card.getAttribute('data-left-mode') === 'skip') {
|
||||
animateAndAdvance(card, 'left');
|
||||
return;
|
||||
}
|
||||
|
||||
var form = qs(card, 'form[data-review-submit=\"' + dir + '\"]');
|
||||
if (!form) {
|
||||
animateAndAdvance(card, dir);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await backgroundSubmit(form);
|
||||
animateAndAdvance(card, dir);
|
||||
} catch (err) {
|
||||
showToast((err && err.message) ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
var t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
|
||||
if (e.key === 'Escape' && emptyUrl) {
|
||||
try { history.replaceState({}, '', emptyUrl); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyboardAction(e) {
|
||||
var dir = e.detail && e.detail.direction;
|
||||
if (dir === 'left' || dir === 'right') {
|
||||
act(dir);
|
||||
}
|
||||
}
|
||||
|
||||
function wireButtons(card) {
|
||||
var leftBtn = qs(card, '[data-review-action=\"left\"]');
|
||||
var rightBtn = qs(card, '[data-review-action=\"right\"]');
|
||||
|
||||
if (leftBtn) {
|
||||
leftBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (window.fluxerReviewKeyboard) {
|
||||
window.fluxerReviewKeyboard.enable(deck);
|
||||
}
|
||||
act('left');
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
if (rightBtn) {
|
||||
rightBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (window.fluxerReviewKeyboard) {
|
||||
window.fluxerReviewKeyboard.enable(deck);
|
||||
}
|
||||
act('right');
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
var forms = qsa(card, 'form[data-review-submit]');
|
||||
forms.forEach(function (f) {
|
||||
f.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var dir = f.getAttribute('data-review-submit');
|
||||
if (dir === 'left' || dir === 'right') {
|
||||
if (window.fluxerReviewKeyboard) {
|
||||
window.fluxerReviewKeyboard.enable(deck);
|
||||
}
|
||||
}
|
||||
act(dir);
|
||||
}, { capture: true });
|
||||
});
|
||||
}
|
||||
|
||||
function wireAll() { cards.forEach(wireButtons); }
|
||||
|
||||
function wireSwipe(card) {
|
||||
var tracking = null;
|
||||
|
||||
card.addEventListener('pointerdown', function (e) {
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
tracking = {
|
||||
id: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
x: 0,
|
||||
y: 0,
|
||||
moved: false
|
||||
};
|
||||
try { card.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
});
|
||||
|
||||
card.addEventListener('pointermove', function (e) {
|
||||
if (!tracking || tracking.id !== e.pointerId) return;
|
||||
tracking.x = e.clientX - tracking.startX;
|
||||
tracking.y = e.clientY - tracking.startY;
|
||||
|
||||
if (!tracking.moved) {
|
||||
if (Math.abs(tracking.y) > 12 && Math.abs(tracking.y) > Math.abs(tracking.x)) {
|
||||
tracking = null;
|
||||
card.style.transform = '';
|
||||
return;
|
||||
}
|
||||
tracking.moved = true;
|
||||
}
|
||||
|
||||
var w = Math.max(320, card.getBoundingClientRect().width);
|
||||
var pct = clamp(tracking.x / w, -1, 1);
|
||||
var rot = pct * 8;
|
||||
card.style.transform = 'translateX(' + tracking.x + 'px) rotate(' + rot + 'deg)';
|
||||
card.style.opacity = String(1 - Math.min(0.35, Math.abs(pct) * 0.35));
|
||||
});
|
||||
|
||||
function endSwipe(e) {
|
||||
if (!tracking || tracking.id !== e.pointerId) return;
|
||||
var dx = tracking.x;
|
||||
tracking = null;
|
||||
|
||||
var w = Math.max(320, card.getBoundingClientRect().width);
|
||||
var threshold = Math.max(110, w * 0.22);
|
||||
if (Math.abs(dx) >= threshold) {
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
var dir = dx < 0 ? 'left' : 'right';
|
||||
act(dir);
|
||||
return;
|
||||
}
|
||||
card.style.transition = 'transform 120ms ease-out, opacity 120ms ease-out';
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
setTimeout(function () { card.style.transition = ''; }, 140);
|
||||
}
|
||||
|
||||
card.addEventListener('pointerup', endSwipe);
|
||||
card.addEventListener('pointercancel', endSwipe);
|
||||
}
|
||||
|
||||
function wireSwipeAll() { cards.forEach(wireSwipe); }
|
||||
|
||||
async function maybePrefetchMore() {
|
||||
if (!canPaginate) return;
|
||||
if (!fragmentBase) return;
|
||||
if (prefetchInFlight) return;
|
||||
if (remainingCount() > prefetchWhenRemaining) return;
|
||||
|
||||
prefetchInFlight = true;
|
||||
try {
|
||||
var url = asURL(fragmentBase);
|
||||
if (!url) return;
|
||||
url.searchParams.set('page', String(nextPage));
|
||||
var resp = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||
if (!resp.ok) return;
|
||||
var html = await resp.text();
|
||||
var doc = parseHTML(html);
|
||||
var frag = doc.querySelector('[data-review-fragment]');
|
||||
if (!frag) return;
|
||||
var newCards = Array.prototype.slice.call(frag.querySelectorAll('[data-review-card]'));
|
||||
if (newCards.length === 0) return;
|
||||
|
||||
newCards.forEach(function (c) {
|
||||
c.hidden = true;
|
||||
deck.appendChild(c);
|
||||
});
|
||||
cards = qsa(deck, '[data-review-card]');
|
||||
newCards.forEach(function (c) { wireButtons(c); wireSwipe(c); });
|
||||
nextPage = nextPage + 1;
|
||||
} finally {
|
||||
prefetchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePrefetchDetails(card) {
|
||||
var expandUrl = card.getAttribute('data-expand-url');
|
||||
var targetSel = card.getAttribute('data-expand-target') || '';
|
||||
if (!expandUrl || !targetSel) return;
|
||||
if (card.getAttribute('data-expanded') === 'true') return;
|
||||
|
||||
var target = qs(card, targetSel);
|
||||
if (!target) return;
|
||||
|
||||
card.setAttribute('data-expanded', 'inflight');
|
||||
try {
|
||||
var resp = await fetch(expandUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) { card.setAttribute('data-expanded', 'false'); return; }
|
||||
var html = await resp.text();
|
||||
var doc = parseHTML(html);
|
||||
var frag = doc.querySelector('[data-report-fragment]');
|
||||
if (!frag) { card.setAttribute('data-expanded', 'false'); return; }
|
||||
target.innerHTML = '';
|
||||
target.appendChild(frag);
|
||||
target.hidden = false;
|
||||
card.setAttribute('data-expanded', 'true');
|
||||
} catch (_) {
|
||||
card.setAttribute('data-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function wireExpandButtons() {
|
||||
cards.forEach(function (card) {
|
||||
var btn = qs(card, '[data-review-expand]');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
card.setAttribute('data-expanded', 'false');
|
||||
maybePrefetchDetails(card);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deck.addEventListener('keydown', onKeyDown);
|
||||
deck.addEventListener('review:keyboard', onKeyboardAction);
|
||||
wireAll();
|
||||
wireSwipeAll();
|
||||
wireExpandButtons();
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
ensureActiveCard();
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
var decks = document.querySelectorAll('[data-review-deck]');
|
||||
for (var i = 0; i < decks.length; i++) {
|
||||
enhanceDeck(decks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/option
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn view(
|
||||
left_key: String,
|
||||
left_label: String,
|
||||
right_key: String,
|
||||
right_label: String,
|
||||
exit_key: String,
|
||||
exit_label: String,
|
||||
note: option.Option(String),
|
||||
) {
|
||||
let note_element = case note {
|
||||
option.Some(text) ->
|
||||
h.div([a.class("body-sm text-neutral-600")], [element.text(text)])
|
||||
option.None -> element.none()
|
||||
}
|
||||
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"review-hintbar mt-6 p-4 bg-neutral-50 border-t border-neutral-200",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.div([a.class("max-w-7xl mx-auto flex items-center justify-between")], [
|
||||
h.div([a.class("flex gap-6 items-center")], [
|
||||
h.div([a.class("flex items-center gap-2")], [
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
|
||||
),
|
||||
],
|
||||
[element.text(left_key)],
|
||||
),
|
||||
h.span([a.class("body-sm text-neutral-700")], [
|
||||
element.text(left_label),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex items-center gap-2")], [
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
|
||||
),
|
||||
],
|
||||
[element.text(right_key)],
|
||||
),
|
||||
h.span([a.class("body-sm text-neutral-700")], [
|
||||
element.text(right_label),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex items-center gap-2")], [
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
|
||||
),
|
||||
],
|
||||
[element.text(exit_key)],
|
||||
),
|
||||
h.span([a.class("body-sm text-neutral-700")], [
|
||||
element.text(exit_label),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
note_element,
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
101
fluxer_admin/src/fluxer_admin/components/review_keyboard.gleam
Normal file
101
fluxer_admin/src/fluxer_admin/components/review_keyboard.gleam
Normal file
@@ -0,0 +1,101 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn script_tag() -> element.Element(a) {
|
||||
h.script([a.attribute("defer", "defer")], script())
|
||||
}
|
||||
|
||||
pub fn script() -> String {
|
||||
"
|
||||
(function () {
|
||||
var globalKeyboardMode = false;
|
||||
var activeDeck = null;
|
||||
|
||||
function isEditable(el) {
|
||||
if (!el) return false;
|
||||
var tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
return el.isContentEditable;
|
||||
}
|
||||
|
||||
function triggerAction(deck, direction) {
|
||||
if (!deck) return;
|
||||
var event = new CustomEvent('review:keyboard', {
|
||||
detail: { direction: direction },
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
deck.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function onGlobalKeyDown(e) {
|
||||
if (!globalKeyboardMode) return;
|
||||
if (!activeDeck) return;
|
||||
if (isEditable(e.target)) return;
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
triggerAction(activeDeck, 'left');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
triggerAction(activeDeck, 'right');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
disableKeyboardMode();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function enableKeyboardMode(deckElement) {
|
||||
if (!deckElement) return;
|
||||
activeDeck = deckElement;
|
||||
globalKeyboardMode = true;
|
||||
deckElement.setAttribute('data-keyboard-mode', 'true');
|
||||
}
|
||||
|
||||
function disableKeyboardMode() {
|
||||
if (activeDeck) {
|
||||
activeDeck.removeAttribute('data-keyboard-mode');
|
||||
}
|
||||
globalKeyboardMode = false;
|
||||
activeDeck = null;
|
||||
}
|
||||
|
||||
window.fluxerReviewKeyboard = {
|
||||
enable: enableKeyboardMode,
|
||||
disable: disableKeyboardMode,
|
||||
isEnabled: function () { return globalKeyboardMode; },
|
||||
getActiveDeck: function () { return activeDeck; }
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onGlobalKeyDown, { capture: true });
|
||||
})();
|
||||
"
|
||||
}
|
||||
99
fluxer_admin/src/fluxer_admin/components/search_form.gleam
Normal file
99
fluxer_admin/src/fluxer_admin/components/search_form.gleam
Normal file
@@ -0,0 +1,99 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/components/ui
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn search_form(
|
||||
ctx: Context,
|
||||
query: Option(String),
|
||||
placeholder: String,
|
||||
help_text: Option(String),
|
||||
clear_url: String,
|
||||
additional_filters: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
ui.card(ui.PaddingSmall, [
|
||||
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
||||
case list.is_empty(additional_filters) {
|
||||
True ->
|
||||
h.div([a.class("flex gap-2")], [
|
||||
h.input([
|
||||
a.type_("text"),
|
||||
a.name("q"),
|
||||
a.value(option.unwrap(query, "")),
|
||||
a.placeholder(placeholder),
|
||||
a.class(
|
||||
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
|
||||
),
|
||||
a.attribute("autocomplete", "off"),
|
||||
]),
|
||||
ui.button_primary("Search", "submit", []),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, clear_url),
|
||||
a.class(
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("Clear")],
|
||||
),
|
||||
])
|
||||
False ->
|
||||
h.div([a.class("flex flex-col gap-4")], [
|
||||
h.div([a.class("flex gap-2")], [
|
||||
h.input([
|
||||
a.type_("text"),
|
||||
a.name("q"),
|
||||
a.value(option.unwrap(query, "")),
|
||||
a.placeholder(placeholder),
|
||||
a.class(
|
||||
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
|
||||
),
|
||||
a.attribute("autocomplete", "off"),
|
||||
]),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("grid grid-cols-1 md:grid-cols-4 gap-4")],
|
||||
additional_filters,
|
||||
),
|
||||
h.div([a.class("flex gap-2")], [
|
||||
ui.button_primary("Search", "submit", []),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, clear_url),
|
||||
a.class(
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("Clear")],
|
||||
),
|
||||
]),
|
||||
])
|
||||
},
|
||||
case help_text {
|
||||
option.Some(text) ->
|
||||
h.p([a.class("text-xs text-neutral-500")], [element.text(text)])
|
||||
option.None -> element.none()
|
||||
},
|
||||
]),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/int
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn range_slider_section(
|
||||
slider_id: String,
|
||||
value_id: String,
|
||||
min_value: Int,
|
||||
max_value: Int,
|
||||
current_value: Int,
|
||||
) {
|
||||
[
|
||||
h.input([
|
||||
a.id(slider_id),
|
||||
a.type_("range"),
|
||||
a.name("count"),
|
||||
a.min(int.to_string(min_value)),
|
||||
a.max(int.to_string(max_value)),
|
||||
a.value(int.to_string(current_value)),
|
||||
a.class("w-full h-2 bg-neutral-200 rounded-lg accent-neutral-900"),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("flex items-baseline justify-between text-xs text-neutral-500")],
|
||||
[
|
||||
h.span([], [element.text("Selected amount")]),
|
||||
h.span([a.id(value_id), a.class("font-semibold text-neutral-900")], [
|
||||
element.text(int.to_string(current_value)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn slider_sync_script(
|
||||
slider_id: String,
|
||||
value_id: String,
|
||||
) -> element.Element(a) {
|
||||
let script =
|
||||
"(function(){const slider=document.getElementById('"
|
||||
<> slider_id
|
||||
<> "');const value=document.getElementById('"
|
||||
<> value_id
|
||||
<> "');if(!slider||!value)return;const update=()=>value.textContent=slider.value;update();slider.addEventListener('input',update);})();"
|
||||
|
||||
h.script([a.attribute("defer", "defer")], script)
|
||||
}
|
||||
45
fluxer_admin/src/fluxer_admin/components/tabs.gleam
Normal file
45
fluxer_admin/src/fluxer_admin/components/tabs.gleam
Normal file
@@ -0,0 +1,45 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/list
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub type Tab {
|
||||
Tab(label: String, path: String, active: Bool)
|
||||
}
|
||||
|
||||
pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
|
||||
h.div([a.class("border-b border-neutral-200 mb-6")], [
|
||||
h.nav(
|
||||
[a.class("flex gap-6")],
|
||||
list.map(tabs, fn(tab) { render_tab(ctx, tab) }),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) {
|
||||
let class_active = case tab.active {
|
||||
True -> "border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3"
|
||||
False ->
|
||||
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors"
|
||||
}
|
||||
|
||||
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])
|
||||
}
|
||||
662
fluxer_admin/src/fluxer_admin/components/ui.gleam
Normal file
662
fluxer_admin/src/fluxer_admin/components/ui.gleam
Normal file
@@ -0,0 +1,662 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden"
|
||||
|
||||
pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider"
|
||||
|
||||
pub const table_cell_class = "px-6 py-4 text-sm text-neutral-900"
|
||||
|
||||
pub const table_cell_muted_class = "px-6 py-4 text-sm text-neutral-600"
|
||||
|
||||
pub fn table_container(children: List(element.Element(a))) -> element.Element(a) {
|
||||
h.div([a.class(table_container_class)], children)
|
||||
}
|
||||
|
||||
pub fn table_header_cell(label: String) -> element.Element(a) {
|
||||
h.th([a.class(table_header_cell_class)], [element.text(label)])
|
||||
}
|
||||
|
||||
pub type PillTone {
|
||||
PillNeutral
|
||||
PillInfo
|
||||
PillSuccess
|
||||
PillWarning
|
||||
PillDanger
|
||||
PillPrimary
|
||||
PillPurple
|
||||
PillOrange
|
||||
}
|
||||
|
||||
pub fn pill(label: String, tone: PillTone) -> element.Element(a) {
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"inline-flex items-center px-2 py-1 rounded text-xs font-medium "
|
||||
<> pill_classes(tone),
|
||||
),
|
||||
],
|
||||
[element.text(label)],
|
||||
)
|
||||
}
|
||||
|
||||
fn pill_classes(tone: PillTone) -> String {
|
||||
case tone {
|
||||
PillNeutral -> "bg-neutral-100 text-neutral-700"
|
||||
PillInfo -> "bg-blue-100 text-blue-700"
|
||||
PillSuccess -> "bg-green-100 text-green-700"
|
||||
PillWarning -> "bg-yellow-100 text-yellow-700"
|
||||
PillDanger -> "bg-red-100 text-red-700"
|
||||
PillPrimary -> "bg-neutral-900 text-white"
|
||||
PillPurple -> "bg-purple-100 text-purple-700"
|
||||
PillOrange -> "bg-orange-100 text-orange-700"
|
||||
}
|
||||
}
|
||||
|
||||
pub type ButtonSize {
|
||||
Small
|
||||
Medium
|
||||
Large
|
||||
}
|
||||
|
||||
pub type ButtonVariant {
|
||||
Primary
|
||||
Secondary
|
||||
Danger
|
||||
Success
|
||||
Info
|
||||
Ghost
|
||||
}
|
||||
|
||||
pub type ButtonWidth {
|
||||
Auto
|
||||
Full
|
||||
}
|
||||
|
||||
pub fn button_primary(
|
||||
text: String,
|
||||
type_: String,
|
||||
attrs: List(a.Attribute(msg)),
|
||||
) -> element.Element(msg) {
|
||||
button(text, type_, Primary, Medium, Auto, attrs)
|
||||
}
|
||||
|
||||
pub fn button_danger(
|
||||
text: String,
|
||||
type_: String,
|
||||
attrs: List(a.Attribute(msg)),
|
||||
) -> element.Element(msg) {
|
||||
button(text, type_, Danger, Medium, Auto, attrs)
|
||||
}
|
||||
|
||||
pub fn button_success(
|
||||
text: String,
|
||||
type_: String,
|
||||
attrs: List(a.Attribute(msg)),
|
||||
) -> element.Element(msg) {
|
||||
button(text, type_, Success, Medium, Auto, attrs)
|
||||
}
|
||||
|
||||
pub fn button_info(
|
||||
text: String,
|
||||
type_: String,
|
||||
attrs: List(a.Attribute(msg)),
|
||||
) -> element.Element(msg) {
|
||||
button(text, type_, Info, Medium, Auto, attrs)
|
||||
}
|
||||
|
||||
pub fn button_secondary(
|
||||
text: String,
|
||||
type_: String,
|
||||
attrs: List(a.Attribute(msg)),
|
||||
) -> element.Element(msg) {
|
||||
button(text, type_, Secondary, Medium, Auto, attrs)
|
||||
}
|
||||
|
||||
pub fn button(
|
||||
text: String,
|
||||
type_: String,
|
||||
variant: ButtonVariant,
|
||||
size: ButtonSize,
|
||||
width: ButtonWidth,
|
||||
extra_attrs: List(a.Attribute(msg)),
|
||||
) -> element.Element(msg) {
|
||||
let base_classes = "text-sm font-medium rounded-lg transition-colors"
|
||||
|
||||
let size_classes = case size {
|
||||
Small -> "px-3 py-1.5 text-sm"
|
||||
Medium -> "px-4 py-2"
|
||||
Large -> "px-6 py-3 text-base"
|
||||
}
|
||||
|
||||
let width_classes = case width {
|
||||
Auto -> ""
|
||||
Full -> "w-full"
|
||||
}
|
||||
|
||||
let variant_classes = case variant {
|
||||
Primary -> "bg-neutral-900 text-white hover:bg-neutral-800"
|
||||
Secondary ->
|
||||
"text-neutral-700 hover:text-neutral-900 border border-neutral-300 hover:border-neutral-400"
|
||||
Danger -> "bg-red-600 text-white hover:bg-red-700"
|
||||
Success -> "bg-blue-600 text-white hover:bg-blue-700"
|
||||
Info -> "bg-blue-50 text-blue-700 hover:bg-blue-100"
|
||||
Ghost -> "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100"
|
||||
}
|
||||
|
||||
let classes =
|
||||
[base_classes, size_classes, width_classes, variant_classes]
|
||||
|> list.filter(fn(c) { c != "" })
|
||||
|> list.fold("", fn(acc, c) { acc <> " " <> c })
|
||||
|> string_trim
|
||||
|
||||
let attrs = [a.type_(type_), a.class(classes), ..extra_attrs]
|
||||
|
||||
h.button(attrs, [element.text(text)])
|
||||
}
|
||||
|
||||
@external(erlang, "string", "trim")
|
||||
fn string_trim(string: String) -> String
|
||||
|
||||
pub type InputType {
|
||||
Text
|
||||
Email
|
||||
Password
|
||||
Tel
|
||||
Number
|
||||
Date
|
||||
Url
|
||||
}
|
||||
|
||||
fn input_type_to_string(input_type: InputType) -> String {
|
||||
case input_type {
|
||||
Text -> "text"
|
||||
Email -> "email"
|
||||
Password -> "password"
|
||||
Tel -> "tel"
|
||||
Number -> "number"
|
||||
Date -> "date"
|
||||
Url -> "url"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input(
|
||||
label: String,
|
||||
name: String,
|
||||
input_type: InputType,
|
||||
value: Option(String),
|
||||
required: Bool,
|
||||
placeholder: Option(String),
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("space-y-2")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text(label),
|
||||
]),
|
||||
input_field(name, input_type, value, required, placeholder),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn input_field(
|
||||
name: String,
|
||||
input_type: InputType,
|
||||
value: Option(String),
|
||||
required: Bool,
|
||||
placeholder: Option(String),
|
||||
) -> element.Element(a) {
|
||||
let base_attrs = [
|
||||
a.type_(input_type_to_string(input_type)),
|
||||
a.name(name),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||
),
|
||||
]
|
||||
|
||||
let value_attr = case value {
|
||||
option.Some(v) -> [a.value(v)]
|
||||
option.None -> []
|
||||
}
|
||||
|
||||
let required_attr = case required {
|
||||
True -> [a.required(True)]
|
||||
False -> []
|
||||
}
|
||||
|
||||
let placeholder_attr = case placeholder {
|
||||
option.Some(p) -> [a.placeholder(p)]
|
||||
option.None -> []
|
||||
}
|
||||
|
||||
let attrs =
|
||||
list.flatten([base_attrs, value_attr, required_attr, placeholder_attr])
|
||||
|
||||
h.input(attrs)
|
||||
}
|
||||
|
||||
pub fn textarea(
|
||||
label: String,
|
||||
name: String,
|
||||
value: Option(String),
|
||||
required: Bool,
|
||||
placeholder: Option(String),
|
||||
rows: Int,
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("space-y-2")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text(label),
|
||||
]),
|
||||
textarea_field(name, value, required, placeholder, rows),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn textarea_field(
|
||||
name: String,
|
||||
value: Option(String),
|
||||
required: Bool,
|
||||
placeholder: Option(String),
|
||||
rows: Int,
|
||||
) -> element.Element(a) {
|
||||
let base_attrs = [
|
||||
a.name(name),
|
||||
a.attribute("rows", int.to_string(rows)),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||
),
|
||||
]
|
||||
|
||||
let required_attr = case required {
|
||||
True -> [a.required(True)]
|
||||
False -> []
|
||||
}
|
||||
|
||||
let placeholder_attr = case placeholder {
|
||||
option.Some(p) -> [a.placeholder(p)]
|
||||
option.None -> []
|
||||
}
|
||||
|
||||
let value_text = option.unwrap(value, "")
|
||||
|
||||
let attrs = list.flatten([base_attrs, required_attr, placeholder_attr])
|
||||
|
||||
h.textarea(attrs, value_text)
|
||||
}
|
||||
|
||||
pub type CardPadding {
|
||||
PaddingNone
|
||||
PaddingSmall
|
||||
PaddingMedium
|
||||
PaddingLarge
|
||||
PaddingExtraLarge
|
||||
}
|
||||
|
||||
pub fn card(
|
||||
padding: CardPadding,
|
||||
children: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
let padding_class = case padding {
|
||||
PaddingNone -> "p-0"
|
||||
PaddingSmall -> "p-4"
|
||||
PaddingMedium -> "p-6"
|
||||
PaddingLarge -> "p-8"
|
||||
PaddingExtraLarge -> "p-12"
|
||||
}
|
||||
|
||||
h.div(
|
||||
[a.class("bg-white border border-neutral-200 rounded-lg " <> padding_class)],
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn card_elevated(
|
||||
padding: CardPadding,
|
||||
children: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
let padding_class = case padding {
|
||||
PaddingNone -> "p-0"
|
||||
PaddingSmall -> "p-4"
|
||||
PaddingMedium -> "p-6"
|
||||
PaddingLarge -> "p-8"
|
||||
PaddingExtraLarge -> "p-12"
|
||||
}
|
||||
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"bg-white border border-neutral-200 rounded-lg shadow-sm "
|
||||
<> padding_class,
|
||||
),
|
||||
],
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn card_empty(children: List(element.Element(a))) -> element.Element(a) {
|
||||
h.div(
|
||||
[
|
||||
a.class("bg-white border border-neutral-200 rounded-lg p-12 text-center"),
|
||||
],
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn heading_page(text: String) -> element.Element(a) {
|
||||
h.h1([a.class("text-lg font-semibold text-neutral-900")], [element.text(text)])
|
||||
}
|
||||
|
||||
pub fn heading_section(text: String) -> element.Element(a) {
|
||||
h.h2([a.class("text-base font-semibold text-neutral-900")], [
|
||||
element.text(text),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn heading_card(text: String) -> element.Element(a) {
|
||||
h.h3([a.class("text-base font-medium text-neutral-900")], [element.text(text)])
|
||||
}
|
||||
|
||||
pub fn heading_card_with_margin(text: String) -> element.Element(a) {
|
||||
h.div([a.class("mb-4")], [heading_card(text)])
|
||||
}
|
||||
|
||||
pub fn text_muted(text: String) -> element.Element(a) {
|
||||
h.p([a.class("text-sm text-neutral-600")], [element.text(text)])
|
||||
}
|
||||
|
||||
pub fn text_small_muted(text: String) -> element.Element(a) {
|
||||
h.p([a.class("text-xs text-neutral-500")], [element.text(text)])
|
||||
}
|
||||
|
||||
pub fn detail_header(
|
||||
title: String,
|
||||
subtitle_items: List(#(String, element.Element(a))),
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("flex-1")], [
|
||||
h.div([a.class("flex items-center gap-3 mb-3")], [
|
||||
h.h1([a.class("text-base font-semibold text-neutral-900")], [
|
||||
element.text(title),
|
||||
]),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("flex flex-wrap items-start gap-4")],
|
||||
list.map(subtitle_items, fn(item) {
|
||||
let #(label, value) = item
|
||||
h.div([a.class("flex items-start gap-2")], [
|
||||
h.div([a.class("text-sm font-medium text-neutral-600")], [
|
||||
element.text(label),
|
||||
]),
|
||||
value,
|
||||
])
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn info_item_text(label: String, value: String) -> element.Element(a) {
|
||||
info_item(
|
||||
label,
|
||||
h.div([a.class("text-sm text-neutral-900")], [element.text(value)]),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn info_item(label: String, value: element.Element(a)) -> element.Element(a) {
|
||||
h.div([], [
|
||||
h.div([a.class("text-sm font-medium text-neutral-600 mb-1")], [
|
||||
element.text(label),
|
||||
]),
|
||||
value,
|
||||
])
|
||||
}
|
||||
|
||||
pub fn info_grid(items: List(element.Element(a))) -> element.Element(a) {
|
||||
h.div([a.class("grid grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-3")], items)
|
||||
}
|
||||
|
||||
pub type BadgeVariant {
|
||||
BadgeDefault
|
||||
BadgeInfo
|
||||
BadgeSuccess
|
||||
BadgeWarning
|
||||
BadgeDanger
|
||||
}
|
||||
|
||||
pub fn badge(text: String, variant: BadgeVariant) -> element.Element(a) {
|
||||
let variant_classes = case variant {
|
||||
BadgeDefault -> "bg-neutral-100 text-neutral-700"
|
||||
BadgeInfo -> "bg-blue-100 text-blue-700"
|
||||
BadgeSuccess -> "bg-green-100 text-green-700"
|
||||
BadgeWarning -> "bg-yellow-100 text-yellow-700"
|
||||
BadgeDanger -> "bg-red-100 text-red-700"
|
||||
}
|
||||
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs "
|
||||
<> variant_classes,
|
||||
),
|
||||
],
|
||||
[element.text(text)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn flex_row(
|
||||
gap: String,
|
||||
children: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("flex items-center gap-" <> gap)], children)
|
||||
}
|
||||
|
||||
pub fn flex_row_between(
|
||||
children: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("mb-6 flex items-center justify-between")], children)
|
||||
}
|
||||
|
||||
pub fn stack(
|
||||
gap: String,
|
||||
children: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("space-y-" <> gap)], children)
|
||||
}
|
||||
|
||||
pub fn grid(
|
||||
cols: String,
|
||||
gap: String,
|
||||
children: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("grid grid-cols-" <> cols <> " gap-" <> gap)], children)
|
||||
}
|
||||
|
||||
pub fn definition_list(
|
||||
cols: Int,
|
||||
items: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
let cols_class = case cols {
|
||||
1 -> "grid-cols-1"
|
||||
2 -> "grid-cols-1 sm:grid-cols-2"
|
||||
3 -> "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||
4 -> "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
|
||||
_ -> "grid-cols-1 sm:grid-cols-2"
|
||||
}
|
||||
|
||||
h.dl([a.class("grid " <> cols_class <> " gap-x-6 gap-y-2")], items)
|
||||
}
|
||||
|
||||
pub fn back_button(
|
||||
ctx: Context,
|
||||
url: String,
|
||||
label: String,
|
||||
) -> element.Element(a) {
|
||||
h.a(
|
||||
[
|
||||
href(ctx, url),
|
||||
a.class(
|
||||
"inline-flex items-center gap-2 text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500 text-sm",
|
||||
),
|
||||
],
|
||||
[element.text("← " <> label)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn not_found_view(
|
||||
ctx: Context,
|
||||
resource_name: String,
|
||||
back_url: String,
|
||||
back_label: String,
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("max-w-2xl mx-auto")], [
|
||||
card(PaddingLarge, [
|
||||
h.div([a.class("text-center space-y-4")], [
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"mx-auto w-16 h-16 bg-neutral-100 rounded-full flex items-center justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.span([a.class("text-neutral-400 text-2xl font-semibold")], [
|
||||
element.text("?"),
|
||||
]),
|
||||
],
|
||||
),
|
||||
h.h2([a.class("text-lg font-semibold text-neutral-900")], [
|
||||
element.text(resource_name <> " Not Found"),
|
||||
]),
|
||||
h.p([a.class("text-neutral-600")], [
|
||||
element.text(
|
||||
"The "
|
||||
<> resource_name
|
||||
<> " you're looking for doesn't exist or you don't have permission to view it.",
|
||||
),
|
||||
]),
|
||||
h.div([a.class("pt-4")], [back_button(ctx, back_url, back_label)]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
pub type TableColumn(row, msg) {
|
||||
TableColumn(
|
||||
header: String,
|
||||
cell_class: String,
|
||||
render: fn(row) -> element.Element(msg),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn data_table(
|
||||
columns: List(TableColumn(row, msg)),
|
||||
rows: List(row),
|
||||
) -> element.Element(msg) {
|
||||
h.div(
|
||||
[a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")],
|
||||
[
|
||||
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
|
||||
h.thead([a.class("bg-neutral-50")], [
|
||||
h.tr(
|
||||
[],
|
||||
list.map(columns, fn(col) {
|
||||
let TableColumn(header, _, _) = col
|
||||
h.th(
|
||||
[
|
||||
a.class(
|
||||
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
|
||||
),
|
||||
],
|
||||
[element.text(header)],
|
||||
)
|
||||
}),
|
||||
),
|
||||
]),
|
||||
h.tbody(
|
||||
[a.class("bg-white divide-y divide-neutral-200")],
|
||||
list.map(rows, fn(row) {
|
||||
h.tr(
|
||||
[a.class("hover:bg-neutral-50 transition-colors")],
|
||||
list.map(columns, fn(col) {
|
||||
let TableColumn(_, cell_class, render) = col
|
||||
h.td([a.class(cell_class)], [render(row)])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn custom_checkbox(
|
||||
name: String,
|
||||
value: String,
|
||||
label: String,
|
||||
checked: Bool,
|
||||
on_change: Option(String),
|
||||
) -> element.Element(a) {
|
||||
let checkbox_attrs = case on_change {
|
||||
option.Some(script) -> [
|
||||
a.type_("checkbox"),
|
||||
a.name(name),
|
||||
a.value(value),
|
||||
a.checked(checked),
|
||||
a.class("peer hidden"),
|
||||
a.attribute("onchange", script),
|
||||
]
|
||||
option.None -> [
|
||||
a.type_("checkbox"),
|
||||
a.name(name),
|
||||
a.value(value),
|
||||
a.checked(checked),
|
||||
a.class("peer hidden"),
|
||||
]
|
||||
}
|
||||
|
||||
h.label([a.class("flex items-center gap-3 cursor-pointer group")], [
|
||||
h.input(checkbox_attrs),
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class(
|
||||
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors",
|
||||
),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"polyline",
|
||||
[
|
||||
a.attribute("points", "40 144 96 200 224 72"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
),
|
||||
h.span([a.class("text-sm text-neutral-900 group-hover:text-neutral-700")], [
|
||||
element.text(label),
|
||||
]),
|
||||
])
|
||||
}
|
||||
56
fluxer_admin/src/fluxer_admin/components/url_builder.gleam
Normal file
56
fluxer_admin/src/fluxer_admin/components/url_builder.gleam
Normal file
@@ -0,0 +1,56 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import gleam/string
|
||||
import gleam/uri
|
||||
|
||||
pub fn build_url(
|
||||
base: String,
|
||||
params: List(#(String, Option(String))),
|
||||
) -> String {
|
||||
let filtered_params =
|
||||
params
|
||||
|> list.filter_map(fn(param) {
|
||||
let #(key, value_opt) = param
|
||||
case value_opt {
|
||||
option.Some(value) -> {
|
||||
let trimmed = string.trim(value)
|
||||
case trimmed {
|
||||
"" -> Error(Nil)
|
||||
v -> Ok(#(key, v))
|
||||
}
|
||||
}
|
||||
option.None -> Error(Nil)
|
||||
}
|
||||
})
|
||||
|
||||
case filtered_params {
|
||||
[] -> base
|
||||
params -> {
|
||||
let query_string =
|
||||
params
|
||||
|> list.map(fn(pair) {
|
||||
let #(key, value) = pair
|
||||
key <> "=" <> uri.percent_encode(value)
|
||||
})
|
||||
|> string.join("&")
|
||||
base <> "?" <> query_string
|
||||
}
|
||||
}
|
||||
}
|
||||
243
fluxer_admin/src/fluxer_admin/components/voice.gleam
Normal file
243
fluxer_admin/src/fluxer_admin/components/voice.gleam
Normal file
@@ -0,0 +1,243 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn vip_checkbox(checked: Bool) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("flex items-center gap-2")], [
|
||||
h.input([
|
||||
a.type_("checkbox"),
|
||||
a.name("vip_only"),
|
||||
a.value("true"),
|
||||
a.checked(checked),
|
||||
]),
|
||||
h.span([a.class("text-sm text-neutral-700")], [
|
||||
element.text("Require VIP_VOICE feature"),
|
||||
]),
|
||||
]),
|
||||
h.p([a.class("text-xs text-neutral-500 ml-6")], [
|
||||
element.text(
|
||||
"When enabled, guilds MUST have VIP_VOICE feature. This becomes a base requirement that works with AND logic alongside other restrictions.",
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn features_field(current_features: List(String)) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text("Allowed Guild Features (OR logic)"),
|
||||
]),
|
||||
h.textarea(
|
||||
[
|
||||
a.name("required_guild_features"),
|
||||
a.placeholder("VANITY_URL, COMMUNITY, PARTNERED"),
|
||||
a.attribute("rows", "2"),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
|
||||
),
|
||||
],
|
||||
string.join(current_features, ", "),
|
||||
),
|
||||
h.p([a.class("text-xs text-neutral-500")], [
|
||||
element.text(
|
||||
"Comma-separated. Guild needs ANY ONE of these features. Leave empty for no feature restrictions.",
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn guild_ids_field(current_ids: List(String)) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text("Allowed Guild IDs (OR logic, bypasses other checks)"),
|
||||
]),
|
||||
h.textarea(
|
||||
[
|
||||
a.name("allowed_guild_ids"),
|
||||
a.placeholder("123456789012345678, 987654321098765432"),
|
||||
a.attribute("rows", "2"),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
|
||||
),
|
||||
],
|
||||
string.join(current_ids, ", "),
|
||||
),
|
||||
h.p([a.class("text-xs text-neutral-500")], [
|
||||
element.text(
|
||||
"Comma-separated. If guild ID matches ANY of these, immediate access (bypasses VIP & feature checks).",
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn access_logic_summary() {
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"mt-4 p-3 bg-blue-50 border border-blue-200 rounded text-sm space-y-2",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.p([a.class("text-sm font-medium text-blue-900")], [
|
||||
element.text("Access Logic Summary:"),
|
||||
]),
|
||||
h.ul([a.class("text-blue-800 space-y-1 ml-4 list-disc")], [
|
||||
h.li([], [
|
||||
element.text(
|
||||
"Guild IDs provide immediate access (bypass all other checks)",
|
||||
),
|
||||
]),
|
||||
h.li([], [
|
||||
element.text(
|
||||
"VIP_VOICE requirement: Base requirement that must be satisfied (AND logic)",
|
||||
),
|
||||
]),
|
||||
h.li([], [
|
||||
element.text(
|
||||
"Features: Guild needs ANY ONE of the listed features (OR logic)",
|
||||
),
|
||||
]),
|
||||
h.li([], [
|
||||
element.text(
|
||||
"Combined: VIP_VOICE (if set) AND (feature1 OR feature2 OR ...)",
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn restriction_fields(
|
||||
vip_only: Bool,
|
||||
features: List(String),
|
||||
guild_ids: List(String),
|
||||
) {
|
||||
element.fragment([
|
||||
vip_checkbox(vip_only),
|
||||
features_field(features),
|
||||
guild_ids_field(guild_ids),
|
||||
access_logic_summary(),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn info_item(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.p([a.class("text-xs text-neutral-600")], [element.text(label)]),
|
||||
h.p([a.class("text-sm text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn vip_badge() {
|
||||
h.span(
|
||||
[
|
||||
a.class("px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded"),
|
||||
],
|
||||
[element.text("VIP ONLY")],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn feature_gated_badge() {
|
||||
h.span(
|
||||
[
|
||||
a.class("px-2 py-1 bg-amber-100 text-amber-800 text-xs rounded"),
|
||||
],
|
||||
[element.text("FEATURE GATED")],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn guild_restricted_badge() {
|
||||
h.span(
|
||||
[
|
||||
a.class("px-2 py-1 bg-green-100 text-green-800 text-xs rounded"),
|
||||
],
|
||||
[element.text("GUILD RESTRICTED")],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn status_badges(vip_only: Bool, has_features: Bool, has_guild_ids: Bool) {
|
||||
h.div([a.class("flex items-center gap-2 flex-wrap")], [
|
||||
case vip_only {
|
||||
True -> vip_badge()
|
||||
False -> element.none()
|
||||
},
|
||||
case has_features {
|
||||
True -> feature_gated_badge()
|
||||
False -> element.none()
|
||||
},
|
||||
case has_guild_ids {
|
||||
True -> guild_restricted_badge()
|
||||
False -> element.none()
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
pub fn features_list(features: List(String)) {
|
||||
case list.is_empty(features) {
|
||||
True -> element.none()
|
||||
False ->
|
||||
h.div([a.class("mb-3")], [
|
||||
h.p([a.class("text-xs text-neutral-700 mb-1")], [
|
||||
element.text("Allowed Guild Features:"),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("flex flex-wrap gap-1")],
|
||||
list.map(features, fn(feature) {
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"px-2 py-0.5 bg-amber-50 border border-amber-200 text-amber-800 text-xs rounded",
|
||||
),
|
||||
],
|
||||
[element.text(feature)],
|
||||
)
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn guild_ids_list(guild_ids: List(String)) {
|
||||
case list.is_empty(guild_ids) {
|
||||
True -> element.none()
|
||||
False ->
|
||||
h.div([a.class("mb-3")], [
|
||||
h.p([a.class("text-xs text-neutral-700 mb-1")], [
|
||||
element.text(
|
||||
"Allowed Guild IDs ("
|
||||
<> int.to_string(list.length(guild_ids))
|
||||
<> "):",
|
||||
),
|
||||
]),
|
||||
h.details([a.class("text-xs text-neutral-600")], [
|
||||
h.summary([a.class("cursor-pointer hover:text-neutral-900")], [
|
||||
element.text("Show IDs"),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("mt-1 max-h-20 overflow-y-auto text-xs")],
|
||||
list.map(guild_ids, fn(id) { h.div([], [element.text(id)]) }),
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
}
|
||||
151
fluxer_admin/src/fluxer_admin/config.gleam
Normal file
151
fluxer_admin/src/fluxer_admin/config.gleam
Normal file
@@ -0,0 +1,151 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import envoy
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/option
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
|
||||
pub type Config {
|
||||
Config(
|
||||
secret_key_base: String,
|
||||
api_endpoint: String,
|
||||
media_endpoint: String,
|
||||
cdn_endpoint: String,
|
||||
admin_endpoint: String,
|
||||
web_app_endpoint: String,
|
||||
metrics_endpoint: option.Option(String),
|
||||
oauth_client_id: String,
|
||||
oauth_client_secret: String,
|
||||
oauth_redirect_uri: String,
|
||||
port: Int,
|
||||
base_path: String,
|
||||
build_timestamp: String,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_base_path(base_path: String) -> String {
|
||||
let segments =
|
||||
base_path
|
||||
|> string.trim
|
||||
|> string.split("/")
|
||||
|> list.filter(fn(segment) { segment != "" })
|
||||
|
||||
case segments {
|
||||
[] -> ""
|
||||
_ -> "/" <> string.join(segments, "/")
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_endpoint(endpoint: String) -> String {
|
||||
let len = string.length(endpoint)
|
||||
case len > 0 && string.ends_with(endpoint, "/") {
|
||||
True -> normalize_endpoint(string.slice(endpoint, 0, len - 1))
|
||||
False -> endpoint
|
||||
}
|
||||
}
|
||||
|
||||
fn required_env(key: String) -> Result(String, String) {
|
||||
case envoy.get(key) {
|
||||
Ok(value) ->
|
||||
case string.trim(value) {
|
||||
"" -> Error("Missing required env: " <> key)
|
||||
trimmed -> Ok(trimmed)
|
||||
}
|
||||
Error(_) -> Error("Missing required env: " <> key)
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_env(key: String) -> option.Option(String) {
|
||||
case envoy.get(key) {
|
||||
Ok(value) ->
|
||||
case string.trim(value) {
|
||||
"" -> option.None
|
||||
trimmed -> option.Some(trimmed)
|
||||
}
|
||||
Error(_) -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
fn required_int_env(key: String) -> Result(Int, String) {
|
||||
use raw <- result.try(required_env(key))
|
||||
case int.parse(raw) {
|
||||
Ok(n) -> Ok(n)
|
||||
Error(_) -> Error("Invalid integer for env " <> key <> ": " <> raw)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_redirect_uri(
|
||||
endpoint: String,
|
||||
base_path: String,
|
||||
override: option.Option(String),
|
||||
) -> String {
|
||||
case override {
|
||||
option.Some(uri) -> uri
|
||||
option.None ->
|
||||
endpoint <> normalize_base_path(base_path) <> "/oauth2_callback"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_config() -> Result(Config, String) {
|
||||
use api_endpoint_raw <- result.try(required_env("FLUXER_API_PUBLIC_ENDPOINT"))
|
||||
use media_endpoint_raw <- result.try(required_env("FLUXER_MEDIA_ENDPOINT"))
|
||||
use cdn_endpoint_raw <- result.try(required_env("FLUXER_CDN_ENDPOINT"))
|
||||
use admin_endpoint_raw <- result.try(required_env("FLUXER_ADMIN_ENDPOINT"))
|
||||
use web_app_endpoint_raw <- result.try(required_env("FLUXER_APP_ENDPOINT"))
|
||||
use secret_key_base <- result.try(required_env("SECRET_KEY_BASE"))
|
||||
use client_id <- result.try(required_env("ADMIN_OAUTH2_CLIENT_ID"))
|
||||
use client_secret <- result.try(required_env("ADMIN_OAUTH2_CLIENT_SECRET"))
|
||||
use base_path_raw <- result.try(required_env("FLUXER_PATH_ADMIN"))
|
||||
use port <- result.try(required_int_env("FLUXER_ADMIN_PORT"))
|
||||
|
||||
let api_endpoint = normalize_endpoint(api_endpoint_raw)
|
||||
let media_endpoint = normalize_endpoint(media_endpoint_raw)
|
||||
let cdn_endpoint = normalize_endpoint(cdn_endpoint_raw)
|
||||
let admin_endpoint = normalize_endpoint(admin_endpoint_raw)
|
||||
let web_app_endpoint = normalize_endpoint(web_app_endpoint_raw)
|
||||
let base_path = normalize_base_path(base_path_raw)
|
||||
let redirect_uri =
|
||||
build_redirect_uri(
|
||||
admin_endpoint,
|
||||
base_path,
|
||||
optional_env("ADMIN_OAUTH2_REDIRECT_URI"),
|
||||
)
|
||||
|
||||
let metrics_endpoint = case optional_env("FLUXER_METRICS_HOST") {
|
||||
option.Some(host) -> option.Some("http://" <> host)
|
||||
option.None -> option.None
|
||||
}
|
||||
|
||||
Ok(Config(
|
||||
secret_key_base: secret_key_base,
|
||||
api_endpoint: api_endpoint,
|
||||
media_endpoint: media_endpoint,
|
||||
cdn_endpoint: cdn_endpoint,
|
||||
admin_endpoint: admin_endpoint,
|
||||
web_app_endpoint: web_app_endpoint,
|
||||
metrics_endpoint: metrics_endpoint,
|
||||
oauth_client_id: client_id,
|
||||
oauth_client_secret: client_secret,
|
||||
oauth_redirect_uri: redirect_uri,
|
||||
port: port,
|
||||
base_path: base_path,
|
||||
build_timestamp: envoy.get("BUILD_TIMESTAMP") |> result.unwrap(""),
|
||||
))
|
||||
}
|
||||
562
fluxer_admin/src/fluxer_admin/constants.gleam
Normal file
562
fluxer_admin/src/fluxer_admin/constants.gleam
Normal file
@@ -0,0 +1,562 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
pub type Flag {
|
||||
Flag(name: String, value: Int)
|
||||
}
|
||||
|
||||
pub const flag_staff = Flag("STAFF", 1)
|
||||
|
||||
pub const flag_ctp_member = Flag("CTP_MEMBER", 2)
|
||||
|
||||
pub const flag_partner = Flag("PARTNER", 4)
|
||||
|
||||
pub const flag_bug_hunter = Flag("BUG_HUNTER", 8)
|
||||
|
||||
pub const flag_high_global_rate_limit = Flag(
|
||||
"HIGH_GLOBAL_RATE_LIMIT",
|
||||
8_589_934_592,
|
||||
)
|
||||
|
||||
pub const flag_premium_purchase_disabled = Flag(
|
||||
"PREMIUM_PURCHASE_DISABLED",
|
||||
35_184_372_088_832,
|
||||
)
|
||||
|
||||
pub const flag_premium_enabled_override = Flag(
|
||||
"PREMIUM_ENABLED_OVERRIDE",
|
||||
70_368_744_177_664,
|
||||
)
|
||||
|
||||
pub const flag_rate_limit_bypass = Flag(
|
||||
"RATE_LIMIT_BYPASS",
|
||||
140_737_488_355_328,
|
||||
)
|
||||
|
||||
pub const flag_report_banned = Flag("REPORT_BANNED", 281_474_976_710_656)
|
||||
|
||||
pub const flag_verified_not_underage = Flag(
|
||||
"VERIFIED_NOT_UNDERAGE",
|
||||
562_949_953_421_312,
|
||||
)
|
||||
|
||||
pub const flag_pending_manual_verification = Flag(
|
||||
"PENDING_MANUAL_VERIFICATION",
|
||||
1_125_899_906_842_624,
|
||||
)
|
||||
|
||||
pub const flag_used_mobile_client = Flag(
|
||||
"USED_MOBILE_CLIENT",
|
||||
4_503_599_627_370_496,
|
||||
)
|
||||
|
||||
pub const flag_app_store_reviewer = Flag(
|
||||
"APP_STORE_REVIEWER",
|
||||
9_007_199_254_740_992,
|
||||
)
|
||||
|
||||
pub fn get_patchable_flags() -> List(Flag) {
|
||||
[
|
||||
flag_staff,
|
||||
flag_ctp_member,
|
||||
flag_partner,
|
||||
flag_bug_hunter,
|
||||
flag_high_global_rate_limit,
|
||||
flag_premium_purchase_disabled,
|
||||
flag_premium_enabled_override,
|
||||
flag_rate_limit_bypass,
|
||||
flag_report_banned,
|
||||
flag_verified_not_underage,
|
||||
flag_pending_manual_verification,
|
||||
flag_used_mobile_client,
|
||||
flag_app_store_reviewer,
|
||||
]
|
||||
}
|
||||
|
||||
pub const suspicious_flag_require_verified_email = Flag(
|
||||
"REQUIRE_VERIFIED_EMAIL",
|
||||
1,
|
||||
)
|
||||
|
||||
pub const suspicious_flag_require_reverified_email = Flag(
|
||||
"REQUIRE_REVERIFIED_EMAIL",
|
||||
2,
|
||||
)
|
||||
|
||||
pub const suspicious_flag_require_verified_phone = Flag(
|
||||
"REQUIRE_VERIFIED_PHONE",
|
||||
4,
|
||||
)
|
||||
|
||||
pub const suspicious_flag_require_reverified_phone = Flag(
|
||||
"REQUIRE_REVERIFIED_PHONE",
|
||||
8,
|
||||
)
|
||||
|
||||
pub const suspicious_flag_require_verified_email_or_verified_phone = Flag(
|
||||
"REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE",
|
||||
16,
|
||||
)
|
||||
|
||||
pub const suspicious_flag_require_reverified_email_or_verified_phone = Flag(
|
||||
"REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE",
|
||||
32,
|
||||
)
|
||||
|
||||
pub const suspicious_flag_require_verified_email_or_reverified_phone = Flag(
|
||||
"REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE",
|
||||
64,
|
||||
)
|
||||
|
||||
pub const suspicious_flag_require_reverified_email_or_reverified_phone = Flag(
|
||||
"REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE",
|
||||
128,
|
||||
)
|
||||
|
||||
pub fn get_suspicious_activity_flags() -> List(Flag) {
|
||||
[
|
||||
suspicious_flag_require_verified_email,
|
||||
suspicious_flag_require_reverified_email,
|
||||
suspicious_flag_require_verified_phone,
|
||||
suspicious_flag_require_reverified_phone,
|
||||
suspicious_flag_require_verified_email_or_verified_phone,
|
||||
suspicious_flag_require_reverified_email_or_verified_phone,
|
||||
suspicious_flag_require_verified_email_or_reverified_phone,
|
||||
suspicious_flag_require_reverified_email_or_reverified_phone,
|
||||
]
|
||||
}
|
||||
|
||||
pub const deletion_reason_user_requested = #(1, "User Requested")
|
||||
|
||||
pub const deletion_reason_other = #(2, "Other")
|
||||
|
||||
pub const deletion_reason_spam = #(3, "Spam")
|
||||
|
||||
pub const deletion_reason_hacks_cheats = #(4, "Hacks / Cheats")
|
||||
|
||||
pub const deletion_reason_raids = #(5, "Raids")
|
||||
|
||||
pub const deletion_reason_selfbot = #(6, "Selfbot")
|
||||
|
||||
pub const deletion_reason_nonconsensual_pornography = #(
|
||||
7,
|
||||
"Nonconsensual Pornography",
|
||||
)
|
||||
|
||||
pub const deletion_reason_scam = #(8, "Scam")
|
||||
|
||||
pub const deletion_reason_lolicon = #(9, "Lolicon")
|
||||
|
||||
pub const deletion_reason_doxxing = #(10, "Doxxing")
|
||||
|
||||
pub const deletion_reason_harassment = #(11, "Harassment")
|
||||
|
||||
pub const deletion_reason_fraudulent_charge = #(12, "Fraudulent Charge")
|
||||
|
||||
pub const deletion_reason_coppa = #(13, "COPPA")
|
||||
|
||||
pub const deletion_reason_friendly_fraud = #(14, "Friendly Fraud")
|
||||
|
||||
pub const deletion_reason_unsolicited_nsfw = #(15, "Unsolicited NSFW")
|
||||
|
||||
pub const deletion_reason_gore = #(16, "Gore")
|
||||
|
||||
pub const deletion_reason_ban_evasion = #(17, "Ban Evasion")
|
||||
|
||||
pub const deletion_reason_token_solicitation = #(18, "Token Solicitation")
|
||||
|
||||
pub fn get_deletion_reasons() -> List(#(Int, String)) {
|
||||
[
|
||||
deletion_reason_user_requested,
|
||||
deletion_reason_other,
|
||||
deletion_reason_spam,
|
||||
deletion_reason_hacks_cheats,
|
||||
deletion_reason_raids,
|
||||
deletion_reason_selfbot,
|
||||
deletion_reason_nonconsensual_pornography,
|
||||
deletion_reason_scam,
|
||||
deletion_reason_lolicon,
|
||||
deletion_reason_doxxing,
|
||||
deletion_reason_harassment,
|
||||
deletion_reason_fraudulent_charge,
|
||||
deletion_reason_coppa,
|
||||
deletion_reason_friendly_fraud,
|
||||
deletion_reason_unsolicited_nsfw,
|
||||
deletion_reason_gore,
|
||||
deletion_reason_ban_evasion,
|
||||
deletion_reason_token_solicitation,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_temp_ban_durations() -> List(#(Int, String)) {
|
||||
[
|
||||
#(1, "1 hour"),
|
||||
#(12, "12 hours"),
|
||||
#(24, "1 day"),
|
||||
#(72, "3 days"),
|
||||
#(120, "5 days"),
|
||||
#(168, "1 week"),
|
||||
#(336, "2 weeks"),
|
||||
#(720, "30 days"),
|
||||
]
|
||||
}
|
||||
|
||||
pub const acl_wildcard = "*"
|
||||
|
||||
pub const acl_authenticate = "admin:authenticate"
|
||||
|
||||
pub const acl_process_memory_stats = "process:memory_stats"
|
||||
|
||||
pub const acl_user_lookup = "user:lookup"
|
||||
|
||||
pub const acl_user_list_sessions = "user:list:sessions"
|
||||
|
||||
pub const acl_user_list_guilds = "user:list:guilds"
|
||||
|
||||
pub const acl_user_terminate_sessions = "user:terminate:sessions"
|
||||
|
||||
pub const acl_user_update_mfa = "user:update:mfa"
|
||||
|
||||
pub const acl_user_update_avatar = "user:update:avatar"
|
||||
|
||||
pub const acl_user_update_banner = "user:update:banner"
|
||||
|
||||
pub const acl_user_update_profile = "user:update:profile"
|
||||
|
||||
pub const acl_user_update_bot_status = "user:update:bot_status"
|
||||
|
||||
pub const acl_user_update_email = "user:update:email"
|
||||
|
||||
pub const acl_user_update_phone = "user:update:phone"
|
||||
|
||||
pub const acl_user_update_dob = "user:update:dob"
|
||||
|
||||
pub const acl_user_update_username = "user:update:username"
|
||||
|
||||
pub const acl_user_update_flags = "user:update:flags"
|
||||
|
||||
pub const acl_user_update_suspicious_activity = "user:update:suspicious_activity"
|
||||
|
||||
pub const acl_user_temp_ban = "user:temp_ban"
|
||||
|
||||
pub const acl_user_disable_suspicious = "user:disable:suspicious"
|
||||
|
||||
pub const acl_user_delete = "user:delete"
|
||||
|
||||
pub const acl_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion"
|
||||
|
||||
pub const acl_beta_codes_generate = "beta_codes:generate"
|
||||
|
||||
pub const acl_gift_codes_generate = "gift_codes:generate"
|
||||
|
||||
pub const acl_guild_lookup = "guild:lookup"
|
||||
|
||||
pub const acl_guild_list_members = "guild:list:members"
|
||||
|
||||
pub const acl_guild_reload = "guild:reload"
|
||||
|
||||
pub const acl_guild_shutdown = "guild:shutdown"
|
||||
|
||||
pub const acl_guild_delete = "guild:delete"
|
||||
|
||||
pub const acl_guild_update_name = "guild:update:name"
|
||||
|
||||
pub const acl_guild_update_icon = "guild:update:icon"
|
||||
|
||||
pub const acl_guild_update_banner = "guild:update:banner"
|
||||
|
||||
pub const acl_guild_update_splash = "guild:update:splash"
|
||||
|
||||
pub const acl_guild_update_vanity = "guild:update:vanity"
|
||||
|
||||
pub const acl_guild_update_features = "guild:update:features"
|
||||
|
||||
pub const acl_guild_update_settings = "guild:update:settings"
|
||||
|
||||
pub const acl_guild_transfer_ownership = "guild:transfer_ownership"
|
||||
|
||||
pub const acl_guild_force_add_member = "guild:force_add_member"
|
||||
|
||||
pub const acl_asset_purge = "asset:purge"
|
||||
|
||||
pub const acl_message_lookup = "message:lookup"
|
||||
|
||||
pub const acl_message_delete = "message:delete"
|
||||
|
||||
pub const acl_message_shred = "message:shred"
|
||||
|
||||
pub const acl_message_delete_all = "message:delete_all"
|
||||
|
||||
pub const acl_ban_ip_check = "ban:ip:check"
|
||||
|
||||
pub const acl_ban_ip_add = "ban:ip:add"
|
||||
|
||||
pub const acl_ban_ip_remove = "ban:ip:remove"
|
||||
|
||||
pub const acl_ban_email_check = "ban:email:check"
|
||||
|
||||
pub const acl_ban_email_add = "ban:email:add"
|
||||
|
||||
pub const acl_ban_email_remove = "ban:email:remove"
|
||||
|
||||
pub const acl_ban_phone_check = "ban:phone:check"
|
||||
|
||||
pub const acl_ban_phone_add = "ban:phone:add"
|
||||
|
||||
pub const acl_ban_phone_remove = "ban:phone:remove"
|
||||
|
||||
pub const acl_bulk_update_user_flags = "bulk:update:user_flags"
|
||||
|
||||
pub const acl_bulk_update_guild_features = "bulk:update:guild_features"
|
||||
|
||||
pub const acl_bulk_add_guild_members = "bulk:add:guild_members"
|
||||
|
||||
pub const acl_archive_view_all = "archive:view_all"
|
||||
|
||||
pub const acl_archive_trigger_user = "archive:trigger:user"
|
||||
|
||||
pub const acl_archive_trigger_guild = "archive:trigger:guild"
|
||||
|
||||
pub const acl_bulk_delete_users = "bulk:delete:users"
|
||||
|
||||
pub const acl_audit_log_view = "audit_log:view"
|
||||
|
||||
pub const acl_report_view = "report:view"
|
||||
|
||||
pub const acl_report_resolve = "report:resolve"
|
||||
|
||||
pub const acl_voice_region_list = "voice:region:list"
|
||||
|
||||
pub const acl_voice_region_create = "voice:region:create"
|
||||
|
||||
pub const acl_voice_region_update = "voice:region:update"
|
||||
|
||||
pub const acl_voice_region_delete = "voice:region:delete"
|
||||
|
||||
pub const acl_voice_server_list = "voice:server:list"
|
||||
|
||||
pub const acl_voice_server_create = "voice:server:create"
|
||||
|
||||
pub const acl_voice_server_update = "voice:server:update"
|
||||
|
||||
pub const acl_voice_server_delete = "voice:server:delete"
|
||||
|
||||
pub const acl_acl_set_user = "acl:set:user"
|
||||
|
||||
pub const acl_metrics_view = "metrics:view"
|
||||
|
||||
pub const acl_feature_flag_view = "feature_flag:view"
|
||||
|
||||
pub const acl_feature_flag_manage = "feature_flag:manage"
|
||||
|
||||
pub type FeatureFlag {
|
||||
FeatureFlag(id: String, name: String, description: String)
|
||||
}
|
||||
|
||||
pub const feature_flag_message_scheduling = FeatureFlag(
|
||||
"message_scheduling",
|
||||
"Message Scheduling",
|
||||
"Allows users to schedule messages to be sent at a later time",
|
||||
)
|
||||
|
||||
pub const feature_flag_expression_packs = FeatureFlag(
|
||||
"expression_packs",
|
||||
"Expression Packs",
|
||||
"Allows users to create and use custom expression packs",
|
||||
)
|
||||
|
||||
pub fn get_feature_flags() -> List(FeatureFlag) {
|
||||
[feature_flag_message_scheduling, feature_flag_expression_packs]
|
||||
}
|
||||
|
||||
pub type GuildFeature {
|
||||
GuildFeature(value: String)
|
||||
}
|
||||
|
||||
pub const feature_invite_splash = GuildFeature("INVITE_SPLASH")
|
||||
|
||||
pub const feature_vip_voice = GuildFeature("VIP_VOICE")
|
||||
|
||||
pub const feature_vanity_url = GuildFeature("VANITY_URL")
|
||||
|
||||
pub const feature_more_emoji = GuildFeature("MORE_EMOJI")
|
||||
|
||||
pub const feature_more_stickers = GuildFeature("MORE_STICKERS")
|
||||
|
||||
pub const feature_unlimited_emoji = GuildFeature("UNLIMITED_EMOJI")
|
||||
|
||||
pub const feature_unlimited_stickers = GuildFeature("UNLIMITED_STICKERS")
|
||||
|
||||
pub const feature_verified = GuildFeature("VERIFIED")
|
||||
|
||||
pub const feature_banner = GuildFeature("BANNER")
|
||||
|
||||
pub const feature_animated_banner = GuildFeature("ANIMATED_BANNER")
|
||||
|
||||
pub const feature_animated_icon = GuildFeature("ANIMATED_ICON")
|
||||
|
||||
pub const feature_invites_disabled = GuildFeature("INVITES_DISABLED")
|
||||
|
||||
pub const feature_text_channel_flexible_names = GuildFeature(
|
||||
"TEXT_CHANNEL_FLEXIBLE_NAMES",
|
||||
)
|
||||
|
||||
pub const feature_unavailable_for_everyone = GuildFeature(
|
||||
"UNAVAILABLE_FOR_EVERYONE",
|
||||
)
|
||||
|
||||
pub const feature_unavailable_for_everyone_but_staff = GuildFeature(
|
||||
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
|
||||
)
|
||||
|
||||
pub const feature_detached_banner = GuildFeature("DETACHED_BANNER")
|
||||
|
||||
pub const feature_expression_purge_allowed = GuildFeature(
|
||||
"EXPRESSION_PURGE_ALLOWED",
|
||||
)
|
||||
|
||||
pub const feature_disallow_unclaimed_accounts = GuildFeature(
|
||||
"DISALLOW_UNCLAIMED_ACCOUNTS",
|
||||
)
|
||||
|
||||
pub const feature_large_guild_override = GuildFeature("LARGE_GUILD_OVERRIDE")
|
||||
|
||||
pub fn get_guild_features() -> List(GuildFeature) {
|
||||
[
|
||||
feature_animated_icon,
|
||||
feature_animated_banner,
|
||||
feature_banner,
|
||||
feature_invite_splash,
|
||||
feature_invites_disabled,
|
||||
feature_more_emoji,
|
||||
feature_more_stickers,
|
||||
feature_unlimited_emoji,
|
||||
feature_unlimited_stickers,
|
||||
feature_text_channel_flexible_names,
|
||||
feature_unavailable_for_everyone,
|
||||
feature_unavailable_for_everyone_but_staff,
|
||||
feature_vanity_url,
|
||||
feature_verified,
|
||||
feature_vip_voice,
|
||||
feature_detached_banner,
|
||||
feature_expression_purge_allowed,
|
||||
feature_disallow_unclaimed_accounts,
|
||||
feature_large_guild_override,
|
||||
]
|
||||
}
|
||||
|
||||
pub const disabled_op_push_notifications = Flag("PUSH_NOTIFICATIONS", 1)
|
||||
|
||||
pub const disabled_op_everyone_mentions = Flag("EVERYONE_MENTIONS", 2)
|
||||
|
||||
pub const disabled_op_typing_events = Flag("TYPING_EVENTS", 4)
|
||||
|
||||
pub const disabled_op_instant_invites = Flag("INSTANT_INVITES", 8)
|
||||
|
||||
pub const disabled_op_send_message = Flag("SEND_MESSAGE", 16)
|
||||
|
||||
pub const disabled_op_reactions = Flag("REACTIONS", 32)
|
||||
|
||||
pub fn get_disabled_operations() -> List(Flag) {
|
||||
[
|
||||
disabled_op_push_notifications,
|
||||
disabled_op_everyone_mentions,
|
||||
disabled_op_typing_events,
|
||||
disabled_op_instant_invites,
|
||||
disabled_op_send_message,
|
||||
disabled_op_reactions,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn get_all_acls() -> List(String) {
|
||||
[
|
||||
acl_wildcard,
|
||||
acl_authenticate,
|
||||
acl_process_memory_stats,
|
||||
acl_user_lookup,
|
||||
acl_user_list_sessions,
|
||||
acl_user_list_guilds,
|
||||
acl_user_terminate_sessions,
|
||||
acl_user_update_mfa,
|
||||
acl_user_update_avatar,
|
||||
acl_user_update_banner,
|
||||
acl_user_update_profile,
|
||||
acl_user_update_bot_status,
|
||||
acl_user_update_email,
|
||||
acl_user_update_phone,
|
||||
acl_user_update_dob,
|
||||
acl_user_update_username,
|
||||
acl_user_update_flags,
|
||||
acl_user_update_suspicious_activity,
|
||||
acl_user_temp_ban,
|
||||
acl_user_disable_suspicious,
|
||||
acl_user_delete,
|
||||
acl_user_cancel_bulk_message_deletion,
|
||||
acl_beta_codes_generate,
|
||||
acl_gift_codes_generate,
|
||||
acl_guild_lookup,
|
||||
acl_guild_list_members,
|
||||
acl_guild_reload,
|
||||
acl_guild_shutdown,
|
||||
acl_guild_delete,
|
||||
acl_guild_update_name,
|
||||
acl_guild_update_icon,
|
||||
acl_guild_update_banner,
|
||||
acl_guild_update_splash,
|
||||
acl_guild_update_vanity,
|
||||
acl_guild_update_features,
|
||||
acl_guild_update_settings,
|
||||
acl_guild_transfer_ownership,
|
||||
acl_guild_force_add_member,
|
||||
acl_asset_purge,
|
||||
acl_message_lookup,
|
||||
acl_message_delete,
|
||||
acl_message_shred,
|
||||
acl_message_delete_all,
|
||||
acl_ban_ip_check,
|
||||
acl_ban_ip_add,
|
||||
acl_ban_ip_remove,
|
||||
acl_ban_email_check,
|
||||
acl_ban_email_add,
|
||||
acl_ban_email_remove,
|
||||
acl_ban_phone_check,
|
||||
acl_ban_phone_add,
|
||||
acl_ban_phone_remove,
|
||||
acl_bulk_update_user_flags,
|
||||
acl_bulk_update_guild_features,
|
||||
acl_bulk_add_guild_members,
|
||||
acl_archive_view_all,
|
||||
acl_archive_trigger_user,
|
||||
acl_archive_trigger_guild,
|
||||
acl_bulk_delete_users,
|
||||
acl_audit_log_view,
|
||||
acl_report_view,
|
||||
acl_report_resolve,
|
||||
acl_voice_region_list,
|
||||
acl_voice_region_create,
|
||||
acl_voice_region_update,
|
||||
acl_voice_region_delete,
|
||||
acl_voice_server_list,
|
||||
acl_voice_server_create,
|
||||
acl_voice_server_update,
|
||||
acl_voice_server_delete,
|
||||
acl_acl_set_user,
|
||||
acl_metrics_view,
|
||||
acl_feature_flag_view,
|
||||
acl_feature_flag_manage,
|
||||
]
|
||||
}
|
||||
31
fluxer_admin/src/fluxer_admin/log.gleam
Normal file
31
fluxer_admin/src/fluxer_admin/log.gleam
Normal file
@@ -0,0 +1,31 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
@external(erlang, "io", "format")
|
||||
fn erlang_io_format(fmt: String, args: List(String)) -> Nil
|
||||
|
||||
pub fn debug(msg: String) {
|
||||
erlang_io_format("[debug] " <> msg <> "\n", [])
|
||||
}
|
||||
|
||||
pub fn info(msg: String) {
|
||||
erlang_io_format("[info] " <> msg <> "\n", [])
|
||||
}
|
||||
|
||||
pub fn error(msg: String) {
|
||||
erlang_io_format("[error] " <> msg <> "\n", [])
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/http/response.{type Response}
|
||||
import gleam/list
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
import wisp
|
||||
|
||||
pub fn add_cache_headers(res: Response(wisp.Body)) -> Response(wisp.Body) {
|
||||
case list.key_find(res.headers, "cache-control") {
|
||||
Ok(_) -> res
|
||||
Error(_) -> {
|
||||
let content_type =
|
||||
list.key_find(res.headers, "content-type")
|
||||
|> result.unwrap("")
|
||||
|
||||
let cache_header = case should_cache(content_type) {
|
||||
True -> "public, max-age=31536000, immutable"
|
||||
False -> "no-cache"
|
||||
}
|
||||
|
||||
response.set_header(res, "cache-control", cache_header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_cache(content_type: String) -> Bool {
|
||||
let cacheable_types = [
|
||||
"text/css", "application/javascript", "font/", "image/", "video/", "audio/",
|
||||
"application/font-woff2",
|
||||
]
|
||||
|
||||
list.any(cacheable_types, fn(type_prefix) {
|
||||
string.starts_with(content_type, type_prefix)
|
||||
})
|
||||
}
|
||||
20
fluxer_admin/src/fluxer_admin/mode.gleam
Normal file
20
fluxer_admin/src/fluxer_admin/mode.gleam
Normal file
@@ -0,0 +1,20 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
pub fn get_app_title() -> String {
|
||||
"Fluxer Admin"
|
||||
}
|
||||
42
fluxer_admin/src/fluxer_admin/oauth2.gleam
Normal file
42
fluxer_admin/src/fluxer_admin/oauth2.gleam
Normal file
@@ -0,0 +1,42 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context}
|
||||
import gleam/bit_array
|
||||
import gleam/crypto
|
||||
|
||||
pub fn authorize_url(ctx: Context, state: String) -> String {
|
||||
ctx.web_app_endpoint
|
||||
<> "/oauth2/authorize?response_type=code&client_id="
|
||||
<> ctx.oauth_client_id
|
||||
<> "&redirect_uri="
|
||||
<> ctx.oauth_redirect_uri
|
||||
<> "&scope=identify%20email"
|
||||
<> "&state="
|
||||
<> state
|
||||
}
|
||||
|
||||
pub fn base64_encode_string(value: String) -> String {
|
||||
value
|
||||
|> bit_array.from_string
|
||||
|> bit_array.base64_encode(True)
|
||||
}
|
||||
|
||||
pub fn generate_state() -> String {
|
||||
crypto.strong_random_bytes(32)
|
||||
|> bit_array.base64_url_encode(False)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user