feat: initial fluxer codebase import

This commit is contained in:
root
2026-03-13 09:47:47 +01:00
parent 5ceda343b8
commit 570a3f3051
8134 changed files with 1409671 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
{
"name": "Homelab Development Environment",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"version": "latest",
"enableNonRootDocker": "true"
},
"ghcr.io/devcontainers/features/python:1": {
"version": "3.11"
},
"ghcr.io/devcontainers/features/git:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": true,
"configureZshAsDefaultShell": true,
"installOhMyZsh": true
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.pylint",
"redhat.vscode-yaml",
"ms-vscode.vscode-docker",
"ms-vscode-remote.remote-containers",
"redhat.ansible",
"timonwong.shellcheck",
"foxundermoon.shell-format"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"yaml.schemas": {
"https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json": [
"docker-compose*.yml",
"docker-compose*.yaml",
"compose*.yml",
"compose*.yaml"
]
},
"yaml.validate": true,
"yaml.format.enable": true,
"files.associations": {
"*.yml": "yaml",
"*.yaml": "yaml"
}
}
}
},
"postCreateCommand": "pip install -r requirements.txt && pre-commit install",
"remoteUser": "vscode",
"mounts": [
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
],
"forwardPorts": [
3000,
8080,
9090
],
"portsAttributes": {
"3000": {
"label": "Development Server"
},
"8080": {
"label": "Test Service"
},
"9090": {
"label": "Monitoring"
}
}
}

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
Dockerfile
target
.mongo
.env

84
.env.example Normal file
View File

@@ -0,0 +1,84 @@
# Homelab Environment Variables Template
# Copy this file to .env and fill in your actual values
# DO NOT commit .env file - it contains secrets!
# ===========================================
# Git Repository Configuration
# ===========================================
GITEA_URL=https://git.vish.gg
GITEA_TOKEN=REDACTED_TOKEN
GITEA_USERNAME=Vish
# ===========================================
# Portainer API Configuration
# ===========================================
PORTAINER_URL=http://vishinator.synology.me:10000
PORTAINER_TOKEN=REDACTED_TOKEN
# Portainer Endpoint IDs (from AGENTS.md)
PORTAINER_ENDPOINT_ATLANTIS=2
PORTAINER_ENDPOINT_CALYPSO=443397
PORTAINER_ENDPOINT_CONCORD_NUC=443395
PORTAINER_ENDPOINT_HOMELAB_VM=443399
PORTAINER_ENDPOINT_RPI5=443398
PORTAINER_ENDPOINT_GUAVA=3
# ===========================================
# Network Configuration
# ===========================================
TAILSCALE_KEY=your_tailscale_auth_key_here
CLOUDFLARE_API_TOKEN=REDACTED_TOKEN
# ===========================================
# Monitoring & Alerting
# ===========================================
NTFY_URL=https://ntfy.vish.gg
NTFY_TOPIC=REDACTED_NTFY_TOPIC
SIGNAL_API_URL=http://192.168.0.210:8080
# ===========================================
# Development & Testing
# ===========================================
# Set to 'true' to enable debug logging
DEBUG=false
# Docker registry for custom images (if any)
DOCKER_REGISTRY=your_registry_here
# ===========================================
# Host-Specific Configuration
# ===========================================
# Primary NAS
ATLANTIS_IP=192.168.0.200
ATLANTIS_TAILSCALE=100.83.230.112
# Secondary NAS
CALYPSO_IP=192.168.0.80
CALYPSO_TAILSCALE=100.103.48.78
# Homelab VM
HOMELAB_VM_IP=192.168.0.210
HOMELAB_VM_TAILSCALE=100.67.40.126
# TrueNAS Scale
GUAVA_IP=192.168.0.100
GUAVA_TAILSCALE=100.75.252.64
# ===========================================
# Service-Specific Secrets (Examples)
# ===========================================
# These would typically be set per-service in their compose files
# Listed here for reference only
# Database passwords
# POSTGRES_PASSWORD=REDACTED_PASSWORD
# MYSQL_ROOT_PASSWORD=REDACTED_PASSWORD
# API keys for services
# PLEX_TOKEN=your_plex_token
# GRAFANA_ADMIN_PASSWORD=REDACTED_PASSWORD
# OAuth/OIDC configuration
# AUTHENTIK_SECRET_KEY=REDACTED_SECRET_KEY
# OAUTH_CLIENT_ID=REDACTED_OAUTH_CLIENT_ID
# OAUTH_CLIENT_SECRET=your_oauth_client_secret

34
.gitattributes vendored Normal file
View File

@@ -0,0 +1,34 @@
# Auto-detect text files and normalize line endings to LF
* text=auto eol=lf
# Explicitly declare text files
*.yml text eol=lf
*.yaml text eol=lf
*.json text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.sh text eol=lf
*.py text eol=lf
*.conf text eol=lf
*.cfg text eol=lf
*.ini text eol=lf
*.toml text eol=lf
*.env text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
*.xml text eol=lf
*.sql text eol=lf
Dockerfile text eol=lf
.gitignore text eol=lf
.gitattributes text eol=lf
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pem binary
*.ppk binary
*.asc binary

94
.gitignore vendored Normal file
View File

@@ -0,0 +1,94 @@
# 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
/dev/secret.txt
# 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

19
.mise/config.toml Normal file
View File

@@ -0,0 +1,19 @@
[tools]
node = "25.4.0"
pnpm = "10.28.1"
gh = "2.25.0"
rust = "1.92.0"
"cargo:cargo-nextest" = "0.9.122"
"github:git-town/git-town" = "22.4.0"
[settings]
experimental = true
idiomatic_version_file_enable_tools = ["rust"]
[tasks.start]
description = "Run all services"
depends = ["docker:start", "build"]
run = [{ task = "service:*" }]

5
.mise/tasks/build Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Build project"
set -e
cargo build "$@"

5
.mise/tasks/check Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Check project with clippy"
set -e
cargo clippy

5
.mise/tasks/docker/start Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Start Docker containers"
set -e
docker compose up -d

5
.mise/tasks/docker/stop Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Stop Docker containers"
set -e
docker compose down

7
.mise/tasks/docs/_default Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
#MISE description="Start the Stoat Developers website"
#MISE depends=["docs:install"]
#MISE dir="{{config_root}}/docs"
set -e
pnpm build

7
.mise/tasks/docs/build Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
#MISE description="Build the Stoat Developers website"
#MISE depends=["docs:install"]
#MISE dir="{{config_root}}/docs"
set -e
pnpm build

6
.mise/tasks/docs/install Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#MISE description="Install dependencies for docs site"
#MISE dir="{{config_root}}/docs"
set -e
pnpm i --frozen-lockfile

5
.mise/tasks/publish Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Publish project"
set -e
cargo publish "$@"

5
.mise/tasks/service/api Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run API server"
set -e
cargo run --bin revolt-delta

5
.mise/tasks/service/crond Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run cron daemon"
set -e
cargo run --bin revolt-crond

5
.mise/tasks/service/events Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run events server"
set -e
cargo run --bin revolt-bonfire

5
.mise/tasks/service/files Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run file server"
set -e
cargo run --bin revolt-autumn

5
.mise/tasks/service/gifbox Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run GIF proxy server"
set -e
cargo run --bin revolt-gifbox

5
.mise/tasks/service/proxy Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run proxy server"
set -e
cargo run --bin revolt-january

5
.mise/tasks/service/pushd Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
#MISE description="Run push daemon"
set -e
cargo run --bin revolt-pushd

8
.mise/tasks/test Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
#MISE description="Test project"
set -e
: "${TEST_DB:=REFERENCE}"
export TEST_DB
cargo nextest run

69
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,69 @@
---
# Pre-commit hooks for Homelab repository
# Ensures code quality and prevents broken deployments
repos:
# Basic file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
hooks:
- id: trailing-whitespace
exclude: '\.md$'
- id: end-of-file-fixer
exclude: '\.md$'
- id: check-yaml
args: ['--allow-multiple-documents']
# log_rotation.yml contains a shell heredoc at column 0 inside a YAML
# block scalar - PyYAML incorrectly parses the embedded logrotate config
# content as YAML rather than treating it as opaque string data.
exclude: '^(archive/|\.git/|ansible/automation/playbooks/log_rotation\.yml)'
- id: check-added-large-files
args: ['--maxkb=10240'] # 10MB limit
- id: check-merge-conflict
- id: check-case-conflict
# YAML linting
- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
hooks:
- id: yamllint
args: [-c=.yamllint]
# Docker Compose validation
- repo: local
hooks:
- id: docker-compose-check
name: Docker Compose Syntax Check
entry: scripts/validate-compose.sh
language: script
files: '\.ya?ml$'
exclude: '^(archive/|ansible/|\.git/|docker/monitoring/prometheus/|prometheus/)'
pass_filenames: true
# Secret detection - blocks commits containing passwords, tokens, API keys
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
exclude: '^(archive/|\.git/|\.secrets\.baseline$)'
# Ansible playbook validation
# Disabled: playbooks use {{.Names}} Docker Go template syntax in shell tasks
# which ansible-lint's Jinja2 parser chokes on (false positives, not real errors).
# To lint manually: ansible-lint --skip-list=yaml[line-length] ansible/
# - repo: https://github.com/ansible/ansible-lint
# rev: v25.1.3
# hooks:
# - id: ansible-lint
# files: '^ansible/.*\.(yml|yaml)$'
# exclude: '^(archive/|\.git/)'
# args:
# - --exclude=ansible/archive/
# - --skip-list=yaml[line-length]
# additional_dependencies: ["ansible-core>=2.16,<2.17"]
# Global settings
default_stages: [pre-commit]
fail_fast: false
minimum_pre_commit_version: '3.0.0'

1687
.secrets.baseline Normal file

File diff suppressed because it is too large Load Diff

58
.yamllint Normal file
View File

@@ -0,0 +1,58 @@
---
# YAML Linting Configuration for Homelab
# Validates Docker Compose files and other YAML configurations
extends: default
rules:
# Allow longer lines for Docker image names and URLs
line-length:
max: 120
level: warning
# Allow multiple spaces for alignment in Docker Compose
indentation:
spaces: 2
indent-sequences: true
check-multi-line-strings: false
# Be flexible with comments (useful for service documentation)
comments:
min-spaces-from-content: 1
# Allow empty values (common in Docker Compose environment variables)
empty-values:
forbid-in-block-mappings: false
forbid-in-flow-mappings: false
# Allow truthy values (yes/no, on/off common in Docker Compose)
truthy:
allowed-values: ['true', 'false', 'yes', 'no', 'on', 'off']
check-keys: false
# Allow duplicate keys in different contexts
key-duplicates: disable
# Allow document start marker to be optional
document-start: disable
ignore: |
# Ignore generated or external files
archive/
.git/
**/*.md
**/*.txt
**/*.py
**/*.sh
**/*.conf
**/*.ini
# Ansible uses different YAML conventions (0-indent block sequences,
# 2-indent task lists) that conflict with Docker Compose style rules.
# Jinja2 {{ }} template expressions also trigger false positives.
ansible/
docs/advanced/ansible/
# SNMP exporter generator configs use auto-generated 1/3-space indentation
# that differs from standard YAML style but is valid and not hand-edited.
**/prometheus/snmp.yml
**/grafana_prometheus/snmp.yml
**/grafana_prometheus/snmp_mariushosting.yml

91
dev/Caddyfile.dev Normal file
View File

