feat: initial fluxer codebase import
This commit is contained in:
80
.devcontainer/devcontainer.json
Normal file
80
.devcontainer/devcontainer.json
Normal 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
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
Dockerfile
|
||||
target
|
||||
.mongo
|
||||
.env
|
||||
84
.env.example
Normal file
84
.env.example
Normal 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
34
.gitattributes
vendored
Normal 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
94
.gitignore
vendored
Normal 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
19
.mise/config.toml
Normal 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
5
.mise/tasks/build
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Build project"
|
||||
set -e
|
||||
|
||||
cargo build "$@"
|
||||
5
.mise/tasks/check
Executable file
5
.mise/tasks/check
Executable 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
5
.mise/tasks/docker/start
Executable 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
5
.mise/tasks/docker/stop
Executable 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
7
.mise/tasks/docs/_default
Executable 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
7
.mise/tasks/docs/build
Executable 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
6
.mise/tasks/docs/install
Executable 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
5
.mise/tasks/publish
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Publish project"
|
||||
set -e
|
||||
|
||||
cargo publish "$@"
|
||||
5
.mise/tasks/service/api
Executable file
5
.mise/tasks/service/api
Executable 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
5
.mise/tasks/service/crond
Executable 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
5
.mise/tasks/service/events
Executable 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
5
.mise/tasks/service/files
Executable 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
5
.mise/tasks/service/gifbox
Executable 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
5
.mise/tasks/service/proxy
Executable 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
5
.mise/tasks/service/pushd
Executable 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
8
.mise/tasks/test
Executable 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
69
.pre-commit-config.yaml
Normal 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
1687
.secrets.baseline
Normal file
File diff suppressed because it is too large
Load Diff
58
.yamllint
Normal file
58
.yamllint
Normal 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
91
dev/Caddyfile.dev
Normal 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
160
dev/compose.data.yaml
Normal file
@@ -0,0 +1,160 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: fluxer
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
cassandra:
|
||||
image: scylladb/scylla:latest
|
||||
command: --smp 1 --memory 512M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0
|
||||
ports:
|
||||
- '9042:9042'
|
||||
volumes:
|
||||
- scylla_data:/var/lib/scylla
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'cqlsh -e "describe cluster"']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:latest
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: valkey-server --save 60 1 --loglevel warning
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', 'mc', 'ready', 'local']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-setup:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set minio http://minio:9000 minioadmin minioadmin;
|
||||
mc mb --ignore-existing minio/fluxer-metrics;
|
||||
mc mb --ignore-existing minio/fluxer-uploads;
|
||||
exit 0;
|
||||
"
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: 'no'
|
||||
|
||||
clamav:
|
||||
image: clamav/clamav:latest
|
||||
volumes:
|
||||
- clamav_data:/var/lib/clamav
|
||||
environment:
|
||||
CLAMAV_NO_FRESHCLAMD: 'false'
|
||||
CLAMAV_NO_CLAMD: 'false'
|
||||
CLAMAV_NO_MILTERD: 'true'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', '/usr/local/bin/clamdcheck.sh']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 300s
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.25.0
|
||||
volumes:
|
||||
- meilisearch_data:/meili_data
|
||||
environment:
|
||||
MEILI_ENV: development
|
||||
MEILI_MASTER_KEY: masterKey
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /etc/livekit.yaml --dev
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- '7880:7880'
|
||||
- '7882:7882/udp'
|
||||
- '7999:7999/udp'
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:24.8
|
||||
hostname: clickhouse
|
||||
profiles:
|
||||
- clickhouse
|
||||
environment:
|
||||
- CLICKHOUSE_DB=fluxer_metrics
|
||||
- CLICKHOUSE_USER=fluxer
|
||||
- CLICKHOUSE_PASSWORD=fluxer_dev
|
||||
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
|
||||
volumes:
|
||||
- clickhouse_data:/var/lib/clickhouse
|
||||
- clickhouse_logs:/var/log/clickhouse-server
|
||||
networks:
|
||||
- fluxer-shared
|
||||
ports:
|
||||
- '8123:8123'
|
||||
- '9000:9000'
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD', 'clickhouse-client', '--query', 'SELECT 1']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
name: fluxer-shared
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
scylla_data:
|
||||
redis_data:
|
||||
minio_data:
|
||||
clamav_data:
|
||||
meilisearch_data:
|
||||
clickhouse_data:
|
||||
clickhouse_logs:
|
||||
386
dev/compose.yaml
Normal file
386
dev/compose.yaml
Normal 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
48
fluxer_admin/Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
ARG BUILD_TIMESTAMP=0
|
||||
FROM erlang:27.1.1.0-alpine AS builder
|
||||
|
||||
COPY --from=ghcr.io/gleam-lang/gleam:nightly-erlang /bin/gleam /bin/gleam
|
||||
|
||||
RUN apk add --no-cache git curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY gleam.toml manifest.toml ./
|
||||
COPY src ./src
|
||||
COPY priv ./priv
|
||||
COPY tailwind.css ./
|
||||
|
||||
RUN gleam deps download
|
||||
RUN gleam export erlang-shipment
|
||||
|
||||
ARG TAILWIND_VERSION=v4.1.17
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
TAILWIND_ARCH="x64"; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then \
|
||||
TAILWIND_ARCH="arm64"; \
|
||||
else \
|
||||
TAILWIND_ARCH="x64"; \
|
||||
fi && \
|
||||
echo "Downloading Tailwind CSS $TAILWIND_VERSION for Alpine Linux: linux-$TAILWIND_ARCH-musl" && \
|
||||
curl -sSLf -o /tmp/tailwindcss "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${TAILWIND_ARCH}-musl" && \
|
||||
chmod +x /tmp/tailwindcss && \
|
||||
/tmp/tailwindcss -i ./tailwind.css -o ./priv/static/app.css --minify
|
||||
|
||||
FROM erlang:27.1.1.0-alpine
|
||||
|
||||
ARG BUILD_TIMESTAMP
|
||||
|
||||
RUN apk add --no-cache openssl ncurses-libs curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/build/erlang-shipment /app
|
||||
COPY --from=builder /app/priv ./priv
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV PORT=8080
|
||||
ENV BUILD_TIMESTAMP=${BUILD_TIMESTAMP}
|
||||
|
||||
CMD ["/app/entrypoint.sh", "run"]
|
||||
21
fluxer_admin/Dockerfile.dev
Normal file
21
fluxer_admin/Dockerfile.dev
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM ghcr.io/gleam-lang/gleam:v1.13.0-erlang-alpine
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
# Install dependencies
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Download gleam dependencies
|
||||
COPY gleam.toml manifest.toml* ./
|
||||
RUN gleam deps download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Download and setup tailwindcss, then build CSS
|
||||
RUN mkdir -p build/bin && \
|
||||
curl -sLo build/bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.17/tailwindcss-linux-x64-musl && \
|
||||
chmod +x build/bin/tailwindcss && \
|
||||
build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css
|
||||
|
||||
CMD ["gleam", "run"]
|
||||
21
fluxer_admin/gleam.toml
Normal file
21
fluxer_admin/gleam.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
name = "fluxer_admin"
|
||||
version = "1.0.0"
|
||||
|
||||
[dependencies]
|
||||
gleam_stdlib = ">= 0.63.2 and < 1.0.0"
|
||||
gleam_http = ">= 4.2.0 and < 5.0.0"
|
||||
gleam_erlang = ">= 1.0.0 and < 2.0.0"
|
||||
gleam_json = ">= 3.0.0 and < 4.0.0"
|
||||
gleam_httpc = ">= 5.0.0 and < 6.0.0"
|
||||
wisp = ">= 2.0.0 and < 3.0.0"
|
||||
mist = ">= 5.0.0 and < 6.0.0"
|
||||
lustre = ">= 5.3.0 and < 6.0.0"
|
||||
dot_env = ">= 1.2.0 and < 2.0.0"
|
||||
birl = ">= 1.8.0 and < 2.0.0"
|
||||
logging = ">= 1.3.0 and < 2.0.0"
|
||||
gleam_crypto = ">= 1.5.1 and < 2.0.0"
|
||||
envoy = ">= 1.0.2 and < 2.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
gleeunit = ">= 1.6.1 and < 2.0.0"
|
||||
glailglind = ">= 2.2.0 and < 3.0.0"
|
||||
34
fluxer_admin/justfile
Normal file
34
fluxer_admin/justfile
Normal file
@@ -0,0 +1,34 @@
|
||||
default:
|
||||
@just --list
|
||||
|
||||
build:
|
||||
gleam build
|
||||
|
||||
run:
|
||||
just css && gleam run
|
||||
|
||||
test:
|
||||
gleam test
|
||||
|
||||
css:
|
||||
./build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css
|
||||
|
||||
css-watch:
|
||||
./build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css --watch
|
||||
|
||||
clean:
|
||||
rm -rf build/
|
||||
rm -rf priv/static/app.css
|
||||
|
||||
deps:
|
||||
gleam deps download
|
||||
|
||||
format:
|
||||
gleam format
|
||||
|
||||
check: format build test
|
||||
|
||||
install-tailwind:
|
||||
gleam run -m tailwind/install
|
||||
|
||||
setup: deps install-tailwind css
|
||||
55
fluxer_admin/manifest.toml
Normal file
55
fluxer_admin/manifest.toml
Normal file
@@ -0,0 +1,55 @@
|
||||
# This file was generated by Gleam
|
||||
# You typically do not need to edit this file
|
||||
|
||||
packages = [
|
||||
{ name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" },
|
||||
{ name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
|
||||
{ name = "dot_env", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "simplifile"], otp_app = "dot_env", source = "hex", outer_checksum = "F2B4815F1B5AF8F20A6EADBB393E715C4C35203EBD5BE8200F766EA83A0B18DE" },
|
||||
{ name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
|
||||
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
|
||||
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
|
||||
{ name = "glailglind", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_httpc", "gleam_stdlib", "shellout", "simplifile", "tom"], otp_app = "glailglind", source = "hex", outer_checksum = "B0306F2C0A03A5A03633FC2BDF2D52B1E76FCAED656FB3F5EBCB7C31770E2524" },
|
||||
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
|
||||
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
|
||||
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
|
||||
{ name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },
|
||||
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
|
||||
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
|
||||
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
|
||||
{ name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
|
||||
{ name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" },
|
||||
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
|
||||
{ name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
|
||||
{ name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" },
|
||||
{ name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
|
||||
{ name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
|
||||
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
|
||||
{ name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
|
||||
{ name = "lustre", version = "5.3.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "5CBB5DD2849D8316A2101792FC35AEB58CE4B151451044A9C2A2A70A2F7FCEB8" },
|
||||
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
|
||||
{ name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" },
|
||||
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
|
||||
{ name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" },
|
||||
{ name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" },
|
||||
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
|
||||
{ name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
|
||||
{ name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" },
|
||||
{ name = "wisp", version = "2.1.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "362BDDD11BF48EB38CDE51A73BC7D1B89581B395CA998E3F23F11EC026151C54" },
|
||||
]
|
||||
|
||||
[requirements]
|
||||
birl = { version = ">= 1.8.0 and < 2.0.0" }
|
||||
dot_env = { version = ">= 1.2.0 and < 2.0.0" }
|
||||
glailglind = { version = ">= 2.2.0 and < 3.0.0" }
|
||||
gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" }
|
||||
gleam_http = { version = ">= 4.2.0 and < 5.0.0" }
|
||||
gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" }
|
||||
gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
|
||||
gleam_stdlib = { version = ">= 0.63.2 and < 1.0.0" }
|
||||
gleeunit = { version = ">= 1.6.1 and < 2.0.0" }
|
||||
logging = { version = ">= 1.3.0 and < 2.0.0" }
|
||||
lustre = { version = ">= 5.3.0 and < 6.0.0" }
|
||||
mist = { version = ">= 5.0.0 and < 6.0.0" }
|
||||
wisp = { version = ">= 2.0.0 and < 3.0.0" }
|
||||
gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" }
|
||||
envoy = { version = ">= 1.0.2 and < 2.0.0" }
|
||||
0
fluxer_admin/priv/static/.gitkeep
Normal file
0
fluxer_admin/priv/static/.gitkeep
Normal file
72
fluxer_admin/src/fluxer_admin.gleam
Normal file
72
fluxer_admin/src/fluxer_admin.gleam
Normal file
@@ -0,0 +1,72 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/config
|
||||
import fluxer_admin/middleware/cache_middleware
|
||||
import fluxer_admin/router
|
||||
import fluxer_admin/web.{type Context, Context, normalize_base_path}
|
||||
import gleam/erlang/process
|
||||
import mist
|
||||
import wisp
|
||||
import wisp/wisp_mist
|
||||
|
||||
pub fn main() {
|
||||
wisp.configure_logger()
|
||||
|
||||
let assert Ok(cfg) = config.load_config()
|
||||
|
||||
let base_path = normalize_base_path(cfg.base_path)
|
||||
|
||||
let ctx =
|
||||
Context(
|
||||
api_endpoint: cfg.api_endpoint,
|
||||
oauth_client_id: cfg.oauth_client_id,
|
||||
oauth_client_secret: cfg.oauth_client_secret,
|
||||
oauth_redirect_uri: cfg.oauth_redirect_uri,
|
||||
secret_key_base: cfg.secret_key_base,
|
||||
static_directory: "priv/static",
|
||||
media_endpoint: cfg.media_endpoint,
|
||||
cdn_endpoint: cfg.cdn_endpoint,
|
||||
asset_version: cfg.build_timestamp,
|
||||
base_path: base_path,
|
||||
app_endpoint: cfg.admin_endpoint,
|
||||
web_app_endpoint: cfg.web_app_endpoint,
|
||||
metrics_endpoint: cfg.metrics_endpoint,
|
||||
)
|
||||
|
||||
let assert Ok(_) =
|
||||
wisp_mist.handler(handle_request(_, ctx), cfg.secret_key_base)
|
||||
|> mist.new
|
||||
|> mist.bind("0.0.0.0")
|
||||
|> mist.port(cfg.port)
|
||||
|> mist.start
|
||||
|
||||
process.sleep_forever()
|
||||
}
|
||||
|
||||
fn handle_request(req: wisp.Request, ctx: Context) -> wisp.Response {
|
||||
let static_dir = ctx.static_directory
|
||||
|
||||
case wisp.path_segments(req) {
|
||||
["static", ..] -> {
|
||||
use <- wisp.serve_static(req, under: "/static", from: static_dir)
|
||||
router.handle_request(req, ctx)
|
||||
}
|
||||
_ -> router.handle_request(req, ctx)
|
||||
}
|
||||
|> cache_middleware.add_cache_headers
|
||||
}
|
||||
24
fluxer_admin/src/fluxer_admin/acl.gleam
Normal file
24
fluxer_admin/src/fluxer_admin/acl.gleam
Normal file
@@ -0,0 +1,24 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/constants
|
||||
import gleam/list
|
||||
|
||||
pub fn has_permission(admin_acls: List(String), required_acl: String) -> Bool {
|
||||
list.contains(admin_acls, required_acl)
|
||||
|| list.contains(admin_acls, constants.acl_wildcard)
|
||||
}
|
||||
264
fluxer_admin/src/fluxer_admin/api/archives.gleam
Normal file
264
fluxer_admin/src/fluxer_admin/api/archives.gleam
Normal file
@@ -0,0 +1,264 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
|
||||
pub type Archive {
|
||||
Archive(
|
||||
archive_id: String,
|
||||
subject_type: String,
|
||||
subject_id: String,
|
||||
requested_by: String,
|
||||
requested_at: String,
|
||||
started_at: Option(String),
|
||||
completed_at: Option(String),
|
||||
failed_at: Option(String),
|
||||
file_size: Option(String),
|
||||
progress_percent: Int,
|
||||
progress_step: Option(String),
|
||||
error_message: Option(String),
|
||||
download_url_expires_at: Option(String),
|
||||
expires_at: Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListArchivesResponse {
|
||||
ListArchivesResponse(archives: List(Archive))
|
||||
}
|
||||
|
||||
pub fn trigger_user_archive(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
user_id: String,
|
||||
audit_log_reason: Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/archives/user",
|
||||
[#("user_id", json.string(user_id))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn trigger_guild_archive(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
guild_id: String,
|
||||
audit_log_reason: Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/archives/guild",
|
||||
[#("guild_id", json.string(guild_id))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
fn archive_decoder() {
|
||||
use archive_id <- decode.field("archive_id", decode.string)
|
||||
use subject_type <- decode.field("subject_type", decode.string)
|
||||
use subject_id <- decode.field("subject_id", decode.string)
|
||||
use requested_by <- decode.field("requested_by", decode.string)
|
||||
use requested_at <- decode.field("requested_at", decode.string)
|
||||
use started_at <- decode.optional_field(
|
||||
"started_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use completed_at <- decode.optional_field(
|
||||
"completed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use failed_at <- decode.optional_field(
|
||||
"failed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use file_size <- decode.optional_field(
|
||||
"file_size",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use progress_percent <- decode.field("progress_percent", decode.int)
|
||||
use progress_step <- decode.optional_field(
|
||||
"progress_step",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use error_message <- decode.optional_field(
|
||||
"error_message",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use download_url_expires_at <- decode.optional_field(
|
||||
"download_url_expires_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use expires_at <- decode.optional_field(
|
||||
"expires_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(Archive(
|
||||
archive_id: archive_id,
|
||||
subject_type: subject_type,
|
||||
subject_id: subject_id,
|
||||
requested_by: requested_by,
|
||||
requested_at: requested_at,
|
||||
started_at: started_at,
|
||||
completed_at: completed_at,
|
||||
failed_at: failed_at,
|
||||
file_size: file_size,
|
||||
progress_percent: progress_percent,
|
||||
progress_step: progress_step,
|
||||
error_message: error_message,
|
||||
download_url_expires_at: download_url_expires_at,
|
||||
expires_at: expires_at,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn list_archives(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
subject_type: String,
|
||||
subject_id: Option(String),
|
||||
include_expired: Bool,
|
||||
) -> Result(ListArchivesResponse, ApiError) {
|
||||
let fields = [
|
||||
#("subject_type", json.string(subject_type)),
|
||||
#("include_expired", json.bool(include_expired)),
|
||||
]
|
||||
let fields = case subject_id {
|
||||
option.Some(id) -> fields |> list.append([#("subject_id", json.string(id))])
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let url = ctx.api_endpoint <> "/admin/archives/list"
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use archives <- decode.field("archives", decode.list(archive_decoder()))
|
||||
decode.success(ListArchivesResponse(archives: archives))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_archive_download_url(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
subject_type: String,
|
||||
subject_id: String,
|
||||
archive_id: String,
|
||||
) -> Result(#(String, String), ApiError) {
|
||||
let url =
|
||||
ctx.api_endpoint
|
||||
<> "/admin/archives/"
|
||||
<> subject_type
|
||||
<> "/"
|
||||
<> subject_id
|
||||
<> "/"
|
||||
<> archive_id
|
||||
<> "/download"
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use download_url <- decode.field("downloadUrl", decode.string)
|
||||
use expires_at <- decode.field("expiresAt", decode.string)
|
||||
decode.success(#(download_url, expires_at))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
128
fluxer_admin/src/fluxer_admin/api/assets.gleam
Normal file
128
fluxer_admin/src/fluxer_admin/api/assets.gleam
Normal file
@@ -0,0 +1,128 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type AssetPurgeResult {
|
||||
AssetPurgeResult(
|
||||
id: String,
|
||||
asset_type: String,
|
||||
found_in_db: Bool,
|
||||
guild_id: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type AssetPurgeError {
|
||||
AssetPurgeError(id: String, error: String)
|
||||
}
|
||||
|
||||
pub type AssetPurgeResponse {
|
||||
AssetPurgeResponse(
|
||||
processed: List(AssetPurgeResult),
|
||||
errors: List(AssetPurgeError),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn purge_assets(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(AssetPurgeResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/assets/purge"
|
||||
let body =
|
||||
json.object([#("ids", json.array(ids, json.string))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let result_decoder = {
|
||||
use processed <- decode.field(
|
||||
"processed",
|
||||
decode.list({
|
||||
use id <- decode.field("id", decode.string)
|
||||
use asset_type <- decode.field("asset_type", decode.string)
|
||||
use found_in_db <- decode.field("found_in_db", decode.bool)
|
||||
use guild_id <- decode.field(
|
||||
"guild_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(AssetPurgeResult(
|
||||
id: id,
|
||||
asset_type: asset_type,
|
||||
found_in_db: found_in_db,
|
||||
guild_id: guild_id,
|
||||
))
|
||||
}),
|
||||
)
|
||||
use errors <- decode.field(
|
||||
"errors",
|
||||
decode.list({
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(AssetPurgeError(id: id, error: error))
|
||||
}),
|
||||
)
|
||||
decode.success(AssetPurgeResponse(processed: processed, errors: errors))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, result_decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
169
fluxer_admin/src/fluxer_admin/api/audit.gleam
Normal file
169
fluxer_admin/src/fluxer_admin/api/audit.gleam
Normal file
@@ -0,0 +1,169 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dict
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type AuditLog {
|
||||
AuditLog(
|
||||
log_id: String,
|
||||
admin_user_id: String,
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
action: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
metadata: List(#(String, String)),
|
||||
created_at: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListAuditLogsResponse {
|
||||
ListAuditLogsResponse(logs: List(AuditLog), total: Int)
|
||||
}
|
||||
|
||||
pub fn search_audit_logs(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
query: option.Option(String),
|
||||
admin_user_id_filter: option.Option(String),
|
||||
target_type: option.Option(String),
|
||||
target_id: option.Option(String),
|
||||
action: option.Option(String),
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(ListAuditLogsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/audit-logs/search"
|
||||
|
||||
let mut_fields = [#("limit", json.int(limit)), #("offset", json.int(offset))]
|
||||
|
||||
let mut_fields = case query {
|
||||
option.Some(q) if q != "" -> [#("query", json.string(q)), ..mut_fields]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case admin_user_id_filter {
|
||||
option.Some(id) if id != "" -> [
|
||||
#("admin_user_id", json.string(id)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case target_type {
|
||||
option.Some(tt) if tt != "" -> [
|
||||
#("target_type", json.string(tt)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case target_id {
|
||||
option.Some(tid) if tid != "" -> [
|
||||
#("target_id", json.string(tid)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
let mut_fields = case action {
|
||||
option.Some(act) if act != "" -> [
|
||||
#("action", json.string(act)),
|
||||
..mut_fields
|
||||
]
|
||||
_ -> mut_fields
|
||||
}
|
||||
|
||||
let body = json.object(mut_fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let audit_log_decoder = {
|
||||
use log_id <- decode.field("log_id", decode.string)
|
||||
use admin_user_id <- decode.field("admin_user_id", decode.string)
|
||||
use target_type_val <- decode.field("target_type", decode.string)
|
||||
use target_id_val <- decode.field("target_id", decode.string)
|
||||
use action <- decode.field("action", decode.string)
|
||||
use audit_log_reason <- decode.field(
|
||||
"audit_log_reason",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use metadata <- decode.field(
|
||||
"metadata",
|
||||
decode.dict(decode.string, decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.string)
|
||||
|
||||
let metadata_list =
|
||||
metadata
|
||||
|> dict.to_list
|
||||
|
||||
decode.success(AuditLog(
|
||||
log_id: log_id,
|
||||
admin_user_id: admin_user_id,
|
||||
target_type: target_type_val,
|
||||
target_id: target_id_val,
|
||||
action: action,
|
||||
audit_log_reason: audit_log_reason,
|
||||
metadata: metadata_list,
|
||||
created_at: created_at,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use logs <- decode.field("logs", decode.list(audit_log_decoder))
|
||||
use total <- decode.field("total", decode.int)
|
||||
decode.success(ListAuditLogsResponse(logs: logs, total: total))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
249
fluxer_admin/src/fluxer_admin/api/bans.gleam
Normal file
249
fluxer_admin/src/fluxer_admin/api/bans.gleam
Normal file
@@ -0,0 +1,249 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_simple, admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type CheckBanResponse {
|
||||
CheckBanResponse(banned: Bool)
|
||||
}
|
||||
|
||||
pub fn ban_email(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
email: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/bans/email/add",
|
||||
[#("email", json.string(email))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn unban_email(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
email: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/bans/email/remove",
|
||||
[#("email", json.string(email))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn check_email_ban(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
email: String,
|
||||
) -> Result(CheckBanResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bans/email/check"
|
||||
let body = json.object([#("email", json.string(email))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use banned <- decode.field("banned", decode.bool)
|
||||
decode.success(CheckBanResponse(banned: banned))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ban_ip(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ip: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/ip/add", [
|
||||
#("ip", json.string(ip)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn unban_ip(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ip: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/ip/remove", [
|
||||
#("ip", json.string(ip)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn check_ip_ban(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
ip: String,
|
||||
) -> Result(CheckBanResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bans/ip/check"
|
||||
let body = json.object([#("ip", json.string(ip))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use banned <- decode.field("banned", decode.bool)
|
||||
decode.success(CheckBanResponse(banned: banned))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ban_phone(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
phone: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/phone/add", [
|
||||
#("phone", json.string(phone)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn unban_phone(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
phone: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/bans/phone/remove", [
|
||||
#("phone", json.string(phone)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn check_phone_ban(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
phone: String,
|
||||
) -> Result(CheckBanResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bans/phone/check"
|
||||
let body = json.object([#("phone", json.string(phone))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use banned <- decode.field("banned", decode.bool)
|
||||
decode.success(CheckBanResponse(banned: banned))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
332
fluxer_admin/src/fluxer_admin/api/bulk.gleam
Normal file
332
fluxer_admin/src/fluxer_admin/api/bulk.gleam
Normal file
@@ -0,0 +1,332 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type BulkOperationError {
|
||||
BulkOperationError(id: String, error: String)
|
||||
}
|
||||
|
||||
pub type BulkOperationResponse {
|
||||
BulkOperationResponse(
|
||||
successful: List(String),
|
||||
failed: List(BulkOperationError),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bulk_update_user_flags(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
add_flags: List(String),
|
||||
remove_flags: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/users/bulk-update-flags"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
#("add_flags", json.array(add_flags, json.string)),
|
||||
#("remove_flags", json.array(remove_flags, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_update_guild_features(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_ids: List(String),
|
||||
add_features: List(String),
|
||||
remove_features: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/bulk-update-features"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_ids", json.array(guild_ids, json.string)),
|
||||
#("add_features", json.array(add_features, json.string)),
|
||||
#("remove_features", json.array(remove_features, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_add_guild_members(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
user_ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bulk/add-guild-members"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bulk_schedule_user_deletion(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
reason_code: Int,
|
||||
public_reason: option.Option(String),
|
||||
days_until_deletion: Int,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(BulkOperationResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/bulk/schedule-user-deletion"
|
||||
let fields = [
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
#("reason_code", json.int(reason_code)),
|
||||
#("days_until_deletion", json.int(days_until_deletion)),
|
||||
]
|
||||
let fields = case public_reason {
|
||||
option.Some(r) -> [#("public_reason", json.string(r)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let error_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use error <- decode.field("error", decode.string)
|
||||
decode.success(BulkOperationError(id: id, error: error))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use successful <- decode.field("successful", decode.list(decode.string))
|
||||
use failed <- decode.field("failed", decode.list(error_decoder))
|
||||
decode.success(BulkOperationResponse(
|
||||
successful: successful,
|
||||
failed: failed,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
124
fluxer_admin/src/fluxer_admin/api/codes.gleam
Normal file
124
fluxer_admin/src/fluxer_admin/api/codes.gleam
Normal file
@@ -0,0 +1,124 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
|
||||
fn parse_codes(body: String) -> Result(List(String), ApiError) {
|
||||
let decoder = {
|
||||
use codes <- decode.field("codes", decode.list(decode.string))
|
||||
decode.success(codes)
|
||||
}
|
||||
|
||||
case json.parse(body, decoder) {
|
||||
Ok(codes) -> Ok(codes)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_beta_codes(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
count: Int,
|
||||
) -> Result(List(String), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/codes/beta"
|
||||
let body = json.object([#("count", json.int(count))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) ->
|
||||
case resp.status {
|
||||
200 -> parse_codes(resp.body)
|
||||
401 -> Error(Unauthorized)
|
||||
403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
404 -> Error(NotFound)
|
||||
_ -> Error(ServerError)
|
||||
}
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_gift_codes(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
count: Int,
|
||||
product_type: String,
|
||||
) -> Result(List(String), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/codes/gift"
|
||||
let body =
|
||||
json.object([
|
||||
#("count", json.int(count)),
|
||||
#("product_type", json.string(product_type)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) ->
|
||||
case resp.status {
|
||||
200 -> parse_codes(resp.body)
|
||||
401 -> Error(Unauthorized)
|
||||
403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
404 -> Error(NotFound)
|
||||
_ -> Error(ServerError)
|
||||
}
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
240
fluxer_admin/src/fluxer_admin/api/common.gleam
Normal file
240
fluxer_admin/src/fluxer_admin/api/common.gleam
Normal 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,
|
||||
))
|
||||
}
|
||||
109
fluxer_admin/src/fluxer_admin/api/feature_flags.gleam
Normal file
109
fluxer_admin/src/fluxer_admin/api/feature_flags.gleam
Normal file
@@ -0,0 +1,109 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dict
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
pub type FeatureFlagConfig {
|
||||
FeatureFlagConfig(guild_ids: List(String))
|
||||
}
|
||||
|
||||
pub fn get_feature_flags(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
) -> Result(List(#(String, FeatureFlagConfig)), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/feature-flags/get"
|
||||
let body = json.object([]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use feature_flags <- decode.field(
|
||||
"feature_flags",
|
||||
decode.dict(decode.string, decode.list(decode.string)),
|
||||
)
|
||||
decode.success(feature_flags)
|
||||
}
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(flags_dict) -> {
|
||||
let entries =
|
||||
dict.to_list(flags_dict)
|
||||
|> list.map(fn(entry) {
|
||||
let #(flag, guild_ids) = entry
|
||||
#(flag, FeatureFlagConfig(guild_ids:))
|
||||
})
|
||||
Ok(entries)
|
||||
}
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_feature_flag(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
flag_id: String,
|
||||
guild_ids: List(String),
|
||||
) -> Result(FeatureFlagConfig, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/feature-flags/update"
|
||||
let guild_ids_str = string.join(guild_ids, ",")
|
||||
let body =
|
||||
json.object([
|
||||
#("flag", json.string(flag_id)),
|
||||
#("guild_ids", json.string(guild_ids_str)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> Ok(FeatureFlagConfig(guild_ids:))
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
182
fluxer_admin/src/fluxer_admin/api/guild_assets.gleam
Normal file
182
fluxer_admin/src/fluxer_admin/api/guild_assets.gleam
Normal file
@@ -0,0 +1,182 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
|
||||
pub type GuildEmojiAsset {
|
||||
GuildEmojiAsset(
|
||||
id: String,
|
||||
name: String,
|
||||
animated: Bool,
|
||||
creator_id: String,
|
||||
media_url: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildEmojisResponse {
|
||||
ListGuildEmojisResponse(guild_id: String, emojis: List(GuildEmojiAsset))
|
||||
}
|
||||
|
||||
pub type GuildStickerAsset {
|
||||
GuildStickerAsset(
|
||||
id: String,
|
||||
name: String,
|
||||
format_type: Int,
|
||||
creator_id: String,
|
||||
media_url: String,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildStickersResponse {
|
||||
ListGuildStickersResponse(guild_id: String, stickers: List(GuildStickerAsset))
|
||||
}
|
||||
|
||||
pub fn list_guild_emojis(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(ListGuildEmojisResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/" <> guild_id <> "/emojis"
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let emoji_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use animated <- decode.field("animated", decode.bool)
|
||||
use creator_id <- decode.field("creator_id", decode.string)
|
||||
use media_url <- decode.field("media_url", decode.string)
|
||||
decode.success(GuildEmojiAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
animated: animated,
|
||||
creator_id: creator_id,
|
||||
media_url: media_url,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guild_id <- decode.field("guild_id", decode.string)
|
||||
use emojis <- decode.field("emojis", decode.list(emoji_decoder))
|
||||
decode.success(ListGuildEmojisResponse(
|
||||
guild_id: guild_id,
|
||||
emojis: emojis,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_guild_stickers(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(ListGuildStickersResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/" <> guild_id <> "/stickers"
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let sticker_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use format_type <- decode.field("format_type", decode.int)
|
||||
use creator_id <- decode.field("creator_id", decode.string)
|
||||
use media_url <- decode.field("media_url", decode.string)
|
||||
decode.success(GuildStickerAsset(
|
||||
id: id,
|
||||
name: name,
|
||||
format_type: format_type,
|
||||
creator_id: creator_id,
|
||||
media_url: media_url,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guild_id <- decode.field("guild_id", decode.string)
|
||||
use stickers <- decode.field("stickers", decode.list(sticker_decoder))
|
||||
decode.success(ListGuildStickersResponse(
|
||||
guild_id: guild_id,
|
||||
stickers: stickers,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
529
fluxer_admin/src/fluxer_admin/api/guilds.gleam
Normal file
529
fluxer_admin/src/fluxer_admin/api/guilds.gleam
Normal file
@@ -0,0 +1,529 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_simple,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type GuildChannel {
|
||||
GuildChannel(
|
||||
id: String,
|
||||
name: String,
|
||||
type_: Int,
|
||||
position: Int,
|
||||
parent_id: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildRole {
|
||||
GuildRole(
|
||||
id: String,
|
||||
name: String,
|
||||
color: Int,
|
||||
position: Int,
|
||||
permissions: String,
|
||||
hoist: Bool,
|
||||
mentionable: Bool,
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildMember {
|
||||
GuildMember(
|
||||
user: GuildMemberUser,
|
||||
nick: option.Option(String),
|
||||
avatar: option.Option(String),
|
||||
roles: List(String),
|
||||
joined_at: String,
|
||||
premium_since: option.Option(String),
|
||||
deaf: Bool,
|
||||
mute: Bool,
|
||||
flags: Int,
|
||||
pending: Bool,
|
||||
communication_disabled_until: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildMemberUser {
|
||||
GuildMemberUser(
|
||||
id: String,
|
||||
username: String,
|
||||
discriminator: String,
|
||||
avatar: option.Option(String),
|
||||
bot: Bool,
|
||||
system: Bool,
|
||||
public_flags: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildMembersResponse {
|
||||
ListGuildMembersResponse(
|
||||
members: List(GuildMember),
|
||||
total: Int,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildLookupResult {
|
||||
GuildLookupResult(
|
||||
id: String,
|
||||
owner_id: String,
|
||||
name: String,
|
||||
vanity_url_code: option.Option(String),
|
||||
icon: option.Option(String),
|
||||
banner: option.Option(String),
|
||||
splash: option.Option(String),
|
||||
features: List(String),
|
||||
verification_level: Int,
|
||||
mfa_level: Int,
|
||||
nsfw_level: Int,
|
||||
explicit_content_filter: Int,
|
||||
default_message_notifications: Int,
|
||||
afk_channel_id: option.Option(String),
|
||||
afk_timeout: Int,
|
||||
system_channel_id: option.Option(String),
|
||||
system_channel_flags: Int,
|
||||
rules_channel_id: option.Option(String),
|
||||
disabled_operations: Int,
|
||||
member_count: Int,
|
||||
channels: List(GuildChannel),
|
||||
roles: List(GuildRole),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildSearchResult {
|
||||
GuildSearchResult(
|
||||
id: String,
|
||||
owner_id: String,
|
||||
name: String,
|
||||
features: List(String),
|
||||
icon: option.Option(String),
|
||||
banner: option.Option(String),
|
||||
member_count: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type SearchGuildsResponse {
|
||||
SearchGuildsResponse(guilds: List(GuildSearchResult), total: Int)
|
||||
}
|
||||
|
||||
pub fn lookup_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(option.Option(GuildLookupResult), ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/lookup"
|
||||
let body =
|
||||
json.object([#("guild_id", json.string(guild_id))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let channel_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use type_ <- decode.field("type", decode.int)
|
||||
use position <- decode.field("position", decode.int)
|
||||
use parent_id <- decode.field(
|
||||
"parent_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(GuildChannel(
|
||||
id: id,
|
||||
name: name,
|
||||
type_: type_,
|
||||
position: position,
|
||||
parent_id: parent_id,
|
||||
))
|
||||
}
|
||||
|
||||
let role_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use color <- decode.field("color", decode.int)
|
||||
use position <- decode.field("position", decode.int)
|
||||
use permissions <- decode.field("permissions", decode.string)
|
||||
use hoist <- decode.field("hoist", decode.bool)
|
||||
use mentionable <- decode.field("mentionable", decode.bool)
|
||||
decode.success(GuildRole(
|
||||
id: id,
|
||||
name: name,
|
||||
color: color,
|
||||
position: position,
|
||||
permissions: permissions,
|
||||
hoist: hoist,
|
||||
mentionable: mentionable,
|
||||
))
|
||||
}
|
||||
|
||||
let guild_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use owner_id <- decode.field("owner_id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use vanity_url_code <- decode.field(
|
||||
"vanity_url_code",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use icon <- decode.field("icon", decode.optional(decode.string))
|
||||
use banner <- decode.field("banner", decode.optional(decode.string))
|
||||
use splash <- decode.field("splash", decode.optional(decode.string))
|
||||
use features <- decode.field("features", decode.list(decode.string))
|
||||
use verification_level <- decode.field("verification_level", decode.int)
|
||||
use mfa_level <- decode.field("mfa_level", decode.int)
|
||||
use nsfw_level <- decode.field("nsfw_level", decode.int)
|
||||
use explicit_content_filter <- decode.field(
|
||||
"explicit_content_filter",
|
||||
decode.int,
|
||||
)
|
||||
use default_message_notifications <- decode.field(
|
||||
"default_message_notifications",
|
||||
decode.int,
|
||||
)
|
||||
use afk_channel_id <- decode.field(
|
||||
"afk_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use afk_timeout <- decode.field("afk_timeout", decode.int)
|
||||
use system_channel_id <- decode.field(
|
||||
"system_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use system_channel_flags <- decode.field(
|
||||
"system_channel_flags",
|
||||
decode.int,
|
||||
)
|
||||
use rules_channel_id <- decode.field(
|
||||
"rules_channel_id",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use disabled_operations <- decode.field(
|
||||
"disabled_operations",
|
||||
decode.int,
|
||||
)
|
||||
use member_count <- decode.field("member_count", decode.int)
|
||||
use channels <- decode.field("channels", decode.list(channel_decoder))
|
||||
use roles <- decode.field("roles", decode.list(role_decoder))
|
||||
decode.success(GuildLookupResult(
|
||||
id: id,
|
||||
owner_id: owner_id,
|
||||
name: name,
|
||||
vanity_url_code: vanity_url_code,
|
||||
icon: icon,
|
||||
banner: banner,
|
||||
splash: splash,
|
||||
features: features,
|
||||
verification_level: verification_level,
|
||||
mfa_level: mfa_level,
|
||||
nsfw_level: nsfw_level,
|
||||
explicit_content_filter: explicit_content_filter,
|
||||
default_message_notifications: default_message_notifications,
|
||||
afk_channel_id: afk_channel_id,
|
||||
afk_timeout: afk_timeout,
|
||||
system_channel_id: system_channel_id,
|
||||
system_channel_flags: system_channel_flags,
|
||||
rules_channel_id: rules_channel_id,
|
||||
disabled_operations: disabled_operations,
|
||||
member_count: member_count,
|
||||
channels: channels,
|
||||
roles: roles,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guild <- decode.field("guild", decode.optional(guild_decoder))
|
||||
decode.success(guild)
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_guild_fields(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
fields: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/clear-fields", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("fields", json.array(fields, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn update_guild_features(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
add_features: List(String),
|
||||
remove_features: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-features", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("add_features", json.array(add_features, json.string)),
|
||||
#("remove_features", json.array(remove_features, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn update_guild_settings(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
verification_level: option.Option(Int),
|
||||
mfa_level: option.Option(Int),
|
||||
nsfw_level: option.Option(Int),
|
||||
explicit_content_filter: option.Option(Int),
|
||||
default_message_notifications: option.Option(Int),
|
||||
disabled_operations: option.Option(Int),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let mut_fields = [#("guild_id", json.string(guild_id))]
|
||||
let mut_fields = case verification_level {
|
||||
option.Some(vl) -> [#("verification_level", json.int(vl)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case mfa_level {
|
||||
option.Some(ml) -> [#("mfa_level", json.int(ml)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case nsfw_level {
|
||||
option.Some(nl) -> [#("nsfw_level", json.int(nl)), ..mut_fields]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case explicit_content_filter {
|
||||
option.Some(ecf) -> [
|
||||
#("explicit_content_filter", json.int(ecf)),
|
||||
..mut_fields
|
||||
]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case default_message_notifications {
|
||||
option.Some(dmn) -> [
|
||||
#("default_message_notifications", json.int(dmn)),
|
||||
..mut_fields
|
||||
]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
let mut_fields = case disabled_operations {
|
||||
option.Some(dops) -> [
|
||||
#("disabled_operations", json.int(dops)),
|
||||
..mut_fields
|
||||
]
|
||||
option.None -> mut_fields
|
||||
}
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-settings", mut_fields)
|
||||
}
|
||||
|
||||
pub fn update_guild_name(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
name: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-name", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("name", json.string(name)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn update_guild_vanity(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
vanity_url_code: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = [#("guild_id", json.string(guild_id))]
|
||||
let fields = case vanity_url_code {
|
||||
option.Some(code) -> [#("vanity_url_code", json.string(code)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
admin_post_simple(ctx, session, "/admin/guilds/update-vanity", fields)
|
||||
}
|
||||
|
||||
pub fn transfer_guild_ownership(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
new_owner_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/transfer-ownership", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("new_owner_id", json.string(new_owner_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn reload_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/reload", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn shutdown_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/shutdown", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn delete_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/delete", [
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn force_add_user_to_guild(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
guild_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/guilds/force-add-user", [
|
||||
#("user_id", json.string(user_id)),
|
||||
#("guild_id", json.string(guild_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn search_guilds(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
query: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(SearchGuildsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/search"
|
||||
let body =
|
||||
json.object([
|
||||
#("query", json.string(query)),
|
||||
#("limit", json.int(limit)),
|
||||
#("offset", json.int(offset)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let guild_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use owner_id <- decode.optional_field("owner_id", "", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use features <- decode.field("features", decode.list(decode.string))
|
||||
use icon <- decode.optional_field(
|
||||
"icon",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use banner <- decode.optional_field(
|
||||
"banner",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use member_count <- decode.optional_field("member_count", 0, decode.int)
|
||||
decode.success(GuildSearchResult(
|
||||
id: id,
|
||||
owner_id: owner_id,
|
||||
name: name,
|
||||
features: features,
|
||||
icon: icon,
|
||||
banner: banner,
|
||||
member_count: member_count,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guilds <- decode.field("guilds", decode.list(guild_decoder))
|
||||
use total <- decode.field("total", decode.int)
|
||||
decode.success(SearchGuildsResponse(guilds: guilds, total: total))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
191
fluxer_admin/src/fluxer_admin/api/guilds_members.gleam
Normal file
191
fluxer_admin/src/fluxer_admin/api/guilds_members.gleam
Normal file
@@ -0,0 +1,191 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type GuildMember {
|
||||
GuildMember(
|
||||
user: GuildMemberUser,
|
||||
nick: option.Option(String),
|
||||
avatar: option.Option(String),
|
||||
roles: List(String),
|
||||
joined_at: String,
|
||||
premium_since: option.Option(String),
|
||||
deaf: Bool,
|
||||
mute: Bool,
|
||||
flags: Int,
|
||||
pending: Bool,
|
||||
communication_disabled_until: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type GuildMemberUser {
|
||||
GuildMemberUser(
|
||||
id: String,
|
||||
username: String,
|
||||
discriminator: String,
|
||||
avatar: option.Option(String),
|
||||
bot: Bool,
|
||||
system: Bool,
|
||||
public_flags: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListGuildMembersResponse {
|
||||
ListGuildMembersResponse(
|
||||
members: List(GuildMember),
|
||||
total: Int,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_guild_members(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
guild_id: String,
|
||||
limit: Int,
|
||||
offset: Int,
|
||||
) -> Result(ListGuildMembersResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/guilds/list-members"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_id", json.string(guild_id)),
|
||||
#("limit", json.int(limit)),
|
||||
#("offset", json.int(offset)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let user_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use username <- decode.field("username", decode.string)
|
||||
use discriminator <- decode.field("discriminator", decode.string)
|
||||
use avatar <- decode.field("avatar", decode.optional(decode.string))
|
||||
use bot <- decode.optional_field("bot", False, decode.bool)
|
||||
use system <- decode.optional_field("system", False, decode.bool)
|
||||
use public_flags <- decode.optional_field("public_flags", 0, decode.int)
|
||||
decode.success(GuildMemberUser(
|
||||
id: id,
|
||||
username: username,
|
||||
discriminator: discriminator,
|
||||
avatar: avatar,
|
||||
bot: bot,
|
||||
system: system,
|
||||
public_flags: public_flags,
|
||||
))
|
||||
}
|
||||
|
||||
let member_decoder = {
|
||||
use user <- decode.field("user", user_decoder)
|
||||
use nick <- decode.optional_field(
|
||||
"nick",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use avatar <- decode.optional_field(
|
||||
"avatar",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use roles <- decode.field("roles", decode.list(decode.string))
|
||||
use joined_at <- decode.field("joined_at", decode.string)
|
||||
use premium_since <- decode.optional_field(
|
||||
"premium_since",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use deaf <- decode.optional_field("deaf", False, decode.bool)
|
||||
use mute <- decode.optional_field("mute", False, decode.bool)
|
||||
use flags <- decode.optional_field("flags", 0, decode.int)
|
||||
use pending <- decode.optional_field("pending", False, decode.bool)
|
||||
use communication_disabled_until <- decode.optional_field(
|
||||
"communication_disabled_until",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(GuildMember(
|
||||
user: user,
|
||||
nick: nick,
|
||||
avatar: avatar,
|
||||
roles: roles,
|
||||
joined_at: joined_at,
|
||||
premium_since: premium_since,
|
||||
deaf: deaf,
|
||||
mute: mute,
|
||||
flags: flags,
|
||||
pending: pending,
|
||||
communication_disabled_until: communication_disabled_until,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use members <- decode.field("members", decode.list(member_decoder))
|
||||
use total <- decode.field("total", decode.int)
|
||||
use limit <- decode.field("limit", decode.int)
|
||||
use offset <- decode.field("offset", decode.int)
|
||||
decode.success(ListGuildMembersResponse(
|
||||
members: members,
|
||||
total: total,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
259
fluxer_admin/src/fluxer_admin/api/instance_config.gleam
Normal file
259
fluxer_admin/src/fluxer_admin/api/instance_config.gleam
Normal 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,
|
||||
)
|
||||
}
|
||||
508
fluxer_admin/src/fluxer_admin/api/messages.gleam
Normal file
508
fluxer_admin/src/fluxer_admin/api/messages.gleam
Normal file
@@ -0,0 +1,508 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type MessageAttachment {
|
||||
MessageAttachment(filename: String, url: String)
|
||||
}
|
||||
|
||||
pub type Message {
|
||||
Message(
|
||||
id: String,
|
||||
channel_id: String,
|
||||
author_id: String,
|
||||
author_username: String,
|
||||
content: String,
|
||||
timestamp: String,
|
||||
attachments: List(MessageAttachment),
|
||||
)
|
||||
}
|
||||
|
||||
pub type LookupMessageResponse {
|
||||
LookupMessageResponse(messages: List(Message), message_id: String)
|
||||
}
|
||||
|
||||
pub type MessageShredResponse {
|
||||
MessageShredResponse(job_id: String, requested: option.Option(Int))
|
||||
}
|
||||
|
||||
pub type DeleteAllUserMessagesResponse {
|
||||
DeleteAllUserMessagesResponse(
|
||||
dry_run: Bool,
|
||||
channel_count: Int,
|
||||
message_count: Int,
|
||||
job_id: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type MessageShredStatus {
|
||||
MessageShredStatus(
|
||||
status: String,
|
||||
requested: option.Option(Int),
|
||||
total: option.Option(Int),
|
||||
processed: option.Option(Int),
|
||||
skipped: option.Option(Int),
|
||||
started_at: option.Option(String),
|
||||
completed_at: option.Option(String),
|
||||
failed_at: option.Option(String),
|
||||
error: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_message(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let fields = [
|
||||
#("channel_id", json.string(channel_id)),
|
||||
#("message_id", json.string(message_id)),
|
||||
]
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/messages/delete",
|
||||
fields,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn lookup_message(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
channel_id: String,
|
||||
message_id: String,
|
||||
context_limit: Int,
|
||||
) -> Result(LookupMessageResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/lookup"
|
||||
let body =
|
||||
json.object([
|
||||
#("channel_id", json.string(channel_id)),
|
||||
#("message_id", json.string(message_id)),
|
||||
#("context_limit", json.int(context_limit)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let attachment_decoder = {
|
||||
use filename <- decode.field("filename", decode.string)
|
||||
use url <- decode.field("url", decode.string)
|
||||
decode.success(MessageAttachment(filename: filename, url: url))
|
||||
}
|
||||
|
||||
let message_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use channel_id <- decode.field("channel_id", decode.string)
|
||||
use author_id <- decode.field("author_id", decode.string)
|
||||
use author_username <- decode.field("author_username", decode.string)
|
||||
use content <- decode.field("content", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.string)
|
||||
use attachments <- decode.optional_field(
|
||||
"attachments",
|
||||
[],
|
||||
decode.list(attachment_decoder),
|
||||
)
|
||||
decode.success(Message(
|
||||
id: id,
|
||||
channel_id: channel_id,
|
||||
author_id: author_id,
|
||||
author_username: author_username,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
attachments: attachments,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use messages <- decode.field("messages", decode.list(message_decoder))
|
||||
use message_id <- decode.field("message_id", decode.string)
|
||||
decode.success(LookupMessageResponse(
|
||||
messages: messages,
|
||||
message_id: message_id,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn queue_message_shred(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
entries: json.Json,
|
||||
) -> Result(MessageShredResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/shred"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_id", json.string(user_id)),
|
||||
#("entries", entries),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use job_id <- decode.field("job_id", decode.string)
|
||||
use requested <- decode.optional_field(
|
||||
"requested",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
decode.success(MessageShredResponse(
|
||||
job_id: job_id,
|
||||
requested: requested,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_all_user_messages(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
dry_run: Bool,
|
||||
) -> Result(DeleteAllUserMessagesResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/delete-all"
|
||||
let body =
|
||||
json.object([
|
||||
#("user_id", json.string(user_id)),
|
||||
#("dry_run", json.bool(dry_run)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use dry_run <- decode.field("dry_run", decode.bool)
|
||||
use channel_count <- decode.field("channel_count", decode.int)
|
||||
use message_count <- decode.field("message_count", decode.int)
|
||||
use job_id <- decode.optional_field(
|
||||
"job_id",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(DeleteAllUserMessagesResponse(
|
||||
dry_run: dry_run,
|
||||
channel_count: channel_count,
|
||||
message_count: message_count,
|
||||
job_id: job_id,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_message_shred_status(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
job_id: String,
|
||||
) -> Result(MessageShredStatus, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/shred-status"
|
||||
let body =
|
||||
json.object([#("job_id", json.string(job_id))])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use status <- decode.field("status", decode.string)
|
||||
use requested <- decode.optional_field(
|
||||
"requested",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use total <- decode.optional_field(
|
||||
"total",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use processed <- decode.optional_field(
|
||||
"processed",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use skipped <- decode.optional_field(
|
||||
"skipped",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use started_at <- decode.optional_field(
|
||||
"started_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use completed_at <- decode.optional_field(
|
||||
"completed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use failed_at <- decode.optional_field(
|
||||
"failed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use error <- decode.optional_field(
|
||||
"error",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(MessageShredStatus(
|
||||
status: status,
|
||||
requested: requested,
|
||||
total: total,
|
||||
processed: processed,
|
||||
skipped: skipped,
|
||||
started_at: started_at,
|
||||
completed_at: completed_at,
|
||||
failed_at: failed_at,
|
||||
error: error,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup_message_by_attachment(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
channel_id: String,
|
||||
attachment_id: String,
|
||||
filename: String,
|
||||
context_limit: Int,
|
||||
) -> Result(LookupMessageResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/messages/lookup-by-attachment"
|
||||
let body =
|
||||
json.object([
|
||||
#("channel_id", json.string(channel_id)),
|
||||
#("attachment_id", json.string(attachment_id)),
|
||||
#("filename", json.string(filename)),
|
||||
#("context_limit", json.int(context_limit)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let attachment_decoder = {
|
||||
use filename <- decode.field("filename", decode.string)
|
||||
use url <- decode.field("url", decode.string)
|
||||
decode.success(MessageAttachment(filename: filename, url: url))
|
||||
}
|
||||
|
||||
let message_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use channel_id <- decode.field("channel_id", decode.string)
|
||||
use author_id <- decode.field("author_id", decode.string)
|
||||
use author_username <- decode.field("author_username", decode.string)
|
||||
use content <- decode.field("content", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.string)
|
||||
use attachments <- decode.optional_field(
|
||||
"attachments",
|
||||
[],
|
||||
decode.list(attachment_decoder),
|
||||
)
|
||||
decode.success(Message(
|
||||
id: id,
|
||||
channel_id: channel_id,
|
||||
author_id: author_id,
|
||||
author_username: author_username,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
attachments: attachments,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use messages <- decode.field("messages", decode.list(message_decoder))
|
||||
use message_id <- decode.field("message_id", decode.string)
|
||||
decode.success(LookupMessageResponse(
|
||||
messages: messages,
|
||||
message_id: message_id,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
264
fluxer_admin/src/fluxer_admin/api/metrics.gleam
Normal file
264
fluxer_admin/src/fluxer_admin/api/metrics.gleam
Normal file
@@ -0,0 +1,264 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, NetworkError, NotFound, ServerError,
|
||||
}
|
||||
import fluxer_admin/web.{type Context}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/int
|
||||
import gleam/json
|
||||
import gleam/option.{type Option, None, Some}
|
||||
|
||||
pub type DataPoint {
|
||||
DataPoint(timestamp: Int, value: Float)
|
||||
}
|
||||
|
||||
pub type QueryResponse {
|
||||
QueryResponse(metric: String, data: List(DataPoint))
|
||||
}
|
||||
|
||||
pub type TopEntry {
|
||||
TopEntry(label: String, value: Float)
|
||||
}
|
||||
|
||||
pub type AggregateResponse {
|
||||
AggregateResponse(
|
||||
metric: String,
|
||||
total: Float,
|
||||
breakdown: option.Option(List(TopEntry)),
|
||||
)
|
||||
}
|
||||
|
||||
pub type TopQueryResponse {
|
||||
TopQueryResponse(metric: String, entries: List(TopEntry))
|
||||
}
|
||||
|
||||
pub type CrashEvent {
|
||||
CrashEvent(
|
||||
id: String,
|
||||
timestamp: Int,
|
||||
guild_id: String,
|
||||
stacktrace: String,
|
||||
notified: Bool,
|
||||
)
|
||||
}
|
||||
|
||||
pub type CrashesResponse {
|
||||
CrashesResponse(crashes: List(CrashEvent))
|
||||
}
|
||||
|
||||
pub fn query_metrics(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
start: Option(String),
|
||||
end: Option(String),
|
||||
) -> Result(QueryResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let query_params = case start, end {
|
||||
Some(s), Some(e) ->
|
||||
"?metric=" <> metric <> "&start=" <> s <> "&end=" <> e
|
||||
Some(s), None -> "?metric=" <> metric <> "&start=" <> s
|
||||
None, Some(e) -> "?metric=" <> metric <> "&end=" <> e
|
||||
None, None -> "?metric=" <> metric
|
||||
}
|
||||
let url = endpoint <> "/query" <> query_params
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let data_point_decoder = {
|
||||
use timestamp <- decode.field("timestamp", decode.int)
|
||||
use value <- decode.field("value", decode.float)
|
||||
decode.success(DataPoint(timestamp: timestamp, value: value))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use metric_name <- decode.field("metric", decode.string)
|
||||
use data <- decode.field("data", decode.list(data_point_decoder))
|
||||
decode.success(QueryResponse(metric: metric_name, data: data))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_aggregate(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
) -> Result(AggregateResponse, ApiError) {
|
||||
query_aggregate_grouped(ctx, metric, option.None)
|
||||
}
|
||||
|
||||
fn top_entry_decoder() -> decode.Decoder(TopEntry) {
|
||||
{
|
||||
use label <- decode.field("label", decode.string)
|
||||
use value <- decode.field("value", decode.float)
|
||||
decode.success(TopEntry(label: label, value: value))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_aggregate_grouped(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
group_by: option.Option(String),
|
||||
) -> Result(AggregateResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let query_params = case group_by {
|
||||
option.Some(group) -> "?metric=" <> metric <> "&group_by=" <> group
|
||||
option.None -> "?metric=" <> metric
|
||||
}
|
||||
let url = endpoint <> "/query/aggregate" <> query_params
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use metric_name <- decode.field("metric", decode.string)
|
||||
use total <- decode.field("total", decode.float)
|
||||
use breakdown <- decode.optional_field(
|
||||
"breakdown",
|
||||
option.None,
|
||||
decode.list(top_entry_decoder()) |> decode.map(option.Some),
|
||||
)
|
||||
decode.success(AggregateResponse(
|
||||
metric: metric_name,
|
||||
total: total,
|
||||
breakdown: breakdown,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_top(
|
||||
ctx: Context,
|
||||
metric: String,
|
||||
limit: Int,
|
||||
) -> Result(TopQueryResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let url =
|
||||
endpoint
|
||||
<> "/query/top?metric="
|
||||
<> metric
|
||||
<> "&limit="
|
||||
<> int.to_string(limit)
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use metric_name <- decode.field("metric", decode.string)
|
||||
use entries <- decode.field(
|
||||
"entries",
|
||||
decode.list(top_entry_decoder()),
|
||||
)
|
||||
decode.success(TopQueryResponse(
|
||||
metric: metric_name,
|
||||
entries: entries,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_crashes(
|
||||
ctx: Context,
|
||||
limit: Int,
|
||||
) -> Result(CrashesResponse, ApiError) {
|
||||
case ctx.metrics_endpoint {
|
||||
None -> Error(NotFound)
|
||||
Some(endpoint) -> {
|
||||
let url = endpoint <> "/query/crashes?limit=" <> int.to_string(limit)
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = req |> request.set_method(http.Get)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let crash_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.int)
|
||||
use guild_id <- decode.field("guild_id", decode.string)
|
||||
use stacktrace <- decode.field("stacktrace", decode.string)
|
||||
use notified <- decode.field("notified", decode.bool)
|
||||
decode.success(CrashEvent(
|
||||
id: id,
|
||||
timestamp: timestamp,
|
||||
guild_id: guild_id,
|
||||
stacktrace: stacktrace,
|
||||
notified: notified,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use crashes <- decode.field("crashes", decode.list(crash_decoder))
|
||||
decode.success(CrashesResponse(crashes: crashes))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(_) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1010
fluxer_admin/src/fluxer_admin/api/oauth.gleam
Normal file
1010
fluxer_admin/src/fluxer_admin/api/oauth.gleam
Normal file
File diff suppressed because it is too large
Load Diff
728
fluxer_admin/src/fluxer_admin/api/reports.gleam
Normal file
728
fluxer_admin/src/fluxer_admin/api/reports.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
202
fluxer_admin/src/fluxer_admin/api/search.gleam
Normal file
202
fluxer_admin/src/fluxer_admin/api/search.gleam
Normal file
@@ -0,0 +1,202 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type RefreshSearchIndexResponse {
|
||||
RefreshSearchIndexResponse(job_id: String)
|
||||
}
|
||||
|
||||
pub type IndexRefreshStatus {
|
||||
IndexRefreshStatus(
|
||||
status: String,
|
||||
total: option.Option(Int),
|
||||
indexed: option.Option(Int),
|
||||
started_at: option.Option(String),
|
||||
completed_at: option.Option(String),
|
||||
error: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn refresh_search_index(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
index_type: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(RefreshSearchIndexResponse, ApiError) {
|
||||
refresh_search_index_with_guild(
|
||||
ctx,
|
||||
session,
|
||||
index_type,
|
||||
option.None,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn refresh_search_index_with_guild(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
index_type: String,
|
||||
guild_id: option.Option(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(RefreshSearchIndexResponse, ApiError) {
|
||||
let fields = case guild_id {
|
||||
option.Some(id) -> [
|
||||
#("index_type", json.string(index_type)),
|
||||
#("guild_id", json.string(id)),
|
||||
]
|
||||
option.None -> [#("index_type", json.string(index_type))]
|
||||
}
|
||||
let url = ctx.api_endpoint <> "/admin/search/refresh-index"
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
let req = case audit_log_reason {
|
||||
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
|
||||
option.None -> req
|
||||
}
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use job_id <- decode.field("job_id", decode.string)
|
||||
decode.success(RefreshSearchIndexResponse(job_id: job_id))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_index_refresh_status(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
job_id: String,
|
||||
) -> Result(IndexRefreshStatus, ApiError) {
|
||||
let fields = [#("job_id", json.string(job_id))]
|
||||
let url = ctx.api_endpoint <> "/admin/search/refresh-status"
|
||||
let body = json.object(fields) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use status <- decode.field("status", decode.string)
|
||||
use total <- decode.optional_field(
|
||||
"total",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use indexed <- decode.optional_field(
|
||||
"indexed",
|
||||
option.None,
|
||||
decode.optional(decode.int),
|
||||
)
|
||||
use started_at <- decode.optional_field(
|
||||
"started_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use completed_at <- decode.optional_field(
|
||||
"completed_at",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use error <- decode.optional_field(
|
||||
"error",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
decode.success(IndexRefreshStatus(
|
||||
status: status,
|
||||
total: total,
|
||||
indexed: indexed,
|
||||
started_at: started_at,
|
||||
completed_at: completed_at,
|
||||
error: error,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
247
fluxer_admin/src/fluxer_admin/api/system.gleam
Normal file
247
fluxer_admin/src/fluxer_admin/api/system.gleam
Normal file
@@ -0,0 +1,247 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/int
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type ProcessMemoryStats {
|
||||
ProcessMemoryStats(
|
||||
guild_id: option.Option(String),
|
||||
guild_name: String,
|
||||
guild_icon: option.Option(String),
|
||||
memory_mb: Float,
|
||||
member_count: Int,
|
||||
session_count: Int,
|
||||
presence_count: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub type ProcessMemoryStatsResponse {
|
||||
ProcessMemoryStatsResponse(guilds: List(ProcessMemoryStats))
|
||||
}
|
||||
|
||||
pub fn get_guild_memory_stats(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
limit: Int,
|
||||
) -> Result(ProcessMemoryStatsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/gateway/memory-stats"
|
||||
let body = json.object([#("limit", json.int(limit))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let guild_decoder = {
|
||||
use guild_id <- decode.field("guild_id", decode.optional(decode.string))
|
||||
use guild_name <- decode.field("guild_name", decode.string)
|
||||
use guild_icon <- decode.field(
|
||||
"guild_icon",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use memory <- decode.field("memory", decode.int)
|
||||
use member_count <- decode.field("member_count", decode.int)
|
||||
use session_count <- decode.field("session_count", decode.int)
|
||||
use presence_count <- decode.field("presence_count", decode.int)
|
||||
|
||||
let memory_mb = int.to_float(memory) /. 1_024_000.0
|
||||
|
||||
decode.success(ProcessMemoryStats(
|
||||
guild_id: guild_id,
|
||||
guild_name: guild_name,
|
||||
guild_icon: guild_icon,
|
||||
memory_mb: memory_mb,
|
||||
member_count: member_count,
|
||||
session_count: session_count,
|
||||
presence_count: presence_count,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use guilds <- decode.field("guilds", decode.list(guild_decoder))
|
||||
decode.success(ProcessMemoryStatsResponse(guilds: guilds))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reload_all_guilds(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
guild_ids: List(String),
|
||||
) -> Result(Int, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/gateway/reload-all"
|
||||
let body =
|
||||
json.object([
|
||||
#("guild_ids", json.array(guild_ids, json.string)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use count <- decode.field("count", decode.int)
|
||||
decode.success(count)
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(count) -> Ok(count)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub type NodeStats {
|
||||
NodeStats(
|
||||
status: String,
|
||||
sessions: Int,
|
||||
guilds: Int,
|
||||
presences: Int,
|
||||
calls: Int,
|
||||
memory_total: Int,
|
||||
memory_processes: Int,
|
||||
memory_system: Int,
|
||||
process_count: Int,
|
||||
process_limit: Int,
|
||||
uptime_seconds: Int,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_node_stats(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
) -> Result(NodeStats, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/gateway/stats"
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Get)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use status <- decode.field("status", decode.string)
|
||||
use sessions <- decode.field("sessions", decode.int)
|
||||
use guilds <- decode.field("guilds", decode.int)
|
||||
use presences <- decode.field("presences", decode.int)
|
||||
use calls <- decode.field("calls", decode.int)
|
||||
use memory <- decode.field("memory", {
|
||||
use total <- decode.field("total", decode.int)
|
||||
use processes <- decode.field("processes", decode.int)
|
||||
use system <- decode.field("system", decode.int)
|
||||
decode.success(#(total, processes, system))
|
||||
})
|
||||
use process_count <- decode.field("process_count", decode.int)
|
||||
use process_limit <- decode.field("process_limit", decode.int)
|
||||
use uptime_seconds <- decode.field("uptime_seconds", decode.int)
|
||||
|
||||
let #(mem_total, mem_proc, mem_sys) = memory
|
||||
|
||||
decode.success(NodeStats(
|
||||
status: status,
|
||||
sessions: sessions,
|
||||
guilds: guilds,
|
||||
presences: presences,
|
||||
calls: calls,
|
||||
memory_total: mem_total,
|
||||
memory_processes: mem_proc,
|
||||
memory_system: mem_sys,
|
||||
process_count: process_count,
|
||||
process_limit: process_limit,
|
||||
uptime_seconds: uptime_seconds,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Forbidden"))
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
740
fluxer_admin/src/fluxer_admin/api/users.gleam
Normal file
740
fluxer_admin/src/fluxer_admin/api/users.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
160
fluxer_admin/src/fluxer_admin/api/verifications.gleam
Normal file
160
fluxer_admin/src/fluxer_admin/api/verifications.gleam
Normal file
@@ -0,0 +1,160 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, type UserLookupResult, Forbidden, NetworkError, NotFound,
|
||||
ServerError, Unauthorized, admin_post_simple, user_lookup_decoder,
|
||||
}
|
||||
import fluxer_admin/web
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
|
||||
pub type PendingVerificationMetadata {
|
||||
PendingVerificationMetadata(key: String, value: String)
|
||||
}
|
||||
|
||||
pub type PendingVerification {
|
||||
PendingVerification(
|
||||
user_id: String,
|
||||
created_at: String,
|
||||
user: UserLookupResult,
|
||||
metadata: List(PendingVerificationMetadata),
|
||||
)
|
||||
}
|
||||
|
||||
pub type PendingVerificationsResponse {
|
||||
PendingVerificationsResponse(pending_verifications: List(PendingVerification))
|
||||
}
|
||||
|
||||
pub fn list_pending_verifications(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
limit: Int,
|
||||
) -> Result(PendingVerificationsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/pending-verifications/list"
|
||||
let body = json.object([#("limit", json.int(limit))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let pending_verification_metadata_decoder = {
|
||||
use key <- decode.field("key", decode.string)
|
||||
use value <- decode.field("value", decode.string)
|
||||
decode.success(PendingVerificationMetadata(key: key, value: value))
|
||||
}
|
||||
|
||||
let pending_verification_decoder = {
|
||||
use user_id <- decode.field("user_id", decode.string)
|
||||
use created_at <- decode.field("created_at", decode.string)
|
||||
use user <- decode.field("user", user_lookup_decoder())
|
||||
use metadata <- decode.field(
|
||||
"metadata",
|
||||
decode.list(pending_verification_metadata_decoder),
|
||||
)
|
||||
decode.success(PendingVerification(
|
||||
user_id: user_id,
|
||||
created_at: created_at,
|
||||
user: user,
|
||||
metadata: metadata,
|
||||
))
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use pending_verifications <- decode.field(
|
||||
"pending_verifications",
|
||||
decode.list(pending_verification_decoder),
|
||||
)
|
||||
decode.success(PendingVerificationsResponse(
|
||||
pending_verifications: pending_verifications,
|
||||
))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> {
|
||||
let message_decoder = {
|
||||
use message <- decode.field("message", decode.string)
|
||||
decode.success(message)
|
||||
}
|
||||
|
||||
let message = case json.parse(resp.body, message_decoder) {
|
||||
Ok(msg) -> msg
|
||||
Error(_) ->
|
||||
"Missing required permissions. Contact an administrator to request access."
|
||||
}
|
||||
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn approve_registration(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/approve", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn reject_registration(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_id: String,
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/reject", [
|
||||
#("user_id", json.string(user_id)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn bulk_approve_registrations(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/bulk-approve", [
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn bulk_reject_registrations(
|
||||
ctx: web.Context,
|
||||
session: web.Session,
|
||||
user_ids: List(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_simple(ctx, session, "/admin/pending-verifications/bulk-reject", [
|
||||
#("user_ids", json.array(user_ids, json.string)),
|
||||
])
|
||||
}
|
||||
567
fluxer_admin/src/fluxer_admin/api/voice.gleam
Normal file
567
fluxer_admin/src/fluxer_admin/api/voice.gleam
Normal file
@@ -0,0 +1,567 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common.{
|
||||
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
|
||||
admin_post_with_audit,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
|
||||
pub type VoiceRegion {
|
||||
VoiceRegion(
|
||||
id: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
latitude: Float,
|
||||
longitude: Float,
|
||||
is_default: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
allowed_user_ids: List(String),
|
||||
created_at: option.Option(String),
|
||||
updated_at: option.Option(String),
|
||||
servers: option.Option(List(VoiceServer)),
|
||||
)
|
||||
}
|
||||
|
||||
pub type VoiceServer {
|
||||
VoiceServer(
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
endpoint: String,
|
||||
is_active: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
allowed_user_ids: List(String),
|
||||
created_at: option.Option(String),
|
||||
updated_at: option.Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
pub type ListVoiceRegionsResponse {
|
||||
ListVoiceRegionsResponse(regions: List(VoiceRegion))
|
||||
}
|
||||
|
||||
pub type GetVoiceRegionResponse {
|
||||
GetVoiceRegionResponse(region: option.Option(VoiceRegion))
|
||||
}
|
||||
|
||||
pub type ListVoiceServersResponse {
|
||||
ListVoiceServersResponse(servers: List(VoiceServer))
|
||||
}
|
||||
|
||||
pub type GetVoiceServerResponse {
|
||||
GetVoiceServerResponse(server: option.Option(VoiceServer))
|
||||
}
|
||||
|
||||
fn voice_region_decoder() {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use emoji <- decode.field("emoji", decode.string)
|
||||
use latitude <- decode.field("latitude", decode.float)
|
||||
use longitude <- decode.field("longitude", decode.float)
|
||||
use is_default <- decode.field("is_default", decode.bool)
|
||||
use vip_only <- decode.field("vip_only", decode.bool)
|
||||
use required_guild_features <- decode.field(
|
||||
"required_guild_features",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_guild_ids <- decode.field(
|
||||
"allowed_guild_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_user_ids <- decode.field(
|
||||
"allowed_user_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.optional(decode.string))
|
||||
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
|
||||
|
||||
decode.success(VoiceRegion(
|
||||
id: id,
|
||||
name: name,
|
||||
emoji: emoji,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
is_default: is_default,
|
||||
vip_only: vip_only,
|
||||
required_guild_features: required_guild_features,
|
||||
allowed_guild_ids: allowed_guild_ids,
|
||||
allowed_user_ids: allowed_user_ids,
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
servers: option.None,
|
||||
))
|
||||
}
|
||||
|
||||
fn voice_region_with_servers_decoder() {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use name <- decode.field("name", decode.string)
|
||||
use emoji <- decode.field("emoji", decode.string)
|
||||
use latitude <- decode.field("latitude", decode.float)
|
||||
use longitude <- decode.field("longitude", decode.float)
|
||||
use is_default <- decode.field("is_default", decode.bool)
|
||||
use vip_only <- decode.field("vip_only", decode.bool)
|
||||
use required_guild_features <- decode.field(
|
||||
"required_guild_features",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_guild_ids <- decode.field(
|
||||
"allowed_guild_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_user_ids <- decode.field(
|
||||
"allowed_user_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.optional(decode.string))
|
||||
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
|
||||
use servers <- decode.field("servers", decode.list(voice_server_decoder()))
|
||||
|
||||
decode.success(VoiceRegion(
|
||||
id: id,
|
||||
name: name,
|
||||
emoji: emoji,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
is_default: is_default,
|
||||
vip_only: vip_only,
|
||||
required_guild_features: required_guild_features,
|
||||
allowed_guild_ids: allowed_guild_ids,
|
||||
allowed_user_ids: allowed_user_ids,
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
servers: option.Some(servers),
|
||||
))
|
||||
}
|
||||
|
||||
fn voice_server_decoder() {
|
||||
use region_id <- decode.field("region_id", decode.string)
|
||||
use server_id <- decode.field("server_id", decode.string)
|
||||
use endpoint <- decode.field("endpoint", decode.string)
|
||||
use is_active <- decode.field("is_active", decode.bool)
|
||||
use vip_only <- decode.field("vip_only", decode.bool)
|
||||
use required_guild_features <- decode.field(
|
||||
"required_guild_features",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_guild_ids <- decode.field(
|
||||
"allowed_guild_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use allowed_user_ids <- decode.field(
|
||||
"allowed_user_ids",
|
||||
decode.list(decode.string),
|
||||
)
|
||||
use created_at <- decode.field("created_at", decode.optional(decode.string))
|
||||
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
|
||||
|
||||
decode.success(VoiceServer(
|
||||
region_id: region_id,
|
||||
server_id: server_id,
|
||||
endpoint: endpoint,
|
||||
is_active: is_active,
|
||||
vip_only: vip_only,
|
||||
required_guild_features: required_guild_features,
|
||||
allowed_guild_ids: allowed_guild_ids,
|
||||
allowed_user_ids: allowed_user_ids,
|
||||
created_at: created_at,
|
||||
updated_at: updated_at,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn list_voice_regions(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
include_servers: Bool,
|
||||
) -> Result(ListVoiceRegionsResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/voice/regions/list"
|
||||
let body =
|
||||
json.object([#("include_servers", json.bool(include_servers))])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder_fn = case include_servers {
|
||||
True -> voice_region_with_servers_decoder
|
||||
False -> voice_region_decoder
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use regions <- decode.field("regions", decode.list(decoder_fn()))
|
||||
decode.success(ListVoiceRegionsResponse(regions: regions))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
include_servers: Bool,
|
||||
) -> Result(GetVoiceRegionResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/voice/regions/get"
|
||||
let body =
|
||||
json.object([
|
||||
#("id", json.string(region_id)),
|
||||
#("include_servers", json.bool(include_servers)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder_fn = case include_servers {
|
||||
True -> voice_region_with_servers_decoder
|
||||
False -> voice_region_decoder
|
||||
}
|
||||
|
||||
let decoder = {
|
||||
use region <- decode.field("region", decode.optional(decoder_fn()))
|
||||
decode.success(GetVoiceRegionResponse(region: region))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
id: String,
|
||||
name: String,
|
||||
emoji: String,
|
||||
latitude: Float,
|
||||
longitude: Float,
|
||||
is_default: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/regions/create",
|
||||
[
|
||||
#("id", json.string(id)),
|
||||
#("name", json.string(name)),
|
||||
#("emoji", json.string(emoji)),
|
||||
#("latitude", json.float(latitude)),
|
||||
#("longitude", json.float(longitude)),
|
||||
#("is_default", json.bool(is_default)),
|
||||
#("vip_only", json.bool(vip_only)),
|
||||
#(
|
||||
"required_guild_features",
|
||||
json.array(required_guild_features, json.string),
|
||||
),
|
||||
#("allowed_guild_ids", json.array(allowed_guild_ids, json.string)),
|
||||
],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
id: String,
|
||||
name: option.Option(String),
|
||||
emoji: option.Option(String),
|
||||
latitude: option.Option(Float),
|
||||
longitude: option.Option(Float),
|
||||
is_default: option.Option(Bool),
|
||||
vip_only: option.Option(Bool),
|
||||
required_guild_features: option.Option(List(String)),
|
||||
allowed_guild_ids: option.Option(List(String)),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let base_fields = [#("id", json.string(id))]
|
||||
|
||||
let fields = case name {
|
||||
option.Some(n) -> [#("name", json.string(n)), ..base_fields]
|
||||
option.None -> base_fields
|
||||
}
|
||||
|
||||
let fields = case emoji {
|
||||
option.Some(e) -> [#("emoji", json.string(e)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case latitude {
|
||||
option.Some(lat) -> [#("latitude", json.float(lat)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case longitude {
|
||||
option.Some(lng) -> [#("longitude", json.float(lng)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case is_default {
|
||||
option.Some(d) -> [#("is_default", json.bool(d)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case vip_only {
|
||||
option.Some(v) -> [#("vip_only", json.bool(v)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case required_guild_features {
|
||||
option.Some(features) -> [
|
||||
#("required_guild_features", json.array(features, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case allowed_guild_ids {
|
||||
option.Some(ids) -> [
|
||||
#("allowed_guild_ids", json.array(ids, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/regions/update",
|
||||
fields,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_voice_region(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
id: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/regions/delete",
|
||||
[#("id", json.string(id))],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_voice_servers(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
) -> Result(ListVoiceServersResponse, ApiError) {
|
||||
let url = ctx.api_endpoint <> "/admin/voice/servers/list"
|
||||
let body =
|
||||
json.object([#("region_id", json.string(region_id))]) |> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.set_header("authorization", "Bearer " <> session.access_token)
|
||||
|> request.set_header("content-type", "application/json")
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status == 200 -> {
|
||||
let decoder = {
|
||||
use servers <- decode.field(
|
||||
"servers",
|
||||
decode.list(voice_server_decoder()),
|
||||
)
|
||||
decode.success(ListVoiceServersResponse(servers: servers))
|
||||
}
|
||||
|
||||
case json.parse(resp.body, decoder) {
|
||||
Ok(response) -> Ok(response)
|
||||
Error(_) -> Error(ServerError)
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_voice_server(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
endpoint: String,
|
||||
api_key: String,
|
||||
api_secret: String,
|
||||
is_active: Bool,
|
||||
vip_only: Bool,
|
||||
required_guild_features: List(String),
|
||||
allowed_guild_ids: List(String),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/servers/create",
|
||||
[
|
||||
#("region_id", json.string(region_id)),
|
||||
#("server_id", json.string(server_id)),
|
||||
#("endpoint", json.string(endpoint)),
|
||||
#("api_key", json.string(api_key)),
|
||||
#("api_secret", json.string(api_secret)),
|
||||
#("is_active", json.bool(is_active)),
|
||||
#("vip_only", json.bool(vip_only)),
|
||||
#(
|
||||
"required_guild_features",
|
||||
json.array(required_guild_features, json.string),
|
||||
),
|
||||
#("allowed_guild_ids", json.array(allowed_guild_ids, json.string)),
|
||||
],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update_voice_server(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
endpoint: option.Option(String),
|
||||
api_key: option.Option(String),
|
||||
api_secret: option.Option(String),
|
||||
is_active: option.Option(Bool),
|
||||
vip_only: option.Option(Bool),
|
||||
required_guild_features: option.Option(List(String)),
|
||||
allowed_guild_ids: option.Option(List(String)),
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
let base_fields = [
|
||||
#("region_id", json.string(region_id)),
|
||||
#("server_id", json.string(server_id)),
|
||||
]
|
||||
|
||||
let fields = case endpoint {
|
||||
option.Some(e) -> [#("endpoint", json.string(e)), ..base_fields]
|
||||
option.None -> base_fields
|
||||
}
|
||||
|
||||
let fields = case api_key {
|
||||
option.Some(k) -> [#("api_key", json.string(k)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case api_secret {
|
||||
option.Some(s) -> [#("api_secret", json.string(s)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case is_active {
|
||||
option.Some(a) -> [#("is_active", json.bool(a)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case vip_only {
|
||||
option.Some(v) -> [#("vip_only", json.bool(v)), ..fields]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case required_guild_features {
|
||||
option.Some(features) -> [
|
||||
#("required_guild_features", json.array(features, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
let fields = case allowed_guild_ids {
|
||||
option.Some(ids) -> [
|
||||
#("allowed_guild_ids", json.array(ids, json.string)),
|
||||
..fields
|
||||
]
|
||||
option.None -> fields
|
||||
}
|
||||
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/servers/update",
|
||||
fields,
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_voice_server(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
region_id: String,
|
||||
server_id: String,
|
||||
audit_log_reason: option.Option(String),
|
||||
) -> Result(Nil, ApiError) {
|
||||
admin_post_with_audit(
|
||||
ctx,
|
||||
session,
|
||||
"/admin/voice/servers/delete",
|
||||
[
|
||||
#("region_id", json.string(region_id)),
|
||||
#("server_id", json.string(server_id)),
|
||||
],
|
||||
audit_log_reason,
|
||||
)
|
||||
}
|
||||
138
fluxer_admin/src/fluxer_admin/avatar.gleam
Normal file
138
fluxer_admin/src/fluxer_admin/avatar.gleam
Normal file
@@ -0,0 +1,138 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import gleam/string
|
||||
|
||||
pub fn get_user_avatar_url(
|
||||
media_endpoint: String,
|
||||
cdn_endpoint: String,
|
||||
user_id: String,
|
||||
avatar: Option(String),
|
||||
animated: Bool,
|
||||
asset_version: String,
|
||||
) -> String {
|
||||
case avatar {
|
||||
option.Some(hash) -> {
|
||||
let is_animated = string.starts_with(hash, "a_")
|
||||
let actual_hash = case is_animated {
|
||||
True -> string.drop_start(hash, 2)
|
||||
False -> hash
|
||||
}
|
||||
let should_animate = is_animated && animated
|
||||
let format = case should_animate {
|
||||
True -> "gif"
|
||||
False -> "webp"
|
||||
}
|
||||
media_endpoint
|
||||
<> "/avatars/"
|
||||
<> user_id
|
||||
<> "/"
|
||||
<> actual_hash
|
||||
<> "."
|
||||
<> format
|
||||
<> "?size=160"
|
||||
}
|
||||
option.None -> get_default_avatar(cdn_endpoint, user_id, asset_version)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_default_avatar(
|
||||
cdn_endpoint: String,
|
||||
user_id: String,
|
||||
asset_version: String,
|
||||
) -> String {
|
||||
let id = do_parse_bigint(user_id)
|
||||
let index = do_rem(id, 6)
|
||||
cdn_endpoint
|
||||
<> "/avatars/"
|
||||
<> int.to_string(index)
|
||||
<> ".png"
|
||||
|> web.cache_busted_with_version(asset_version)
|
||||
}
|
||||
|
||||
@external(erlang, "erlang", "binary_to_integer")
|
||||
fn do_parse_bigint(id: String) -> Int
|
||||
|
||||
@external(erlang, "erlang", "rem")
|
||||
fn do_rem(a: Int, b: Int) -> Int
|
||||
|
||||
pub fn get_guild_icon_url(
|
||||
media_proxy_endpoint: String,
|
||||
guild_id: String,
|
||||
icon: Option(String),
|
||||
animated: Bool,
|
||||
) -> Option(String) {
|
||||
case icon {
|
||||
option.Some(hash) -> {
|
||||
let is_animated = string.starts_with(hash, "a_")
|
||||
let actual_hash = case is_animated {
|
||||
True -> string.drop_start(hash, 2)
|
||||
False -> hash
|
||||
}
|
||||
let should_animate = is_animated && animated
|
||||
let format = case should_animate {
|
||||
True -> "gif"
|
||||
False -> "webp"
|
||||
}
|
||||
option.Some(
|
||||
media_proxy_endpoint
|
||||
<> "/icons/"
|
||||
<> guild_id
|
||||
<> "/"
|
||||
<> actual_hash
|
||||
<> "."
|
||||
<> format
|
||||
<> "?size=160",
|
||||
)
|
||||
}
|
||||
option.None -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_initials_from_name(name: String) -> String {
|
||||
name
|
||||
|> string.to_graphemes
|
||||
|> do_get_initials(True, [])
|
||||
|> list.reverse
|
||||
|> string.join("")
|
||||
|> string.uppercase
|
||||
}
|
||||
|
||||
fn do_get_initials(
|
||||
chars: List(String),
|
||||
is_start: Bool,
|
||||
acc: List(String),
|
||||
) -> List(String) {
|
||||
case chars {
|
||||
[] -> acc
|
||||
[char, ..rest] -> {
|
||||
case char {
|
||||
" " -> do_get_initials(rest, True, acc)
|
||||
_ -> {
|
||||
case is_start {
|
||||
True -> do_get_initials(rest, False, [char, ..acc])
|
||||
False -> do_get_initials(rest, False, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
fluxer_admin/src/fluxer_admin/badge.gleam
Normal file
73
fluxer_admin/src/fluxer_admin/badge.gleam
Normal file
@@ -0,0 +1,73 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
|
||||
pub type Badge {
|
||||
Badge(name: String, icon: String)
|
||||
}
|
||||
|
||||
const flag_staff = 1
|
||||
|
||||
const flag_ctp_member = 2
|
||||
|
||||
const flag_partner = 4
|
||||
|
||||
const flag_bug_hunter = 8
|
||||
|
||||
pub fn get_user_badges(cdn_endpoint: String, flags: String) -> List(Badge) {
|
||||
case int.parse(flags) {
|
||||
Ok(flags_int) -> {
|
||||
[]
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_staff,
|
||||
Badge("Staff", cdn_endpoint <> "/badges/staff.svg"),
|
||||
)
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_ctp_member,
|
||||
Badge("CTP Member", cdn_endpoint <> "/badges/ctp.svg"),
|
||||
)
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_partner,
|
||||
Badge("Partner", cdn_endpoint <> "/badges/partner.svg"),
|
||||
)
|
||||
|> add_badge_if_has_flag(
|
||||
flags_int,
|
||||
flag_bug_hunter,
|
||||
Badge("Bug Hunter", cdn_endpoint <> "/badges/bug-hunter.svg"),
|
||||
)
|
||||
|> list.reverse
|
||||
}
|
||||
Error(_) -> []
|
||||
}
|
||||
}
|
||||
|
||||
fn add_badge_if_has_flag(
|
||||
badges: List(Badge),
|
||||
flags: Int,
|
||||
flag: Int,
|
||||
badge: Badge,
|
||||
) -> List(Badge) {
|
||||
case int.bitwise_and(flags, flag) == flag {
|
||||
True -> [badge, ..badges]
|
||||
False -> badges
|
||||
}
|
||||
}
|
||||
61
fluxer_admin/src/fluxer_admin/components/date_time.gleam
Normal file
61
fluxer_admin/src/fluxer_admin/components/date_time.gleam
Normal file
@@ -0,0 +1,61 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/string
|
||||
|
||||
pub fn format_timestamp(timestamp: String) -> String {
|
||||
case string.split(timestamp, "T") {
|
||||
[date_part, time_part] -> {
|
||||
let time_clean = case string.split(time_part, ".") {
|
||||
[hms, _] -> hms
|
||||
_ -> time_part
|
||||
}
|
||||
let time_clean = string.replace(time_clean, "Z", "")
|
||||
|
||||
case string.split(time_clean, ":") {
|
||||
[hour, minute, _] -> date_part <> " " <> hour <> ":" <> minute
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_date(timestamp: String) -> String {
|
||||
case string.split(timestamp, "T") {
|
||||
[date_part, _] -> date_part
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_time(timestamp: String) -> String {
|
||||
case string.split(timestamp, "T") {
|
||||
[_, time_part] -> {
|
||||
let time_clean = case string.split(time_part, ".") {
|
||||
[hms, _] -> hms
|
||||
_ -> time_part
|
||||
}
|
||||
let time_clean = string.replace(time_clean, "Z", "")
|
||||
|
||||
case string.split(time_clean, ":") {
|
||||
[hour, minute, _] -> hour <> ":" <> minute
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
_ -> timestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn render() {
|
||||
let script =
|
||||
"(function(){const configs=[{selectId:'user-deletion-reason',inputId:'user-deletion-days'},{selectId:'bulk-deletion-reason',inputId:'bulk-deletion-days'}];const userReason='1';const userMin=14;const defaultMin=60;const update=(select,input)=>{const min=select.value===userReason?userMin:defaultMin;input.min=min.toString();const current=parseInt(input.value,10);if(isNaN(current)||current<min){input.value=min.toString();}};configs.forEach(({selectId,inputId})=>{const select=document.getElementById(selectId);const input=document.getElementById(inputId);if(!select||!input){return;}select.addEventListener('change',()=>update(select,input));update(select,input);});})();"
|
||||
|
||||
h.script([a.attribute("defer", "defer")], script)
|
||||
}
|
||||
108
fluxer_admin/src/fluxer_admin/components/errors.gleam
Normal file
108
fluxer_admin/src/fluxer_admin/components/errors.gleam
Normal file
@@ -0,0 +1,108 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/common
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/option
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn error_view(error: common.ApiError) {
|
||||
h.div(
|
||||
[a.class("bg-red-50 border border-red-200 rounded-lg p-6 text-center")],
|
||||
[
|
||||
h.p([a.class("text-red-800 text-sm font-medium mb-2")], [
|
||||
element.text("Error"),
|
||||
]),
|
||||
h.p([a.class("text-red-600")], [
|
||||
element.text(case error {
|
||||
common.Unauthorized -> "Unauthorized"
|
||||
common.Forbidden(msg) -> "Forbidden - " <> msg
|
||||
common.NotFound -> "Not found"
|
||||
common.ServerError -> "Server error"
|
||||
common.NetworkError -> "Network error"
|
||||
}),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn api_error_view(
|
||||
ctx: Context,
|
||||
err: common.ApiError,
|
||||
back_url: option.Option(String),
|
||||
back_label: option.Option(String),
|
||||
) {
|
||||
let #(title, message) = case err {
|
||||
common.Unauthorized -> #(
|
||||
"Authentication Required",
|
||||
"Your session has expired. Please log in again.",
|
||||
)
|
||||
common.Forbidden(msg) -> #("Permission Denied", msg)
|
||||
common.NotFound -> #("Not Found", "The requested resource was not found.")
|
||||
common.ServerError -> #(
|
||||
"Server Error",
|
||||
"An internal server error occurred. Please try again later.",
|
||||
)
|
||||
common.NetworkError -> #(
|
||||
"Network Error",
|
||||
"Could not connect to the API. Please try again later.",
|
||||
)
|
||||
}
|
||||
|
||||
h.div([a.class("max-w-4xl mx-auto")], [
|
||||
h.div([a.class("bg-red-50 border border-red-200 rounded-lg p-8")], [
|
||||
h.div([a.class("flex items-start gap-4")], [
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.span([a.class("text-red-600 text-base font-semibold")], [
|
||||
element.text("!"),
|
||||
]),
|
||||
],
|
||||
),
|
||||
h.div([a.class("flex-1")], [
|
||||
h.h2([a.class("text-base font-semibold text-red-900 mb-2")], [
|
||||
element.text(title),
|
||||
]),
|
||||
h.p([a.class("text-red-700 mb-6")], [element.text(message)]),
|
||||
case back_url {
|
||||
option.Some(url) ->
|
||||
h.a(
|
||||
[
|
||||
href(ctx, url),
|
||||
a.class(
|
||||
"inline-flex items-center gap-2 px-4 py-2 bg-red-900 text-white rounded-lg text-sm font-medium hover:bg-red-800 transition-colors",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.span([a.class("text-lg")], [element.text("←")]),
|
||||
element.text(option.unwrap(back_label, "Go Back")),
|
||||
],
|
||||
)
|
||||
option.None -> element.none()
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
152
fluxer_admin/src/fluxer_admin/components/flash.gleam
Normal file
152
fluxer_admin/src/fluxer_admin/components/flash.gleam
Normal file
@@ -0,0 +1,152 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context}
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import gleam/string
|
||||
import gleam/uri
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
import wisp.{type Request, type Response}
|
||||
|
||||
pub type Flash {
|
||||
Flash(message: String, flash_type: FlashType)
|
||||
}
|
||||
|
||||
pub type FlashType {
|
||||
Success
|
||||
Error
|
||||
Info
|
||||
Warning
|
||||
}
|
||||
|
||||
pub fn flash_type_to_string(flash_type: FlashType) -> String {
|
||||
case flash_type {
|
||||
Success -> "success"
|
||||
Error -> "error"
|
||||
Info -> "info"
|
||||
Warning -> "warning"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_flash_type(type_str: String) -> FlashType {
|
||||
case type_str {
|
||||
"success" -> Success
|
||||
"error" -> Error
|
||||
"warning" -> Warning
|
||||
"info" | _ -> Info
|
||||
}
|
||||
}
|
||||
|
||||
fn flash_classes(flash_type: FlashType) -> String {
|
||||
case flash_type {
|
||||
Success ->
|
||||
"bg-green-50 border border-green-200 rounded-lg p-4 text-green-800"
|
||||
Error -> "bg-red-50 border border-red-200 rounded-lg p-4 text-red-800"
|
||||
Warning ->
|
||||
"bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800"
|
||||
Info -> "bg-blue-50 border border-blue-200 rounded-lg p-4 text-blue-800"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flash_view(
|
||||
message: Option(String),
|
||||
flash_type: Option(FlashType),
|
||||
) -> element.Element(t) {
|
||||
case message {
|
||||
option.Some(msg) -> {
|
||||
let type_ = option.unwrap(flash_type, Info)
|
||||
h.div([a.class(flash_classes(type_))], [element.text(msg)])
|
||||
}
|
||||
option.None -> element.none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect_url(
|
||||
path: String,
|
||||
message: String,
|
||||
flash_type: FlashType,
|
||||
) -> String {
|
||||
let encoded_message = uri.percent_encode(message)
|
||||
let type_param = flash_type_to_string(flash_type)
|
||||
|
||||
case string.contains(path, "?") {
|
||||
True -> path <> "&flash=" <> encoded_message <> "&flash_type=" <> type_param
|
||||
False ->
|
||||
path <> "?flash=" <> encoded_message <> "&flash_type=" <> type_param
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect_with_success(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Success)))
|
||||
}
|
||||
|
||||
pub fn redirect_with_error(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Error)))
|
||||
}
|
||||
|
||||
pub fn redirect_with_info(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Info)))
|
||||
}
|
||||
|
||||
pub fn redirect_with_warning(
|
||||
ctx: Context,
|
||||
path: String,
|
||||
message: String,
|
||||
) -> Response {
|
||||
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Warning)))
|
||||
}
|
||||
|
||||
pub fn from_request(req: Request) -> Option(Flash) {
|
||||
let query = wisp.get_query(req)
|
||||
|
||||
let flash_msg = list.key_find(query, "flash") |> option.from_result
|
||||
let flash_type_str = list.key_find(query, "flash_type") |> option.from_result
|
||||
|
||||
case flash_msg {
|
||||
option.Some(msg) -> {
|
||||
let type_ = case flash_type_str {
|
||||
option.Some(type_str) -> parse_flash_type(type_str)
|
||||
option.None -> Info
|
||||
}
|
||||
option.Some(Flash(msg, type_))
|
||||
}
|
||||
option.None -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(flash: Option(Flash)) -> element.Element(t) {
|
||||
case flash {
|
||||
option.Some(Flash(msg, type_)) ->
|
||||
h.div([a.class(flash_classes(type_))], [element.text(msg)])
|
||||
option.None -> element.none()
|
||||
}
|
||||
}
|
||||
106
fluxer_admin/src/fluxer_admin/components/helpers.gleam
Normal file
106
fluxer_admin/src/fluxer_admin/components/helpers.gleam
Normal file
@@ -0,0 +1,106 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn compact_info(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
|
||||
h.span([a.class("text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn compact_info_mono(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
|
||||
h.span([a.class("text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn compact_info_with_element(label: String, value: element.Element(a)) {
|
||||
h.div([], [
|
||||
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
|
||||
h.span([a.class("text-neutral-900")], [value]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn form_field(
|
||||
label: String,
|
||||
name: String,
|
||||
type_: String,
|
||||
placeholder: String,
|
||||
required: Bool,
|
||||
help: String,
|
||||
) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text(label),
|
||||
]),
|
||||
h.input([
|
||||
a.type_(type_),
|
||||
a.name(name),
|
||||
a.placeholder(placeholder),
|
||||
a.required(required),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||
),
|
||||
case type_ == "number" {
|
||||
True -> a.attribute("step", "any")
|
||||
False -> a.class("")
|
||||
},
|
||||
]),
|
||||
h.p([a.class("text-xs text-neutral-500")], [element.text(help)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn form_field_with_value(
|
||||
label: String,
|
||||
name: String,
|
||||
type_: String,
|
||||
value: String,
|
||||
required: Bool,
|
||||
help: String,
|
||||
) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text(label),
|
||||
]),
|
||||
h.input([
|
||||
a.type_(type_),
|
||||
a.name(name),
|
||||
a.value(value),
|
||||
a.required(required),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||
),
|
||||
case type_ == "number" {
|
||||
True -> a.attribute("step", "any")
|
||||
False -> a.class("")
|
||||
},
|
||||
]),
|
||||
h.p([a.class("text-xs text-neutral-500")], [element.text(help)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn info_item(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.p([a.class("text-xs text-neutral-600")], [element.text(label)]),
|
||||
h.p([a.class("text-sm text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
157
fluxer_admin/src/fluxer_admin/components/icons.gleam
Normal file
157
fluxer_admin/src/fluxer_admin/components/icons.gleam
Normal file
@@ -0,0 +1,157 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
|
||||
pub fn paperclip_icon(color: String) {
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class("w-3 h-3 inline-block " <> color),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"rect",
|
||||
[
|
||||
a.attribute("width", "256"),
|
||||
a.attribute("height", "256"),
|
||||
a.attribute("fill", "none"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"path",
|
||||
[
|
||||
a.attribute(
|
||||
"d",
|
||||
"M108.71,197.23l-5.11,5.11a46.63,46.63,0,0,1-66-.05h0a46.63,46.63,0,0,1,.06-65.89L72.4,101.66a46.62,46.62,0,0,1,65.94,0h0A46.34,46.34,0,0,1,150.78,124",
|
||||
),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"path",
|
||||
[
|
||||
a.attribute(
|
||||
"d",
|
||||
"M147.29,58.77l5.11-5.11a46.62,46.62,0,0,1,65.94,0h0a46.62,46.62,0,0,1,0,65.94L193.94,144,183.6,154.34a46.63,46.63,0,0,1-66-.05h0A46.46,46.46,0,0,1,105.22,132",
|
||||
),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn checkmark_icon(color: String) {
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class("w-4 h-4 inline-block " <> color),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"rect",
|
||||
[
|
||||
a.attribute("width", "256"),
|
||||
a.attribute("height", "256"),
|
||||
a.attribute("fill", "none"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"polyline",
|
||||
[
|
||||
a.attribute("points", "40 144 96 200 224 72"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn x_icon(color: String) {
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class("w-4 h-4 inline-block " <> color),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"rect",
|
||||
[
|
||||
a.attribute("width", "256"),
|
||||
a.attribute("height", "256"),
|
||||
a.attribute("fill", "none"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"line",
|
||||
[
|
||||
a.attribute("x1", "200"),
|
||||
a.attribute("y1", "56"),
|
||||
a.attribute("x2", "56"),
|
||||
a.attribute("y2", "200"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"line",
|
||||
[
|
||||
a.attribute("x1", "200"),
|
||||
a.attribute("y1", "200"),
|
||||
a.attribute("x2", "56"),
|
||||
a.attribute("y2", "56"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-linecap", "round"),
|
||||
a.attribute("stroke-linejoin", "round"),
|
||||
a.attribute("stroke-width", "24"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
46
fluxer_admin/src/fluxer_admin/components/icons_meta.gleam
Normal file
46
fluxer_admin/src/fluxer_admin/components/icons_meta.gleam
Normal file
@@ -0,0 +1,46 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element.{type Element}
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn build_icon_links(cdn_endpoint: String) -> List(Element(t)) {
|
||||
[
|
||||
h.link([
|
||||
a.rel("icon"),
|
||||
a.attribute("type", "image/x-icon"),
|
||||
a.href(cdn_endpoint <> "/web/favicon.ico"),
|
||||
]),
|
||||
h.link([
|
||||
a.rel("apple-touch-icon"),
|
||||
a.href(cdn_endpoint <> "/web/apple-touch-icon.png"),
|
||||
]),
|
||||
h.link([
|
||||
a.rel("icon"),
|
||||
a.attribute("type", "image/png"),
|
||||
a.attribute("sizes", "32x32"),
|
||||
a.href(cdn_endpoint <> "/web/favicon-32x32.png"),
|
||||
]),
|
||||
h.link([
|
||||
a.rel("icon"),
|
||||
a.attribute("type", "image/png"),
|
||||
a.attribute("sizes", "16x16"),
|
||||
a.href(cdn_endpoint <> "/web/favicon-16x16.png"),
|
||||
]),
|
||||
]
|
||||
}
|
||||
442
fluxer_admin/src/fluxer_admin/components/layout.gleam
Normal file
442
fluxer_admin/src/fluxer_admin/components/layout.gleam
Normal 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 -> []
|
||||
}
|
||||
}
|
||||
180
fluxer_admin/src/fluxer_admin/components/message_list.gleam
Normal file
180
fluxer_admin/src/fluxer_admin/components/message_list.gleam
Normal file
@@ -0,0 +1,180 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/api/messages
|
||||
import fluxer_admin/components/icons
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn render(
|
||||
ctx: Context,
|
||||
messages: List(messages.Message),
|
||||
include_delete_button: Bool,
|
||||
) {
|
||||
h.div([a.class("space-y-1")], {
|
||||
list.map(messages, fn(message) {
|
||||
render_message_row(ctx, message, include_delete_button)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_message_row(
|
||||
ctx: Context,
|
||||
message: messages.Message,
|
||||
include_delete_button: Bool,
|
||||
) {
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"group flex items-start gap-3 px-4 py-2 hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
a.attribute("data-message-id", message.id),
|
||||
],
|
||||
[
|
||||
h.div([a.class("flex-shrink-0 pt-0.5")], [
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/users/" <> message.author_id),
|
||||
a.class("text-xs text-neutral-900 hover:underline cursor-pointer"),
|
||||
a.title(message.author_id),
|
||||
],
|
||||
[element.text(message.author_username)],
|
||||
),
|
||||
h.div([a.class("text-xs text-neutral-500")], [
|
||||
element.text(message.timestamp),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex-1 min-w-0 message-content")], [
|
||||
h.div(
|
||||
[a.class("text-sm text-neutral-900 whitespace-pre-wrap break-words")],
|
||||
[element.text(message.content)],
|
||||
),
|
||||
case list.is_empty(message.attachments) {
|
||||
True -> element.none()
|
||||
False ->
|
||||
h.div([a.class("mt-2 space-y-1")], {
|
||||
list.map(message.attachments, fn(att) {
|
||||
h.div([a.class("text-xs flex items-center gap-1")], [
|
||||
icons.paperclip_icon("text-neutral-500"),
|
||||
h.a(
|
||||
[
|
||||
a.href(att.url),
|
||||
a.target("_blank"),
|
||||
a.class("text-blue-600 hover:underline"),
|
||||
],
|
||||
[element.text(att.filename)],
|
||||
),
|
||||
])
|
||||
})
|
||||
})
|
||||
},
|
||||
h.div([a.class("text-xs text-neutral-400 mt-1")], [
|
||||
element.text("ID: " <> message.id),
|
||||
]),
|
||||
]),
|
||||
case include_delete_button && !string.is_empty(message.channel_id) {
|
||||
True ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.button(
|
||||
[
|
||||
a.type_("button"),
|
||||
a.class(
|
||||
"px-2 py-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors",
|
||||
),
|
||||
a.title("Delete message"),
|
||||
a.attribute(
|
||||
"onclick",
|
||||
"deleteMessage('"
|
||||
<> message.channel_id
|
||||
<> "', '"
|
||||
<> message.id
|
||||
<> "', this)",
|
||||
),
|
||||
],
|
||||
[element.text("Delete")],
|
||||
),
|
||||
],
|
||||
)
|
||||
False -> element.none()
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn deletion_script() {
|
||||
"<script>
|
||||
function deleteMessage(channelId, messageId, button) {
|
||||
if (!confirm('Are you sure you want to delete this message?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('channel_id', channelId);
|
||||
formData.append('message_id', messageId);
|
||||
|
||||
button.disabled = true;
|
||||
button.textContent = 'Deleting...';
|
||||
|
||||
const basePath = document.documentElement.dataset.basePath || '';
|
||||
fetch(basePath + '/messages?action=delete', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
const messageRow = button.closest('[data-message-id]');
|
||||
if (messageRow) {
|
||||
messageRow.style.opacity = '0.5';
|
||||
messageRow.style.pointerEvents = 'none';
|
||||
const messageContent = messageRow.querySelector('.message-content');
|
||||
if (messageContent) {
|
||||
messageContent.style.textDecoration = 'line-through';
|
||||
}
|
||||
}
|
||||
const buttonContainer = button.parentElement;
|
||||
const deletedBadge = document.createElement('span');
|
||||
deletedBadge.className = 'px-2 py-1 bg-red-100 text-red-800 text-xs rounded opacity-100';
|
||||
deletedBadge.textContent = 'DELETED';
|
||||
button.replaceWith(deletedBadge);
|
||||
if (buttonContainer) {
|
||||
buttonContainer.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Delete';
|
||||
alert('Failed to delete message');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
button.disabled = false;
|
||||
button.textContent = 'Delete';
|
||||
alert('Error deleting message');
|
||||
});
|
||||
}
|
||||
</script>"
|
||||
}
|
||||
93
fluxer_admin/src/fluxer_admin/components/pagination.gleam
Normal file
93
fluxer_admin/src/fluxer_admin/components/pagination.gleam
Normal file
@@ -0,0 +1,93 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/int
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn pagination(
|
||||
ctx: Context,
|
||||
total: Int,
|
||||
limit: Int,
|
||||
current_page: Int,
|
||||
build_url_fn: fn(Int) -> String,
|
||||
) -> element.Element(a) {
|
||||
let total_pages = { total + limit - 1 } / limit
|
||||
let has_previous = current_page > 0
|
||||
let has_next = current_page < total_pages - 1
|
||||
|
||||
h.div([a.class("mt-6 flex justify-center gap-3 items-center")], [
|
||||
case has_previous {
|
||||
True -> {
|
||||
let prev_url = build_url_fn(current_page - 1)
|
||||
|
||||
h.a(
|
||||
[
|
||||
href(ctx, prev_url),
|
||||
a.class(
|
||||
"px-6 py-2 bg-white text-neutral-900 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("← Previous")],
|
||||
)
|
||||
}
|
||||
False ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"px-6 py-2 bg-neutral-100 text-neutral-400 border border-neutral-200 rounded-lg text-sm font-medium cursor-not-allowed",
|
||||
),
|
||||
],
|
||||
[element.text("← Previous")],
|
||||
)
|
||||
},
|
||||
h.span([a.class("text-sm text-neutral-600")], [
|
||||
element.text(
|
||||
"Page "
|
||||
<> int.to_string(current_page + 1)
|
||||
<> " of "
|
||||
<> int.to_string(total_pages),
|
||||
),
|
||||
]),
|
||||
case has_next {
|
||||
True -> {
|
||||
let next_url = build_url_fn(current_page + 1)
|
||||
|
||||
h.a(
|
||||
[
|
||||
href(ctx, next_url),
|
||||
a.class(
|
||||
"px-6 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors no-underline",
|
||||
),
|
||||
],
|
||||
[element.text("Next →")],
|
||||
)
|
||||
}
|
||||
False ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"px-6 py-2 bg-neutral-100 text-neutral-400 rounded-lg text-sm font-medium cursor-not-allowed",
|
||||
),
|
||||
],
|
||||
[element.text("Next →")],
|
||||
)
|
||||
},
|
||||
])
|
||||
}
|
||||
419
fluxer_admin/src/fluxer_admin/components/review_deck.gleam
Normal file
419
fluxer_admin/src/fluxer_admin/components/review_deck.gleam
Normal file
@@ -0,0 +1,419 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/components/review_keyboard
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn styles() -> element.Element(a) {
|
||||
let css =
|
||||
"
|
||||
[data-review-deck] { position: relative; }
|
||||
[data-review-card] { will-change: transform, opacity; touch-action: pan-y; }
|
||||
[data-review-card][hidden] { display: none !important; }
|
||||
|
||||
.review-card-enter { animation: reviewEnter 120ms ease-out; }
|
||||
@keyframes reviewEnter { from { opacity: .6; transform: translateY(6px) scale(.995);} to { opacity: 1; transform: translateY(0) scale(1);} }
|
||||
|
||||
.review-card-leave-left { animation: reviewLeaveLeft 180ms ease-in forwards; }
|
||||
.review-card-leave-right { animation: reviewLeaveRight 180ms ease-in forwards; }
|
||||
@keyframes reviewLeaveLeft { to { opacity: 0; transform: translateX(-120%) rotate(-10deg);} }
|
||||
@keyframes reviewLeaveRight { to { opacity: 0; transform: translateX(120%) rotate(10deg);} }
|
||||
|
||||
.review-toast { position: fixed; left: 16px; right: 16px; bottom: 16px; z-index: 80; }
|
||||
.review-toast-inner { max-width: 720px; margin: 0 auto; }
|
||||
|
||||
.review-hintbar { position: sticky; bottom: 0; z-index: 10; }
|
||||
.review-kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
|
||||
"
|
||||
h.style([a.type_("text/css")], css)
|
||||
}
|
||||
|
||||
pub fn script_tags() -> List(element.Element(a)) {
|
||||
[
|
||||
review_keyboard.script_tag(),
|
||||
h.script([a.attribute("defer", "defer")], script()),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn script() -> String {
|
||||
"
|
||||
(function () {
|
||||
function qs(el, sel) { return el.querySelector(sel); }
|
||||
function qsa(el, sel) { return Array.prototype.slice.call(el.querySelectorAll(sel)); }
|
||||
|
||||
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
||||
|
||||
function showToast(message) {
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'review-toast';
|
||||
toast.innerHTML =
|
||||
'<div class=\"review-toast-inner\">' +
|
||||
'<div class=\"bg-red-50 border border-red-200 text-red-800 rounded-xl px-4 py-3 shadow-lg\">' +
|
||||
'<div class=\"text-sm font-semibold\">Action failed</div>' +
|
||||
'<div class=\"text-sm mt-1\" style=\"word-break: break-word;\">' + (message || 'Unknown error') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function () { toast.remove(); }, 4200);
|
||||
}
|
||||
|
||||
function parseHTML(html) {
|
||||
var parser = new DOMParser();
|
||||
return parser.parseFromString(html, 'text/html');
|
||||
}
|
||||
|
||||
function asURL(url) {
|
||||
try { return new URL(url, window.location.origin); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function enhanceDeck(deck) {
|
||||
var cards = qsa(deck, '[data-review-card]');
|
||||
var idx = 0;
|
||||
|
||||
var fragmentBase = deck.getAttribute('data-fragment-base') || '';
|
||||
var nextPage = parseInt(deck.getAttribute('data-next-page') || '0', 10);
|
||||
var canPaginate = deck.getAttribute('data-can-paginate') === 'true';
|
||||
var prefetchWhenRemaining = parseInt(deck.getAttribute('data-prefetch-when-remaining') || '6', 10);
|
||||
var prefetchInFlight = false;
|
||||
|
||||
var emptyUrl = deck.getAttribute('data-empty-url') || '';
|
||||
|
||||
function currentCard() { return cards[idx] || null; }
|
||||
function remainingCount() { return Math.max(0, cards.length - idx); }
|
||||
|
||||
function setHiddenAllExcept(active) {
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var c = cards[i];
|
||||
c.hidden = (c !== active);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrlFor(card) {
|
||||
var directUrl = card && card.getAttribute('data-direct-url');
|
||||
if (!directUrl) return;
|
||||
try {
|
||||
history.replaceState({ review: true, directUrl: directUrl }, '', directUrl);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function focusCard(card) {
|
||||
if (!card) return;
|
||||
requestAnimationFrame(function () {
|
||||
try { card.focus({ preventScroll: true }); } catch (_) { try { card.focus(); } catch (_) {} }
|
||||
});
|
||||
}
|
||||
|
||||
function ensureActiveCard() {
|
||||
var card = currentCard();
|
||||
if (!card) {
|
||||
if (emptyUrl) {
|
||||
try { history.replaceState({}, '', emptyUrl); } catch (_) {}
|
||||
}
|
||||
deck.dispatchEvent(new CustomEvent('review:empty'));
|
||||
return;
|
||||
}
|
||||
setHiddenAllExcept(card);
|
||||
card.classList.remove('review-card-leave-left', 'review-card-leave-right');
|
||||
card.classList.add('review-card-enter');
|
||||
setTimeout(function () { card.classList.remove('review-card-enter'); }, 160);
|
||||
updateUrlFor(card);
|
||||
focusCard(card);
|
||||
maybePrefetchDetails(card);
|
||||
maybePrefetchMore();
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
var el = qs(deck, '[data-review-progress]');
|
||||
if (!el) return;
|
||||
el.textContent = remainingCount().toString() + ' remaining';
|
||||
}
|
||||
|
||||
async function backgroundSubmit(form) {
|
||||
var actionUrl = asURL(form.action);
|
||||
if (!actionUrl) throw new Error('Invalid action URL');
|
||||
actionUrl.searchParams.set('background', '1');
|
||||
|
||||
var fd = new FormData(form);
|
||||
var body = new URLSearchParams();
|
||||
fd.forEach(function (v, k) { body.append(k, v); });
|
||||
|
||||
var resp = await fetch(actionUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (resp.status === 204) return;
|
||||
var text = '';
|
||||
try { text = await resp.text(); } catch (_) {}
|
||||
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
|
||||
}
|
||||
|
||||
function advance() {
|
||||
idx = idx + 1;
|
||||
ensureActiveCard();
|
||||
}
|
||||
|
||||
function animateAndAdvance(card, dir) {
|
||||
card.classList.remove('review-card-enter');
|
||||
card.classList.add(dir === 'left' ? 'review-card-leave-left' : 'review-card-leave-right');
|
||||
setTimeout(function () { advance(); }, 190);
|
||||
}
|
||||
|
||||
async function act(dir) {
|
||||
var card = currentCard();
|
||||
if (!card) return;
|
||||
|
||||
if (dir === 'left' && card.getAttribute('data-left-mode') === 'skip') {
|
||||
animateAndAdvance(card, 'left');
|
||||
return;
|
||||
}
|
||||
|
||||
var form = qs(card, 'form[data-review-submit=\"' + dir + '\"]');
|
||||
if (!form) {
|
||||
animateAndAdvance(card, dir);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await backgroundSubmit(form);
|
||||
animateAndAdvance(card, dir);
|
||||
} catch (err) {
|
||||
showToast((err && err.message) ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
var t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
|
||||
if (e.key === 'Escape' && emptyUrl) {
|
||||
try { history.replaceState({}, '', emptyUrl); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyboardAction(e) {
|
||||
var dir = e.detail && e.detail.direction;
|
||||
if (dir === 'left' || dir === 'right') {
|
||||
act(dir);
|
||||
}
|
||||
}
|
||||
|
||||
function wireButtons(card) {
|
||||
var leftBtn = qs(card, '[data-review-action=\"left\"]');
|
||||
var rightBtn = qs(card, '[data-review-action=\"right\"]');
|
||||
|
||||
if (leftBtn) {
|
||||
leftBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (window.fluxerReviewKeyboard) {
|
||||
window.fluxerReviewKeyboard.enable(deck);
|
||||
}
|
||||
act('left');
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
if (rightBtn) {
|
||||
rightBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (window.fluxerReviewKeyboard) {
|
||||
window.fluxerReviewKeyboard.enable(deck);
|
||||
}
|
||||
act('right');
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
var forms = qsa(card, 'form[data-review-submit]');
|
||||
forms.forEach(function (f) {
|
||||
f.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var dir = f.getAttribute('data-review-submit');
|
||||
if (dir === 'left' || dir === 'right') {
|
||||
if (window.fluxerReviewKeyboard) {
|
||||
window.fluxerReviewKeyboard.enable(deck);
|
||||
}
|
||||
}
|
||||
act(dir);
|
||||
}, { capture: true });
|
||||
});
|
||||
}
|
||||
|
||||
function wireAll() { cards.forEach(wireButtons); }
|
||||
|
||||
function wireSwipe(card) {
|
||||
var tracking = null;
|
||||
|
||||
card.addEventListener('pointerdown', function (e) {
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
tracking = {
|
||||
id: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
x: 0,
|
||||
y: 0,
|
||||
moved: false
|
||||
};
|
||||
try { card.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
});
|
||||
|
||||
card.addEventListener('pointermove', function (e) {
|
||||
if (!tracking || tracking.id !== e.pointerId) return;
|
||||
tracking.x = e.clientX - tracking.startX;
|
||||
tracking.y = e.clientY - tracking.startY;
|
||||
|
||||
if (!tracking.moved) {
|
||||
if (Math.abs(tracking.y) > 12 && Math.abs(tracking.y) > Math.abs(tracking.x)) {
|
||||
tracking = null;
|
||||
card.style.transform = '';
|
||||
return;
|
||||
}
|
||||
tracking.moved = true;
|
||||
}
|
||||
|
||||
var w = Math.max(320, card.getBoundingClientRect().width);
|
||||
var pct = clamp(tracking.x / w, -1, 1);
|
||||
var rot = pct * 8;
|
||||
card.style.transform = 'translateX(' + tracking.x + 'px) rotate(' + rot + 'deg)';
|
||||
card.style.opacity = String(1 - Math.min(0.35, Math.abs(pct) * 0.35));
|
||||
});
|
||||
|
||||
function endSwipe(e) {
|
||||
if (!tracking || tracking.id !== e.pointerId) return;
|
||||
var dx = tracking.x;
|
||||
tracking = null;
|
||||
|
||||
var w = Math.max(320, card.getBoundingClientRect().width);
|
||||
var threshold = Math.max(110, w * 0.22);
|
||||
if (Math.abs(dx) >= threshold) {
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
var dir = dx < 0 ? 'left' : 'right';
|
||||
act(dir);
|
||||
return;
|
||||
}
|
||||
card.style.transition = 'transform 120ms ease-out, opacity 120ms ease-out';
|
||||
card.style.transform = '';
|
||||
card.style.opacity = '';
|
||||
setTimeout(function () { card.style.transition = ''; }, 140);
|
||||
}
|
||||
|
||||
card.addEventListener('pointerup', endSwipe);
|
||||
card.addEventListener('pointercancel', endSwipe);
|
||||
}
|
||||
|
||||
function wireSwipeAll() { cards.forEach(wireSwipe); }
|
||||
|
||||
async function maybePrefetchMore() {
|
||||
if (!canPaginate) return;
|
||||
if (!fragmentBase) return;
|
||||
if (prefetchInFlight) return;
|
||||
if (remainingCount() > prefetchWhenRemaining) return;
|
||||
|
||||
prefetchInFlight = true;
|
||||
try {
|
||||
var url = asURL(fragmentBase);
|
||||
if (!url) return;
|
||||
url.searchParams.set('page', String(nextPage));
|
||||
var resp = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||
if (!resp.ok) return;
|
||||
var html = await resp.text();
|
||||
var doc = parseHTML(html);
|
||||
var frag = doc.querySelector('[data-review-fragment]');
|
||||
if (!frag) return;
|
||||
var newCards = Array.prototype.slice.call(frag.querySelectorAll('[data-review-card]'));
|
||||
if (newCards.length === 0) return;
|
||||
|
||||
newCards.forEach(function (c) {
|
||||
c.hidden = true;
|
||||
deck.appendChild(c);
|
||||
});
|
||||
cards = qsa(deck, '[data-review-card]');
|
||||
newCards.forEach(function (c) { wireButtons(c); wireSwipe(c); });
|
||||
nextPage = nextPage + 1;
|
||||
} finally {
|
||||
prefetchInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybePrefetchDetails(card) {
|
||||
var expandUrl = card.getAttribute('data-expand-url');
|
||||
var targetSel = card.getAttribute('data-expand-target') || '';
|
||||
if (!expandUrl || !targetSel) return;
|
||||
if (card.getAttribute('data-expanded') === 'true') return;
|
||||
|
||||
var target = qs(card, targetSel);
|
||||
if (!target) return;
|
||||
|
||||
card.setAttribute('data-expanded', 'inflight');
|
||||
try {
|
||||
var resp = await fetch(expandUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) { card.setAttribute('data-expanded', 'false'); return; }
|
||||
var html = await resp.text();
|
||||
var doc = parseHTML(html);
|
||||
var frag = doc.querySelector('[data-report-fragment]');
|
||||
if (!frag) { card.setAttribute('data-expanded', 'false'); return; }
|
||||
target.innerHTML = '';
|
||||
target.appendChild(frag);
|
||||
target.hidden = false;
|
||||
card.setAttribute('data-expanded', 'true');
|
||||
} catch (_) {
|
||||
card.setAttribute('data-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function wireExpandButtons() {
|
||||
cards.forEach(function (card) {
|
||||
var btn = qs(card, '[data-review-expand]');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
card.setAttribute('data-expanded', 'false');
|
||||
maybePrefetchDetails(card);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deck.addEventListener('keydown', onKeyDown);
|
||||
deck.addEventListener('review:keyboard', onKeyboardAction);
|
||||
wireAll();
|
||||
wireSwipeAll();
|
||||
wireExpandButtons();
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
ensureActiveCard();
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
var decks = document.querySelectorAll('[data-review-deck]');
|
||||
for (var i = 0; i < decks.length; i++) {
|
||||
enhanceDeck(decks[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/option
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn view(
|
||||
left_key: String,
|
||||
left_label: String,
|
||||
right_key: String,
|
||||
right_label: String,
|
||||
exit_key: String,
|
||||
exit_label: String,
|
||||
note: option.Option(String),
|
||||
) {
|
||||
let note_element = case note {
|
||||
option.Some(text) ->
|
||||
h.div([a.class("body-sm text-neutral-600")], [element.text(text)])
|
||||
option.None -> element.none()
|
||||
}
|
||||
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"review-hintbar mt-6 p-4 bg-neutral-50 border-t border-neutral-200",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.div([a.class("max-w-7xl mx-auto flex items-center justify-between")], [
|
||||
h.div([a.class("flex gap-6 items-center")], [
|
||||
h.div([a.class("flex items-center gap-2")], [
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
|
||||
),
|
||||
],
|
||||
[element.text(left_key)],
|
||||
),
|
||||
h.span([a.class("body-sm text-neutral-700")], [
|
||||
element.text(left_label),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex items-center gap-2")], [
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
|
||||
),
|
||||
],
|
||||
[element.text(right_key)],
|
||||
),
|
||||
h.span([a.class("body-sm text-neutral-700")], [
|
||||
element.text(right_label),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex items-center gap-2")], [
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
|
||||
),
|
||||
],
|
||||
[element.text(exit_key)],
|
||||
),
|
||||
h.span([a.class("body-sm text-neutral-700")], [
|
||||
element.text(exit_label),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
note_element,
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
101
fluxer_admin/src/fluxer_admin/components/review_keyboard.gleam
Normal file
101
fluxer_admin/src/fluxer_admin/components/review_keyboard.gleam
Normal file
@@ -0,0 +1,101 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn script_tag() -> element.Element(a) {
|
||||
h.script([a.attribute("defer", "defer")], script())
|
||||
}
|
||||
|
||||
pub fn script() -> String {
|
||||
"
|
||||
(function () {
|
||||
var globalKeyboardMode = false;
|
||||
var activeDeck = null;
|
||||
|
||||
function isEditable(el) {
|
||||
if (!el) return false;
|
||||
var tag = el.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||
return el.isContentEditable;
|
||||
}
|
||||
|
||||
function triggerAction(deck, direction) {
|
||||
if (!deck) return;
|
||||
var event = new CustomEvent('review:keyboard', {
|
||||
detail: { direction: direction },
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
deck.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function onGlobalKeyDown(e) {
|
||||
if (!globalKeyboardMode) return;
|
||||
if (!activeDeck) return;
|
||||
if (isEditable(e.target)) return;
|
||||
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
triggerAction(activeDeck, 'left');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
triggerAction(activeDeck, 'right');
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
disableKeyboardMode();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function enableKeyboardMode(deckElement) {
|
||||
if (!deckElement) return;
|
||||
activeDeck = deckElement;
|
||||
globalKeyboardMode = true;
|
||||
deckElement.setAttribute('data-keyboard-mode', 'true');
|
||||
}
|
||||
|
||||
function disableKeyboardMode() {
|
||||
if (activeDeck) {
|
||||
activeDeck.removeAttribute('data-keyboard-mode');
|
||||
}
|
||||
globalKeyboardMode = false;
|
||||
activeDeck = null;
|
||||
}
|
||||
|
||||
window.fluxerReviewKeyboard = {
|
||||
enable: enableKeyboardMode,
|
||||
disable: disableKeyboardMode,
|
||||
isEnabled: function () { return globalKeyboardMode; },
|
||||
getActiveDeck: function () { return activeDeck; }
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onGlobalKeyDown, { capture: true });
|
||||
})();
|
||||
"
|
||||
}
|
||||
99
fluxer_admin/src/fluxer_admin/components/search_form.gleam
Normal file
99
fluxer_admin/src/fluxer_admin/components/search_form.gleam
Normal file
@@ -0,0 +1,99 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/components/ui
|
||||
import fluxer_admin/web.{type Context, href}
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn search_form(
|
||||
ctx: Context,
|
||||
query: Option(String),
|
||||
placeholder: String,
|
||||
help_text: Option(String),
|
||||
clear_url: String,
|
||||
additional_filters: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
ui.card(ui.PaddingSmall, [
|
||||
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
||||
case list.is_empty(additional_filters) {
|
||||
True ->
|
||||
h.div([a.class("flex gap-2")], [
|
||||
h.input([
|
||||
a.type_("text"),
|
||||
a.name("q"),
|
||||
a.value(option.unwrap(query, "")),
|
||||
a.placeholder(placeholder),
|
||||
a.class(
|
||||
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
|
||||
),
|
||||
a.attribute("autocomplete", "off"),
|
||||
]),
|
||||
ui.button_primary("Search", "submit", []),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, clear_url),
|
||||
a.class(
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("Clear")],
|
||||
),
|
||||
])
|
||||
False ->
|
||||
h.div([a.class("flex flex-col gap-4")], [
|
||||
h.div([a.class("flex gap-2")], [
|
||||
h.input([
|
||||
a.type_("text"),
|
||||
a.name("q"),
|
||||
a.value(option.unwrap(query, "")),
|
||||
a.placeholder(placeholder),
|
||||
a.class(
|
||||
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
|
||||
),
|
||||
a.attribute("autocomplete", "off"),
|
||||
]),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("grid grid-cols-1 md:grid-cols-4 gap-4")],
|
||||
additional_filters,
|
||||
),
|
||||
h.div([a.class("flex gap-2")], [
|
||||
ui.button_primary("Search", "submit", []),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, clear_url),
|
||||
a.class(
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("Clear")],
|
||||
),
|
||||
]),
|
||||
])
|
||||
},
|
||||
case help_text {
|
||||
option.Some(text) ->
|
||||
h.p([a.class("text-xs text-neutral-500")], [element.text(text)])
|
||||
option.None -> element.none()
|
||||
},
|
||||
]),
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/int
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn range_slider_section(
|
||||
slider_id: String,
|
||||
value_id: String,
|
||||
min_value: Int,
|
||||
max_value: Int,
|
||||
current_value: Int,
|
||||
) {
|
||||
[
|
||||
h.input([
|
||||
a.id(slider_id),
|
||||
a.type_("range"),
|
||||
a.name("count"),
|
||||
a.min(int.to_string(min_value)),
|
||||
a.max(int.to_string(max_value)),
|
||||
a.value(int.to_string(current_value)),
|
||||
a.class("w-full h-2 bg-neutral-200 rounded-lg accent-neutral-900"),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("flex items-baseline justify-between text-xs text-neutral-500")],
|
||||
[
|
||||
h.span([], [element.text("Selected amount")]),
|
||||
h.span([a.id(value_id), a.class("font-semibold text-neutral-900")], [
|
||||
element.text(int.to_string(current_value)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn slider_sync_script(
|
||||
slider_id: String,
|
||||
value_id: String,
|
||||
) -> element.Element(a) {
|
||||
let script =
|
||||
"(function(){const slider=document.getElementById('"
|
||||
<> slider_id
|
||||
<> "');const value=document.getElementById('"
|
||||
<> value_id
|
||||
<> "');if(!slider||!value)return;const update=()=>value.textContent=slider.value;update();slider.addEventListener('input',update);})();"
|
||||
|
||||
h.script([a.attribute("defer", "defer")], script)
|
||||
}
|
||||
46
fluxer_admin/src/fluxer_admin/components/tabs.gleam
Normal file
46
fluxer_admin/src/fluxer_admin/components/tabs.gleam
Normal file
@@ -0,0 +1,46 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import 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)])
|
||||
}
|
||||
674
fluxer_admin/src/fluxer_admin/components/ui.gleam
Normal file
674
fluxer_admin/src/fluxer_admin/components/ui.gleam
Normal 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)],
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
56
fluxer_admin/src/fluxer_admin/components/url_builder.gleam
Normal file
56
fluxer_admin/src/fluxer_admin/components/url_builder.gleam
Normal file
@@ -0,0 +1,56 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/list
|
||||
import gleam/option.{type Option}
|
||||
import gleam/string
|
||||
import gleam/uri
|
||||
|
||||
pub fn build_url(
|
||||
base: String,
|
||||
params: List(#(String, Option(String))),
|
||||
) -> String {
|
||||
let filtered_params =
|
||||
params
|
||||
|> list.filter_map(fn(param) {
|
||||
let #(key, value_opt) = param
|
||||
case value_opt {
|
||||
option.Some(value) -> {
|
||||
let trimmed = string.trim(value)
|
||||
case trimmed {
|
||||
"" -> Error(Nil)
|
||||
v -> Ok(#(key, v))
|
||||
}
|
||||
}
|
||||
option.None -> Error(Nil)
|
||||
}
|
||||
})
|
||||
|
||||
case filtered_params {
|
||||
[] -> base
|
||||
params -> {
|
||||
let query_string =
|
||||
params
|
||||
|> list.map(fn(pair) {
|
||||
let #(key, value) = pair
|
||||
key <> "=" <> uri.percent_encode(value)
|
||||
})
|
||||
|> string.join("&")
|
||||
base <> "?" <> query_string
|
||||
}
|
||||
}
|
||||
}
|
||||
243
fluxer_admin/src/fluxer_admin/components/voice.gleam
Normal file
243
fluxer_admin/src/fluxer_admin/components/voice.gleam
Normal file
@@ -0,0 +1,243 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub fn vip_checkbox(checked: Bool) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("flex items-center gap-2")], [
|
||||
h.input([
|
||||
a.type_("checkbox"),
|
||||
a.name("vip_only"),
|
||||
a.value("true"),
|
||||
a.checked(checked),
|
||||
]),
|
||||
h.span([a.class("text-sm text-neutral-700")], [
|
||||
element.text("Require VIP_VOICE feature"),
|
||||
]),
|
||||
]),
|
||||
h.p([a.class("text-xs text-neutral-500 ml-6")], [
|
||||
element.text(
|
||||
"When enabled, guilds MUST have VIP_VOICE feature. This becomes a base requirement that works with AND logic alongside other restrictions.",
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn features_field(current_features: List(String)) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text("Allowed Guild Features (OR logic)"),
|
||||
]),
|
||||
h.textarea(
|
||||
[
|
||||
a.name("required_guild_features"),
|
||||
a.placeholder("VANITY_URL, COMMUNITY, PARTNERED"),
|
||||
a.attribute("rows", "2"),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
|
||||
),
|
||||
],
|
||||
string.join(current_features, ", "),
|
||||
),
|
||||
h.p([a.class("text-xs text-neutral-500")], [
|
||||
element.text(
|
||||
"Comma-separated. Guild needs ANY ONE of these features. Leave empty for no feature restrictions.",
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn guild_ids_field(current_ids: List(String)) {
|
||||
h.div([a.class("space-y-1")], [
|
||||
h.label([a.class("text-sm text-neutral-700")], [
|
||||
element.text("Allowed Guild IDs (OR logic, bypasses other checks)"),
|
||||
]),
|
||||
h.textarea(
|
||||
[
|
||||
a.name("allowed_guild_ids"),
|
||||
a.placeholder("123456789012345678, 987654321098765432"),
|
||||
a.attribute("rows", "2"),
|
||||
a.class(
|
||||
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
|
||||
),
|
||||
],
|
||||
string.join(current_ids, ", "),
|
||||
),
|
||||
h.p([a.class("text-xs text-neutral-500")], [
|
||||
element.text(
|
||||
"Comma-separated. If guild ID matches ANY of these, immediate access (bypasses VIP & feature checks).",
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn access_logic_summary() {
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"mt-4 p-3 bg-blue-50 border border-blue-200 rounded text-sm space-y-2",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.p([a.class("text-sm font-medium text-blue-900")], [
|
||||
element.text("Access Logic Summary:"),
|
||||
]),
|
||||
h.ul([a.class("text-blue-800 space-y-1 ml-4 list-disc")], [
|
||||
h.li([], [
|
||||
element.text(
|
||||
"Guild IDs provide immediate access (bypass all other checks)",
|
||||
),
|
||||
]),
|
||||
h.li([], [
|
||||
element.text(
|
||||
"VIP_VOICE requirement: Base requirement that must be satisfied (AND logic)",
|
||||
),
|
||||
]),
|
||||
h.li([], [
|
||||
element.text(
|
||||
"Features: Guild needs ANY ONE of the listed features (OR logic)",
|
||||
),
|
||||
]),
|
||||
h.li([], [
|
||||
element.text(
|
||||
"Combined: VIP_VOICE (if set) AND (feature1 OR feature2 OR ...)",
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn restriction_fields(
|
||||
vip_only: Bool,
|
||||
features: List(String),
|
||||
guild_ids: List(String),
|
||||
) {
|
||||
element.fragment([
|
||||
vip_checkbox(vip_only),
|
||||
features_field(features),
|
||||
guild_ids_field(guild_ids),
|
||||
access_logic_summary(),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn info_item(label: String, value: String) {
|
||||
h.div([], [
|
||||
h.p([a.class("text-xs text-neutral-600")], [element.text(label)]),
|
||||
h.p([a.class("text-sm text-neutral-900")], [element.text(value)]),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn vip_badge() {
|
||||
h.span(
|
||||
[
|
||||
a.class("px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded"),
|
||||
],
|
||||
[element.text("VIP ONLY")],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn feature_gated_badge() {
|
||||
h.span(
|
||||
[
|
||||
a.class("px-2 py-1 bg-amber-100 text-amber-800 text-xs rounded"),
|
||||
],
|
||||
[element.text("FEATURE GATED")],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn guild_restricted_badge() {
|
||||
h.span(
|
||||
[
|
||||
a.class("px-2 py-1 bg-green-100 text-green-800 text-xs rounded"),
|
||||
],
|
||||
[element.text("GUILD RESTRICTED")],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn status_badges(vip_only: Bool, has_features: Bool, has_guild_ids: Bool) {
|
||||
h.div([a.class("flex items-center gap-2 flex-wrap")], [
|
||||
case vip_only {
|
||||
True -> vip_badge()
|
||||
False -> element.none()
|
||||
},
|
||||
case has_features {
|
||||
True -> feature_gated_badge()
|
||||
False -> element.none()
|
||||
},
|
||||
case has_guild_ids {
|
||||
True -> guild_restricted_badge()
|
||||
False -> element.none()
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
pub fn features_list(features: List(String)) {
|
||||
case list.is_empty(features) {
|
||||
True -> element.none()
|
||||
False ->
|
||||
h.div([a.class("mb-3")], [
|
||||
h.p([a.class("text-xs text-neutral-700 mb-1")], [
|
||||
element.text("Allowed Guild Features:"),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("flex flex-wrap gap-1")],
|
||||
list.map(features, fn(feature) {
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"px-2 py-0.5 bg-amber-50 border border-amber-200 text-amber-800 text-xs rounded",
|
||||
),
|
||||
],
|
||||
[element.text(feature)],
|
||||
)
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn guild_ids_list(guild_ids: List(String)) {
|
||||
case list.is_empty(guild_ids) {
|
||||
True -> element.none()
|
||||
False ->
|
||||
h.div([a.class("mb-3")], [
|
||||
h.p([a.class("text-xs text-neutral-700 mb-1")], [
|
||||
element.text(
|
||||
"Allowed Guild IDs ("
|
||||
<> int.to_string(list.length(guild_ids))
|
||||
<> "):",
|
||||
),
|
||||
]),
|
||||
h.details([a.class("text-xs text-neutral-600")], [
|
||||
h.summary([a.class("cursor-pointer hover:text-neutral-900")], [
|
||||
element.text("Show IDs"),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("mt-1 max-h-20 overflow-y-auto text-xs")],
|
||||
list.map(guild_ids, fn(id) { h.div([], [element.text(id)]) }),
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
}
|
||||
151
fluxer_admin/src/fluxer_admin/config.gleam
Normal file
151
fluxer_admin/src/fluxer_admin/config.gleam
Normal file
@@ -0,0 +1,151 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import envoy
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/option
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
|
||||
pub type Config {
|
||||
Config(
|
||||
secret_key_base: String,
|
||||
api_endpoint: String,
|
||||
media_endpoint: String,
|
||||
cdn_endpoint: String,
|
||||
admin_endpoint: String,
|
||||
web_app_endpoint: String,
|
||||
metrics_endpoint: option.Option(String),
|
||||
oauth_client_id: String,
|
||||
oauth_client_secret: String,
|
||||
oauth_redirect_uri: String,
|
||||
port: Int,
|
||||
base_path: String,
|
||||
build_timestamp: String,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_base_path(base_path: String) -> String {
|
||||
let segments =
|
||||
base_path
|
||||
|> string.trim
|
||||
|> string.split("/")
|
||||
|> list.filter(fn(segment) { segment != "" })
|
||||
|
||||
case segments {
|
||||
[] -> ""
|
||||
_ -> "/" <> string.join(segments, "/")
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_endpoint(endpoint: String) -> String {
|
||||
let len = string.length(endpoint)
|
||||
case len > 0 && string.ends_with(endpoint, "/") {
|
||||
True -> normalize_endpoint(string.slice(endpoint, 0, len - 1))
|
||||
False -> endpoint
|
||||
}
|
||||
}
|
||||
|
||||
fn required_env(key: String) -> Result(String, String) {
|
||||
case envoy.get(key) {
|
||||
Ok(value) ->
|
||||
case string.trim(value) {
|
||||
"" -> Error("Missing required env: " <> key)
|
||||
trimmed -> Ok(trimmed)
|
||||
}
|
||||
Error(_) -> Error("Missing required env: " <> key)
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_env(key: String) -> option.Option(String) {
|
||||
case envoy.get(key) {
|
||||
Ok(value) ->
|
||||
case string.trim(value) {
|
||||
"" -> option.None
|
||||
trimmed -> option.Some(trimmed)
|
||||
}
|
||||
Error(_) -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
fn required_int_env(key: String) -> Result(Int, String) {
|
||||
use raw <- result.try(required_env(key))
|
||||
case int.parse(raw) {
|
||||
Ok(n) -> Ok(n)
|
||||
Error(_) -> Error("Invalid integer for env " <> key <> ": " <> raw)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_redirect_uri(
|
||||
endpoint: String,
|
||||
base_path: String,
|
||||
override: option.Option(String),
|
||||
) -> String {
|
||||
case override {
|
||||
option.Some(uri) -> uri
|
||||
option.None ->
|
||||
endpoint <> normalize_base_path(base_path) <> "/oauth2_callback"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_config() -> Result(Config, String) {
|
||||
use api_endpoint_raw <- result.try(required_env("FLUXER_API_PUBLIC_ENDPOINT"))
|
||||
use media_endpoint_raw <- result.try(required_env("FLUXER_MEDIA_ENDPOINT"))
|
||||
use cdn_endpoint_raw <- result.try(required_env("FLUXER_CDN_ENDPOINT"))
|
||||
use admin_endpoint_raw <- result.try(required_env("FLUXER_ADMIN_ENDPOINT"))
|
||||
use web_app_endpoint_raw <- result.try(required_env("FLUXER_APP_ENDPOINT"))
|
||||
use secret_key_base <- result.try(required_env("SECRET_KEY_BASE"))
|
||||
use client_id <- result.try(required_env("ADMIN_OAUTH2_CLIENT_ID"))
|
||||
use client_secret <- result.try(required_env("ADMIN_OAUTH2_CLIENT_SECRET"))
|
||||
use base_path_raw <- result.try(required_env("FLUXER_PATH_ADMIN"))
|
||||
use port <- result.try(required_int_env("FLUXER_ADMIN_PORT"))
|
||||
|
||||
let api_endpoint = normalize_endpoint(api_endpoint_raw)
|
||||
let media_endpoint = normalize_endpoint(media_endpoint_raw)
|
||||
let cdn_endpoint = normalize_endpoint(cdn_endpoint_raw)
|
||||
let admin_endpoint = normalize_endpoint(admin_endpoint_raw)
|
||||
let web_app_endpoint = normalize_endpoint(web_app_endpoint_raw)
|
||||
let base_path = normalize_base_path(base_path_raw)
|
||||
let redirect_uri =
|
||||
build_redirect_uri(
|
||||
admin_endpoint,
|
||||
base_path,
|
||||
optional_env("ADMIN_OAUTH2_REDIRECT_URI"),
|
||||
)
|
||||
|
||||
let metrics_endpoint = case optional_env("FLUXER_METRICS_HOST") {
|
||||
option.Some(host) -> option.Some("http://" <> host)
|
||||
option.None -> option.None
|
||||
}
|
||||
|
||||
Ok(Config(
|
||||
secret_key_base: secret_key_base,
|
||||
api_endpoint: api_endpoint,
|
||||
media_endpoint: media_endpoint,
|
||||
cdn_endpoint: cdn_endpoint,
|
||||
admin_endpoint: admin_endpoint,
|
||||
web_app_endpoint: web_app_endpoint,
|
||||
metrics_endpoint: metrics_endpoint,
|
||||
oauth_client_id: client_id,
|
||||
oauth_client_secret: client_secret,
|
||||
oauth_redirect_uri: redirect_uri,
|
||||
port: port,
|
||||
base_path: base_path,
|
||||
build_timestamp: envoy.get("BUILD_TIMESTAMP") |> result.unwrap(""),
|
||||
))
|
||||
}
|
||||
592
fluxer_admin/src/fluxer_admin/constants.gleam
Normal file
592
fluxer_admin/src/fluxer_admin/constants.gleam
Normal 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,
|
||||
]
|
||||
}
|
||||
31
fluxer_admin/src/fluxer_admin/log.gleam
Normal file
31
fluxer_admin/src/fluxer_admin/log.gleam
Normal file
@@ -0,0 +1,31 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
@external(erlang, "io", "format")
|
||||
fn erlang_io_format(fmt: String, args: List(String)) -> Nil
|
||||
|
||||
pub fn debug(msg: String) {
|
||||
erlang_io_format("[debug] " <> msg <> "\n", [])
|
||||
}
|
||||
|
||||
pub fn info(msg: String) {
|
||||
erlang_io_format("[info] " <> msg <> "\n", [])
|
||||
}
|
||||
|
||||
pub fn error(msg: String) {
|
||||
erlang_io_format("[error] " <> msg <> "\n", [])
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/http/response.{type Response}
|
||||
import gleam/list
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
import wisp
|
||||
|
||||
pub fn add_cache_headers(res: Response(wisp.Body)) -> Response(wisp.Body) {
|
||||
case list.key_find(res.headers, "cache-control") {
|
||||
Ok(_) -> res
|
||||
Error(_) -> {
|
||||
let content_type =
|
||||
list.key_find(res.headers, "content-type")
|
||||
|> result.unwrap("")
|
||||
|
||||
let cache_header = case should_cache(content_type) {
|
||||
True -> "public, max-age=31536000, immutable"
|
||||
False -> "no-cache"
|
||||
}
|
||||
|
||||
response.set_header(res, "cache-control", cache_header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_cache(content_type: String) -> Bool {
|
||||
let cacheable_types = [
|
||||
"text/css", "application/javascript", "font/", "image/", "video/", "audio/",
|
||||
"application/font-woff2",
|
||||
]
|
||||
|
||||
list.any(cacheable_types, fn(type_prefix) {
|
||||
string.starts_with(content_type, type_prefix)
|
||||
})
|
||||
}
|
||||
20
fluxer_admin/src/fluxer_admin/mode.gleam
Normal file
20
fluxer_admin/src/fluxer_admin/mode.gleam
Normal file
@@ -0,0 +1,20 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
pub fn get_app_title() -> String {
|
||||
"Fluxer Admin"
|
||||
}
|
||||
171
fluxer_admin/src/fluxer_admin/navigation.gleam
Normal file
171
fluxer_admin/src/fluxer_admin/navigation.gleam
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
42
fluxer_admin/src/fluxer_admin/oauth2.gleam
Normal file
42
fluxer_admin/src/fluxer_admin/oauth2.gleam
Normal file
@@ -0,0 +1,42 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/web.{type Context}
|
||||
import gleam/bit_array
|
||||
import gleam/crypto
|
||||
|
||||
pub fn authorize_url(ctx: Context, state: String) -> String {
|
||||
ctx.web_app_endpoint
|
||||
<> "/oauth2/authorize?response_type=code&client_id="
|
||||
<> ctx.oauth_client_id
|
||||
<> "&redirect_uri="
|
||||
<> ctx.oauth_redirect_uri
|
||||
<> "&scope=identify%20email"
|
||||
<> "&state="
|
||||
<> state
|
||||
}
|
||||
|
||||
pub fn base64_encode_string(value: String) -> String {
|
||||
value
|
||||
|> bit_array.from_string
|
||||
|> bit_array.base64_encode(True)
|
||||
}
|
||||
|
||||
pub fn generate_state() -> String {
|
||||
crypto.strong_random_bytes(32)
|
||||
|> bit_array.base64_url_encode(False)
|
||||
}
|
||||
233
fluxer_admin/src/fluxer_admin/pages/archives_page.gleam
Normal file
233
fluxer_admin/src/fluxer_admin/pages/archives_page.gleam
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
fluxer_admin/src/fluxer_admin/pages/asset_purge_page.gleam
Normal file
244
fluxer_admin/src/fluxer_admin/pages/asset_purge_page.gleam
Normal 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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
683
fluxer_admin/src/fluxer_admin/pages/audit_logs_page.gleam
Normal file
683
fluxer_admin/src/fluxer_admin/pages/audit_logs_page.gleam
Normal 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")],
|
||||
)
|
||||
},
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
279
fluxer_admin/src/fluxer_admin/pages/ban_management_page.gleam
Normal file
279
fluxer_admin/src/fluxer_admin/pages/ban_management_page.gleam
Normal 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))
|
||||
}
|
||||
}
|
||||
336
fluxer_admin/src/fluxer_admin/pages/beta_codes_page.gleam
Normal file
336
fluxer_admin/src/fluxer_admin/pages/beta_codes_page.gleam
Normal 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"
|
||||
}
|
||||
}
|
||||
736
fluxer_admin/src/fluxer_admin/pages/bulk_actions_page.gleam
Normal file
736
fluxer_admin/src/fluxer_admin/pages/bulk_actions_page.gleam
Normal 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"))
|
||||
}
|
||||
}
|
||||
53
fluxer_admin/src/fluxer_admin/pages/email_bans_page.gleam
Normal file
53
fluxer_admin/src/fluxer_admin/pages/email_bans_page.gleam
Normal 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,
|
||||
)
|
||||
}
|
||||
237
fluxer_admin/src/fluxer_admin/pages/feature_flags_page.gleam
Normal file
237
fluxer_admin/src/fluxer_admin/pages/feature_flags_page.gleam
Normal 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
916
fluxer_admin/src/fluxer_admin/pages/gateway_page.gleam
Normal file
916
fluxer_admin/src/fluxer_admin/pages/gateway_page.gleam
Normal 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),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
393
fluxer_admin/src/fluxer_admin/pages/gift_codes_page.gleam
Normal file
393
fluxer_admin/src/fluxer_admin/pages/gift_codes_page.gleam
Normal 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"
|
||||
}
|
||||
}
|
||||
183
fluxer_admin/src/fluxer_admin/pages/guild_detail/forms.gleam
Normal file
183
fluxer_admin/src/fluxer_admin/pages/guild_detail/forms.gleam
Normal 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')",
|
||||
),
|
||||
)
|
||||
}
|
||||
535
fluxer_admin/src/fluxer_admin/pages/guild_detail/handlers.gleam
Normal file
535
fluxer_admin/src/fluxer_admin/pages/guild_detail/handlers.gleam
Normal 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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
]),
|
||||
])
|
||||
}
|
||||
@@ -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)],
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
])
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user