@@ -0,0 +1,91 @@
:8088 {
encode zstd gzip
# Security headers
header {
# HSTS
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
# Prevent clickjacking
X-Frame-Options "SAMEORIGIN"
# XSS protection
X-Content-Type-Options "nosniff"
# Referrer policy
Referrer-Policy "strict-origin-when-cross-origin"
# Remove server info
-Server
}
@api path /api/*
handle @api {
handle_path /api/* {
reverse_proxy api:8080 {
header_up X-Forwarded-For {remote}
}
}
}
@media path /media/*
handle @media {
handle_path /media/* {
reverse_proxy media:8080 {
header_up X-Forwarded-For {remote}
}
}
}
@s3 path /s3/*
handle @s3 {
handle_path /s3/* {
reverse_proxy minio:9000 {
header_up X-Forwarded-For {remote}
}
}
}
@admin path /admin /admin/*
handle @admin {
uri strip_prefix /admin
reverse_proxy admin:8080 {
header_up X-Forwarded-For {remote}
}
}
@marketing path /marketing /marketing/*
handle @marketing {
uri strip_prefix /marketing
reverse_proxy marketing:8080 {
header_up X-Forwarded-For {remote}
}
}
@gateway path /gateway /gateway/*
handle @gateway {
uri strip_prefix /gateway
reverse_proxy gateway:8080 {
header_up X-Forwarded-For {remote}
}
}
@livekit path /livekit /livekit/*
handle @livekit {
handle_path /livekit/* {
reverse_proxy livekit:7880 {
header_up X-Forwarded-For {remote}
}
}
}
@metrics path /metrics /metrics/*
handle @metrics {
uri strip_prefix /metrics
reverse_proxy metrics:8080 {
header_up X-Forwarded-For {remote}
}
}
handle {
root * /app/dist
try_files {path} /index.html
file_server
}
}

160
dev/compose.data.yaml Normal file
View 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:

386
dev/compose.yaml Normal file
View File

@@ -0,0 +1,386 @@
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
- FLUXER_API_PUBLIC_ENDPOINT=http://api: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
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:

48
fluxer_admin/Dockerfile Normal file
View 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"]

View 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
View 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
View 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

View 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" }

View File

View 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
}

View 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)
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -0,0 +1,240 @@
//// 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 == 204 -> 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,
))
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -0,0 +1,259 @@
//// 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 type SnowflakeReservation {
SnowflakeReservation(
email: String,
snowflake: String,
updated_at: option.Option(String),
)
}
fn snowflake_reservation_decoder() {
use email <- decode.field("email", decode.string)
use snowflake <- decode.field("snowflake", decode.string)
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
decode.success(SnowflakeReservation(
email:,
snowflake:,
updated_at: updated_at,
))
}
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)
}
}
pub fn list_snowflake_reservations(
ctx: Context,
session: Session,
) -> Result(List(SnowflakeReservation), ApiError) {
let url = ctx.api_endpoint <> "/admin/snowflake-reservations/list"
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 reservations <- decode.field(
"reservations",
decode.list(snowflake_reservation_decoder()),
)
decode.success(reservations)
}
case json.parse(resp.body, decoder) {
Ok(reservations) -> Ok(reservations)
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 add_snowflake_reservation(
ctx: Context,
session: Session,
email: String,
snowflake: String,
) -> Result(Nil, ApiError) {
let fields = [
#("email", json.string(email)),
#("snowflake", json.string(snowflake)),
]
common.admin_post_simple(
ctx,
session,
"/admin/snowflake-reservations/add",
fields,
)
}
pub fn delete_snowflake_reservation(
ctx: Context,
session: Session,
email: String,
) -> Result(Nil, ApiError) {
let fields = [#("email", json.string(email))]
common.admin_post_simple(
ctx,
session,
"/admin/snowflake-reservations/delete",
fields,
)
}

View 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)
}
}

View 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)
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,728 @@
//// 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/int
import gleam/io
import gleam/json
import gleam/option
import gleam/string
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),
)
let reported_guild_icon_hash = option.None
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.optional_field("channel_id", "", decode.string)
use author_id <- decode.optional_field("author_id", "", decode.string)
use author_username <- decode.optional_field(
"author_username",
"",
decode.string,
)
use content <- decode.optional_field("content", "", decode.string)
use timestamp <- decode.optional_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 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.optional_field(
"reported_guild_icon_hash",
option.None,
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(err) -> {
io.println(
"reports.get_report_detail decode failed: "
<> string.inspect(err)
<> " body="
<> string.slice(resp.body, 0, 4000),
)
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) -> {
io.println(
"reports.get_report_detail unexpected status "
<> int.to_string(resp.status)
<> " body="
<> string.slice(resp.body, 0, 1000),
)
Error(ServerError)
}
Error(err) -> {
io.println(
"reports.get_report_detail network error: " <> string.inspect(err),
)
Error(NetworkError)
}
}
}

View 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)
}
}

View 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)
}
}

View File

@@ -0,0 +1,740 @@
//// 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: Option(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) {
let url = ctx.api_endpoint <> "/admin/users/me"
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 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 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.optional(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)
}
}

View 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)),
])
}

View 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,
)
}

View 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)
}
}
}
}
}
}

View 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
}
}

View 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
}
}

View File

@@ -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)
}

View 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()
},
]),
]),
]),
])
}

View 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()
}
}

View 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)]),
])
}

View 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"),
],
[],
),
],
)
}

View 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"),
]),
]
}

View File

@@ -0,0 +1,442 @@
//// 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/navigation
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,
) {
let admin_acls = admin_acls_from(current_admin)
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 overflow-hidden")], [
h.div([a.class("flex h-screen")], [
sidebar(ctx, active_page, admin_acls),
h.div(
[
a.attribute("data-sidebar-overlay", ""),
a.class("fixed inset-0 bg-black/50 z-30 hidden lg:hidden"),
],
[],
),
h.div(
[
a.class("flex-1 flex flex-col w-full h-screen overflow-y-auto"),
],
[
header(ctx, session, current_admin),
h.main([a.class("flex-1 p-4 sm:p-6 lg:p-8")], [
h.div([a.class("w-full 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,
]),
]),
],
),
]),
sidebar_interaction_script(),
]),
],
)
}
fn sidebar(ctx: Context, active_page: String, admin_acls: List(String)) {
h.div(
[
a.attribute("data-sidebar", ""),
a.class(
"fixed inset-y-0 left-0 z-40 w-64 h-screen bg-neutral-900 text-white flex flex-col transform -translate-x-full transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-auto shadow-xl lg:shadow-none",
),
],
[
h.div(
[
a.class(
"p-6 border-b border-neutral-800 flex items-center justify-between gap-3",
),
],
[
h.a([href(ctx, "/users")], [
h.h1([a.class("text-base font-semibold")], [
element.text("Fluxer Admin"),
]),
]),
h.button(
[
a.type_("button"),
a.attribute("data-sidebar-close", ""),
a.class(
"lg:hidden inline-flex items-center justify-center p-2 rounded-md text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40",
),
a.attribute("aria-label", "Close sidebar"),
],
[element.text("Close")],
),
],
),
h.nav(
[
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
],
admin_sidebar(ctx, active_page, admin_acls),
),
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,
admin_acls: List(String),
) -> List(element.Element(a)) {
navigation.accessible_sections(admin_acls)
|> list.map(fn(section) {
let items =
list.map(section.items, fn(item) {
sidebar_item(ctx, item.title, item.path, active_page == item.active_key)
})
sidebar_section(section.title, items)
})
}
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-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between gap-4 sticky top-0 z-10",
),
],
[
h.div([a.class("flex items-center gap-3 min-w-0")], [
h.button(
[
a.type_("button"),
a.attribute("data-sidebar-toggle", ""),
a.class(
"lg:hidden inline-flex items-center justify-center p-2 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-400",
),
a.attribute("aria-label", "Toggle sidebar"),
],
[
element.element(
"svg",
[
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 24 24"),
a.class("w-5 h-5"),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-width", "2"),
],
[
element.element(
"line",
[
a.attribute("x1", "3"),
a.attribute("y1", "6"),
a.attribute("x2", "21"),
a.attribute("y2", "6"),
],
[],
),
element.element(
"line",
[
a.attribute("x1", "3"),
a.attribute("y1", "12"),
a.attribute("x2", "21"),
a.attribute("y2", "12"),
],
[],
),
element.element(
"line",
[
a.attribute("x1", "3"),
a.attribute("y1", "18"),
a.attribute("x2", "21"),
a.attribute("y2", "18"),
],
[],
),
],
),
],
),
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"),
])
}
fn sidebar_interaction_script() {
h.script(
[a.attribute("defer", "defer")],
"
(function() {
const sidebar = document.querySelector('[data-sidebar]');
const overlay = document.querySelector('[data-sidebar-overlay]');
const toggles = document.querySelectorAll('[data-sidebar-toggle]');
const closes = document.querySelectorAll('[data-sidebar-close]');
if (!sidebar || !overlay) return;
const open = () => {
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('hidden');
document.body.classList.add('overflow-hidden');
};
const close = () => {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
};
toggles.forEach((btn) => btn.addEventListener('click', () => {
if (sidebar.classList.contains('-translate-x-full')) {
open();
} else {
close();
}
}));
closes.forEach((btn) => btn.addEventListener('click', close));
overlay.addEventListener('click', close);
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') close();
});
const syncForDesktop = () => {
if (window.innerWidth >= 1024) {
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
sidebar.classList.remove('-translate-x-full');
} else {
sidebar.classList.add('-translate-x-full');
}
};
window.addEventListener('resize', syncForDesktop);
syncForDesktop();
})();
",
)
}
fn admin_acls_from(current_admin: Option(UserLookupResult)) -> List(String) {
case current_admin {
option.Some(admin) -> admin.acls
option.None -> []
}
}

View 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>"
}

View 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 →")],
)
},
])
}

View 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();
}
})();
"
}

View File

@@ -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,
]),
],
)
}

View 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 });
})();
"
}

View 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()
},
]),
])
}

View File

@@ -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)
}

View 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 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 overflow-x-auto no-scrollbar -mb-px px-1")],
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 whitespace-nowrap"
False ->
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors whitespace-nowrap"
}
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])
}

View File

@@ -0,0 +1,674 @@
//// 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 overflow-x-auto"
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 flex-wrap items-center justify-between gap-3")],
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.div([a.class("overflow-x-auto")], [
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 w-full")], [
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 flex-shrink-0",
),
],
[
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.div([a.class("flex-1 min-w-0")], [
h.span(
[
a.class(
"text-sm text-neutral-900 group-hover:text-neutral-700 leading-snug truncate",
),
],
[element.text(label)],
),
]),
])
}

View 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
}
}
}

View 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)]) }),
),
]),
])
}
}

View 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(""),
))
}

View File

@@ -0,0 +1,592 @@
//// 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 const flag_dm_history_backfilled = Flag(
"DM_HISTORY_BACKFILLED",
18_014_398_509_481_984,
)
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,
flag_dm_history_backfilled,
]
}
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_gateway_memory_stats = "gateway:memory_stats"
pub const acl_process_memory_stats = acl_gateway_memory_stats
pub const acl_gateway_reload_all = "gateway:reload_all"
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_pending_verification_view = "pending_verification:view"
pub const acl_pending_verification_review = "pending_verification:review"
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 const acl_instance_config_view = "instance:config:view"
pub const acl_instance_config_update = "instance:config:update"
pub const acl_instance_snowflake_reservation_view = "instance:snowflake_reservation:view"
pub const acl_instance_snowflake_reservation_manage = "instance:snowflake_reservation: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 const feature_very_large_guild = GuildFeature("VERY_LARGE_GUILD")
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,
feature_very_large_guild,
]
}
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_gateway_memory_stats,
acl_gateway_reload_all,
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_pending_verification_view,
acl_pending_verification_review,
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,
acl_instance_config_view,
acl_instance_config_update,
]
}

View 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", [])
}

View File

@@ -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)
})
}

View 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"
}

View File

@@ -0,0 +1,171 @@
//// 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/acl
import fluxer_admin/constants
import gleam/list
import gleam/option
pub type NavItem {
NavItem(
title: String,
path: String,
active_key: String,
required_acls: List(String),
)
}
pub type NavSection {
NavSection(title: String, items: List(NavItem))
}
pub fn sections() -> List(NavSection) {
[
NavSection("Lookup", [
NavItem("Users", "/users", "users", [constants.acl_user_lookup]),
NavItem("Guilds", "/guilds", "guilds", [constants.acl_guild_lookup]),
]),
NavSection("Moderation", [
NavItem("Reports", "/reports", "reports", [constants.acl_report_view]),
NavItem(
"Pending Verifications",
"/pending-verifications",
"pending-verifications",
[constants.acl_pending_verification_view],
),
NavItem("Bulk Actions", "/bulk-actions", "bulk-actions", [
constants.acl_bulk_update_user_flags,
constants.acl_bulk_update_guild_features,
constants.acl_bulk_add_guild_members,
constants.acl_bulk_delete_users,
]),
]),
NavSection("Bans", [
NavItem("IP Bans", "/ip-bans", "ip-bans", [
constants.acl_ban_ip_check,
constants.acl_ban_ip_add,
constants.acl_ban_ip_remove,
]),
NavItem("Email Bans", "/email-bans", "email-bans", [
constants.acl_ban_email_check,
constants.acl_ban_email_add,
constants.acl_ban_email_remove,
]),
NavItem("Phone Bans", "/phone-bans", "phone-bans", [
constants.acl_ban_phone_check,
constants.acl_ban_phone_add,
constants.acl_ban_phone_remove,
]),
]),
NavSection("Content", [
NavItem("Message Tools", "/messages", "message-tools", [
constants.acl_message_lookup,
constants.acl_message_delete,
constants.acl_message_shred,
constants.acl_message_delete_all,
]),
NavItem("Archives", "/archives", "archives", [
constants.acl_archive_view_all,
constants.acl_archive_trigger_user,
constants.acl_archive_trigger_guild,
]),
NavItem("Asset Purge", "/asset-purge", "asset-purge", [
constants.acl_asset_purge,
]),
]),
NavSection("Metrics", [
NavItem("Overview", "/metrics", "metrics", [constants.acl_metrics_view]),
NavItem("Messaging & API", "/messages-metrics", "messages-metrics", [
constants.acl_metrics_view,
]),
]),
NavSection("Observability", [
NavItem("Gateway", "/gateway", "gateway", [
constants.acl_gateway_memory_stats,
constants.acl_gateway_reload_all,
]),
NavItem("Jobs", "/jobs", "jobs", [constants.acl_metrics_view]),
NavItem("Storage", "/storage", "storage", [constants.acl_metrics_view]),
NavItem("Audit Logs", "/audit-logs", "audit-logs", [
constants.acl_audit_log_view,
]),
]),
NavSection("Platform", [
NavItem("Search Index", "/search-index", "search-index", [
constants.acl_guild_lookup,
]),
NavItem("Voice Regions", "/voice-regions", "voice-regions", [
constants.acl_voice_region_list,
]),
NavItem("Voice Servers", "/voice-servers", "voice-servers", [
constants.acl_voice_server_list,
]),
]),
NavSection("Configuration", [
NavItem("Instance Config", "/instance-config", "instance-config", [
constants.acl_instance_config_view,
constants.acl_instance_config_update,
]),
NavItem("Feature Flags", "/feature-flags", "feature-flags", [
constants.acl_feature_flag_view,
constants.acl_feature_flag_manage,
]),
]),
NavSection("Codes", [
NavItem("Beta Codes", "/beta-codes", "beta-codes", [
constants.acl_beta_codes_generate,
]),
NavItem("Gift Codes", "/gift-codes", "gift-codes", [
constants.acl_gift_codes_generate,
]),
]),
]
}
pub fn accessible_sections(admin_acls: List(String)) -> List(NavSection) {
sections()
|> list.map(fn(section) {
let visible_items =
list.filter(section.items, fn(item) {
has_access(admin_acls, item.required_acls)
})
NavSection(section.title, visible_items)
})
|> list.filter(fn(section) { !list.is_empty(section.items) })
}
pub fn first_accessible_path(admin_acls: List(String)) -> option.Option(String) {
case accessible_sections(admin_acls) {
[] -> option.None
[section, ..] ->
case section.items {
[] -> option.None
[item, ..] -> option.Some(item.path)
}
}
}
fn has_access(admin_acls: List(String), required_acls: List(String)) -> Bool {
case required_acls {
[] -> True
_ ->
list.any(required_acls, fn(required_acl) {
acl.has_permission(admin_acls, required_acl)
})
}
}

View 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)
}

View File

@@ -0,0 +1,233 @@
//// 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/archives
import fluxer_admin/api/common
import fluxer_admin/components/date_time
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
subject_type: String,
subject_id: option.Option(String),
) -> Response {
let result =
archives.list_archives(ctx, session, subject_type, subject_id, False)
let content = case result {
Ok(response) ->
render_archives(ctx, response.archives, subject_type, subject_id)
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
let html =
layout.page(
"Archives",
"archives",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_archives(
ctx: Context,
archives: List(archives.Archive),
subject_type: String,
subject_id: option.Option(String),
) {
let filter_hint = case subject_id {
option.Some(id) -> " for " <> subject_type <> " " <> id
option.None -> ""
}
h.div([a.class("max-w-7xl mx-auto")], [
ui.flex_row_between([
ui.heading_page("Archives" <> filter_hint),
h.div([], []),
]),
case list.is_empty(archives) {
True ->
h.div(
[
a.class(
"bg-white border border-dashed border-neutral-300 rounded-lg p-8 text-center",
),
],
[
h.p([a.class("text-neutral-600")], [
element.text("No archives found" <> filter_hint <> "."),
]),
],
)
False -> render_table(ctx, archives)
},
])
}
fn render_table(ctx: Context, archives: List(archives.Archive)) {
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([], [
header_cell("Subject"),
header_cell("Requested By"),
header_cell("Requested At"),
header_cell("Status"),
header_cell("Actions"),
]),
]),
h.tbody(
[a.class("divide-y divide-neutral-200")],
list.map(archives, fn(archive) {
h.tr([], [
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900",
),
],
[
h.div([], [
h.div([a.class("font-semibold")], [
element.text(
archive.subject_type <> " " <> archive.subject_id,
),
]),
h.div([a.class("text-neutral-500 text-xs")], [
element.text("Archive ID: " <> archive.archive_id),
]),
]),
],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900",
),
],
[
element.text(archive.requested_by),
],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900",
),
],
[
element.text(date_time.format_timestamp(archive.requested_at)),
],
),
h.td([a.class("px-6 py-4 text-sm")], [
h.div([a.class("flex items-center gap-2")], [
h.span(
[
a.class(
"inline-flex items-center px-2 py-1 rounded-full bg-neutral-100 text-neutral-800 text-xs",
),
],
[element.text(status_label(archive))],
),
h.span([a.class("text-neutral-600 text-xs")], [
element.text(int.to_string(archive.progress_percent) <> "%"),
]),
]),
case archive.progress_step {
option.Some(step) ->
h.div([a.class("text-xs text-neutral-500 mt-1")], [
element.text(step),
])
option.None -> element.none()
},
]),
h.td([a.class("px-6 py-4 whitespace-nowrap text-sm")], [
case archive.completed_at {
option.Some(_) ->
h.a(
[
href(
ctx,
"/archives/download?subject_type="
<> archive.subject_type
<> "&subject_id="
<> archive.subject_id
<> "&archive_id="
<> archive.archive_id,
),
a.class(
"inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-neutral-900 rounded-md hover:bg-neutral-800 transition-colors",
),
],
[element.text("Download")],
)
option.None ->
h.span([a.class("text-neutral-500")], [
element.text("Not ready"),
])
},
]),
])
}),
),
]),
],
)
}
fn header_cell(label: String) {
h.th(
[
a.class(
"px-6 py-3 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[element.text(label)],
)
}
fn status_label(archive: archives.Archive) -> String {
case archive.failed_at {
option.Some(_) -> "Failed"
option.None -> {
case archive.completed_at {
option.Some(_) -> "Completed"
option.None -> "In Progress"
}
}
}
}

View File

@@ -0,0 +1,244 @@
//// 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/acl
import fluxer_admin/api/assets
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: web.Context,
session: web.Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
result: option.Option(assets.AssetPurgeResponse),
) -> Response {
let has_permission = case current_admin {
option.Some(admin) ->
acl.has_permission(admin.acls, constants.acl_asset_purge)
option.None -> False
}
let content =
h.div([a.class("space-y-6")], [
h.div([a.class("mb-6")], [ui.heading_page("Asset Purge")]),
h.div([a.class("text-sm text-neutral-600")], [
element.text(
"Purge emojis or stickers from the storage and CDN. Provide one or more IDs (comma-separated).",
),
]),
case result {
option.Some(response) -> render_result(response)
option.None -> element.none()
},
case has_permission {
True -> render_form()
False -> render_permission_notice()
},
])
let html =
layout.page(
"Asset Purge",
"asset-purge",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_form() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Purge Assets"),
h.p([a.class("text-sm text-neutral-500 mb-4")], [
element.text(
"Enter the emoji or sticker IDs that should be removed from S3 and CDN caches.",
),
]),
h.form(
[
a.method("POST"),
a.action("?action=purge-assets"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("IDs (comma or newline separated)"),
]),
h.textarea(
[
a.name("asset_ids"),
a.required(True),
a.placeholder("123456789012345678\n876543210987654321"),
a.attribute("rows", "4"),
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",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("DMCA takedown request"),
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",
),
]),
]),
ui.button("Purge Assets", "submit", ui.Danger, ui.Medium, ui.Full, []),
],
),
])
}
fn render_permission_notice() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Permission required"),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You need the asset:purge ACL to use this tool."),
]),
])
}
fn render_result(result: assets.AssetPurgeResponse) {
h.div([a.class("space-y-4")], [
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Purge Result"),
h.div([a.class("text-sm text-neutral-600 mb-4")], [
element.text(
"Processed "
<> int.to_string(list.length(result.processed))
<> " ID(s); "
<> int.to_string(list.length(result.errors))
<> " error(s).",
),
]),
render_processed_table(result.processed),
case list.is_empty(result.errors) {
True -> element.none()
False -> render_errors(result.errors)
},
]),
])
}
fn render_processed_table(items: List(assets.AssetPurgeResult)) {
h.div([a.class("overflow-x-auto border border-neutral-200 rounded-lg")], [
h.table([a.class("min-w-full text-left text-sm text-neutral-700")], [
h.thead([a.class("bg-neutral-50 text-xs uppercase text-neutral-500")], [
h.tr([], [
h.th([a.class("px-4 py-2 font-medium")], [element.text("ID")]),
h.th([a.class("px-4 py-2 font-medium")], [element.text("Type")]),
h.th([a.class("px-4 py-2 font-medium")], [element.text("In DB")]),
h.th([a.class("px-4 py-2 font-medium")], [element.text("Guild ID")]),
]),
]),
h.tbody([], {
list.map(items, fn(item) {
h.tr([a.class("border-t border-neutral-100")], [
h.td([a.class("px-4 py-3 break-words")], [element.text(item.id)]),
h.td([a.class("px-4 py-3")], [element.text(item.asset_type)]),
h.td([a.class("px-4 py-3")], [
element.text(case item.found_in_db {
True -> "Yes"
False -> "No"
}),
]),
h.td([a.class("px-4 py-3")], [
element.text(option.unwrap(item.guild_id, "")),
]),
])
})
}),
]),
])
}
fn render_errors(errors: List(assets.AssetPurgeError)) {
h.div([a.class("mt-4 space-y-2")], {
list.map(errors, fn(err) {
h.div([a.class("text-sm text-red-600")], [
element.text(err.id <> ": " <> err.error),
])
})
})
}
pub fn handle_action(
req: Request,
ctx: web.Context,
session: web.Session,
current_admin: option.Option(common.UserLookupResult),
) -> Response {
use form_data <- wisp.require_form(req)
let ids_input =
list.key_find(form_data.values, "asset_ids")
|> option.from_result
|> option.unwrap("")
let normalized =
string.replace(ids_input, "\n", ",")
|> string.replace("\r", ",")
let ids =
string.split(normalized, ",")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case list.is_empty(ids) {
True ->
flash.redirect_with_error(ctx, "/asset-purge", "Provide at least one ID.")
False ->
case assets.purge_assets(ctx, session, ids, audit_log_reason) {
Ok(response) ->
view(ctx, session, current_admin, option.None, option.Some(response))
Error(_) ->
flash.redirect_with_error(
ctx,
"/asset-purge",
"Failed to purge assets.",
)
}
}
}

View File

@@ -0,0 +1,683 @@
//// 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/audit
import fluxer_admin/api/common
import fluxer_admin/components/date_time
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
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),
current_page: Int,
) -> Response {
let limit = 50
let offset = { current_page - 1 } * limit
let result =
audit.search_audit_logs(
ctx,
session,
query,
admin_user_id_filter,
target_type,
target_id,
action,
limit,
offset,
)
let content = case result {
Ok(response) -> {
let total_pages = { response.total + limit - 1 } / limit
h.div([a.class("max-w-7xl mx-auto")], [
ui.flex_row_between([
ui.heading_page("Audit Logs"),
h.div([a.class("flex items-center gap-4")], [
h.span([a.class("text-sm text-neutral-600")], [
element.text(
"Showing "
<> int.to_string(list.length(response.logs))
<> " of "
<> int.to_string(response.total)
<> " entries",
),
]),
]),
]),
render_filters(
ctx,
query,
admin_user_id_filter,
target_type,
target_id,
action,
),
case list.is_empty(response.logs) {
True -> empty_state()
False -> render_logs_table(ctx, response.logs)
},
case response.total > limit {
True ->
render_pagination(
ctx,
current_page,
total_pages,
query,
admin_user_id_filter,
target_type,
target_id,
action,
)
False -> element.none()
},
])
}
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
let html =
layout.page(
"Audit Logs",
"audit-logs",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_filters(
ctx: Context,
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),
) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-4 mb-6")], [
h.form([a.method("get"), a.class("space-y-4")], [
h.div([a.class("w-full")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Search"),
]),
h.input([
a.type_("text"),
a.name("q"),
a.value(option.unwrap(query, "")),
a.placeholder("Search audit logs by action, reason, or metadata..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
]),
]),
h.div([a.class("grid grid-cols-1 md:grid-cols-4 gap-4")], [
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Action"),
]),
h.select(
[
a.name("action"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
],
[
h.option([a.value(""), a.selected(option.is_none(action))], "All"),
h.option(
[
a.value("temp_ban"),
a.selected(action == option.Some("temp_ban")),
],
"Temp Ban",
),
h.option(
[a.value("unban"), a.selected(action == option.Some("unban"))],
"Unban",
),
h.option(
[
a.value("schedule_deletion"),
a.selected(action == option.Some("schedule_deletion")),
],
"Schedule Deletion",
),
h.option(
[
a.value("cancel_deletion"),
a.selected(action == option.Some("cancel_deletion")),
],
"Cancel Deletion",
),
h.option(
[
a.value("update_flags"),
a.selected(action == option.Some("update_flags")),
],
"Update Flags",
),
h.option(
[
a.value("update_features"),
a.selected(action == option.Some("update_features")),
],
"Update Features",
),
h.option(
[
a.value("delete_message"),
a.selected(action == option.Some("delete_message")),
],
"Delete Message",
),
h.option(
[a.value("ban_ip"), a.selected(action == option.Some("ban_ip"))],
"Ban IP",
),
h.option(
[
a.value("ban_email"),
a.selected(action == option.Some("ban_email")),
],
"Ban Email",
),
h.option(
[
a.value("ban_phone"),
a.selected(action == option.Some("ban_phone")),
],
"Ban Phone",
),
],
),
]),
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Target Type"),
]),
h.select(
[
a.name("target_type"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
],
[
h.option([a.value("")], "All types"),
h.option(
[
a.value("user"),
a.selected(target_type == option.Some("user")),
],
"User",
),
h.option(
[
a.value("guild"),
a.selected(target_type == option.Some("guild")),
],
"Guild",
),
h.option(
[
a.value("message"),
a.selected(target_type == option.Some("message")),
],
"Message",
),
h.option(
[a.value("ip"), a.selected(target_type == option.Some("ip"))],
"IP",
),
h.option(
[
a.value("email"),
a.selected(target_type == option.Some("email")),
],
"Email",
),
h.option(
[
a.value("phone"),
a.selected(target_type == option.Some("phone")),
],
"Phone",
),
],
),
]),
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Target ID"),
]),
h.input([
a.type_("text"),
a.name("target_id"),
a.value(option.unwrap(target_id, "")),
a.placeholder("Filter by target ID..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
]),
]),
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Admin User ID (optional)"),
]),
h.input([
a.type_("text"),
a.name("admin_user_id"),
a.value(option.unwrap(admin_user_id_filter, "")),
a.placeholder("Specific admin user ID..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
]),
]),
]),
h.div([a.class("flex gap-2")], [
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Search & Filter")],
),
h.a(
[
href(ctx, "/audit-logs"),
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")],
),
]),
]),
])
}
fn render_logs_table(ctx: Context, logs: List(audit.AuditLog)) {
ui.table_container([
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
h.thead([a.class("bg-neutral-50")], [
h.tr([], [
ui.table_header_cell("Timestamp"),
ui.table_header_cell("Action"),
ui.table_header_cell("Admin"),
ui.table_header_cell("Target"),
ui.table_header_cell("Reason"),
ui.table_header_cell("Details"),
]),
]),
h.tbody(
[a.class("bg-white divide-y divide-neutral-200")],
list.map(logs, fn(log) { render_log_row(ctx, log) }),
),
]),
])
}
fn render_log_row(ctx: Context, log: audit.AuditLog) {
let expanded_id = "expanded-" <> log.log_id
case list.is_empty(log.metadata) {
True ->
h.tr([a.class("hover:bg-neutral-50 transition-colors")], [
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
element.text(date_time.format_timestamp(log.created_at)),
]),
h.td([a.class("px-6 py-4 whitespace-nowrap")], [
action_pill(log.action),
]),
render_admin_cell(ctx, log.admin_user_id),
render_target_cell(ctx, log.target_type, log.target_id),
h.td([a.class(ui.table_cell_muted_class)], [
case log.audit_log_reason {
option.Some(reason) -> element.text(reason)
option.None ->
h.span([a.class("text-neutral-400 italic")], [element.text("")])
},
]),
h.td([a.class(ui.table_cell_muted_class)], [
h.span([a.class("text-neutral-400 italic")], [element.text("")]),
]),
])
False ->
element.fragment([
h.tr([a.class("hover:bg-neutral-50 transition-colors")], [
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
element.text(date_time.format_timestamp(log.created_at)),
]),
h.td([a.class("px-6 py-4 whitespace-nowrap")], [
action_pill(log.action),
]),
render_admin_cell(ctx, log.admin_user_id),
render_target_cell(ctx, log.target_type, log.target_id),
h.td([a.class(ui.table_cell_muted_class)], [
case log.audit_log_reason {
option.Some(reason) -> element.text(reason)
option.None ->
h.span([a.class("text-neutral-400 italic")], [element.text("")])
},
]),
h.td([a.class(ui.table_cell_muted_class)], [
h.button(
[
a.class(
"cursor-pointer text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
a.attribute(
"onclick",
"document.getElementById('"
<> expanded_id
<> "').classList.toggle('hidden')",
),
],
[element.text("Toggle details")],
),
]),
]),
h.tr([a.id(expanded_id), a.class("hidden bg-neutral-50")], [
h.td([a.attribute("colspan", "6"), a.class("px-6 py-4")], [
render_metadata_expanded(log.metadata),
]),
]),
])
}
}
fn render_admin_cell(ctx: Context, admin_user_id: String) {
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
case string.is_empty(admin_user_id) {
True -> h.span([a.class("text-neutral-400 italic")], [element.text("")])
False ->
h.a(
[
href(ctx, "/users/" <> admin_user_id),
a.class(
"text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
],
[element.text("User " <> admin_user_id)],
)
},
])
}
fn render_target_cell(ctx: Context, target_type: String, target_id: String) {
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
case target_type, target_id {
"user", id -> {
h.a(
[
href(ctx, "/users/" <> id),
a.class(
"text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
],
[element.text("User " <> id)],
)
}
"guild", id -> {
h.a(
[
href(ctx, "/guilds/" <> id),
a.class(
"text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
],
[element.text("Guild " <> id)],
)
}
type_, id ->
h.span([a.class("text-neutral-900")], [
element.text(string.capitalise(type_) <> " " <> id),
])
},
])
}
fn render_metadata_expanded(metadata: List(#(String, String))) {
h.div(
[a.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3")],
list.map(metadata, fn(entry) {
let #(key, value) = entry
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-3")], [
h.div([a.class("text-xs text-neutral-500 uppercase mb-1")], [
element.text(key),
]),
h.div([a.class("text-sm text-neutral-900 break-all")], [
element.text(value),
]),
])
}),
)
}
fn format_action(action: String) -> String {
action
|> string.replace("_", " ")
|> string.capitalise
}
fn action_pill(action: String) {
ui.pill(format_action(action), action_tone(action))
}
fn action_tone(action: String) -> ui.PillTone {
case action {
"temp_ban"
| "disable_suspicious_activity"
| "schedule_deletion"
| "ban_ip"
| "ban_email"
| "ban_phone" -> ui.PillDanger
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" | "unban_phone" ->
ui.PillSuccess
"update_flags" | "update_features" | "set_acls" | "update_settings" ->
ui.PillInfo
"delete_message" -> ui.PillOrange
_ -> ui.PillNeutral
}
}
fn empty_state() {
ui.card_empty([
ui.text_muted("No audit logs found"),
ui.text_small_muted("Try adjusting your filters or check back later"),
])
}
fn render_pagination(
ctx: Context,
current_page: Int,
total_pages: Int,
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),
) {
let build_url = fn(page: Int) {
let base = "/audit-logs?page=" <> int.to_string(page)
let with_query = case query {
option.Some(q) if q != "" -> base <> "&q=" <> q
_ -> base
}
let with_admin_user = case admin_user_id_filter {
option.Some(id) if id != "" -> with_query <> "&admin_user_id=" <> id
_ -> with_query
}
let with_target_type = case target_type {
option.Some(tt) if tt != "" -> with_admin_user <> "&target_type=" <> tt
_ -> with_admin_user
}
let with_target_id = case target_id {
option.Some(tid) if tid != "" -> with_target_type <> "&target_id=" <> tid
_ -> with_target_type
}
let with_action = case action {
option.Some(act) if act != "" -> with_target_id <> "&action=" <> act
_ -> with_target_id
}
with_action
}
h.div(
[
a.class(
"mt-6 flex items-center justify-between border-t border-neutral-200 bg-white px-4 py-3 sm:px-6 rounded-b-lg",
),
],
[
h.div([a.class("flex flex-1 justify-between sm:hidden")], [
case current_page > 1 {
True ->
h.a(
[
href(ctx, build_url(current_page - 1)),
a.class(
"relative inline-flex items-center rounded-md border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50",
),
],
[element.text("Previous")],
)
False ->
h.span(
[
a.class(
"relative inline-flex items-center rounded-md border border-neutral-300 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-400 cursor-not-allowed",
),
],
[element.text("Previous")],
)
},
case current_page < total_pages {
True ->
h.a(
[
href(ctx, build_url(current_page + 1)),
a.class(
"relative ml-3 inline-flex items-center rounded-md border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50",
),
],
[element.text("Next")],
)
False ->
h.span(
[
a.class(
"relative ml-3 inline-flex items-center rounded-md border border-neutral-300 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-400 cursor-not-allowed",
),
],
[element.text("Next")],
)
},
]),
h.div(
[a.class("hidden sm:flex sm:flex-1 sm:items-center sm:justify-between")],
[
h.div([], [
h.p([a.class("text-sm text-neutral-700")], [
element.text(
"Page "
<> int.to_string(current_page)
<> " of "
<> int.to_string(total_pages),
),
]),
]),
h.div([], [
h.nav(
[a.class("isolate inline-flex -space-x-px rounded-md shadow-sm")],
[
case current_page > 1 {
True ->
h.a(
[
href(ctx, build_url(current_page - 1)),
a.class(
"relative inline-flex items-center rounded-l-md px-4 py-2 text-neutral-900 ring-1 ring-inset ring-neutral-300 hover:bg-neutral-50 focus:z-20 focus:outline-offset-0",
),
],
[element.text("Previous")],
)
False ->
h.span(
[
a.class(
"relative inline-flex items-center rounded-l-md px-4 py-2 text-neutral-400 ring-1 ring-inset ring-neutral-300 bg-neutral-100 cursor-not-allowed",
),
],
[element.text("Previous")],
)
},
case current_page < total_pages {
True ->
h.a(
[
href(ctx, build_url(current_page + 1)),
a.class(
"relative inline-flex items-center rounded-r-md px-4 py-2 text-neutral-900 ring-1 ring-inset ring-neutral-300 hover:bg-neutral-50 focus:z-20 focus:outline-offset-0",
),
],
[element.text("Next")],
)
False ->
h.span(
[
a.class(
"relative inline-flex items-center rounded-r-md px-4 py-2 text-neutral-400 ring-1 ring-inset ring-neutral-300 bg-neutral-100 cursor-not-allowed",
),
],
[element.text("Next")],
)
},
],
),
]),
],
),
],
)
}

View File

@@ -0,0 +1,279 @@
//// 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/bans
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session}
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub type BanType {
IpBan
EmailBan
PhoneBan
}
type BanConfig {
BanConfig(
title: String,
route: String,
input_label: String,
input_name: String,
input_type: ui.InputType,
placeholder: String,
entity_name: String,
active_page: String,
)
}
fn get_config(ban_type: BanType) -> BanConfig {
case ban_type {
IpBan ->
BanConfig(
title: "IP Bans",
route: "/ip-bans",
input_label: "IP Address or CIDR",
input_name: "ip",
input_type: ui.Text,
placeholder: "192.168.1.1 or 192.168.0.0/16",
entity_name: "IP/CIDR",
active_page: "ip-bans",
)
EmailBan ->
BanConfig(
title: "Email Bans",
route: "/email-bans",
input_label: "Email Address",
input_name: "email",
input_type: ui.Email,
placeholder: "user@example.com",
entity_name: "Email",
active_page: "email-bans",
)
PhoneBan ->
BanConfig(
title: "Phone Bans",
route: "/phone-bans",
input_label: "Phone Number",
input_name: "phone",
input_type: ui.Tel,
placeholder: "+1234567890",
entity_name: "Phone",
active_page: "phone-bans",
)
}
}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
ban_type: BanType,
) -> Response {
let config = get_config(ban_type)
let content =
ui.stack("6", [
ui.heading_page(config.title),
ui.grid("1 md:grid-cols-2", "6", [
ui.card(ui.PaddingMedium, [
ui.stack("4", [
ui.heading_card("Ban " <> config.input_label),
h.form([a.method("POST"), a.action("?action=ban")], [
ui.stack("4", [
ui.input(
config.input_label,
config.input_name,
config.input_type,
option.None,
True,
option.Some(config.placeholder),
),
ui.button(
"Ban " <> config.entity_name,
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
]),
]),
]),
]),
ui.card(ui.PaddingMedium, [
ui.stack("4", [
ui.heading_card("Check " <> config.input_label <> " Ban Status"),
h.form([a.method("POST"), a.action("?action=check")], [
ui.stack("4", [
ui.input(
config.input_label,
config.input_name,
config.input_type,
option.None,
True,
option.Some(config.placeholder),
),
ui.button(
"Check Status",
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
]),
]),
]),
]),
]),
ui.card(ui.PaddingMedium, [
ui.stack("4", [
ui.heading_card("Remove " <> config.input_label <> " Ban"),
h.form([a.method("POST"), a.action("?action=unban")], [
ui.stack("4", [
ui.input(
config.input_label,
config.input_name,
config.input_type,
option.None,
True,
option.Some(config.placeholder),
),
ui.button(
"Unban " <> config.entity_name,
"submit",
ui.Danger,
ui.Medium,
ui.Full,
[],
),
]),
]),
]),
]),
])
let html =
layout.page(
config.title,
config.active_page,
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
ban_type: BanType,
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
let config = get_config(ban_type)
let value_result = list.key_find(form_data.values, config.input_name)
case action, value_result {
option.Some("ban"), Ok(value) -> {
let result = case ban_type {
IpBan -> bans.ban_ip(ctx, session, value)
EmailBan -> bans.ban_email(ctx, session, value, option.None)
PhoneBan -> bans.ban_phone(ctx, session, value)
}
case result {
Ok(_) ->
flash.redirect_with_success(
ctx,
config.route,
config.entity_name <> " " <> value <> " banned successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
config.route,
"Failed to ban " <> config.entity_name <> " " <> value,
)
}
}
option.Some("unban"), Ok(value) -> {
let result = case ban_type {
IpBan -> bans.unban_ip(ctx, session, value)
EmailBan -> bans.unban_email(ctx, session, value, option.None)
PhoneBan -> bans.unban_phone(ctx, session, value)
}
case result {
Ok(_) ->
flash.redirect_with_success(
ctx,
config.route,
config.entity_name <> " " <> value <> " unbanned successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
config.route,
"Failed to unban " <> config.entity_name <> " " <> value,
)
}
}
option.Some("check"), Ok(value) -> {
let result = case ban_type {
IpBan -> bans.check_ip_ban(ctx, session, value)
EmailBan -> bans.check_email_ban(ctx, session, value)
PhoneBan -> bans.check_phone_ban(ctx, session, value)
}
case result {
Ok(response) if response.banned ->
flash.redirect_with_info(
ctx,
config.route,
config.entity_name <> " " <> value <> " is banned",
)
Ok(_) ->
flash.redirect_with_info(
ctx,
config.route,
config.entity_name <> " " <> value <> " is NOT banned",
)
Error(_) ->
flash.redirect_with_error(
ctx,
config.route,
"Error checking ban status",
)
}
}
_, _ -> wisp.redirect(web.prepend_base_path(ctx, config.route))
}
}

View File

@@ -0,0 +1,336 @@
//// 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/acl
import fluxer_admin/api/codes
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/slider_control
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
const max_beta_codes = 100
const default_count = 10
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
) -> Response {
render_page(
ctx,
session,
current_admin,
flash_data,
admin_acls,
default_count,
option.None,
option.None,
)
}
fn render_page(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
selected_count: Int,
generation_result: option.Option(flash.Flash),
generated_codes: option.Option(List(String)),
) -> Response {
let has_permission =
acl.has_permission(admin_acls, constants.acl_beta_codes_generate)
let content = case has_permission {
True ->
render_generator_card(generated_codes, generation_result, selected_count)
False -> render_access_denied()
}
let html =
layout.page(
"Beta Codes",
"beta-codes",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_generator_card(
generated_codes: option.Option(List(String)),
generation_result: option.Option(flash.Flash),
selected_count: Int,
) -> element.Element(a) {
let codes_value = case generated_codes {
option.Some(codes) -> string.join(codes, "\n")
option.None -> ""
}
let status_view = flash.view(generation_result)
h.div([a.class("max-w-7xl mx-auto space-y-6")], [
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("space-y-2")], [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Generate Beta Codes"),
]),
]),
status_view,
h.form(
[
a.id("beta-form"),
a.class("space-y-4"),
a.method("POST"),
a.action("?action=generate"),
],
[
h.div([a.class("space-y-4")], [
h.div([a.class("flex items-center justify-between")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("How many codes"),
]),
h.span([a.class("text-xs text-neutral-500")], [
element.text("Range: 1-" <> int.to_string(max_beta_codes)),
]),
]),
h.div(
[a.class("space-y-4")],
list.append(
slider_control.range_slider_section(
"beta-count-slider",
"beta-count-value",
1,
max_beta_codes,
selected_count,
),
[
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Adjust the slider to pick the number of beta codes you need, then submit to generate them.",
),
]),
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Generate Beta Codes")],
),
],
),
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("Generated Codes"),
]),
h.textarea(
[
a.readonly(True),
a.attribute("rows", "10"),
a.class(
"w-full border border-neutral-200 rounded-lg px-4 py-3 text-sm text-neutral-900 bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
a.placeholder(
"Code output will appear here after generation.",
),
],
codes_value,
),
h.p([a.class("text-xs text-neutral-500")], [
element.text("Each code is newline separated for easy copying."),
]),
]),
],
),
slider_control.slider_sync_script(
"beta-count-slider",
"beta-count-value",
),
]),
]),
])
}
fn render_access_denied() -> element.Element(a) {
ui.card(ui.PaddingMedium, [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Beta Codes"),
]),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You do not have permission to generate beta codes."),
]),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
case action {
option.Some("generate") ->
handle_generate(ctx, session, current_admin, admin_acls, form_data)
_ ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_count,
option.Some(flash.Flash("Unknown action", flash.Error)),
option.None,
)
}
}
fn handle_generate(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
form_data: wisp.FormData,
) -> Response {
case acl.has_permission(admin_acls, constants.acl_beta_codes_generate) {
False ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_count,
option.Some(flash.Flash("Permission denied", flash.Error)),
option.None,
)
True ->
case parse_count(form_data) {
option.Some(value) ->
case value < 1 || value > max_beta_codes {
True ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Count must be between 1 and "
<> int.to_string(max_beta_codes),
flash.Error,
)),
option.None,
)
False ->
case codes.generate_beta_codes(ctx, session, value) {
Ok(generated) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Generated "
<> int.to_string(list.length(generated))
<> " beta codes.",
flash.Success,
)),
option.Some(generated),
)
Error(err) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(api_error_message(err), flash.Error)),
option.None,
)
}
}
option.None ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_count,
option.Some(flash.Flash("Count is required", flash.Error)),
option.None,
)
}
}
}
fn parse_count(form_data: wisp.FormData) -> option.Option(Int) {
let raw =
list.key_find(form_data.values, "count")
|> option.from_result
case raw {
option.Some(str) ->
case int.parse(str) {
Ok(value) -> option.Some(value)
Error(_) -> option.None
}
option.None -> option.None
}
}
fn api_error_message(err: common.ApiError) -> String {
case err {
common.Unauthorized -> "Unauthorized"
common.Forbidden(message) -> message
common.NotFound -> "Not Found"
common.NetworkError -> "Network error"
common.ServerError -> "Server error"
}
}

View File

@@ -0,0 +1,736 @@
//// 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/acl
import fluxer_admin/api/bulk
import fluxer_admin/api/common
import fluxer_admin/components/deletion_days_script
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
result: option.Option(bulk.BulkOperationResponse),
) -> Response {
let content =
h.div([a.class("space-y-6")], [
h.div([a.class("mb-6")], [ui.heading_page("Bulk Actions")]),
case result {
option.Some(response) -> render_result(response)
option.None -> element.none()
},
case acl.has_permission(admin_acls, "bulk:update_user_flags") {
True -> render_bulk_update_user_flags()
False -> element.none()
},
case acl.has_permission(admin_acls, "bulk:update_guild_features") {
True -> render_bulk_update_guild_features()
False -> element.none()
},
case acl.has_permission(admin_acls, "bulk:add_guild_members") {
True -> render_bulk_add_guild_members()
False -> element.none()
},
case acl.has_permission(admin_acls, "bulk:delete_users") {
True -> render_bulk_schedule_user_deletion()
False -> element.none()
},
])
let html =
layout.page(
"Bulk Actions",
"bulk-actions",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_custom_checkbox(
name: String,
value: String,
label: String,
) -> element.Element(a) {
ui.custom_checkbox(name, value, label, False, option.None)
}
fn render_result(response: bulk.BulkOperationResponse) {
let success_count = list.length(response.successful)
let fail_count = list.length(response.failed)
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")], [
ui.heading_card_with_margin("Operation Result"),
h.div([a.class("space-y-3")], [
h.div([a.class("text-sm")], [
h.span([a.class("text-sm font-medium text-green-600")], [
element.text("Successful: "),
]),
element.text(int.to_string(success_count)),
]),
h.div([a.class("text-sm")], [
h.span([a.class("text-sm font-medium text-red-600")], [
element.text("Failed: "),
]),
element.text(int.to_string(fail_count)),
]),
case list.is_empty(response.failed) {
True -> element.none()
False ->
h.div([a.class("mt-4")], [
h.h3([a.class("text-sm font-medium text-neutral-900 mb-2")], [
element.text("Errors:"),
]),
h.ul([a.class("space-y-1")], {
list.map(response.failed, fn(error) {
h.li([a.class("text-sm text-red-600")], [
element.text(error.id <> ": " <> error.error),
])
})
}),
])
},
]),
])
}
fn render_bulk_update_user_flags() {
let all_flags = constants.get_patchable_flags()
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Update User Flags"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-update-user-flags"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("User IDs (one per line)"),
]),
h.textarea(
[
a.name("user_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
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",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Flags to Add"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_flags, fn(flag) {
render_custom_checkbox(
"add_flags[]",
int.to_string(flag.value),
flag.name,
)
})
}),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Flags to Remove"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_flags, fn(flag) {
render_custom_checkbox(
"remove_flags[]",
int.to_string(flag.value),
flag.name,
)
})
}),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
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",
),
]),
]),
ui.button(
"Update User Flags",
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
],
),
])
}
fn render_bulk_update_guild_features() {
let all_features = constants.get_guild_features()
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Update Guild Features"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-update-guild-features"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Guild IDs (one per line)"),
]),
h.textarea(
[
a.name("guild_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
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",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Features to Add"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_features, fn(feature) {
render_custom_checkbox(
"add_features[]",
feature.value,
feature.value,
)
})
}),
h.div([a.class("mt-3")], [
h.label([a.class("text-xs text-neutral-600 mb-1 block")], [
element.text("Custom features (comma-separated):"),
]),
h.input([
a.type_("text"),
a.name("custom_add_features"),
a.placeholder("CUSTOM_FEATURE_1, CUSTOM_FEATURE_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",
),
]),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Features to Remove"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_features, fn(feature) {
render_custom_checkbox(
"remove_features[]",
feature.value,
feature.value,
)
})
}),
h.div([a.class("mt-3")], [
h.label([a.class("text-xs text-neutral-600 mb-1 block")], [
element.text("Custom features (comma-separated):"),
]),
h.input([
a.type_("text"),
a.name("custom_remove_features"),
a.placeholder("CUSTOM_FEATURE_1, CUSTOM_FEATURE_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",
),
]),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
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",
),
]),
]),
ui.button(
"Update Guild Features",
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
],
),
])
}
fn render_bulk_add_guild_members() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Add Guild Members"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-add-guild-members"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Guild ID"),
]),
h.input([
a.type_("text"),
a.name("guild_id"),
a.placeholder("123456789"),
a.required(True),
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",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("User IDs (one per line)"),
]),
h.textarea(
[
a.name("user_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
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",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
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",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Add Members")],
),
],
),
])
}
fn render_bulk_schedule_user_deletion() {
let deletion_reasons = constants.get_deletion_reasons()
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Schedule User Deletion"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-schedule-user-deletion"),
a.class("space-y-4"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to schedule these users for deletion?')",
),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("User IDs (one per line)"),
]),
h.textarea(
[
a.name("user_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
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",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Deletion Reason"),
]),
h.select(
[
a.id("bulk-deletion-reason"),
a.name("reason_code"),
a.required(True),
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",
),
],
list.map(deletion_reasons, fn(reason) {
h.option([a.value(int.to_string(reason.0))], reason.1)
}),
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Public Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("public_reason"),
a.placeholder("Terms of service violation"),
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",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Days Until Deletion"),
]),
h.input([
a.type_("number"),
a.id("bulk-deletion-days"),
a.name("days_until_deletion"),
a.value("14"),
a.min("14"),
a.required(True),
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",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
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",
),
]),
]),
ui.button(
"Schedule Deletion",
"submit",
ui.Danger,
ui.Medium,
ui.Full,
[],
),
deletion_days_script.render(),
],
),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
case action {
option.Some("bulk-update-user-flags") -> {
let user_ids_text =
list.key_find(form_data.values, "user_ids") |> option.from_result
let add_flags =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"add_flags[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let remove_flags =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"remove_flags[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case user_ids_text {
option.Some(text) -> {
let user_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_update_user_flags(
ctx,
session,
user_ids,
add_flags,
remove_flags,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.None ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.Some("bulk-update-guild-features") -> {
let guild_ids_text =
list.key_find(form_data.values, "guild_ids") |> option.from_result
let add_features =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"add_features[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let remove_features =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"remove_features[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let custom_add_features =
list.key_find(form_data.values, "custom_add_features")
|> option.from_result
|> option.unwrap("")
|> string.split(",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
let custom_remove_features =
list.key_find(form_data.values, "custom_remove_features")
|> option.from_result
|> option.unwrap("")
|> string.split(",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
let add_features = list.append(add_features, custom_add_features)
let remove_features = list.append(remove_features, custom_remove_features)
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case guild_ids_text {
option.Some(text) -> {
let guild_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_update_guild_features(
ctx,
session,
guild_ids,
add_features,
remove_features,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.None ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.Some("bulk-add-guild-members") -> {
let guild_id =
list.key_find(form_data.values, "guild_id") |> option.from_result
let user_ids_text =
list.key_find(form_data.values, "user_ids") |> option.from_result
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case guild_id, user_ids_text {
option.Some(gid), option.Some(text) -> {
let user_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_add_guild_members(
ctx,
session,
gid,
user_ids,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
_, _ -> wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.Some("bulk-schedule-user-deletion") -> {
let user_ids_text =
list.key_find(form_data.values, "user_ids") |> option.from_result
let reason_code =
list.key_find(form_data.values, "reason_code")
|> option.from_result
|> option.then(fn(s) { int.parse(s) |> option.from_result })
let public_reason =
list.key_find(form_data.values, "public_reason") |> option.from_result
let days_until_deletion =
list.key_find(form_data.values, "days_until_deletion")
|> option.from_result
|> option.then(fn(s) { int.parse(s) |> option.from_result })
|> option.unwrap(30)
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case user_ids_text, reason_code {
option.Some(text), option.Some(code) -> {
let user_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_schedule_user_deletion(
ctx,
session,
user_ids,
code,
public_reason,
days_until_deletion,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
_, _ -> wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
_ -> wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}

View File

@@ -0,0 +1,53 @@
//// 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/components/flash
import fluxer_admin/pages/ban_management_page
import fluxer_admin/web.{type Context, type Session}
import gleam/option
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
) -> Response {
ban_management_page.view(
ctx,
session,
current_admin,
flash_data,
ban_management_page.EmailBan,
)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
action: option.Option(String),
) -> Response {
ban_management_page.handle_action(
req,
ctx,
session,
ban_management_page.EmailBan,
action,
)
}

View File

@@ -0,0 +1,237 @@
//// 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/acl
import fluxer_admin/api/common
import fluxer_admin/api/feature_flags
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session, action}
import gleam/dict
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
) -> Response {
let can_view = acl.has_permission(admin_acls, constants.acl_feature_flag_view)
let content = case can_view {
False ->
errors.api_error_view(
ctx,
common.Forbidden("Access denied"),
option.None,
option.None,
)
True -> {
case feature_flags.get_feature_flags(ctx, session) {
Ok(entries) -> render_page(ctx, flash_data, entries)
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
}
}
let html =
layout.page(
"Feature Flags",
"feature-flags",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_page(
ctx: Context,
flash_data: option.Option(flash.Flash),
entries: List(#(String, feature_flags.FeatureFlagConfig)),
) -> element.Element(a) {
let config_map = build_config_map(entries)
h.div([a.class("space-y-6")], [
ui.heading_page("Feature Flags"),
flash.view(flash_data),
h.div(
[a.class("space-y-6")],
list.map(constants.get_feature_flags(), fn(flag) {
let guild_ids = case dict.get(config_map, flag.id) {
Ok(ids) -> ids
Error(_) -> []
}
render_flag_card(ctx, flag, guild_ids)
}),
),
])
}
fn render_flag_card(
ctx: Context,
flag: constants.FeatureFlag,
guild_ids: List(String),
) -> element.Element(a) {
let guild_text = format_guild_list(guild_ids)
h.div([a.class("space-y-4")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("space-y-4")], [
h.div([a.class("flex items-center justify-between")], [
h.div([a.class("space-y-1")], [
h.h3([a.class("text-lg font-semibold text-neutral-900")], [
element.text(flag.name),
]),
h.p([a.class("text-sm text-neutral-600")], [
element.text(flag.description),
]),
]),
h.span([a.class("text-xs uppercase tracking-wide text-neutral-500")], [
element.text(
"Guilds "
<> int.to_string(list.length(guild_ids))
<> " configured",
),
]),
]),
h.form(
[
a.method("POST"),
action(ctx, "/feature-flags?action=update"),
a.class("space-y-4"),
],
[
h.input([
a.type_("hidden"),
a.name("flag_id"),
a.value(flag.id),
]),
h.div([a.class("space-y-1")], [
h.label([a.class("text-sm font-medium text-neutral-700")], [
element.text("Guild IDs"),
]),
h.textarea(
[
a.name("guild_ids"),
a.attribute("rows", "3"),
a.class(
"w-full border border-neutral-300 rounded text-sm px-3 py-2",
),
],
guild_text,
),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Comma-separated guild IDs that receive this feature.",
),
]),
]),
h.div([a.class("text-right")], [
ui.button_primary("Save", "submit", []),
]),
],
),
]),
]),
])
}
fn build_config_map(
entries: List(#(String, feature_flags.FeatureFlagConfig)),
) -> dict.Dict(String, List(String)) {
list.fold(entries, dict.new(), fn(acc, entry) {
let #(flag, config) = entry
dict.insert(acc, flag, config.guild_ids)
})
}
fn format_guild_list(ids: List(String)) -> String {
case ids {
[] -> ""
[first, ..rest] -> list.fold(rest, first, fn(acc, id) { acc <> ", " <> id })
}
}
fn parse_guild_ids(raw: String) -> List(String) {
list.flatten(
list.map(string.split(string.replace(raw, "\r", ""), "\n"), fn(line) {
list.filter(
list.map(string.split(line, ","), fn(item) { string.trim(item) }),
fn(item) { item != "" },
)
}),
)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
admin_acls: List(String),
action_name: Result(String, Nil),
) -> Response {
case acl.has_permission(admin_acls, constants.acl_feature_flag_manage) {
False -> flash.redirect_with_error(ctx, "/feature-flags", "Access denied")
True ->
case action_name {
Ok("update") -> handle_update(req, ctx, session)
_ -> flash.redirect_with_error(ctx, "/feature-flags", "Unknown action")
}
}
}
fn handle_update(req: Request, ctx: Context, session: Session) -> Response {
use form_data <- wisp.require_form(req)
let flag_id =
list.key_find(form_data.values, "flag_id")
|> result.unwrap("")
let guild_input =
list.key_find(form_data.values, "guild_ids")
|> result.unwrap("")
let guild_ids = parse_guild_ids(guild_input)
case feature_flags.update_feature_flag(ctx, session, flag_id, guild_ids) {
Ok(_) ->
flash.redirect_with_success(ctx, "/feature-flags", "Feature flag updated")
Error(_) ->
flash.redirect_with_error(
ctx,
"/feature-flags",
"Failed to update feature flag",
)
}
}

View File

@@ -0,0 +1,916 @@
//// 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/api/system
import fluxer_admin/avatar
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{
type Context, type Session, action, href, prepend_base_path,
}
import gleam/float
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
_req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
result: option.Option(Int),
) -> Response {
let node_stats_result = system.get_node_stats(ctx, session)
let guild_stats_result = system.get_guild_memory_stats(ctx, session, 100)
let content = case node_stats_result, guild_stats_result {
Ok(node_stats), Ok(guild_stats) ->
render_success(
ctx,
admin_acls,
option.Some(node_stats),
guild_stats.guilds,
result,
)
_, Ok(guild_stats) ->
render_success(ctx, admin_acls, option.None, guild_stats.guilds, result)
_, Error(common.Unauthorized) -> render_error(ctx, "Unauthorized")
_, Error(common.Forbidden(message)) -> render_error(ctx, message)
_, Error(common.NotFound) -> render_error(ctx, "Not found")
_, Error(common.ServerError) -> render_error(ctx, "Server error")
_, Error(common.NetworkError) -> render_error(ctx, "Network error")
}
let html =
layout.page(
"Gateway",
"gateway",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
) -> Response {
let flash_data = flash.from_request(req)
let result = case system.reload_all_guilds(ctx, session, []) {
Ok(count) -> option.Some(count)
Error(_) -> option.None
}
view(req, ctx, session, current_admin, flash_data, admin_acls, result)
}
fn render_error(_ctx: Context, message: String) {
ui.stack("6", [
ui.heading_page("Gateway"),
h.div(
[a.class("bg-red-50 border border-red-200 rounded-lg p-6 text-center")],
[h.p([a.class("text-red-800")], [element.text(message)])],
),
])
}
fn render_success(
ctx: Context,
admin_acls: List(String),
node_stats: option.Option(system.NodeStats),
guilds: List(system.ProcessMemoryStats),
result: option.Option(Int),
) {
let can_reload_all =
list.contains(admin_acls, "gateway:reload_all")
|| list.contains(admin_acls, "*")
h.div([], [
ui.flex_row_between([
ui.heading_page("Gateway"),
case can_reload_all {
True ->
h.form([a.method("POST"), action(ctx, "/gateway?action=reload_all")], [
ui.button_primary("Reload All Guilds", "submit", [
a.attribute(
"onclick",
"return confirm('Are you sure you want to reload all guilds in memory? This may take several minutes.');",
),
]),
])
False -> element.none()
},
]),
case result {
option.Some(count) ->
h.div(
[
a.class(
"mb-6 bg-green-50 border border-green-200 rounded-lg p-4 text-green-800",
),
],
[
element.text(
"Successfully reloaded " <> int.to_string(count) <> " guilds!",
),
],
)
option.None -> element.none()
},
case node_stats {
option.Some(stats) -> render_node_stats(ctx, stats)
option.None -> element.none()
},
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6 border-b border-neutral-200")], [
ui.heading_section("Guild Memory Leaderboard (Top 100)"),
ui.text_small_muted(
"Guilds ranked by memory usage, showing the top 100 consumers",
),
]),
render_guild_table(ctx, guilds),
]),
])
}
fn render_guild_table(ctx: Context, guilds: List(system.ProcessMemoryStats)) {
case list.is_empty(guilds) {
True ->
h.div([a.class("p-6 text-center text-neutral-600")], [
element.text("No guilds in memory"),
])
False ->
h.div([a.class("overflow-x-auto")], [
h.table([a.class("w-full")], [
h.thead([a.class("bg-neutral-50 border-b border-neutral-200")], [
h.tr([], [
h.th(
[
a.class(
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Rank")],
),
h.th(
[
a.class(
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Guild")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("RAM Usage")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Members")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Sessions")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Presences")],
),
]),
]),
h.tbody(
[a.class("divide-y divide-neutral-200")],
list.index_map(guilds, fn(guild, index) {
render_guild_row(ctx, guild, index)
}),
),
]),
])
}
}
fn render_guild_row(ctx: Context, guild: system.ProcessMemoryStats, index: Int) {
let rank = index + 1
h.tr([a.class("hover:bg-neutral-50 transition-colors")], [
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm font-medium text-neutral-900",
),
],
[element.text("#" <> int.to_string(rank))],
),
h.td([a.class("px-6 py-4 whitespace-nowrap")], [
case guild.guild_id {
option.Some(guild_id) ->
h.a(
[
href(ctx, "/guilds/" <> guild_id),
a.class("flex items-center gap-2"),
],
[
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
guild_id,
guild.guild_icon,
True,
)
{
option.Some(icon_url) ->
h.img([
a.src(icon_url),
a.alt(guild.guild_name),
a.class("w-10 h-10 rounded-full"),
])
option.None ->
h.div(
[
a.class(
"w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center text-sm font-medium text-neutral-600",
),
],
[
element.text(
guild.guild_name
|> get_first_char,
),
],
)
},
h.div([], [
h.div([a.class("text-sm font-medium text-neutral-900")], [
element.text(guild.guild_name),
]),
h.div([a.class("text-xs text-neutral-500")], [
element.text(guild_id),
]),
]),
],
)
option.None ->
h.div([a.class("flex items-center gap-2")], [
h.div(
[
a.class(
"w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center text-sm font-medium text-neutral-600",
),
],
[element.text("?")],
),
h.span([a.class("text-sm text-neutral-600")], [
element.text(guild.guild_name),
]),
])
},
]),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right text-sm font-medium",
),
],
[element.text(format_memory(guild.memory_mb))],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right",
),
],
[element.text(format_number(guild.member_count))],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right",
),
],
[element.text(format_number(guild.session_count))],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right",
),
],
[element.text(format_number(guild.presence_count))],
),
])
}
fn get_first_char(s: String) -> String {
case s {
"" -> "?"
_ -> {
let assert Ok(first) = s |> string.first
first
}
}
}
fn format_number(n: Int) -> String {
let s = int.to_string(n)
let len = string.length(s)
case len {
_ if len <= 3 -> s
_ -> {
let groups = reverse_groups(s, [])
string.join(list.reverse(groups), ",")
}
}
}
fn reverse_groups(s: String, acc: List(String)) -> List(String) {
let len = string.length(s)
case len {
0 -> acc
_ if len <= 3 -> [s, ..acc]
_ -> {
let group = string.slice(s, len - 3, 3)
let rest = string.slice(s, 0, len - 3)
reverse_groups(rest, [group, ..acc])
}
}
}
fn format_memory(memory_mb: Float) -> String {
case memory_mb {
_ if memory_mb <. 1.0 -> {
let kb = memory_mb *. 1024.0
float_to_string_rounded(kb, 2) <> " KB"
}
_ if memory_mb <. 1024.0 -> {
float_to_string_rounded(memory_mb, 2) <> " MB"
}
_ -> {
let gb = memory_mb /. 1024.0
float_to_string_rounded(gb, 2) <> " GB"
}
}
}
fn float_to_string_rounded(value: Float, decimals: Int) -> String {
let multiplier = case decimals {
0 -> 1.0
1 -> 10.0
2 -> 100.0
3 -> 1000.0
_ -> 100.0
}
let rounded = float.round(value *. multiplier) |> int.to_float
let result = rounded /. multiplier
case decimals {
0 -> {
let int_value = float.round(result)
int.to_string(int_value)
}
_ -> {
let str = float.to_string(result)
case string.contains(str, ".") {
True -> str
False -> str <> ".0"
}
}
}
}
fn render_node_stats(ctx: Context, stats: system.NodeStats) {
h.div([], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm mb-6")],
[
h.div([a.class("p-6")], [
ui.heading_section("Gateway Statistics"),
h.div(
[
a.class(
"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mt-4",
),
],
[
render_stat_card(ctx, "Sessions", format_number(stats.sessions)),
render_stat_card(ctx, "Guilds", format_number(stats.guilds)),
render_stat_card(ctx, "Presences", format_number(stats.presences)),
render_stat_card(ctx, "Calls", format_number(stats.calls)),
render_stat_card(
ctx,
"Total RAM",
format_memory(int.to_float(stats.memory_total) /. 1_024_000.0),
),
],
),
]),
],
),
render_gateway_charts(ctx),
])
}
fn render_gateway_charts(ctx: Context) {
case ctx.metrics_endpoint {
option.Some(_) -> render_gateway_charts_content(ctx)
option.None -> element.none()
}
}
fn render_gateway_charts_content(ctx: Context) {
let proxy_endpoint = prepend_base_path(ctx, "/api/metrics")
h.div([a.class("space-y-6 mb-6")], [
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Process Counts Over Time"),
ui.text_small_muted(
"Historical view of active sessions, guilds, presences, and calls",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("processCountsChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("WebSocket Connection Activity"),
ui.text_small_muted(
"Connection and disconnection rates per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("wsConnectionChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Heartbeat Health"),
ui.text_small_muted(
"Heartbeat success and failure counts per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("heartbeatChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Session Resume Activity"),
ui.text_small_muted(
"Resume success and failure counts per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("resumeChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Rate Limiting Events"),
ui.text_small_muted(
"Identify rate limiting triggers per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("rateLimitChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("RPC Latency"),
ui.text_small_muted(
"API RPC call latency percentiles (p50, p95, p99) in milliseconds",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("rpcLatencyChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Mailbox Sizes Over Time"),
ui.text_small_muted(
"GenServer message queue lengths - high values may indicate bottlenecks",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("mailboxChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Cache Memory Over Time"),
ui.text_small_muted("Memory usage of presence cache and push cache"),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("cacheMemoryChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.script([a.src("https://fluxerstatic.com/libs/chartjs/chart.min.js")], ""),
h.script([], render_gateway_charts_script(proxy_endpoint)),
])
}
fn render_gateway_charts_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const alignData = (data, timestamps) => {
const map = new Map(data.map(d => [d.timestamp, d.value]));
return timestamps.map(ts => map.get(ts) ?? null);
};
const formatTimeLabel = (ts) => {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
try {
const [sessionsResp, guildsResp, presencesResp, callsResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.sessions.count').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.guilds.count').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.presences.count').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.calls.count').then(r => r.json())
]);
const pcTimestamps = Array.from(new Set([
...sessionsResp.data.map(d => d.timestamp),
...guildsResp.data.map(d => d.timestamp),
...presencesResp.data.map(d => d.timestamp),
...callsResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (pcTimestamps.length > 0) {
new Chart(document.getElementById('processCountsChart'), {
type: 'line',
data: {
labels: pcTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Sessions', data: alignData(sessionsResp.data, pcTimestamps), borderColor: 'rgb(59, 130, 246)', tension: 0.1, spanGaps: true },
{ label: 'Guilds', data: alignData(guildsResp.data, pcTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Presences', data: alignData(presencesResp.data, pcTimestamps), borderColor: 'rgb(168, 85, 247)', tension: 0.1, spanGaps: true },
{ label: 'Calls', data: alignData(callsResp.data, pcTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load process counts chart:', e);
}
try {
const [connResp, disconnResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.websocket.connections').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.websocket.disconnections').then(r => r.json())
]);
const wsTimestamps = Array.from(new Set([
...connResp.data.map(d => d.timestamp),
...disconnResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (wsTimestamps.length > 0) {
new Chart(document.getElementById('wsConnectionChart'), {
type: 'line',
data: {
labels: wsTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Connections', data: alignData(connResp.data, wsTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Disconnections', data: alignData(disconnResp.data, wsTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load WebSocket connection chart:', e);
}
try {
const [hbSuccessResp, hbFailResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.heartbeat.success').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.heartbeat.failure').then(r => r.json())
]);
const hbTimestamps = Array.from(new Set([
...hbSuccessResp.data.map(d => d.timestamp),
...hbFailResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (hbTimestamps.length > 0) {
new Chart(document.getElementById('heartbeatChart'), {
type: 'line',
data: {
labels: hbTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Success', data: alignData(hbSuccessResp.data, hbTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Failure', data: alignData(hbFailResp.data, hbTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load heartbeat chart:', e);
}
try {
const [resumeSuccessResp, resumeFailResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.resume.success').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.resume.failure').then(r => r.json())
]);
const resumeTimestamps = Array.from(new Set([
...resumeSuccessResp.data.map(d => d.timestamp),
...resumeFailResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (resumeTimestamps.length > 0) {
new Chart(document.getElementById('resumeChart'), {
type: 'line',
data: {
labels: resumeTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Success', data: alignData(resumeSuccessResp.data, resumeTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Failure', data: alignData(resumeFailResp.data, resumeTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load resume chart:', e);
}
try {
const rateLimitResp = await fetch(endpoint + '/query?metric=gateway.identify.rate_limited').then(r => r.json());
const rlTimestamps = rateLimitResp.data.map(d => d.timestamp).sort((a, b) => a - b);
if (rlTimestamps.length > 0) {
new Chart(document.getElementById('rateLimitChart'), {
type: 'bar',
data: {
labels: rlTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Rate Limited', data: alignData(rateLimitResp.data, rlTimestamps), backgroundColor: 'rgb(251, 146, 60)' }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load rate limit chart:', e);
}
try {
const [p50Resp, p95Resp, p99Resp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.rpc.latency.p50').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.rpc.latency.p95').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.rpc.latency.p99').then(r => r.json())
]);
const latencyTimestamps = Array.from(new Set([
...p50Resp.data.map(d => d.timestamp),
...p95Resp.data.map(d => d.timestamp),
...p99Resp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (latencyTimestamps.length > 0) {
new Chart(document.getElementById('rpcLatencyChart'), {
type: 'line',
data: {
labels: latencyTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'p50', data: alignData(p50Resp.data, latencyTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'p95', data: alignData(p95Resp.data, latencyTimestamps), borderColor: 'rgb(251, 146, 60)', tension: 0.1, spanGaps: true },
{ label: 'p99', data: alignData(p99Resp.data, latencyTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Latency (ms)' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load RPC latency chart:', e);
}
try {
const [smResp, gmResp, pmResp, cmResp, pushResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.mailbox.session_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.guild_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.presence_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.call_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.push').then(r => r.json())
]);
const mbTimestamps = Array.from(new Set([
...smResp.data.map(d => d.timestamp),
...gmResp.data.map(d => d.timestamp),
...pmResp.data.map(d => d.timestamp),
...cmResp.data.map(d => d.timestamp),
...pushResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (mbTimestamps.length > 0) {
new Chart(document.getElementById('mailboxChart'), {
type: 'line',
data: {
labels: mbTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Session Manager', data: alignData(smResp.data, mbTimestamps), borderColor: 'rgb(59, 130, 246)', tension: 0.1, spanGaps: true },
{ label: 'Guild Manager', data: alignData(gmResp.data, mbTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Presence Manager', data: alignData(pmResp.data, mbTimestamps), borderColor: 'rgb(168, 85, 247)', tension: 0.1, spanGaps: true },
{ label: 'Call Manager', data: alignData(cmResp.data, mbTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true },
{ label: 'Push', data: alignData(pushResp.data, mbTimestamps), borderColor: 'rgb(251, 146, 60)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Queue Length' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load mailbox chart:', e);
}
try {
const [presenceCacheResp, pushMemResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.memory.presence_cache').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.memory.push').then(r => r.json())
]);
const memTimestamps = Array.from(new Set([
...presenceCacheResp.data.map(d => d.timestamp),
...pushMemResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (memTimestamps.length > 0) {
new Chart(document.getElementById('cacheMemoryChart'), {
type: 'line',
data: {
labels: memTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Presence Cache', data: alignData(presenceCacheResp.data, memTimestamps), borderColor: 'rgb(59, 130, 246)', tension: 0.1, spanGaps: true },
{ label: 'Push Cache', data: alignData(pushMemResp.data, memTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Bytes' },
ticks: { callback: function(value) { return formatBytes(value); } }
}
},
plugins: {
legend: { position: 'top' },
tooltip: { callbacks: { label: function(context) { return context.dataset.label + ': ' + formatBytes(context.raw); } } }
}
}
});
}
} catch (e) {
console.error('Failed to load cache memory chart:', e);
}
})();
"
}
fn render_stat_card(_ctx: Context, label: String, value: String) {
h.div(
[
a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200"),
],
[
h.div(
[
a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1"),
],
[
element.text(label),
],
),
h.div([a.class("text-base font-semibold text-neutral-900")], [
element.text(value),
]),
],
)
}

View File

@@ -0,0 +1,393 @@
//// 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/acl
import fluxer_admin/api/codes
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/slider_control
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
const max_gift_codes = 100
const default_gift_count = 10
const gift_product_options = [
#("gift_1_month", "Gift - 1 Month subscription"),
#("gift_1_year", "Gift - 1 Year subscription"),
#("gift_visionary", "Gift - Visionary lifetime"),
]
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
) -> Response {
render_page(
ctx,
session,
current_admin,
flash_data,
admin_acls,
default_gift_count,
option.None,
option.None,
)
}
fn render_page(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
selected_count: Int,
generation_result: option.Option(flash.Flash),
generated_codes: option.Option(List(String)),
) -> Response {
let has_permission =
acl.has_permission(admin_acls, constants.acl_gift_codes_generate)
let content = case has_permission {
True ->
render_generator_card(generated_codes, generation_result, selected_count)
False -> render_access_denied()
}
let html =
layout.page(
"Gift Codes",
"gift-codes",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_generator_card(
generated_codes: option.Option(List(String)),
generation_result: option.Option(flash.Flash),
selected_count: Int,
) -> element.Element(a) {
let codes_value = case generated_codes {
option.Some(codes) -> string.join(codes, "\n")
option.None -> ""
}
let status_view = flash.view(generation_result)
h.div([a.class("max-w-7xl mx-auto space-y-6")], [
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("space-y-2")], [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Generate Gift Codes"),
]),
]),
status_view,
h.form(
[
a.id("gift-form"),
a.class("space-y-4"),
a.method("POST"),
a.action("?action=generate"),
],
[
h.div([a.class("space-y-4")], [
h.div([a.class("flex items-center justify-between")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("How many codes"),
]),
h.span([a.class("text-xs text-neutral-500")], [
element.text("Range: 1-" <> int.to_string(max_gift_codes)),
]),
]),
h.div(
[a.class("space-y-4")],
list.append(
slider_control.range_slider_section(
"gift-count-slider",
"gift-count-value",
1,
max_gift_codes,
selected_count,
),
[
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Select the number of gift codes to generate.",
),
]),
h.div([a.class("space-y-1")], [
h.label(
[a.class("text-sm font-medium text-neutral-800")],
[
element.text("Product"),
],
),
h.select(
[
a.name("product_type"),
a.class(
"w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm text-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
],
list.map(gift_product_options, fn(option) {
let value = option.0
let label = option.1
h.option([a.value(value)], label)
}),
),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Generated codes are rendered as https://fluxer.gift/<code>.",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Generate Gift Codes")],
),
],
),
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("Generated URLs"),
]),
h.textarea(
[
a.readonly(True),
a.attribute("rows", "10"),
a.class(
"w-full border border-neutral-200 rounded-lg px-4 py-3 text-sm text-neutral-900 bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
a.placeholder(
"Full gift URLs will appear here after generation.",
),
],
codes_value,
),
h.p([a.class("text-xs text-neutral-500")], [
element.text("Copy one URL per line when sharing codes."),
]),
]),
],
),
slider_control.slider_sync_script(
"gift-count-slider",
"gift-count-value",
),
]),
]),
])
}
fn render_access_denied() -> element.Element(a) {
ui.card(ui.PaddingMedium, [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Gift Codes"),
]),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You do not have permission to generate gift codes."),
]),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
case action {
option.Some("generate") ->
handle_generate(ctx, session, current_admin, admin_acls, form_data)
_ ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_gift_count,
option.Some(flash.Flash("Unknown action", flash.Error)),
option.None,
)
}
}
fn handle_generate(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
form_data: wisp.FormData,
) -> Response {
case acl.has_permission(admin_acls, constants.acl_gift_codes_generate) {
False ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_gift_count,
option.Some(flash.Flash("Permission denied", flash.Error)),
option.None,
)
True ->
case parse_count(form_data), parse_product_type(form_data) {
option.Some(value), option.Some(product) ->
case value < 1 || value > max_gift_codes {
True ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Count must be between 1 and "
<> int.to_string(max_gift_codes),
flash.Error,
)),
option.None,
)
False ->
case codes.generate_gift_codes(ctx, session, value, product) {
Ok(generated) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Generated "
<> int.to_string(list.length(generated))
<> " gift codes.",
flash.Success,
)),
option.Some(generated),
)
Error(err) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(api_error_message(err), flash.Error)),
option.None,
)
}
}
option.None, _ ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_gift_count,
option.Some(flash.Flash("Count is required", flash.Error)),
option.None,
)
option.Some(value), option.None ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash("Product type is required", flash.Error)),
option.None,
)
}
}
}
fn parse_count(form_data: wisp.FormData) -> option.Option(Int) {
let value =
list.key_find(form_data.values, "count")
|> option.from_result
case value {
option.Some(str) ->
case int.parse(str) {
Ok(num) -> option.Some(num)
Error(_) -> option.None
}
option.None -> option.None
}
}
fn parse_product_type(form_data: wisp.FormData) -> option.Option(String) {
let raw =
list.key_find(form_data.values, "product_type")
|> option.from_result
case raw {
option.Some(value) ->
case list.any(gift_product_options, fn(option) { option.0 == value }) {
True -> option.Some(value)
False -> option.None
}
option.None -> option.None
}
}
fn api_error_message(err: common.ApiError) -> String {
case err {
common.Unauthorized -> "Unauthorized"
common.Forbidden(message) -> message
common.NotFound -> "Not Found"
common.NetworkError -> "Network error"
common.ServerError -> "Server error"
}
}

View File

@@ -0,0 +1,183 @@
//// 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/constants
import fluxer_admin/web.{type Context, action}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn render_features_form(
ctx: Context,
current_features: List(String),
guild_id: String,
) {
let all_features = constants.get_guild_features()
let known_feature_values = list.map(all_features, fn(f) { f.value })
let custom_features =
list.filter(current_features, fn(f) {
!list.contains(known_feature_values, f)
})
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=update-features&tab=features",
),
a.id("features-form"),
],
[
h.div(
[a.class("space-y-3")],
list.map(all_features, fn(feature) {
render_feature_checkbox(feature, current_features)
}),
),
h.div([a.class("mt-6 pt-6 border-t border-neutral-200")], [
h.label([a.class("block")], [
h.span([a.class("text-sm text-neutral-900 mb-2 block")], [
element.text("Custom Features"),
]),
h.p([a.class("text-xs text-neutral-600 mb-2")], [
element.text(
"Enter custom feature strings separated by commas (e.g., CUSTOM_FEATURE_1, CUSTOM_FEATURE_2)",
),
]),
h.input([
a.type_("text"),
a.name("custom_features"),
a.placeholder("CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"),
a.value(
list.fold(custom_features, "", fn(acc, f) {
case acc {
"" -> f
_ -> acc <> ", " <> f
}
}),
),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
a.attribute(
"onchange",
"document.getElementById('features-save-button').classList.remove('hidden')",
),
]),
]),
]),
h.div(
[
a.class("mt-6 pt-6 border-t border-neutral-200"),
a.id("features-save-button"),
],
[
ui.button_primary("Save Changes", "submit", []),
],
),
],
)
}
pub fn render_feature_checkbox(
feature: constants.GuildFeature,
current_features: List(String),
) {
let is_checked = list.contains(current_features, feature.value)
let onchange_script = case feature.value {
"UNAVAILABLE_FOR_EVERYONE" ->
"if(this.checked){const other=document.querySelector('input[value=\"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF\"]');if(other)other.checked=false;}document.getElementById('features-save-button').classList.remove('hidden')"
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF" ->
"if(this.checked){const other=document.querySelector('input[value=\"UNAVAILABLE_FOR_EVERYONE\"]');if(other)other.checked=false;}document.getElementById('features-save-button').classList.remove('hidden')"
_ ->
"document.getElementById('features-save-button').classList.remove('hidden')"
}
ui.custom_checkbox(
"features[]",
feature.value,
feature.value,
is_checked,
option.Some(onchange_script),
)
}
pub fn render_disabled_operations_form(
ctx: Context,
current_disabled_operations: Int,
guild_id: String,
) {
let all_operations = constants.get_disabled_operations()
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/"
<> guild_id
<> "?action=update-disabled-operations&tab=settings",
),
a.id("disabled-ops-form"),
],
[
h.div(
[a.class("space-y-3")],
list.map(all_operations, fn(operation) {
render_disabled_operation_checkbox(
operation,
current_disabled_operations,
)
}),
),
h.div(
[
a.class("mt-6 pt-6 border-t border-neutral-200 hidden"),
a.id("disabled-ops-save-button"),
],
[
ui.button_primary("Save Changes", "submit", []),
],
),
],
)
}
pub fn render_disabled_operation_checkbox(
operation: constants.Flag,
current_disabled_operations: Int,
) {
let is_checked =
int.bitwise_and(current_disabled_operations, operation.value)
== operation.value
ui.custom_checkbox(
"disabled_operations[]",
int.to_string(operation.value),
operation.name,
is_checked,
option.Some(
"document.getElementById('disabled-ops-save-button').classList.remove('hidden')",
),
)
}

View File

@@ -0,0 +1,535 @@
//// 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/assets
import fluxer_admin/api/guilds
import fluxer_admin/api/search
import fluxer_admin/components/flash
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import wisp.{type Request, type Response}
pub fn handle_clear_fields(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let fields = case list.key_find(form_data.values, "fields") {
Ok(value) -> [value]
Error(_) -> []
}
case guilds.clear_guild_fields(ctx, session, guild_id, fields) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild fields cleared successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to clear guild fields",
)
}
}
pub fn handle_update_features(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let guild_result = guilds.lookup_guild(ctx, session, guild_id)
case guild_result {
Error(_) -> flash.redirect_with_error(ctx, redirect_url, "Guild not found")
Ok(option.None) ->
flash.redirect_with_error(ctx, redirect_url, "Guild not found")
Ok(option.Some(current_guild)) -> {
let submitted_features =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"features[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let custom_features_input =
list.key_find(form_data.values, "custom_features")
|> result.unwrap("")
let custom_features =
string.split(custom_features_input, ",")
|> list.map(string.trim)
|> list.filter(fn(s) { s != "" })
let submitted_features = list.append(submitted_features, custom_features)
let submitted_features = case
list.contains(submitted_features, "UNAVAILABLE_FOR_EVERYONE")
&& list.contains(
submitted_features,
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
)
{
True ->
list.filter(submitted_features, fn(f) {
f != "UNAVAILABLE_FOR_EVERYONE_BUT_STAFF"
})
False -> submitted_features
}
let add_features =
list.filter(submitted_features, fn(feature) {
!list.contains(current_guild.features, feature)
})
let remove_features =
list.filter(current_guild.features, fn(feature) {
!list.contains(submitted_features, feature)
})
case
guilds.update_guild_features(
ctx,
session,
guild_id,
add_features,
remove_features,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild features updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update guild features",
)
}
}
}
}
pub fn handle_update_disabled_operations(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let checked_ops =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"disabled_operations[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let disabled_ops_value =
list.fold(checked_ops, 0, fn(acc, op_str) {
case int.parse(op_str) {
Ok(val) -> int.bitwise_or(acc, val)
Error(_) -> acc
}
})
case
guilds.update_guild_settings(
ctx,
session,
guild_id,
option.None,
option.None,
option.None,
option.None,
option.None,
option.Some(disabled_ops_value),
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Disabled operations updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update disabled operations",
)
}
}
pub fn handle_update_name(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let name = list.key_find(form_data.values, "name") |> result.unwrap("")
case guilds.update_guild_name(ctx, session, guild_id, name) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild name updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update guild name",
)
}
}
pub fn handle_update_vanity(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let vanity = case list.key_find(form_data.values, "vanity_url_code") {
Ok("") -> option.None
Ok(code) -> option.Some(code)
Error(_) -> option.None
}
case guilds.update_guild_vanity(ctx, session, guild_id, vanity) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Vanity URL updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update vanity URL",
)
}
}
pub fn handle_transfer_ownership(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let new_owner_id =
list.key_find(form_data.values, "new_owner_id") |> result.unwrap("")
case guilds.transfer_guild_ownership(ctx, session, guild_id, new_owner_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild ownership transferred successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to transfer guild ownership",
)
}
}
pub fn handle_reload(
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
case guilds.reload_guild(ctx, session, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild reloaded successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to reload guild")
}
}
pub fn handle_shutdown(
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
case guilds.shutdown_guild(ctx, session, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild shutdown successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to shutdown guild")
}
}
pub fn handle_delete_guild(
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
case guilds.delete_guild(ctx, session, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild deleted successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to delete guild")
}
}
pub fn handle_update_settings(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let verification_level =
list.key_find(form_data.values, "verification_level")
|> result.try(int.parse)
|> option.from_result
let mfa_level =
list.key_find(form_data.values, "mfa_level")
|> result.try(int.parse)
|> option.from_result
let nsfw_level =
list.key_find(form_data.values, "nsfw_level")
|> result.try(int.parse)
|> option.from_result
let explicit_content_filter =
list.key_find(form_data.values, "explicit_content_filter")
|> result.try(int.parse)
|> option.from_result
let default_message_notifications =
list.key_find(form_data.values, "default_message_notifications")
|> result.try(int.parse)
|> option.from_result
case
guilds.update_guild_settings(
ctx,
session,
guild_id,
verification_level,
mfa_level,
nsfw_level,
explicit_content_filter,
default_message_notifications,
option.None,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild settings updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update guild settings",
)
}
}
pub fn handle_force_add_user(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let user_id = list.key_find(form_data.values, "user_id") |> result.unwrap("")
case guilds.force_add_user_to_guild(ctx, session, user_id, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"User added to guild successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to add user to guild",
)
}
}
pub fn handle_refresh_search_index(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let index_type =
list.key_find(form_data.values, "index_type") |> result.unwrap("")
case
search.refresh_search_index_with_guild(
ctx,
session,
index_type,
option.Some(guild_id),
option.None,
)
{
Ok(response) ->
flash.redirect_with_success(
ctx,
"/search-index?job_id=" <> response.job_id,
"Search index refresh started successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to start search index refresh",
)
}
}
pub fn handle_delete_emoji(
req: Request,
ctx: Context,
session: Session,
_guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let emoji_id =
list.key_find(form_data.values, "emoji_id")
|> result.unwrap("")
|> string.trim
case emoji_id {
"" -> flash.redirect_with_error(ctx, redirect_url, "Emoji ID is required.")
_ -> handle_delete_asset(ctx, session, redirect_url, emoji_id, "Emoji")
}
}
pub fn handle_delete_sticker(
req: Request,
ctx: Context,
session: Session,
_guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let sticker_id =
list.key_find(form_data.values, "sticker_id")
|> result.unwrap("")
|> string.trim
case sticker_id {
"" ->
flash.redirect_with_error(ctx, redirect_url, "Sticker ID is required.")
_ -> handle_delete_asset(ctx, session, redirect_url, sticker_id, "Sticker")
}
}
fn handle_delete_asset(
ctx: Context,
session: Session,
redirect_url: String,
asset_id: String,
asset_label: String,
) -> Response {
case assets.purge_assets(ctx, session, [asset_id], option.None) {
Ok(response) -> {
case list.find(response.errors, fn(err) { err.id == asset_id }) {
Ok(err) ->
flash.redirect_with_error(
ctx,
redirect_url,
asset_label <> " deletion failed: " <> err.error,
)
Error(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
asset_label <> " deleted successfully.",
)
}
}
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
asset_label <> " deletion failed.",
)
}
}

View File

@@ -0,0 +1,159 @@
//// 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/acl
import fluxer_admin/api/guild_assets
import fluxer_admin/components/errors
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session, action, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn emojis_tab(
ctx: Context,
session: Session,
guild_id: String,
admin_acls: List(String),
) {
let has_permission = acl.has_permission(admin_acls, constants.acl_asset_purge)
case has_permission {
True ->
case guild_assets.list_guild_emojis(ctx, session, guild_id) {
Ok(response) -> render_emojis(ctx, guild_id, response.emojis)
Error(err) ->
errors.api_error_view(
ctx,
err,
option.Some("/guilds/" <> guild_id <> "?tab=emojis"),
option.Some("Back to Guild"),
)
}
False -> render_permission_notice()
}
}
fn render_emojis(
ctx: Context,
guild_id: String,
emojis: List(guild_assets.GuildEmojiAsset),
) {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(
"Emojis (" <> int.to_string(list.length(emojis)) <> ")",
),
case list.is_empty(emojis) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No custom emojis found for this guild."),
])
False ->
h.div(
[a.class("mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
list.map(emojis, fn(emoji) { render_emoji_card(ctx, guild_id, emoji) }),
)
},
])
}
fn render_emoji_card(
ctx: Context,
guild_id: String,
emoji: guild_assets.GuildEmojiAsset,
) {
h.div(
[
a.class(
"flex flex-col border border-neutral-200 rounded-lg overflow-hidden bg-white shadow-sm",
),
],
[
h.div(
[a.class("bg-neutral-100 flex items-center justify-center p-6 h-32")],
[
h.img([
a.src(emoji.media_url),
a.alt(emoji.name),
a.class("max-h-full max-w-full object-contain"),
a.loading("lazy"),
]),
],
),
h.div([a.class("px-4 py-3 flex-1 flex flex-col")], [
h.div([a.class("flex items-center justify-between gap-2")], [
h.span([a.class("text-sm font-semibold text-neutral-900")], [
element.text(emoji.name),
]),
case emoji.animated {
True ->
h.span(
[
a.class(
"text-xs font-semibold uppercase tracking-wide text-neutral-500 px-2 py-0.5 border border-neutral-200 rounded",
),
],
[element.text("Animated")],
)
False -> element.none()
},
]),
h.p([a.class("text-xs text-neutral-500 mt-1 break-words")], [
element.text("ID: " <> emoji.id),
]),
h.a(
[
href(ctx, "/users/" <> emoji.creator_id),
a.class("text-xs text-blue-600 hover:underline mt-1"),
],
[
element.text("Uploader: " <> emoji.creator_id),
],
),
h.form(
[
action(
ctx,
"/guilds/" <> guild_id <> "?tab=emojis&action=delete-emoji",
),
a.method("post"),
a.class("mt-4"),
],
[
h.input([a.type_("hidden"), a.name("emoji_id"), a.value(emoji.id)]),
ui.button("Delete Emoji", "submit", ui.Danger, ui.Small, ui.Full, [
a.class("mt-2"),
]),
],
),
]),
],
)
}
fn render_permission_notice() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Permission required"),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You need the asset:purge ACL to manage guild emojis."),
]),
])
}

View File

@@ -0,0 +1,71 @@
//// 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/acl
import fluxer_admin/api/guilds
import fluxer_admin/components/ui
import fluxer_admin/pages/guild_detail/forms
import fluxer_admin/web.{type Context}
import gleam/list
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn features_tab(
ctx: Context,
guild: guilds.GuildLookupResult,
guild_id: String,
admin_acls: List(String),
) {
h.div([a.class("space-y-6")], [
case acl.has_permission(admin_acls, "guild:update:features") {
True ->
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Guild Features"),
]),
h.p([a.class("text-sm text-neutral-600 mb-4")], [
element.text("Select which features are enabled for this guild."),
]),
forms.render_features_form(ctx, guild.features, guild_id),
])
False ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Guild Features"),
case list.is_empty(guild.features) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No features enabled"),
])
False ->
h.div([a.class("flex flex-wrap gap-2")], {
list.map(guild.features, fn(feature) {
h.span(
[
a.class(
"px-3 py-1 bg-purple-100 text-purple-700 text-sm rounded",
),
],
[element.text(feature)],
)
})
})
},
])
},
])
}

View File

@@ -0,0 +1,331 @@
//// 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/acl
import fluxer_admin/api/common
import fluxer_admin/api/guilds_members
import fluxer_admin/avatar
import fluxer_admin/badge
import fluxer_admin/components/ui
import fluxer_admin/user
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn members_tab(
ctx: Context,
session: Session,
guild_id: String,
admin_acls: List(String),
page: Int,
) {
let limit = 50
let offset = page * limit
case acl.has_permission(admin_acls, "guild:list:members") {
True -> {
case
guilds_members.list_guild_members(ctx, session, guild_id, limit, offset)
{
Ok(response) ->
render_members_list(ctx, guild_id, response, page, limit)
Error(common.Forbidden(message)) ->
render_error("Permission Denied", message)
Error(common.NotFound) -> render_error("Not Found", "Guild not found.")
Error(_) ->
render_error(
"Error",
"Failed to load guild members. Please try again.",
)
}
}
False ->
render_error(
"Permission Denied",
"You don't have permission to view guild members.",
)
}
}
fn render_error(title: String, message: String) {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(title),
h.p([a.class("text-sm text-neutral-600")], [element.text(message)]),
])
}
fn render_members_list(
ctx: Context,
guild_id: String,
response: guilds_members.ListGuildMembersResponse,
page: Int,
limit: Int,
) {
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("flex justify-between items-center mb-4")], [
ui.heading_card(
"Guild Members (" <> int.to_string(response.total) <> ")",
),
render_pagination_info(response.offset, response.limit, response.total),
]),
case list.is_empty(response.members) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No members found."),
])
False ->
h.div([a.class("space-y-2")], {
list.map(response.members, render_member(ctx, _))
})
},
render_pagination(ctx, guild_id, page, response.total, limit),
]),
])
}
fn render_pagination_info(offset: Int, limit: Int, total: Int) {
let start = offset + 1
let end = case offset + limit > total {
True -> total
False -> offset + limit
}
h.p([a.class("text-sm text-neutral-600")], [
element.text(
"Showing "
<> int.to_string(start)
<> "-"
<> int.to_string(end)
<> " of "
<> int.to_string(total),
),
])
}
fn render_pagination(
ctx: Context,
guild_id: String,
current_page: Int,
total: Int,
limit: Int,
) {
let total_pages = { total + limit - 1 } / limit
let has_previous = current_page > 0
let has_next = current_page < total_pages - 1
case total_pages > 1 {
False -> element.none()
True ->
h.div([a.class("flex justify-between items-center mt-4 pt-4 border-t")], [
case has_previous {
True ->
h.a(
[
href(
ctx,
"/guilds/"
<> guild_id
<> "?tab=members&page="
<> int.to_string(current_page - 1),
),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("← Previous")],
)
False ->
h.div(
[
a.class(
"px-4 py-2 bg-neutral-300 text-neutral-500 rounded text-sm font-medium cursor-not-allowed",
),
],
[element.text("← Previous")],
)
},
h.div([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 ->
h.a(
[
href(
ctx,
"/guilds/"
<> guild_id
<> "?tab=members&page="
<> int.to_string(current_page + 1),
),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Next →")],
)
False ->
h.div(
[
a.class(
"px-4 py-2 bg-neutral-300 text-neutral-500 rounded text-sm font-medium cursor-not-allowed",
),
],
[element.text("Next →")],
)
},
])
}
}
fn render_member(ctx: Context, member: guilds_members.GuildMember) {
let badges =
badge.get_user_badges(
ctx.cdn_endpoint,
int.to_string(member.user.public_flags),
)
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg overflow-hidden hover:border-neutral-300 transition-colors",
),
],
[
h.div([a.class("p-5")], [
h.div([a.class("flex items-center gap-4")], [
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
ctx.cdn_endpoint,
member.user.id,
member.user.avatar,
True,
ctx.asset_version,
)),
a.alt(member.user.username),
a.class("w-16 h-16 rounded-full flex-shrink-0"),
]),
h.div([a.class("flex-1 min-w-0")], [
h.div([a.class("flex items-center gap-2 mb-1")], [
h.h2([a.class("text-base font-medium text-neutral-900")], [
element.text(
member.user.username
<> "#"
<> case int.parse(member.user.discriminator) {
Ok(disc_int) -> user.format_discriminator(disc_int)
Error(_) -> member.user.discriminator
},
),
]),
case member.user.bot {
True ->
h.span(
[
a.class("px-2 py-0.5 bg-blue-100 text-blue-700 rounded"),
],
[element.text("Bot")],
)
False -> element.none()
},
case member.nick {
option.Some(nick) ->
h.span(
[
a.class("text-sm text-neutral-600 ml-2"),
],
[
element.text("(" <> nick <> ")"),
],
)
option.None -> element.none()
},
]),
case list.is_empty(badges) {
False ->
h.div(
[a.class("flex items-center gap-1.5 mb-2")],
list.map(badges, fn(b) {
h.img([
a.src(b.icon),
a.alt(b.name),
a.title(b.name),
a.class("w-5 h-5"),
])
}),
)
True -> element.none()
},
h.div([a.class("space-y-0.5")], [
h.div([a.class("text-sm text-neutral-600")], [
element.text("ID: " <> member.user.id),
]),
case user.extract_timestamp(member.user.id) {
Ok(created_at) ->
h.div([a.class("text-sm text-neutral-500")], [
element.text("Created: " <> created_at),
])
Error(_) -> element.none()
},
h.div([a.class("text-sm text-neutral-500")], [
element.text("Joined: " <> format_date(member.joined_at)),
]),
case member.roles != [] {
True ->
h.div([a.class("text-sm text-neutral-500")], [
element.text(
int.to_string(list.length(member.roles)) <> " roles",
),
])
False -> element.none()
},
]),
]),
h.a(
[
href(ctx, "/users/" <> member.user.id),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors flex-shrink-0 no-underline",
),
],
[element.text("View Details")],
),
]),
]),
],
)
}
fn format_date(iso_date: String) -> String {
case iso_date {
_ ->
case string.split(iso_date, "T") {
[date, ..] -> date
_ -> iso_date
}
}
}

Some files were not shown because too many files have changed in this diff Show More