Compare commits
30 Commits
5b8ceff991
...
uwu
| Author | SHA1 | Date | |
|---|---|---|---|
| 0db3b40a40 | |||
| 3a8b09e47e | |||
| f327b05bce | |||
| 8e1b609346 | |||
| 5572256b6b | |||
| fe77148fe0 | |||
| f7dd8b24f4 | |||
| 84ec7653d2 | |||
| 2e9010da53 | |||
| a890b11bf2 | |||
| 7c903e72e0 | |||
| 482b7dee25 | |||
| a258752adc | |||
| d848765cc2 | |||
| 3ad2ca08c3 | |||
| 2f443dc661 | |||
| d977b35636 | |||
| 3577f5fb95 | |||
| 0aed4041b8 | |||
| 16b88bca3f | |||
|
|
77a6897180 | ||
|
|
9e8a9dafb8 | ||
|
|
7b1aa6ff2e | ||
|
|
848269a4d4 | ||
|
|
fd59bc219c | ||
|
|
d843d6f3f8 | ||
|
|
4f5704fa1f | ||
|
|
f54f62ae3c | ||
|
|
2db53689a1 | ||
|
|
a129b162b7 |
54
.devcontainer/Caddyfile.dev
Normal file
54
.devcontainer/Caddyfile.dev
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Like dev/Caddyfile.dev, but LiveKit and Mailpit are referenced by their
|
||||||
|
# Docker Compose hostnames instead of 127.0.0.1.
|
||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
admin off
|
||||||
|
}
|
||||||
|
|
||||||
|
:48763 {
|
||||||
|
handle /_caddy_health {
|
||||||
|
respond "OK" 200
|
||||||
|
}
|
||||||
|
|
||||||
|
@gateway path /gateway /gateway/*
|
||||||
|
handle @gateway {
|
||||||
|
uri strip_prefix /gateway
|
||||||
|
reverse_proxy 127.0.0.1:49107
|
||||||
|
}
|
||||||
|
|
||||||
|
@marketing path /marketing /marketing/*
|
||||||
|
handle @marketing {
|
||||||
|
uri strip_prefix /marketing
|
||||||
|
reverse_proxy 127.0.0.1:49531
|
||||||
|
}
|
||||||
|
|
||||||
|
@server path /admin /admin/* /api /api/* /s3 /s3/* /queue /queue/* /media /media/* /_health /_ready /_live /.well-known/fluxer
|
||||||
|
handle @server {
|
||||||
|
reverse_proxy 127.0.0.1:49319
|
||||||
|
}
|
||||||
|
|
||||||
|
@livekit path /livekit /livekit/*
|
||||||
|
handle @livekit {
|
||||||
|
uri strip_prefix /livekit
|
||||||
|
reverse_proxy livekit:7880
|
||||||
|
}
|
||||||
|
|
||||||
|
redir /mailpit /mailpit/
|
||||||
|
handle_path /mailpit/* {
|
||||||
|
rewrite * /mailpit{path}
|
||||||
|
reverse_proxy mailpit:8025
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:49427 {
|
||||||
|
header_up Connection {http.request.header.Connection}
|
||||||
|
header_up Upgrade {http.request.header.Upgrade}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stdout
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
.devcontainer/Dockerfile
Normal file
40
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Language runtimes (Node.js, Go, Rust, Python) are installed via devcontainer
|
||||||
|
# features. This Dockerfile handles Erlang/OTP (no feature available) and
|
||||||
|
# tools like Caddy, process-compose, rebar3, uv, ffmpeg, and exiftool.
|
||||||
|
|
||||||
|
FROM erlang:28-slim AS erlang
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/devcontainers/base:debian-13
|
||||||
|
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG REBAR3_VERSION=3.24.0
|
||||||
|
ARG PROCESS_COMPOSE_VERSION=1.90.0
|
||||||
|
|
||||||
|
# Both erlang:28-slim and debian-13 are Trixie-based, so OpenSSL versions match.
|
||||||
|
COPY --from=erlang /usr/local/lib/erlang /usr/local/lib/erlang
|
||||||
|
RUN ln -sf /usr/local/lib/erlang/bin/* /usr/local/bin/
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libncurses6 libsctp1 \
|
||||||
|
build-essential pkg-config \
|
||||||
|
ffmpeg libimage-exiftool-perl \
|
||||||
|
sqlite3 libsqlite3-dev \
|
||||||
|
libssl-dev openssl \
|
||||||
|
gettext-base lsof iproute2 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -fsSL "https://github.com/erlang/rebar3/releases/download/${REBAR3_VERSION}/rebar3" \
|
||||||
|
-o /usr/local/bin/rebar3 \
|
||||||
|
&& chmod +x /usr/local/bin/rebar3
|
||||||
|
|
||||||
|
RUN curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=amd64" \
|
||||||
|
-o /usr/local/bin/caddy \
|
||||||
|
&& chmod +x /usr/local/bin/caddy
|
||||||
|
|
||||||
|
RUN curl -fsSL "https://github.com/F1bonacc1/process-compose/releases/download/v${PROCESS_COMPOSE_VERSION}/process-compose_linux_amd64.tar.gz" \
|
||||||
|
| tar xz -C /usr/local/bin process-compose \
|
||||||
|
&& chmod +x /usr/local/bin/process-compose
|
||||||
|
|
||||||
|
RUN curl -fsSL "https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz" \
|
||||||
|
| tar xz --strip-components=1 -C /usr/local/bin \
|
||||||
|
&& chmod +x /usr/local/bin/uv /usr/local/bin/uvx
|
||||||
75
.devcontainer/devcontainer.json
Normal file
75
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "Fluxer",
|
||||||
|
"dockerComposeFile": "docker-compose.yml",
|
||||||
|
"service": "app",
|
||||||
|
"workspaceFolder": "/workspace",
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
"version": "24",
|
||||||
|
"pnpmVersion": "10.29.3"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/go:1": {
|
||||||
|
"version": "1.24"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/rust:1": {
|
||||||
|
"version": "1.93.0",
|
||||||
|
"targets": "wasm32-unknown-unknown"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/python:1": {
|
||||||
|
"version": "os-provided",
|
||||||
|
"installTools": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"onCreateCommand": ".devcontainer/on-create.sh",
|
||||||
|
|
||||||
|
"remoteEnv": {
|
||||||
|
"FLUXER_CONFIG": "${containerWorkspaceFolder}/config/config.json",
|
||||||
|
"FLUXER_DATABASE": "sqlite"
|
||||||
|
},
|
||||||
|
|
||||||
|
"forwardPorts": [48763, 6379, 7700, 7880],
|
||||||
|
|
||||||
|
"portsAttributes": {
|
||||||
|
"48763": {
|
||||||
|
"label": "Fluxer (Caddy)",
|
||||||
|
"onAutoForward": "openBrowser",
|
||||||
|
"protocol": "http"
|
||||||
|
},
|
||||||
|
"6379": {
|
||||||
|
"label": "Valkey",
|
||||||
|
"onAutoForward": "silent"
|
||||||
|
},
|
||||||
|
"7700": {
|
||||||
|
"label": "Meilisearch",
|
||||||
|
"onAutoForward": "silent"
|
||||||
|
},
|
||||||
|
"7880": {
|
||||||
|
"label": "LiveKit",
|
||||||
|
"onAutoForward": "silent"
|
||||||
|
},
|
||||||
|
"9229": {
|
||||||
|
"label": "Node.js Debugger",
|
||||||
|
"onAutoForward": "silent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"TypeScriptTeam.native-preview",
|
||||||
|
"biomejs.biome",
|
||||||
|
"clinyong.vscode-css-modules",
|
||||||
|
"pgourlain.erlang",
|
||||||
|
"golang.go",
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"typescript.preferences.includePackageJsonAutoImports": "auto",
|
||||||
|
"typescript.suggest.autoImports": true,
|
||||||
|
"typescript.experimental.useTsgo": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
.devcontainer/docker-compose.yml
Normal file
64
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ..:/workspace:cached
|
||||||
|
command: sleep infinity
|
||||||
|
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ['valkey-server', '--appendonly', 'yes', '--save', '60', '1', '--loglevel', 'warning']
|
||||||
|
volumes:
|
||||||
|
- valkey-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'valkey-cli', 'ping']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.14
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MEILI_NO_ANALYTICS: 'true'
|
||||||
|
MEILI_ENV: development
|
||||||
|
MEILI_MASTER_KEY: fluxer-devcontainer-meili-master-key
|
||||||
|
volumes:
|
||||||
|
- meilisearch-data:/meili_data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'curl', '-f', 'http://localhost:7700/health']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
livekit:
|
||||||
|
image: livekit/livekit-server:v1.9
|
||||||
|
restart: unless-stopped
|
||||||
|
command: --config /etc/livekit.yaml
|
||||||
|
volumes:
|
||||||
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ['--webroot', '/mailpit/']
|
||||||
|
|
||||||
|
nats-core:
|
||||||
|
image: nats:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ['--port', '4222']
|
||||||
|
|
||||||
|
nats-jetstream:
|
||||||
|
image: nats:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ['--port', '4223', '--jetstream', '--store_dir', '/data']
|
||||||
|
volumes:
|
||||||
|
- nats-jetstream-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
valkey-data:
|
||||||
|
meilisearch-data:
|
||||||
|
nats-jetstream-data:
|
||||||
30
.devcontainer/livekit.yaml
Normal file
30
.devcontainer/livekit.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Credentials here must match the values on-create.sh writes to config.json.
|
||||||
|
|
||||||
|
port: 7880
|
||||||
|
|
||||||
|
keys:
|
||||||
|
fluxer-devcontainer-key: fluxer-devcontainer-secret-key-00000000
|
||||||
|
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
port_range_start: 50000
|
||||||
|
port_range_end: 50100
|
||||||
|
use_external_ip: false
|
||||||
|
node_ip: 127.0.0.1
|
||||||
|
|
||||||
|
turn:
|
||||||
|
enabled: true
|
||||||
|
domain: localhost
|
||||||
|
udp_port: 3478
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
api_key: fluxer-devcontainer-key
|
||||||
|
urls:
|
||||||
|
- http://app:49319/api/webhooks/livekit
|
||||||
|
|
||||||
|
room:
|
||||||
|
auto_create: true
|
||||||
|
max_participants: 100
|
||||||
|
empty_timeout: 300
|
||||||
|
|
||||||
|
development: true
|
||||||
70
.devcontainer/on-create.sh
Executable file
70
.devcontainer/on-create.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Runs once when the container is first created.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
export FLUXER_CONFIG="${FLUXER_CONFIG:-$REPO_ROOT/config/config.json}"
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
NC='\033[0m'
|
||||||
|
info() { printf "%b\n" "${GREEN}[devcontainer]${NC} $1"; }
|
||||||
|
|
||||||
|
info "Installing pnpm dependencies..."
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Codegen outputs (e.g. MasterZodSchema.generated.tsx) are gitignored.
|
||||||
|
info "Generating config schema..."
|
||||||
|
pnpm --filter @fluxer/config generate
|
||||||
|
|
||||||
|
if [ ! -f "$FLUXER_CONFIG" ]; then
|
||||||
|
info "Creating config from development template..."
|
||||||
|
cp "$REPO_ROOT/config/config.dev.template.json" "$FLUXER_CONFIG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Point services at Docker Compose hostnames and adjust settings that differ
|
||||||
|
# from the default dev template.
|
||||||
|
info "Patching config for Docker Compose networking..."
|
||||||
|
jq '
|
||||||
|
# rspack defaults public_scheme to "https" when unset
|
||||||
|
.domain.public_scheme = "http" |
|
||||||
|
# Relative path so the app works on any hostname (localhost, 127.0.0.1, etc.)
|
||||||
|
.app_public.bootstrap_api_endpoint = "/api" |
|
||||||
|
|
||||||
|
.internal.kv = "redis://valkey:6379/0" |
|
||||||
|
|
||||||
|
.integrations.search.url = "http://meilisearch:7700" |
|
||||||
|
.integrations.search.api_key = "fluxer-devcontainer-meili-master-key" |
|
||||||
|
|
||||||
|
# Credentials must match .devcontainer/livekit.yaml
|
||||||
|
.integrations.voice.url = "ws://livekit:7880" |
|
||||||
|
.integrations.voice.webhook_url = "http://app:49319/api/webhooks/livekit" |
|
||||||
|
.integrations.voice.api_key = "fluxer-devcontainer-key" |
|
||||||
|
.integrations.voice.api_secret = "fluxer-devcontainer-secret-key-00000000" |
|
||||||
|
|
||||||
|
.integrations.email.smtp.host = "mailpit" |
|
||||||
|
.integrations.email.smtp.port = 1025 |
|
||||||
|
|
||||||
|
.services.nats.core_url = "nats://nats-core:4222" |
|
||||||
|
.services.nats.jetstream_url = "nats://nats-jetstream:4223" |
|
||||||
|
|
||||||
|
# Bluesky OAuth requires HTTPS + loopback IPs (RFC 8252), incompatible with
|
||||||
|
# the HTTP-only devcontainer setup.
|
||||||
|
.auth.bluesky.enabled = false
|
||||||
|
' "$FLUXER_CONFIG" > "$FLUXER_CONFIG.tmp" && mv "$FLUXER_CONFIG.tmp" "$FLUXER_CONFIG"
|
||||||
|
|
||||||
|
info "Running bootstrap..."
|
||||||
|
"$REPO_ROOT/scripts/dev_bootstrap.sh"
|
||||||
|
|
||||||
|
info "Pre-compiling Erlang gateway dependencies..."
|
||||||
|
(cd "$REPO_ROOT/fluxer_gateway" && rebar3 compile) || {
|
||||||
|
info "Gateway pre-compilation failed (non-fatal, will compile on first start)"
|
||||||
|
}
|
||||||
|
|
||||||
|
info "Devcontainer setup complete."
|
||||||
|
info ""
|
||||||
|
info " Start all dev processes: process-compose -f .devcontainer/process-compose.yml up"
|
||||||
|
info " Open the app: http://127.0.0.1:48763"
|
||||||
|
info " Dev email inbox: http://127.0.0.1:48763/mailpit/"
|
||||||
|
info ""
|
||||||
57
.devcontainer/process-compose.yml
Normal file
57
.devcontainer/process-compose.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Application processes only — backing services (Valkey, Meilisearch, LiveKit,
|
||||||
|
# Mailpit, NATS) run via Docker Compose.
|
||||||
|
# process-compose -f .devcontainer/process-compose.yml up
|
||||||
|
|
||||||
|
is_tui_disabled: false
|
||||||
|
log_level: info
|
||||||
|
log_configuration:
|
||||||
|
flush_each_line: true
|
||||||
|
|
||||||
|
processes:
|
||||||
|
caddy:
|
||||||
|
command: caddy run --config .devcontainer/Caddyfile.dev --adapter caddyfile
|
||||||
|
log_location: dev/logs/caddy.log
|
||||||
|
readiness_probe:
|
||||||
|
http_get:
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 48763
|
||||||
|
path: /_caddy_health
|
||||||
|
availability:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
fluxer_server:
|
||||||
|
command: pnpm --filter fluxer_server dev
|
||||||
|
log_location: dev/logs/fluxer_server.log
|
||||||
|
availability:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
fluxer_app:
|
||||||
|
command: ./scripts/dev_fluxer_app.sh
|
||||||
|
environment:
|
||||||
|
- FORCE_COLOR=1
|
||||||
|
- FLUXER_APP_DEV_PORT=49427
|
||||||
|
log_location: dev/logs/fluxer_app.log
|
||||||
|
availability:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
fluxer_gateway:
|
||||||
|
command: ./scripts/dev_gateway.sh
|
||||||
|
environment:
|
||||||
|
- FLUXER_GATEWAY_NO_SHELL=1
|
||||||
|
log_location: dev/logs/fluxer_gateway.log
|
||||||
|
availability:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
marketing_dev:
|
||||||
|
command: pnpm --filter fluxer_marketing dev
|
||||||
|
environment:
|
||||||
|
- FORCE_COLOR=1
|
||||||
|
log_location: dev/logs/marketing_dev.log
|
||||||
|
availability:
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
css_watch:
|
||||||
|
command: ./scripts/dev_css_watch.sh
|
||||||
|
log_location: dev/logs/css_watch.log
|
||||||
|
availability:
|
||||||
|
restart: always
|
||||||
@@ -37,9 +37,19 @@
|
|||||||
**/Thumbs.db
|
**/Thumbs.db
|
||||||
**/yarn-debug.log*
|
**/yarn-debug.log*
|
||||||
**/yarn-error.log*
|
**/yarn-error.log*
|
||||||
/fluxer_app/src/data/emojis.json
|
# Original exclusions for emojis/locales commented out - needed for build
|
||||||
/fluxer_app/src/locales/*/messages.js
|
# /fluxer_app/src/data/emojis.json
|
||||||
|
# /fluxer_app/src/locales/*/messages.js
|
||||||
dev
|
dev
|
||||||
!fluxer_app/dist
|
!fluxer_app/dist
|
||||||
!fluxer_app/dist/**
|
!fluxer_app/dist/**
|
||||||
!fluxer_devops/cassandra/migrations
|
!fluxer_devops/cassandra/migrations
|
||||||
|
# Explicitly allow critical build data (trailing slash means directory)
|
||||||
|
!fluxer_app/src/data
|
||||||
|
!fluxer_app/src/data/**
|
||||||
|
!fluxer_app/src/locales
|
||||||
|
!fluxer_app/src/locales/**
|
||||||
|
!**/scripts/
|
||||||
|
# Allow build scripts directory (not blocked by **/build pattern)
|
||||||
|
!fluxer_app/scripts/build
|
||||||
|
!fluxer_app/scripts/build/**
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,7 +7,7 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
Thanks for the report.
|
Thanks for the report.
|
||||||
|
|
||||||
Please check for existing issues before filing.
|
Please check our status page at https://fluxerstatus.com and search for existing issues before filing.
|
||||||
Security issues should go to https://fluxer.app/security.
|
Security issues should go to https://fluxer.app/security.
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: summary
|
id: summary
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/docs.yml
vendored
2
.github/ISSUE_TEMPLATE/docs.yml
vendored
@@ -7,7 +7,7 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
Thanks.
|
Thanks.
|
||||||
|
|
||||||
Please check for existing issues before filing.
|
Please check our status page at https://fluxerstatus.com and search for existing issues before filing.
|
||||||
Security issues should go to https://fluxer.app/security.
|
Security issues should go to https://fluxer.app/security.
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: issue
|
id: issue
|
||||||
|
|||||||
10
.github/workflows/ci.yaml
vendored
10
.github/workflows/ci.yaml
vendored
@@ -6,7 +6,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
TURBO_TEAM: team_fluxer
|
TURBO_TEAM: team_fluxer
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
TURBO_TEAM: team_fluxer
|
TURBO_TEAM: team_fluxer
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
FLUXER_CONFIG: ../config/config.test.json
|
FLUXER_CONFIG: ../config/config.test.json
|
||||||
|
|
||||||
knip:
|
knip:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
TURBO_TEAM: team_fluxer
|
TURBO_TEAM: team_fluxer
|
||||||
|
|
||||||
ci-scripts:
|
ci-scripts:
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
50
.github/workflows/release-server.yaml
vendored
50
.github/workflows/release-server.yaml
vendored
@@ -2,7 +2,7 @@ name: release server
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [canary]
|
branches: [uwu]
|
||||||
paths:
|
paths:
|
||||||
- packages/**
|
- packages/**
|
||||||
- fluxer_server/**
|
- fluxer_server/**
|
||||||
@@ -51,18 +51,18 @@ defaults:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: git.i5.wtf
|
||||||
IMAGE_NAME_SERVER: ${{ github.repository_owner }}/fluxer-server
|
IMAGE_NAME_SERVER: fluxerapp/fluxer-server
|
||||||
CHANNEL: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.channel) || 'nightly' }}
|
CHANNEL: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.channel) || 'nightly' }}
|
||||||
SOURCE_REF: >-
|
SOURCE_REF: >-
|
||||||
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref)
|
${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref)
|
||||||
|| ((github.event_name == 'workflow_dispatch' && github.event.inputs.channel == 'stable') && 'main')
|
|| ((github.event_name == 'workflow_dispatch' && github.event.inputs.channel == 'stable') && 'main')
|
||||||
|| 'canary' }}
|
|| 'uwu' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
meta:
|
meta:
|
||||||
name: resolve build metadata
|
name: resolve build metadata
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
@@ -102,7 +102,7 @@ jobs:
|
|||||||
name: build fluxer server
|
name: build fluxer server
|
||||||
needs: meta
|
needs: meta
|
||||||
if: needs.meta.outputs.build_server == 'true'
|
if: needs.meta.outputs.build_server == 'true'
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
@@ -123,7 +123,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.registry_token }}
|
||||||
|
|
||||||
- name: docker metadata
|
- name: docker metadata
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: fluxer_server/Dockerfile
|
file: fluxer_server/Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
labels: |
|
labels: |
|
||||||
@@ -165,26 +165,26 @@ jobs:
|
|||||||
BUILD_NUMBER=${{ needs.meta.outputs.build_number }}
|
BUILD_NUMBER=${{ needs.meta.outputs.build_number }}
|
||||||
BUILD_TIMESTAMP=${{ needs.meta.outputs.timestamp }}
|
BUILD_TIMESTAMP=${{ needs.meta.outputs.timestamp }}
|
||||||
RELEASE_CHANNEL=${{ needs.meta.outputs.channel }}
|
RELEASE_CHANNEL=${{ needs.meta.outputs.channel }}
|
||||||
cache-from: type=gha,scope=server-${{ needs.meta.outputs.channel }}
|
# GitHub Actions cache not available in Gitea - disabled
|
||||||
cache-to: type=gha,mode=max,scope=server-${{ needs.meta.outputs.channel }}
|
# cache-from: type=gha,scope=server-${{ needs.meta.outputs.channel }}
|
||||||
provenance: true
|
# cache-to: type=gha,mode=max,scope=server-${{ needs.meta.outputs.channel }}
|
||||||
sbom: true
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
|
||||||
- name: attest
|
# GitHub-specific attestation - not available in Gitea
|
||||||
uses: actions/attest-build-provenance@v2
|
# - name: attest
|
||||||
with:
|
# uses: actions/attest-build-provenance@v2
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_SERVER }}
|
# with:
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_SERVER }}
|
||||||
push-to-registry: true
|
# subject-digest: ${{ steps.build.outputs.digest }}
|
||||||
|
# push-to-registry: true
|
||||||
|
|
||||||
create-release:
|
create-release:
|
||||||
name: create release
|
name: create release (disabled for Gitea)
|
||||||
needs: [meta, build-server]
|
needs: [meta, build-server]
|
||||||
if: |
|
# GitHub release API doesn't exist in Gitea - disabled
|
||||||
always() &&
|
if: false
|
||||||
needs.meta.outputs.version != '' &&
|
runs-on: ubuntu-latest
|
||||||
(needs.build-server.result == 'success' || needs.build-server.result == 'skipped')
|
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
@@ -250,7 +250,7 @@ jobs:
|
|||||||
name: release summary
|
name: release summary
|
||||||
needs: [meta, build-server]
|
needs: [meta, build-server]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
[lfs]
|
[lfs]
|
||||||
url = https://github.com/fluxerapp-old/fluxer-private.git/info/lfs
|
url = https://git.i5.wtf/fluxerapp/fluxer.git/info/lfs
|
||||||
|
|||||||
84
.vscode/launch.json
vendored
Normal file
84
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug: fluxer_server",
|
||||||
|
"program": "${workspaceFolder}/fluxer_server/src/startServer.tsx",
|
||||||
|
"runtimeArgs": ["--import", "tsx"],
|
||||||
|
"cwd": "${workspaceFolder}/fluxer_server",
|
||||||
|
"env": {
|
||||||
|
"FLUXER_CONFIG": "${workspaceFolder}/config/config.json",
|
||||||
|
"FLUXER_DATABASE": "sqlite"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**", "**/node_modules/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug: fluxer_api (standalone)",
|
||||||
|
"program": "${workspaceFolder}/fluxer_api/src/AppEntrypoint.tsx",
|
||||||
|
"runtimeArgs": ["--import", "tsx"],
|
||||||
|
"cwd": "${workspaceFolder}/fluxer_api",
|
||||||
|
"env": {
|
||||||
|
"FLUXER_CONFIG": "${workspaceFolder}/config/config.json",
|
||||||
|
"FLUXER_DATABASE": "sqlite"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**", "**/node_modules/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug: fluxer_marketing",
|
||||||
|
"program": "${workspaceFolder}/fluxer_marketing/src/index.tsx",
|
||||||
|
"runtimeArgs": ["--import", "tsx"],
|
||||||
|
"cwd": "${workspaceFolder}/fluxer_marketing",
|
||||||
|
"env": {
|
||||||
|
"FLUXER_CONFIG": "${workspaceFolder}/config/config.json"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**", "**/node_modules/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug: fluxer_app (DevServer)",
|
||||||
|
"program": "${workspaceFolder}/fluxer_app/scripts/DevServer.tsx",
|
||||||
|
"runtimeArgs": ["--import", "tsx"],
|
||||||
|
"cwd": "${workspaceFolder}/fluxer_app",
|
||||||
|
"env": {
|
||||||
|
"FLUXER_APP_DEV_PORT": "49427",
|
||||||
|
"FORCE_COLOR": "1"
|
||||||
|
},
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**", "**/node_modules/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug: Test Current File",
|
||||||
|
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
|
||||||
|
"args": ["run", "--no-coverage", "${relativeFile}"],
|
||||||
|
"autoAttachChildProcesses": true,
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**", "**/node_modules/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "attach",
|
||||||
|
"name": "Attach to Node Process",
|
||||||
|
"port": 9229,
|
||||||
|
"restart": true,
|
||||||
|
"skipFiles": ["<node_internals>/**", "**/node_modules/**"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Debug: Server + App",
|
||||||
|
"configurations": ["Debug: fluxer_server", "Debug: fluxer_app (DevServer)"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
130
NOTES.md
Normal file
130
NOTES.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# uwu.lc Self-Hosting Notes
|
||||||
|
|
||||||
|
## Branch Setup
|
||||||
|
|
||||||
|
**Current branch**: `uwu` (based on `refactor`)
|
||||||
|
- **Tracks**: `origin/refactor` for rebasing upstream changes
|
||||||
|
- **Pushes to**: `i5/uwu` on your Gitea instance at git.i5.wtf
|
||||||
|
- **Current state**: 1 commit ahead (LFS config change)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Pull upstream changes and rebase
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git rebase origin/refactor
|
||||||
|
```
|
||||||
|
|
||||||
|
### Push your changes to Gitea
|
||||||
|
```bash
|
||||||
|
git push i5 uwu
|
||||||
|
# If you've rebased, use: git push i5 uwu --force-with-lease
|
||||||
|
```
|
||||||
|
|
||||||
|
### View your changes
|
||||||
|
```bash
|
||||||
|
git log origin/refactor..uwu # Show commits you've added
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why track `refactor` branch?
|
||||||
|
|
||||||
|
The `refactor` branch is a complete rewrite that:
|
||||||
|
- Is simpler and lighter to self-host
|
||||||
|
- Uses SQLite instead of complex database setup
|
||||||
|
- Removes payment/Plutonium stuff for self-hosted deployments
|
||||||
|
- Is much better documented
|
||||||
|
- Is where active development happens
|
||||||
|
|
||||||
|
The old `main`/`canary` branches have the legacy stack that's harder to self-host.
|
||||||
|
|
||||||
|
## Configuration Changes Made
|
||||||
|
|
||||||
|
1. **LFS Config** (`.lfsconfig`): Updated to point to Gitea instance
|
||||||
|
- Old: `https://github.com/fluxerapp-old/fluxer-private.git/info/lfs`
|
||||||
|
- New: `https://git.i5.wtf/fluxerapp/fluxer.git/info/lfs`
|
||||||
|
|
||||||
|
2. **CI Workflows**: Updated for Gitea compatibility
|
||||||
|
- Changed all runners from `blacksmith-8vcpu-ubuntu-2404` to `ubuntu-latest`
|
||||||
|
- `ci.yaml`: Main CI workflow (typecheck, test, gateway, knip, ci-scripts)
|
||||||
|
- `release-server.yaml`: Docker build workflow
|
||||||
|
- Registry: `ghcr.io` → `git.i5.wtf`
|
||||||
|
- Image: `fluxerapp/fluxer-server`
|
||||||
|
- Trigger branch: `canary` → `uwu`
|
||||||
|
- Default source ref: `canary` → `uwu`
|
||||||
|
|
||||||
|
## Gitea Setup Requirements
|
||||||
|
|
||||||
|
### Container Registry Authentication
|
||||||
|
|
||||||
|
The workflow tries to use `secrets.GITEA_TOKEN` or `github.token` for registry auth.
|
||||||
|
|
||||||
|
**Required**: Create a Gitea Personal Access Token:
|
||||||
|
1. Go to Gitea Settings → Applications → Generate New Token
|
||||||
|
2. Name: `CI_Container_Registry`
|
||||||
|
3. Permissions: Select `package` (write access)
|
||||||
|
4. Add to repository secrets as `registry_token` (Note: Can't use GITEA_ or GITHUB_ prefix)
|
||||||
|
|
||||||
|
**Alternative**: Update the workflow to use username/password:
|
||||||
|
- Create a secret `REGISTRY_USERNAME` with your Gitea username
|
||||||
|
- Create a secret `REGISTRY_PASSWORD` with a personal access token
|
||||||
|
|
||||||
|
### Container Registry URL Format
|
||||||
|
|
||||||
|
Gitea registry format is typically:
|
||||||
|
- `git.i5.wtf/fluxerapp/fluxer-server:tag`
|
||||||
|
|
||||||
|
If the registry requires a different format, check your Gitea container registry settings.
|
||||||
|
|
||||||
|
## Docker Build Fixes Applied
|
||||||
|
|
||||||
|
Successfully built fluxer-server Docker image! Fixes applied:
|
||||||
|
1. ✅ Fixed package path (app → app_proxy)
|
||||||
|
2. ✅ Added Rust/WASM toolchain for frontend
|
||||||
|
3. ✅ Added ca-certificates
|
||||||
|
4. ✅ Fixed .dockerignore (locale files, emoji data, build scripts)
|
||||||
|
5. ✅ Set FLUXER_CONFIG environment variable
|
||||||
|
6. ✅ Updated ENTRYPOINT to target @fluxer/server
|
||||||
|
7. ✅ Removed redundant typecheck step
|
||||||
|
8. ✅ Generated locale files before build (lingui:compile)
|
||||||
|
9. ✅ Reinstalled dependencies after copying source
|
||||||
|
10. ✅ Allowed scripts/build directory in .dockerignore
|
||||||
|
|
||||||
|
**Built image tags:**
|
||||||
|
- `git.i5.wtf/fluxerapp/fluxer-server:nightly`
|
||||||
|
- `git.i5.wtf/fluxerapp/fluxer-server:nightly-20260301`
|
||||||
|
- `git.i5.wtf/fluxer-server:sha-2e9010d`
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [x] Modify GitHub Actions workflows for Gitea compatibility
|
||||||
|
- [x] Fix container registry authentication
|
||||||
|
- [x] Apply patches from third-party guide
|
||||||
|
- [x] Build Docker image
|
||||||
|
- [ ] Configure for uwu.lc domain
|
||||||
|
- [ ] Deploy to production
|
||||||
|
- [ ] Set up backing services (Valkey, NATS, Meilisearch, LiveKit)
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **Third-party self-hosting guide**: https://gist.github.com/PaulMColeman/e7ef82e05035b24300d2ea1954527f10
|
||||||
|
- Documents 20 gotchas and fixes for building/deploying Fluxer
|
||||||
|
- Critical for successful Docker build
|
||||||
|
- Domain: uwu.lc
|
||||||
|
- Gitea: git.i5.wtf
|
||||||
|
|
||||||
|
## Known Build Issues from Third-Party Guide
|
||||||
|
|
||||||
|
The guide documents these critical Dockerfile fixes needed:
|
||||||
|
1. ✅ Fix package path (app → app_proxy)
|
||||||
|
2. ✅ Add Rust/WASM toolchain (frontend needs WebAssembly)
|
||||||
|
3. ✅ Add ca-certificates (for rustup HTTPS download)
|
||||||
|
4. ✅ Fix .dockerignore (unblock build scripts and locale files)
|
||||||
|
5. ✅ Set FLUXER_CONFIG env var (rspack needs this)
|
||||||
|
6. ✅ Copy config directory for build process
|
||||||
|
7. ✅ Update ENTRYPOINT to target fluxer_server package
|
||||||
|
|
||||||
|
Additional fixes that may be needed (will address if they come up):
|
||||||
|
- Empty CDN endpoint handling (frontend code)
|
||||||
|
- Content Security Policy adjustments
|
||||||
|
- NATS configuration
|
||||||
|
- LiveKit webhook configuration
|
||||||
16
README.md
16
README.md
@@ -73,7 +73,7 @@ TBD
|
|||||||
|
|
||||||
### Devenv development environment
|
### Devenv development environment
|
||||||
|
|
||||||
Fluxer supports development through **devenv** only. It provides a reproducible Nix environment and a single, declarative process manager for the dev stack. If you need a different setup, it is currently unsupported.
|
Fluxer supports development through **devenv** only. It provides a reproducible Nix environment and a single, declarative process manager for the dev stack.
|
||||||
|
|
||||||
1. Install Nix and devenv using the [devenv getting started guide](https://devenv.sh/getting-started/).
|
1. Install Nix and devenv using the [devenv getting started guide](https://devenv.sh/getting-started/).
|
||||||
2. Enter the environment:
|
2. Enter the environment:
|
||||||
@@ -108,6 +108,20 @@ If you develop on a remote VM behind Cloudflare Tunnels (or a similar HTTP-only
|
|||||||
|
|
||||||
The bootstrap script configures LiveKit automatically based on `domain.base_domain` in your `config.json`. When set to a non-localhost domain, it enables external IP discovery so clients can connect directly for media while signaling continues through the tunnel.
|
The bootstrap script configures LiveKit automatically based on `domain.base_domain` in your `config.json`. When set to a non-localhost domain, it enables external IP discovery so clients can connect directly for media while signaling continues through the tunnel.
|
||||||
|
|
||||||
|
### Devcontainer (experimental)
|
||||||
|
|
||||||
|
There is experimental support for developing in a **VS Code Dev Container** / GitHub Codespace without Nix. The `.devcontainer/` directory provides a Docker Compose setup with all required tooling and backing services.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inside the dev container, start all processes:
|
||||||
|
process-compose -f .devcontainer/process-compose.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the app at `http://localhost:48763` and the dev email inbox at `http://localhost:48763/mailpit/`. Predefined VS Code debugging targets are available in `.vscode/launch.json`.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Bluesky OAuth is disabled in the devcontainer because it requires HTTPS. All other features work normally.
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
To develop the documentation site with live preview:
|
To develop the documentation site with live preview:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const AccountSecurityTab: React.FC = observer(() => {
|
|||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
const user = UserStore.currentUser;
|
const user = UserStore.currentUser;
|
||||||
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
|
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
|
||||||
|
const [showMaskedPhone, setShowMaskedPhone] = useState(false);
|
||||||
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
|
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
|
||||||
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
|
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
|
||||||
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
|
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
|
||||||
@@ -100,9 +101,11 @@ const AccountSecurityTab: React.FC = observer(() => {
|
|||||||
loadingPasskeys={loadingPasskeys}
|
loadingPasskeys={loadingPasskeys}
|
||||||
enablingSmsMfa={enablingSmsMfa}
|
enablingSmsMfa={enablingSmsMfa}
|
||||||
disablingSmsMfa={disablingSmsMfa}
|
disablingSmsMfa={disablingSmsMfa}
|
||||||
|
showMaskedPhone={showMaskedPhone}
|
||||||
loadPasskeys={loadPasskeys}
|
loadPasskeys={loadPasskeys}
|
||||||
setEnablingSmsMfa={setEnablingSmsMfa}
|
setEnablingSmsMfa={setEnablingSmsMfa}
|
||||||
setDisablingSmsMfa={setDisablingSmsMfa}
|
setDisablingSmsMfa={setDisablingSmsMfa}
|
||||||
|
setShowMaskedPhone={setShowMaskedPhone}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const AccountSecurityInlineTab = observer(() => {
|
|||||||
const {t} = useLingui();
|
const {t} = useLingui();
|
||||||
const user = UserStore.currentUser;
|
const user = UserStore.currentUser;
|
||||||
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
|
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
|
||||||
|
const [showMaskedPhone, setShowMaskedPhone] = useState(false);
|
||||||
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
|
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
|
||||||
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
|
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
|
||||||
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
|
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
|
||||||
@@ -89,9 +90,11 @@ export const AccountSecurityInlineTab = observer(() => {
|
|||||||
loadingPasskeys={loadingPasskeys}
|
loadingPasskeys={loadingPasskeys}
|
||||||
enablingSmsMfa={enablingSmsMfa}
|
enablingSmsMfa={enablingSmsMfa}
|
||||||
disablingSmsMfa={disablingSmsMfa}
|
disablingSmsMfa={disablingSmsMfa}
|
||||||
|
showMaskedPhone={showMaskedPhone}
|
||||||
loadPasskeys={loadPasskeys}
|
loadPasskeys={loadPasskeys}
|
||||||
setEnablingSmsMfa={setEnablingSmsMfa}
|
setEnablingSmsMfa={setEnablingSmsMfa}
|
||||||
setDisablingSmsMfa={setDisablingSmsMfa}
|
setDisablingSmsMfa={setDisablingSmsMfa}
|
||||||
|
setShowMaskedPhone={setShowMaskedPhone}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<SettingsSection id="danger_zone" title={t`Danger Zone`}>
|
<SettingsSection id="danger_zone" title={t`Danger Zone`}>
|
||||||
|
|||||||
@@ -97,6 +97,48 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phoneRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.phoneRow {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneText {
|
||||||
|
color: var(--text-primary-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneTextSelectable {
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleButton {
|
||||||
|
margin-top: 0.1em;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-link);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleButton:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.toggleButton {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.claimButton {
|
.claimButton {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ import type React from 'react';
|
|||||||
|
|
||||||
const logger = new Logger('SecurityTab');
|
const logger = new Logger('SecurityTab');
|
||||||
|
|
||||||
|
const maskPhone = (phone: string): string => {
|
||||||
|
if (phone.length <= 4) {
|
||||||
|
return phone.replace(/./g, '*');
|
||||||
|
}
|
||||||
|
const lastTwo = phone.slice(-2);
|
||||||
|
const masked = phone.slice(0, -2).replace(/\d/g, '*');
|
||||||
|
return `${masked}${lastTwo}`;
|
||||||
|
};
|
||||||
|
|
||||||
interface SecurityTabProps {
|
interface SecurityTabProps {
|
||||||
user: UserRecord;
|
user: UserRecord;
|
||||||
isClaimed: boolean;
|
isClaimed: boolean;
|
||||||
@@ -51,9 +60,11 @@ interface SecurityTabProps {
|
|||||||
loadingPasskeys: boolean;
|
loadingPasskeys: boolean;
|
||||||
enablingSmsMfa: boolean;
|
enablingSmsMfa: boolean;
|
||||||
disablingSmsMfa: boolean;
|
disablingSmsMfa: boolean;
|
||||||
|
showMaskedPhone: boolean;
|
||||||
loadPasskeys: () => Promise<void>;
|
loadPasskeys: () => Promise<void>;
|
||||||
setEnablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
|
setEnablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setDisablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
|
setDisablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setShowMaskedPhone: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
|
export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
|
||||||
@@ -67,9 +78,11 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
|
|||||||
loadingPasskeys,
|
loadingPasskeys,
|
||||||
enablingSmsMfa,
|
enablingSmsMfa,
|
||||||
disablingSmsMfa,
|
disablingSmsMfa,
|
||||||
|
showMaskedPhone,
|
||||||
loadPasskeys,
|
loadPasskeys,
|
||||||
setEnablingSmsMfa,
|
setEnablingSmsMfa,
|
||||||
setDisablingSmsMfa,
|
setDisablingSmsMfa,
|
||||||
|
setShowMaskedPhone,
|
||||||
}) => {
|
}) => {
|
||||||
const {t, i18n} = useLingui();
|
const {t, i18n} = useLingui();
|
||||||
|
|
||||||
@@ -344,13 +357,24 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
|
|||||||
<div className={styles.label}>
|
<div className={styles.label}>
|
||||||
<Trans>Phone Number</Trans>
|
<Trans>Phone Number</Trans>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.description}>
|
{user.phone ? (
|
||||||
{user.phone ? (
|
<div className={styles.phoneRow}>
|
||||||
<Trans>Phone number added: {user.phone}</Trans>
|
<span className={`${styles.phoneText} ${showMaskedPhone ? styles.phoneTextSelectable : ''}`}>
|
||||||
) : (
|
{showMaskedPhone ? user.phone : maskPhone(user.phone)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggleButton}
|
||||||
|
onClick={() => setShowMaskedPhone(!showMaskedPhone)}
|
||||||
|
>
|
||||||
|
{showMaskedPhone ? t`Hide` : t`Reveal`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.description}>
|
||||||
<Trans>Add a phone number to enable SMS two-factor authentication</Trans>
|
<Trans>Add a phone number to enable SMS two-factor authentication</Trans>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
{user.phone ? (
|
{user.phone ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
|
padding-left: 0;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -370,16 +370,6 @@ const OAuthAuthorizePage: React.FC = observer(() => {
|
|||||||
const appName = publicApp?.name?.trim();
|
const appName = publicApp?.name?.trim();
|
||||||
const clientLabel = appName || t`This application`;
|
const clientLabel = appName || t`This application`;
|
||||||
|
|
||||||
const appAvatarUrl = useMemo<string | null>(() => {
|
|
||||||
if (!publicApp?.id || !publicApp.icon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const url = AvatarUtils.getUserAvatarURL({id: publicApp.id, avatar: publicApp.icon}, false);
|
|
||||||
return url ?? null;
|
|
||||||
}, [publicApp?.icon, publicApp?.id]);
|
|
||||||
|
|
||||||
const appInitial = clientLabel.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
const formattedPermissions = useMemo(() => {
|
const formattedPermissions = useMemo(() => {
|
||||||
if (!hasBotScope || !authParams?.permissions) return authParams?.permissions ?? undefined;
|
if (!hasBotScope || !authParams?.permissions) return authParams?.permissions ?? undefined;
|
||||||
return formatBotPermissionsQuery(Array.from(selectedPermissions ?? []));
|
return formatBotPermissionsQuery(Array.from(selectedPermissions ?? []));
|
||||||
@@ -587,16 +577,6 @@ const OAuthAuthorizePage: React.FC = observer(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.heroCard}>
|
<div className={styles.heroCard}>
|
||||||
<div className={styles.heroAvatarShell}>
|
|
||||||
<BaseAvatar
|
|
||||||
size={48}
|
|
||||||
avatarUrl={appAvatarUrl || ''}
|
|
||||||
shouldPlayAnimated={false}
|
|
||||||
className={!appAvatarUrl ? styles.appAvatarFallback : undefined}
|
|
||||||
userTag={clientLabel}
|
|
||||||
/>
|
|
||||||
{!appAvatarUrl && <span className={styles.appAvatarInitial}>{appInitial}</span>}
|
|
||||||
</div>
|
|
||||||
<div className={styles.heroCopy}>
|
<div className={styles.heroCopy}>
|
||||||
<h1 className={styles.heroTitle}>
|
<h1 className={styles.heroTitle}>
|
||||||
<Trans>Configure bot permissions</Trans>
|
<Trans>Configure bot permissions</Trans>
|
||||||
@@ -724,17 +704,6 @@ const OAuthAuthorizePage: React.FC = observer(() => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.heroCard}>
|
<div className={styles.heroCard}>
|
||||||
<div className={styles.heroAvatarShell}>
|
|
||||||
<BaseAvatar
|
|
||||||
size={48}
|
|
||||||
avatarUrl={appAvatarUrl || ''}
|
|
||||||
shouldPlayAnimated={false}
|
|
||||||
className={!appAvatarUrl ? styles.appAvatarFallback : undefined}
|
|
||||||
userTag={clientLabel}
|
|
||||||
/>
|
|
||||||
{!appAvatarUrl && <span className={styles.appAvatarInitial}>{appInitial}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.heroCopy}>
|
<div className={styles.heroCopy}>
|
||||||
<p className={styles.eyebrow}>
|
<p className={styles.eyebrow}>
|
||||||
<Trans>Authorization request</Trans>
|
<Trans>Authorization request</Trans>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Quickstart: ping-pong bot"
|
title: "Quickstart: ping-pong bot"
|
||||||
description: "Create a bot application, invite it to a guild, and reply "pong" to "ping"."
|
description: 'Create a bot application, invite it to a guild, and reply "pong" to "ping".'
|
||||||
---
|
---
|
||||||
|
|
||||||
<Warning>
|
<Warning>
|
||||||
@@ -97,13 +97,13 @@ Second, install the required dependencies:
|
|||||||
npm i -E @discordjs/core@2.4.0 @discordjs/rest@2.6.0 @discordjs/ws@2.0.4
|
npm i -E @discordjs/core@2.4.0 @discordjs/rest@2.6.0 @discordjs/ws@2.0.4
|
||||||
```
|
```
|
||||||
|
|
||||||
Second, store your token from Step 4 in a `.env` file in this folder:
|
Third, store your token from Step 4 in a `.env` file in this folder:
|
||||||
|
|
||||||
```
|
```
|
||||||
FLUXER_BOT_TOKEN=<your token goes here>
|
FLUXER_BOT_TOKEN=<your token goes here>
|
||||||
```
|
```
|
||||||
|
|
||||||
Third, create a new `bot.mjs` file, looking like so:
|
Fourth, create a new `bot.mjs` file, looking like so:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import {Client, GatewayDispatchEvents} from '@discordjs/core';
|
import {Client, GatewayDispatchEvents} from '@discordjs/core';
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
-define(VOICE_RATE_LIMIT_TABLE, voice_update_rate_limit).
|
-define(VOICE_RATE_LIMIT_TABLE, voice_update_rate_limit).
|
||||||
-define(VOICE_QUEUE_PROCESS_INTERVAL, 100).
|
-define(VOICE_QUEUE_PROCESS_INTERVAL, 100).
|
||||||
-define(MAX_VOICE_QUEUE_LENGTH, 64).
|
-define(MAX_VOICE_QUEUE_LENGTH, 64).
|
||||||
|
-define(RATE_LIMIT_WINDOW_MS, 60000).
|
||||||
|
-define(RATE_LIMIT_MAX_EVENTS, 120).
|
||||||
|
-define(REQUEST_GUILD_MEMBERS_RATE_LIMIT_WINDOW_MS, 10000).
|
||||||
|
-define(REQUEST_GUILD_MEMBERS_RATE_LIMIT_MAX_EVENTS, 3).
|
||||||
|
|
||||||
-type state() :: #{
|
-type state() :: #{
|
||||||
version := 1 | undefined,
|
version := 1 | undefined,
|
||||||
@@ -40,6 +44,7 @@
|
|||||||
socket_pid := pid() | undefined,
|
socket_pid := pid() | undefined,
|
||||||
peer_ip := binary() | undefined,
|
peer_ip := binary() | undefined,
|
||||||
rate_limit_state := map(),
|
rate_limit_state := map(),
|
||||||
|
request_guild_members_pid := pid() | undefined,
|
||||||
otel_span_ctx := term(),
|
otel_span_ctx := term(),
|
||||||
voice_queue_timer := reference() | undefined
|
voice_queue_timer := reference() | undefined
|
||||||
}.
|
}.
|
||||||
@@ -54,7 +59,12 @@ new_state() ->
|
|||||||
heartbeat_state => #{},
|
heartbeat_state => #{},
|
||||||
socket_pid => undefined,
|
socket_pid => undefined,
|
||||||
peer_ip => undefined,
|
peer_ip => undefined,
|
||||||
rate_limit_state => #{events => [], window_start => undefined},
|
rate_limit_state => #{
|
||||||
|
events => [],
|
||||||
|
request_guild_members_events => [],
|
||||||
|
window_start => undefined
|
||||||
|
},
|
||||||
|
request_guild_members_pid => undefined,
|
||||||
otel_span_ctx => undefined,
|
otel_span_ctx => undefined,
|
||||||
voice_queue_timer => undefined
|
voice_queue_timer => undefined
|
||||||
}.
|
}.
|
||||||
@@ -140,7 +150,7 @@ handle_incoming_data(Data, State = #{encoding := Encoding, compress_ctx := Compr
|
|||||||
-spec handle_decode({ok, map()} | {error, term()}, state()) -> ws_result().
|
-spec handle_decode({ok, map()} | {error, term()}, state()) -> ws_result().
|
||||||
handle_decode({ok, #{<<"op">> := Op} = Payload}, State) ->
|
handle_decode({ok, #{<<"op">> := Op} = Payload}, State) ->
|
||||||
OpAtom = constants:gateway_opcode(Op),
|
OpAtom = constants:gateway_opcode(Op),
|
||||||
case check_rate_limit(State) of
|
case check_rate_limit(State, OpAtom) of
|
||||||
{ok, RateLimitedState} ->
|
{ok, RateLimitedState} ->
|
||||||
handle_gateway_payload(OpAtom, Payload, RateLimitedState);
|
handle_gateway_payload(OpAtom, Payload, RateLimitedState);
|
||||||
rate_limited ->
|
rate_limited ->
|
||||||
@@ -160,6 +170,10 @@ websocket_info({session_backpressure_error, Details}, State) ->
|
|||||||
handle_session_backpressure_error(Details, State);
|
handle_session_backpressure_error(Details, State);
|
||||||
websocket_info({'DOWN', _, process, Pid, _}, State = #{session_pid := Pid}) ->
|
websocket_info({'DOWN', _, process, Pid, _}, State = #{session_pid := Pid}) ->
|
||||||
handle_session_down(State);
|
handle_session_down(State);
|
||||||
|
websocket_info(
|
||||||
|
{'DOWN', _, process, Pid, _}, State = #{request_guild_members_pid := Pid}
|
||||||
|
) ->
|
||||||
|
{ok, State#{request_guild_members_pid => undefined}};
|
||||||
websocket_info({process_voice_queue}, State) ->
|
websocket_info({process_voice_queue}, State) ->
|
||||||
NewState = process_queued_voice_updates(State#{voice_queue_timer => undefined}),
|
NewState = process_queued_voice_updates(State#{voice_queue_timer => undefined}),
|
||||||
{ok, NewState};
|
{ok, NewState};
|
||||||
@@ -541,9 +555,13 @@ handle_voice_state_update(Pid, Data, State) ->
|
|||||||
end.
|
end.
|
||||||
|
|
||||||
-spec handle_request_guild_members(map(), pid(), state()) -> ws_result().
|
-spec handle_request_guild_members(map(), pid(), state()) -> ws_result().
|
||||||
|
handle_request_guild_members(
|
||||||
|
_Data, _Pid, State = #{request_guild_members_pid := RequestPid}
|
||||||
|
) when is_pid(RequestPid) ->
|
||||||
|
{ok, State};
|
||||||
handle_request_guild_members(Data, Pid, State) ->
|
handle_request_guild_members(Data, Pid, State) ->
|
||||||
SocketPid = self(),
|
SocketPid = self(),
|
||||||
spawn(fun() ->
|
{WorkerPid, _Ref} = spawn_monitor(fun() ->
|
||||||
try
|
try
|
||||||
case gen_server:call(Pid, {get_state}, 5000) of
|
case gen_server:call(Pid, {get_state}, 5000) of
|
||||||
SessionState when is_map(SessionState) ->
|
SessionState when is_map(SessionState) ->
|
||||||
@@ -555,7 +573,7 @@ handle_request_guild_members(Data, Pid, State) ->
|
|||||||
_:_ -> ok
|
_:_ -> ok
|
||||||
end
|
end
|
||||||
end),
|
end),
|
||||||
{ok, State}.
|
{ok, State#{request_guild_members_pid => WorkerPid}}.
|
||||||
|
|
||||||
-spec handle_lazy_request(map(), pid(), state()) -> ws_result().
|
-spec handle_lazy_request(map(), pid(), state()) -> ws_result().
|
||||||
handle_lazy_request(Data, Pid, State) ->
|
handle_lazy_request(Data, Pid, State) ->
|
||||||
@@ -578,23 +596,41 @@ handle_lazy_request(Data, Pid, State) ->
|
|||||||
schedule_heartbeat_check() ->
|
schedule_heartbeat_check() ->
|
||||||
erlang:send_after(constants:heartbeat_interval() div 3, self(), {heartbeat_check}).
|
erlang:send_after(constants:heartbeat_interval() div 3, self(), {heartbeat_check}).
|
||||||
|
|
||||||
-spec check_rate_limit(state()) -> {ok, state()} | rate_limited.
|
-spec check_rate_limit(state(), atom()) -> {ok, state()} | rate_limited.
|
||||||
check_rate_limit(State = #{rate_limit_state := RateLimitState}) ->
|
check_rate_limit(State = #{rate_limit_state := RateLimitState}, Op) ->
|
||||||
Now = erlang:system_time(millisecond),
|
Now = erlang:system_time(millisecond),
|
||||||
Events = maps:get(events, RateLimitState, []),
|
Events = maps:get(events, RateLimitState, []),
|
||||||
WindowStart = maps:get(window_start, RateLimitState, Now),
|
WindowStart = maps:get(window_start, RateLimitState, Now),
|
||||||
WindowDuration = 60000,
|
EventsInWindow = [T || T <- Events, (Now - T) < ?RATE_LIMIT_WINDOW_MS],
|
||||||
MaxEvents = 120,
|
case length(EventsInWindow) >= ?RATE_LIMIT_MAX_EVENTS of
|
||||||
EventsInWindow = [T || T <- Events, (Now - T) < WindowDuration],
|
|
||||||
case length(EventsInWindow) >= MaxEvents of
|
|
||||||
true ->
|
true ->
|
||||||
rate_limited;
|
rate_limited;
|
||||||
false ->
|
false ->
|
||||||
NewEvents = [Now | EventsInWindow],
|
case check_opcode_rate_limit(Op, RateLimitState, Now) of
|
||||||
NewRateLimitState = #{events => NewEvents, window_start => WindowStart},
|
rate_limited ->
|
||||||
{ok, State#{rate_limit_state => NewRateLimitState}}
|
rate_limited;
|
||||||
|
{ok, OpRateLimitState} ->
|
||||||
|
NewEvents = [Now | EventsInWindow],
|
||||||
|
NewRateLimitState =
|
||||||
|
OpRateLimitState#{events => NewEvents, window_start => WindowStart},
|
||||||
|
{ok, State#{rate_limit_state => NewRateLimitState}}
|
||||||
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
-spec check_opcode_rate_limit(atom(), map(), integer()) -> {ok, map()} | rate_limited.
|
||||||
|
check_opcode_rate_limit(request_guild_members, RateLimitState, Now) ->
|
||||||
|
RequestEvents = maps:get(request_guild_members_events, RateLimitState, []),
|
||||||
|
RequestEventsInWindow =
|
||||||
|
[T || T <- RequestEvents, (Now - T) < ?REQUEST_GUILD_MEMBERS_RATE_LIMIT_WINDOW_MS],
|
||||||
|
case length(RequestEventsInWindow) >= ?REQUEST_GUILD_MEMBERS_RATE_LIMIT_MAX_EVENTS of
|
||||||
|
true ->
|
||||||
|
rate_limited;
|
||||||
|
false ->
|
||||||
|
{ok, RateLimitState#{request_guild_members_events => [Now | RequestEventsInWindow]}}
|
||||||
|
end;
|
||||||
|
check_opcode_rate_limit(_, RateLimitState, _Now) ->
|
||||||
|
{ok, RateLimitState}.
|
||||||
|
|
||||||
-spec extract_client_ip(cowboy_req:req()) -> binary().
|
-spec extract_client_ip(cowboy_req:req()) -> binary().
|
||||||
extract_client_ip(Req) ->
|
extract_client_ip(Req) ->
|
||||||
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
|
case cowboy_req:header(<<"x-forwarded-for">>, Req) of
|
||||||
@@ -954,4 +990,42 @@ adjust_status_test() ->
|
|||||||
?assertEqual(online, adjust_status(online)),
|
?assertEqual(online, adjust_status(online)),
|
||||||
?assertEqual(idle, adjust_status(idle)).
|
?assertEqual(idle, adjust_status(idle)).
|
||||||
|
|
||||||
|
check_rate_limit_blocks_general_flood_test() ->
|
||||||
|
Now = erlang:system_time(millisecond),
|
||||||
|
Events = lists:duplicate(?RATE_LIMIT_MAX_EVENTS, Now - 1000),
|
||||||
|
State = (new_state())#{
|
||||||
|
rate_limit_state => #{
|
||||||
|
events => Events,
|
||||||
|
request_guild_members_events => [],
|
||||||
|
window_start => Now
|
||||||
|
}
|
||||||
|
},
|
||||||
|
?assertEqual(rate_limited, check_rate_limit(State, heartbeat)).
|
||||||
|
|
||||||
|
check_rate_limit_blocks_request_guild_members_burst_test() ->
|
||||||
|
Now = erlang:system_time(millisecond),
|
||||||
|
RequestEvents =
|
||||||
|
lists:duplicate(?REQUEST_GUILD_MEMBERS_RATE_LIMIT_MAX_EVENTS, Now - 1000),
|
||||||
|
State = (new_state())#{
|
||||||
|
rate_limit_state => #{
|
||||||
|
events => [],
|
||||||
|
request_guild_members_events => RequestEvents,
|
||||||
|
window_start => Now
|
||||||
|
}
|
||||||
|
},
|
||||||
|
?assertEqual(rate_limited, check_rate_limit(State, request_guild_members)).
|
||||||
|
|
||||||
|
check_rate_limit_allows_other_ops_when_request_guild_members_is_hot_test() ->
|
||||||
|
Now = erlang:system_time(millisecond),
|
||||||
|
RequestEvents =
|
||||||
|
lists:duplicate(?REQUEST_GUILD_MEMBERS_RATE_LIMIT_MAX_EVENTS, Now - 1000),
|
||||||
|
State = (new_state())#{
|
||||||
|
rate_limit_state => #{
|
||||||
|
events => [],
|
||||||
|
request_guild_members_events => RequestEvents,
|
||||||
|
window_start => Now
|
||||||
|
}
|
||||||
|
},
|
||||||
|
?assertMatch({ok, _}, check_rate_limit(State, heartbeat)).
|
||||||
|
|
||||||
-endif.
|
-endif.
|
||||||
|
|||||||
@@ -130,7 +130,19 @@ filter_sessions_for_event(Event, FinalData, SessionIdOpt, Sessions, UpdatedState
|
|||||||
)
|
)
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
guild_sessions:filter_sessions_exclude_session(Sessions, SessionIdOpt)
|
FilteredSessions = guild_sessions:filter_sessions_exclude_session(
|
||||||
|
Sessions, SessionIdOpt
|
||||||
|
),
|
||||||
|
case Event of
|
||||||
|
guild_member_add ->
|
||||||
|
[
|
||||||
|
{Sid, SessionData}
|
||||||
|
|| {Sid, SessionData} <- FilteredSessions,
|
||||||
|
maps:get(bot, SessionData, false) =:= true
|
||||||
|
];
|
||||||
|
_ ->
|
||||||
|
FilteredSessions
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end.
|
end.
|
||||||
|
|
||||||
@@ -1149,9 +1161,17 @@ filter_sessions_for_event_guild_wide_goes_to_all_sessions_test() ->
|
|||||||
S2 = #{session_id => <<"s2">>, user_id => 11, pid => self()},
|
S2 = #{session_id => <<"s2">>, user_id => 11, pid => self()},
|
||||||
Sessions = #{<<"s1">> => S1, <<"s2">> => S2},
|
Sessions = #{<<"s1">> => S1, <<"s2">> => S2},
|
||||||
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
|
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
|
||||||
Result = filter_sessions_for_event(guild_member_add, #{}, undefined, Sessions, State),
|
Result = filter_sessions_for_event(guild_update, #{}, undefined, Sessions, State),
|
||||||
?assertEqual(2, length(Result)).
|
?assertEqual(2, length(Result)).
|
||||||
|
|
||||||
|
filter_sessions_for_event_guild_member_add_bots_only_test() ->
|
||||||
|
S1 = #{session_id => <<"s1">>, user_id => 10, pid => self(), bot => false},
|
||||||
|
S2 = #{session_id => <<"s2">>, user_id => 11, pid => self(), bot => true},
|
||||||
|
Sessions = #{<<"s1">> => S1, <<"s2">> => S2},
|
||||||
|
State = #{sessions => Sessions, data => #{<<"members">> => #{}}},
|
||||||
|
Result = filter_sessions_for_event(guild_member_add, #{}, undefined, Sessions, State),
|
||||||
|
?assertEqual([{<<"s2">>, S2}], Result).
|
||||||
|
|
||||||
extract_channel_id_message_create_uses_channel_id_field_test() ->
|
extract_channel_id_message_create_uses_channel_id_field_test() ->
|
||||||
Data = #{<<"channel_id">> => <<"42">>},
|
Data = #{<<"channel_id">> => <<"42">>},
|
||||||
?assertEqual(42, extract_channel_id(message_create, Data)).
|
?assertEqual(42, extract_channel_id(message_create, Data)).
|
||||||
|
|||||||
@@ -373,12 +373,9 @@ get_sorted_members_for_list(ListId, State) ->
|
|||||||
Data = maps:get(data, State, #{}),
|
Data = maps:get(data, State, #{}),
|
||||||
Members = guild_data_index:member_values(Data),
|
Members = guild_data_index:member_values(Data),
|
||||||
FilteredMembers = guild_member_list_common:filter_members_for_list(ListId, Members, State),
|
FilteredMembers = guild_member_list_common:filter_members_for_list(ListId, Members, State),
|
||||||
lists:sort(
|
Decorated = [{guild_member_list_common:get_member_sort_key(M), M} || M <- FilteredMembers],
|
||||||
fun(A, B) ->
|
Sorted = lists:sort(fun({KeyA, _}, {KeyB, _}) -> KeyA =< KeyB end, Decorated),
|
||||||
guild_member_list_common:get_member_sort_key(A) =< guild_member_list_common:get_member_sort_key(B)
|
[M || {_, M} <- Sorted].
|
||||||
end,
|
|
||||||
FilteredMembers
|
|
||||||
).
|
|
||||||
|
|
||||||
-spec build_full_items(list_id(), guild_state(), [map()]) -> [list_item()].
|
-spec build_full_items(list_id(), guild_state(), [map()]) -> [list_item()].
|
||||||
build_full_items(ListId, State, SortedMembers) ->
|
build_full_items(ListId, State, SortedMembers) ->
|
||||||
|
|||||||
@@ -24,6 +24,15 @@
|
|||||||
-define(CHUNK_SIZE, 1000).
|
-define(CHUNK_SIZE, 1000).
|
||||||
-define(MAX_USER_IDS, 100).
|
-define(MAX_USER_IDS, 100).
|
||||||
-define(MAX_NONCE_LENGTH, 32).
|
-define(MAX_NONCE_LENGTH, 32).
|
||||||
|
-define(FULL_MEMBER_LIST_LIMIT, 100000).
|
||||||
|
-define(DEFAULT_QUERY_LIMIT, 25).
|
||||||
|
-define(MAX_MEMBER_QUERY_LIMIT, 100).
|
||||||
|
-define(REQUEST_MEMBERS_RATE_LIMIT_TABLE, guild_request_members_rate_limit).
|
||||||
|
-define(REQUEST_MEMBERS_RATE_LIMIT_WINDOW_MS, 10000).
|
||||||
|
-define(REQUEST_MEMBERS_RATE_LIMIT_MAX_EVENTS, 5).
|
||||||
|
-define(REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, guild_request_members_guild_rate_limit).
|
||||||
|
-define(REQUEST_MEMBERS_GUILD_RATE_LIMIT_WINDOW_MS, 10000).
|
||||||
|
-define(REQUEST_MEMBERS_GUILD_RATE_LIMIT_MAX_EVENTS, 25).
|
||||||
|
|
||||||
-type session_state() :: map().
|
-type session_state() :: map().
|
||||||
-type request_data() :: map().
|
-type request_data() :: map().
|
||||||
@@ -121,7 +130,8 @@ ensure_binary(Value) when is_binary(Value) -> Value;
|
|||||||
ensure_binary(_) -> <<>>.
|
ensure_binary(_) -> <<>>.
|
||||||
|
|
||||||
-spec ensure_limit(term()) -> non_neg_integer().
|
-spec ensure_limit(term()) -> non_neg_integer().
|
||||||
ensure_limit(Limit) when is_integer(Limit), Limit >= 0 -> Limit;
|
ensure_limit(Limit) when is_integer(Limit), Limit >= 0 ->
|
||||||
|
min(Limit, ?MAX_MEMBER_QUERY_LIMIT);
|
||||||
ensure_limit(_) -> 0.
|
ensure_limit(_) -> 0.
|
||||||
|
|
||||||
-spec normalize_nonce(term()) -> binary() | null.
|
-spec normalize_nonce(term()) -> binary() | null.
|
||||||
@@ -135,13 +145,99 @@ process_request(Request, SocketPid, SessionState) ->
|
|||||||
#{guild_id := GuildId, query := Query, limit := Limit, user_ids := UserIds} = Request,
|
#{guild_id := GuildId, query := Query, limit := Limit, user_ids := UserIds} = Request,
|
||||||
UserIdBin = maps:get(user_id, SessionState),
|
UserIdBin = maps:get(user_id, SessionState),
|
||||||
UserId = type_conv:to_integer(UserIdBin),
|
UserId = type_conv:to_integer(UserIdBin),
|
||||||
case check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) of
|
case check_request_rate_limit(UserId) of
|
||||||
ok ->
|
ok ->
|
||||||
fetch_and_send_members(Request, SocketPid, SessionState);
|
case check_guild_request_rate_limit(GuildId) of
|
||||||
|
ok ->
|
||||||
|
case check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) of
|
||||||
|
ok ->
|
||||||
|
fetch_and_send_members(Request, SocketPid, SessionState);
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end;
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason}
|
||||||
|
end;
|
||||||
{error, Reason} ->
|
{error, Reason} ->
|
||||||
{error, Reason}
|
{error, Reason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
-spec check_request_rate_limit(integer() | undefined) -> ok | {error, atom()}.
|
||||||
|
check_request_rate_limit(UserId) when is_integer(UserId), UserId > 0 ->
|
||||||
|
ensure_request_rate_limit_table(),
|
||||||
|
Now = erlang:system_time(millisecond),
|
||||||
|
case ets:lookup(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, UserId) of
|
||||||
|
[] ->
|
||||||
|
ets:insert(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, {UserId, [Now]}),
|
||||||
|
ok;
|
||||||
|
[{UserId, Timestamps}] ->
|
||||||
|
RecentTimestamps =
|
||||||
|
[T || T <- Timestamps, (Now - T) < ?REQUEST_MEMBERS_RATE_LIMIT_WINDOW_MS],
|
||||||
|
case length(RecentTimestamps) >= ?REQUEST_MEMBERS_RATE_LIMIT_MAX_EVENTS of
|
||||||
|
true ->
|
||||||
|
{error, rate_limited};
|
||||||
|
false ->
|
||||||
|
ets:insert(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, {UserId, [Now | RecentTimestamps]}),
|
||||||
|
ok
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
check_request_rate_limit(_) ->
|
||||||
|
{error, invalid_session}.
|
||||||
|
|
||||||
|
-spec check_guild_request_rate_limit(integer()) -> ok | {error, atom()}.
|
||||||
|
check_guild_request_rate_limit(GuildId) when is_integer(GuildId), GuildId > 0 ->
|
||||||
|
ensure_guild_request_rate_limit_table(),
|
||||||
|
Now = erlang:system_time(millisecond),
|
||||||
|
case ets:lookup(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, GuildId) of
|
||||||
|
[] ->
|
||||||
|
ets:insert(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, {GuildId, [Now]}),
|
||||||
|
ok;
|
||||||
|
[{GuildId, Timestamps}] ->
|
||||||
|
RecentTimestamps =
|
||||||
|
[T || T <- Timestamps, (Now - T) < ?REQUEST_MEMBERS_GUILD_RATE_LIMIT_WINDOW_MS],
|
||||||
|
case length(RecentTimestamps) >= ?REQUEST_MEMBERS_GUILD_RATE_LIMIT_MAX_EVENTS of
|
||||||
|
true ->
|
||||||
|
{error, rate_limited};
|
||||||
|
false ->
|
||||||
|
ets:insert(
|
||||||
|
?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, {GuildId, [Now | RecentTimestamps]}
|
||||||
|
),
|
||||||
|
ok
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
check_guild_request_rate_limit(_) ->
|
||||||
|
{error, invalid_guild_id}.
|
||||||
|
|
||||||
|
-spec ensure_request_rate_limit_table() -> ok.
|
||||||
|
ensure_request_rate_limit_table() ->
|
||||||
|
case ets:whereis(?REQUEST_MEMBERS_RATE_LIMIT_TABLE) of
|
||||||
|
undefined ->
|
||||||
|
try
|
||||||
|
ets:new(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, [named_table, public, set]),
|
||||||
|
ok
|
||||||
|
catch
|
||||||
|
error:badarg ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec ensure_guild_request_rate_limit_table() -> ok.
|
||||||
|
ensure_guild_request_rate_limit_table() ->
|
||||||
|
case ets:whereis(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE) of
|
||||||
|
undefined ->
|
||||||
|
try
|
||||||
|
ets:new(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, [named_table, public, set]),
|
||||||
|
ok
|
||||||
|
catch
|
||||||
|
error:badarg ->
|
||||||
|
ok
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
ok
|
||||||
|
end.
|
||||||
|
|
||||||
-spec check_permission(
|
-spec check_permission(
|
||||||
integer(), integer(), binary(), non_neg_integer(), [integer()], session_state()
|
integer(), integer(), binary(), non_neg_integer(), [integer()], session_state()
|
||||||
) ->
|
) ->
|
||||||
@@ -215,18 +311,9 @@ fetch_and_send_members(Request, _SocketPid, SessionState) ->
|
|||||||
|
|
||||||
-spec fetch_members(pid(), binary(), non_neg_integer(), [integer()]) -> [member()].
|
-spec fetch_members(pid(), binary(), non_neg_integer(), [integer()]) -> [member()].
|
||||||
fetch_members(GuildPid, _Query, _Limit, UserIds) when UserIds =/= [] ->
|
fetch_members(GuildPid, _Query, _Limit, UserIds) when UserIds =/= [] ->
|
||||||
case gen_server:call(GuildPid, {list_guild_members, #{limit => 100000, offset => 0}}, 10000) of
|
fetch_members_by_user_ids(GuildPid, UserIds);
|
||||||
#{members := AllMembers} ->
|
|
||||||
filter_members_by_ids(AllMembers, UserIds);
|
|
||||||
_ ->
|
|
||||||
[]
|
|
||||||
end;
|
|
||||||
fetch_members(GuildPid, Query, Limit, []) ->
|
fetch_members(GuildPid, Query, Limit, []) ->
|
||||||
ActualLimit =
|
ActualLimit = resolve_member_limit(Query, Limit),
|
||||||
case Limit of
|
|
||||||
0 -> 100000;
|
|
||||||
L -> L
|
|
||||||
end,
|
|
||||||
case
|
case
|
||||||
gen_server:call(GuildPid, {list_guild_members, #{limit => ActualLimit, offset => 0}}, 10000)
|
gen_server:call(GuildPid, {list_guild_members, #{limit => ActualLimit, offset => 0}}, 10000)
|
||||||
of
|
of
|
||||||
@@ -241,17 +328,33 @@ fetch_members(GuildPid, Query, Limit, []) ->
|
|||||||
[]
|
[]
|
||||||
end.
|
end.
|
||||||
|
|
||||||
-spec filter_members_by_ids([member()], [integer()]) -> [member()].
|
-spec fetch_members_by_user_ids(pid(), [integer()]) -> [member()].
|
||||||
filter_members_by_ids(Members, UserIds) ->
|
fetch_members_by_user_ids(GuildPid, UserIds) ->
|
||||||
UserIdSet = sets:from_list(UserIds),
|
lists:filtermap(
|
||||||
lists:filter(
|
fun(UserId) ->
|
||||||
fun(Member) ->
|
try
|
||||||
UserId = extract_user_id(Member),
|
case gen_server:call(GuildPid, {get_guild_member, #{user_id => UserId}}, 5000) of
|
||||||
UserId =/= undefined andalso sets:is_element(UserId, UserIdSet)
|
#{success := true, member_data := Member} when is_map(Member) ->
|
||||||
|
{true, Member};
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
catch
|
||||||
|
exit:_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
Members
|
lists:usort(UserIds)
|
||||||
).
|
).
|
||||||
|
|
||||||
|
-spec resolve_member_limit(binary(), non_neg_integer()) -> pos_integer().
|
||||||
|
resolve_member_limit(<<>>, 0) ->
|
||||||
|
?FULL_MEMBER_LIST_LIMIT;
|
||||||
|
resolve_member_limit(_Query, 0) ->
|
||||||
|
?DEFAULT_QUERY_LIMIT;
|
||||||
|
resolve_member_limit(_Query, Limit) ->
|
||||||
|
Limit.
|
||||||
|
|
||||||
-spec filter_members_by_query([member()], binary(), non_neg_integer()) -> [member()].
|
-spec filter_members_by_query([member()], binary(), non_neg_integer()) -> [member()].
|
||||||
filter_members_by_query(Members, Query, Limit) ->
|
filter_members_by_query(Members, Query, Limit) ->
|
||||||
NormalizedQuery = string:lowercase(binary_to_list(Query)),
|
NormalizedQuery = string:lowercase(binary_to_list(Query)),
|
||||||
@@ -518,6 +621,64 @@ ensure_limit_negative_test() ->
|
|||||||
ensure_limit_non_integer_test() ->
|
ensure_limit_non_integer_test() ->
|
||||||
?assertEqual(0, ensure_limit(<<"10">>)).
|
?assertEqual(0, ensure_limit(<<"10">>)).
|
||||||
|
|
||||||
|
ensure_limit_clamped_test() ->
|
||||||
|
?assertEqual(?MAX_MEMBER_QUERY_LIMIT, ensure_limit(?MAX_MEMBER_QUERY_LIMIT + 1)).
|
||||||
|
|
||||||
|
resolve_member_limit_full_scan_test() ->
|
||||||
|
?assertEqual(?FULL_MEMBER_LIST_LIMIT, resolve_member_limit(<<>>, 0)).
|
||||||
|
|
||||||
|
resolve_member_limit_query_default_test() ->
|
||||||
|
?assertEqual(?DEFAULT_QUERY_LIMIT, resolve_member_limit(<<"ab">>, 0)).
|
||||||
|
|
||||||
|
resolve_member_limit_explicit_test() ->
|
||||||
|
?assertEqual(25, resolve_member_limit(<<"ab">>, 25)).
|
||||||
|
|
||||||
|
check_request_rate_limit_allows_initial_request_test() ->
|
||||||
|
UserId = 987654321,
|
||||||
|
clear_request_rate_limit(UserId),
|
||||||
|
?assertEqual(ok, check_request_rate_limit(UserId)),
|
||||||
|
clear_request_rate_limit(UserId).
|
||||||
|
|
||||||
|
check_request_rate_limit_blocks_burst_test() ->
|
||||||
|
UserId = 987654322,
|
||||||
|
clear_request_rate_limit(UserId),
|
||||||
|
ensure_request_rate_limit_table(),
|
||||||
|
Now = erlang:system_time(millisecond),
|
||||||
|
Timestamps = lists:duplicate(?REQUEST_MEMBERS_RATE_LIMIT_MAX_EVENTS, Now - 1000),
|
||||||
|
ets:insert(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, {UserId, Timestamps}),
|
||||||
|
?assertEqual({error, rate_limited}, check_request_rate_limit(UserId)),
|
||||||
|
clear_request_rate_limit(UserId).
|
||||||
|
|
||||||
|
check_request_rate_limit_invalid_user_test() ->
|
||||||
|
?assertEqual({error, invalid_session}, check_request_rate_limit(undefined)).
|
||||||
|
|
||||||
|
check_guild_request_rate_limit_allows_initial_request_test() ->
|
||||||
|
GuildId = 87654321,
|
||||||
|
clear_guild_request_rate_limit(GuildId),
|
||||||
|
?assertEqual(ok, check_guild_request_rate_limit(GuildId)),
|
||||||
|
clear_guild_request_rate_limit(GuildId).
|
||||||
|
|
||||||
|
check_guild_request_rate_limit_blocks_burst_test() ->
|
||||||
|
GuildId = 87654322,
|
||||||
|
clear_guild_request_rate_limit(GuildId),
|
||||||
|
ensure_guild_request_rate_limit_table(),
|
||||||
|
Now = erlang:system_time(millisecond),
|
||||||
|
Timestamps = lists:duplicate(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_MAX_EVENTS, Now - 1000),
|
||||||
|
ets:insert(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, {GuildId, Timestamps}),
|
||||||
|
?assertEqual({error, rate_limited}, check_guild_request_rate_limit(GuildId)),
|
||||||
|
clear_guild_request_rate_limit(GuildId).
|
||||||
|
|
||||||
|
check_guild_request_rate_limit_invalid_guild_test() ->
|
||||||
|
?assertEqual({error, invalid_guild_id}, check_guild_request_rate_limit(undefined)).
|
||||||
|
|
||||||
|
clear_request_rate_limit(UserId) ->
|
||||||
|
ensure_request_rate_limit_table(),
|
||||||
|
ets:delete(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, UserId).
|
||||||
|
|
||||||
|
clear_guild_request_rate_limit(GuildId) ->
|
||||||
|
ensure_guild_request_rate_limit_table(),
|
||||||
|
ets:delete(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, GuildId).
|
||||||
|
|
||||||
validate_guild_id_integer_test() ->
|
validate_guild_id_integer_test() ->
|
||||||
?assertEqual({ok, 123}, validate_guild_id(123)).
|
?assertEqual({ok, 123}, validate_guild_id(123)).
|
||||||
|
|
||||||
@@ -589,30 +750,6 @@ chunk_presences_no_matching_presences_test() ->
|
|||||||
Result = chunk_presences(Presences, [Members]),
|
Result = chunk_presences(Presences, [Members]),
|
||||||
?assertEqual([[]], Result).
|
?assertEqual([[]], Result).
|
||||||
|
|
||||||
filter_members_by_ids_basic_test() ->
|
|
||||||
Members = [
|
|
||||||
#{<<"user">> => #{<<"id">> => <<"1">>}},
|
|
||||||
#{<<"user">> => #{<<"id">> => <<"2">>}},
|
|
||||||
#{<<"user">> => #{<<"id">> => <<"3">>}}
|
|
||||||
],
|
|
||||||
Result = filter_members_by_ids(Members, [1, 3]),
|
|
||||||
?assertEqual(2, length(Result)).
|
|
||||||
|
|
||||||
filter_members_by_ids_empty_ids_test() ->
|
|
||||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
|
||||||
Result = filter_members_by_ids(Members, []),
|
|
||||||
?assertEqual([], Result).
|
|
||||||
|
|
||||||
filter_members_by_ids_no_match_test() ->
|
|
||||||
Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
|
||||||
Result = filter_members_by_ids(Members, [999]),
|
|
||||||
?assertEqual([], Result).
|
|
||||||
|
|
||||||
filter_members_by_ids_skips_invalid_members_test() ->
|
|
||||||
Members = [#{}, #{<<"user">> => #{}}, #{<<"user">> => #{<<"id">> => <<"1">>}}],
|
|
||||||
Result = filter_members_by_ids(Members, [1]),
|
|
||||||
?assertEqual(1, length(Result)).
|
|
||||||
|
|
||||||
filter_members_by_query_case_insensitive_test() ->
|
filter_members_by_query_case_insensitive_test() ->
|
||||||
Members = [
|
Members = [
|
||||||
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alice">>}},
|
#{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alice">>}},
|
||||||
|
|||||||
@@ -228,6 +228,11 @@ get_cache_stats() ->
|
|||||||
|
|
||||||
-spec do_handle_message_create(map(), state()) -> state().
|
-spec do_handle_message_create(map(), state()) -> state().
|
||||||
do_handle_message_create(Params, State) ->
|
do_handle_message_create(Params, State) ->
|
||||||
|
spawn(fun() -> run_eligibility_and_dispatch(Params, State) end),
|
||||||
|
State.
|
||||||
|
|
||||||
|
-spec run_eligibility_and_dispatch(map(), state()) -> ok.
|
||||||
|
run_eligibility_and_dispatch(Params, State) ->
|
||||||
MessageData = maps:get(message_data, Params),
|
MessageData = maps:get(message_data, Params),
|
||||||
UserIds = maps:get(user_ids, Params),
|
UserIds = maps:get(user_ids, Params),
|
||||||
GuildId = maps:get(guild_id, Params),
|
GuildId = maps:get(guild_id, Params),
|
||||||
@@ -274,7 +279,7 @@ do_handle_message_create(Params, State) ->
|
|||||||
),
|
),
|
||||||
case EligibleUsers of
|
case EligibleUsers of
|
||||||
[] ->
|
[] ->
|
||||||
State;
|
ok;
|
||||||
_ ->
|
_ ->
|
||||||
push_dispatcher:enqueue_send_notifications(
|
push_dispatcher:enqueue_send_notifications(
|
||||||
EligibleUsers,
|
EligibleUsers,
|
||||||
@@ -286,5 +291,5 @@ do_handle_message_create(Params, State) ->
|
|||||||
ChannelName,
|
ChannelName,
|
||||||
State
|
State
|
||||||
),
|
),
|
||||||
State
|
ok
|
||||||
end.
|
end.
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ FROM node:24-trixie-slim AS base
|
|||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
|
ca-certificates \
|
||||||
python3 \
|
python3 \
|
||||||
make \
|
make \
|
||||||
g++ \
|
g++ \
|
||||||
@@ -25,7 +26,7 @@ COPY patches/ ./patches/
|
|||||||
|
|
||||||
COPY packages/admin/package.json ./packages/admin/
|
COPY packages/admin/package.json ./packages/admin/
|
||||||
COPY packages/api/package.json ./packages/api/
|
COPY packages/api/package.json ./packages/api/
|
||||||
COPY packages/app/package.json ./packages/app/
|
COPY packages/app_proxy/package.json ./packages/app_proxy/
|
||||||
COPY packages/cache/package.json ./packages/cache/
|
COPY packages/cache/package.json ./packages/cache/
|
||||||
COPY packages/captcha/package.json ./packages/captcha/
|
COPY packages/captcha/package.json ./packages/captcha/
|
||||||
COPY packages/cassandra/package.json ./packages/cassandra/
|
COPY packages/cassandra/package.json ./packages/cassandra/
|
||||||
@@ -68,14 +69,19 @@ FROM deps AS build
|
|||||||
COPY tsconfigs /usr/src/app/tsconfigs
|
COPY tsconfigs /usr/src/app/tsconfigs
|
||||||
|
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
RUN pnpm --filter @fluxer/config generate
|
|
||||||
COPY fluxer_server/ ./fluxer_server/
|
COPY fluxer_server/ ./fluxer_server/
|
||||||
|
|
||||||
|
# Reinstall to ensure all dependencies are properly linked after copying source
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
RUN pnpm --filter @fluxer/config generate
|
||||||
|
|
||||||
RUN pnpm --filter @fluxer/marketing build:css
|
RUN pnpm --filter @fluxer/marketing build:css
|
||||||
|
|
||||||
COPY fluxer_media_proxy/data/model.onnx ./fluxer_media_proxy/data/model.onnx
|
COPY fluxer_media_proxy/data/model.onnx ./fluxer_media_proxy/data/model.onnx
|
||||||
|
|
||||||
RUN cd fluxer_server && pnpm typecheck
|
# Skip typecheck in Docker build - already validated in CI
|
||||||
|
# RUN cd fluxer_server && pnpm typecheck
|
||||||
|
|
||||||
FROM erlang:28-slim AS gateway-build
|
FROM erlang:28-slim AS gateway-build
|
||||||
|
|
||||||
@@ -107,11 +113,26 @@ RUN LOGGER_LEVEL=${LOGGER_LEVEL} envsubst '${LOGGER_LEVEL}' < fluxer_gateway/con
|
|||||||
|
|
||||||
FROM deps AS app-build
|
FROM deps AS app-build
|
||||||
|
|
||||||
|
# Install Rust and WASM target for frontend WebAssembly compilation
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
RUN rustup target add wasm32-unknown-unknown
|
||||||
|
|
||||||
COPY tsconfigs /usr/src/app/tsconfigs
|
COPY tsconfigs /usr/src/app/tsconfigs
|
||||||
|
COPY config/ ./config/
|
||||||
|
|
||||||
COPY packages/ ./packages/
|
COPY packages/ ./packages/
|
||||||
COPY fluxer_app/ ./fluxer_app/
|
COPY fluxer_app/ ./fluxer_app/
|
||||||
|
|
||||||
|
# Reinstall to ensure all dependencies are properly linked after copying source
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Set FLUXER_CONFIG for rspack to derive API endpoints
|
||||||
|
ENV FLUXER_CONFIG=/usr/src/app/config/config.production.template.json
|
||||||
|
|
||||||
|
# Generate locale message files before build (needed for TypeScript compilation)
|
||||||
|
RUN cd fluxer_app && pnpm lingui:compile
|
||||||
|
|
||||||
RUN cd fluxer_app && pnpm build
|
RUN cd fluxer_app && pnpm build
|
||||||
|
|
||||||
FROM node:24-trixie-slim AS production
|
FROM node:24-trixie-slim AS production
|
||||||
@@ -139,7 +160,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.26.0 --activate
|
RUN corepack enable && corepack prepare pnpm@10.29.3 --activate
|
||||||
|
|
||||||
COPY --from=build /usr/src/app/node_modules ./node_modules
|
COPY --from=build /usr/src/app/node_modules ./node_modules
|
||||||
COPY --from=build /usr/src/app/packages ./packages
|
COPY --from=build /usr/src/app/packages ./packages
|
||||||
@@ -196,4 +217,5 @@ ENV RELEASE_CHANNEL=${RELEASE_CHANNEL}
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||||
CMD curl -f http://localhost:8080/_health || exit 1
|
CMD curl -f http://localhost:8080/_health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["pnpm", "start"]
|
# Target the fluxer_server package specifically
|
||||||
|
ENTRYPOINT ["pnpm", "--filter", "fluxer_server", "start"]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {KVAccountDeletionQueueService} from '@fluxer/api/src/infrastructure/KVAc
|
|||||||
import {initializeMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
import {initializeMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
|
||||||
import {InstanceConfigRepository} from '@fluxer/api/src/instance/InstanceConfigRepository';
|
import {InstanceConfigRepository} from '@fluxer/api/src/instance/InstanceConfigRepository';
|
||||||
import {ipBanCache} from '@fluxer/api/src/middleware/IpBanMiddleware';
|
import {ipBanCache} from '@fluxer/api/src/middleware/IpBanMiddleware';
|
||||||
import {initializeServiceSingletons} from '@fluxer/api/src/middleware/ServiceMiddleware';
|
import {initializeServiceSingletons, shutdownReportService} from '@fluxer/api/src/middleware/ServiceMiddleware';
|
||||||
import {
|
import {
|
||||||
ensureVoiceResourcesInitialized,
|
ensureVoiceResourcesInitialized,
|
||||||
getKVClient,
|
getKVClient,
|
||||||
@@ -207,6 +207,13 @@ export function createShutdown(logger: ILogger): () => Promise<void> {
|
|||||||
logger.error({error}, 'Error shutting down IP ban cache');
|
logger.error({error}, 'Error shutting down IP ban cache');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
shutdownReportService();
|
||||||
|
logger.info('Report service shut down');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({error}, 'Error shutting down report service');
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('API service shutdown complete');
|
logger.info('API service shutdown complete');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,17 @@ export class ClamAV {
|
|||||||
const socket = createConnection(this.port, this.host);
|
const socket = createConnection(this.port, this.host);
|
||||||
let response = '';
|
let response = '';
|
||||||
let isResolved = false;
|
let isResolved = false;
|
||||||
|
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024;
|
||||||
|
const CONNECT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
const connectTimeout = setTimeout(() => {
|
||||||
|
if (!isResolved) {
|
||||||
|
doReject(new Error('ClamAV connection timeout (5s)'));
|
||||||
|
}
|
||||||
|
}, CONNECT_TIMEOUT_MS);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
if (!socket.destroyed) {
|
if (!socket.destroyed) {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
}
|
}
|
||||||
@@ -64,6 +73,7 @@ export class ClamAV {
|
|||||||
};
|
};
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
try {
|
try {
|
||||||
socket.write('zINSTREAM\0');
|
socket.write('zINSTREAM\0');
|
||||||
|
|
||||||
@@ -92,6 +102,9 @@ export class ClamAV {
|
|||||||
|
|
||||||
socket.on('data', (data) => {
|
socket.on('data', (data) => {
|
||||||
response += data.toString();
|
response += data.toString();
|
||||||
|
if (response.length > MAX_RESPONSE_SIZE) {
|
||||||
|
doReject(new Error(`ClamAV response exceeded ${(MAX_RESPONSE_SIZE / 1024 / 1024).toFixed(0)} MB limit`));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('end', () => {
|
socket.on('end', () => {
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ export class GatewayService {
|
|||||||
private circuitBreakerOpenUntilMs = 0;
|
private circuitBreakerOpenUntilMs = 0;
|
||||||
private readonly CIRCUIT_BREAKER_FAILURE_THRESHOLD = 5;
|
private readonly CIRCUIT_BREAKER_FAILURE_THRESHOLD = 5;
|
||||||
private readonly CIRCUIT_BREAKER_COOLDOWN_MS = ms('10 seconds');
|
private readonly CIRCUIT_BREAKER_COOLDOWN_MS = ms('10 seconds');
|
||||||
|
private readonly PENDING_REQUEST_TIMEOUT_MS = ms('30 seconds');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.rpcClient = GatewayRpcClient.getInstance();
|
this.rpcClient = GatewayRpcClient.getInstance();
|
||||||
@@ -260,9 +261,29 @@ export class GatewayService {
|
|||||||
this.circuitBreakerOpenUntilMs = 0;
|
this.circuitBreakerOpenUntilMs = 0;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
this.rejectAllPendingRequests(new ServiceUnavailableError('Gateway circuit breaker open'));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rejectAllPendingRequests(error: Error): void {
|
||||||
|
this.pendingGuildDataRequests.forEach((requests) => {
|
||||||
|
requests.forEach((req) => req.reject(error));
|
||||||
|
});
|
||||||
|
this.pendingGuildDataRequests.clear();
|
||||||
|
|
||||||
|
this.pendingGuildMemberRequests.forEach((requests) => {
|
||||||
|
requests.forEach((req) => req.reject(error));
|
||||||
|
});
|
||||||
|
this.pendingGuildMemberRequests.clear();
|
||||||
|
|
||||||
|
this.pendingPermissionRequests.forEach((requests) => {
|
||||||
|
requests.forEach((req) => req.reject(error));
|
||||||
|
});
|
||||||
|
this.pendingPermissionRequests.clear();
|
||||||
|
|
||||||
|
this.pendingBatchRequestCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
private recordCircuitBreakerSuccess(): void {
|
private recordCircuitBreakerSuccess(): void {
|
||||||
this.circuitBreakerConsecutiveFailures = 0;
|
this.circuitBreakerConsecutiveFailures = 0;
|
||||||
}
|
}
|
||||||
@@ -626,8 +647,25 @@ export class GatewayService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timeoutId: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
|
reject(new GatewayTimeoutError());
|
||||||
|
this.removePendingRequest(this.pendingGuildDataRequests, key, wrappedResolve, wrappedReject);
|
||||||
|
}, this.PENDING_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const wrappedResolve = (value: GuildResponse) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrappedReject = (error: Error) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
const pending = this.pendingGuildDataRequests.get(key) || [];
|
const pending = this.pendingGuildDataRequests.get(key) || [];
|
||||||
pending.push({resolve, reject});
|
pending.push({resolve: wrappedResolve, reject: wrappedReject});
|
||||||
this.pendingGuildDataRequests.set(key, pending);
|
this.pendingGuildDataRequests.set(key, pending);
|
||||||
this.pendingBatchRequestCount += 1;
|
this.pendingBatchRequestCount += 1;
|
||||||
|
|
||||||
@@ -651,8 +689,25 @@ export class GatewayService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timeoutId: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
|
reject(new GatewayTimeoutError());
|
||||||
|
this.removePendingRequest(this.pendingGuildMemberRequests, key, wrappedResolve, wrappedReject);
|
||||||
|
}, this.PENDING_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const wrappedResolve = (value: {success: boolean; memberData?: GuildMemberResponse}) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrappedReject = (error: Error) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
const pending = this.pendingGuildMemberRequests.get(key) || [];
|
const pending = this.pendingGuildMemberRequests.get(key) || [];
|
||||||
pending.push({resolve, reject});
|
pending.push({resolve: wrappedResolve, reject: wrappedReject});
|
||||||
this.pendingGuildMemberRequests.set(key, pending);
|
this.pendingGuildMemberRequests.set(key, pending);
|
||||||
this.pendingBatchRequestCount += 1;
|
this.pendingBatchRequestCount += 1;
|
||||||
|
|
||||||
@@ -804,8 +859,25 @@ export class GatewayService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timeoutId: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
|
reject(new GatewayTimeoutError());
|
||||||
|
this.removePendingRequest(this.pendingPermissionRequests, key, wrappedResolve, wrappedReject);
|
||||||
|
}, this.PENDING_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const wrappedResolve = (value: boolean) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrappedReject = (error: Error) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
const pending = this.pendingPermissionRequests.get(key) || [];
|
const pending = this.pendingPermissionRequests.get(key) || [];
|
||||||
pending.push({resolve, reject});
|
pending.push({resolve: wrappedResolve, reject: wrappedReject});
|
||||||
this.pendingPermissionRequests.set(key, pending);
|
this.pendingPermissionRequests.set(key, pending);
|
||||||
this.pendingBatchRequestCount += 1;
|
this.pendingBatchRequestCount += 1;
|
||||||
|
|
||||||
@@ -817,6 +889,25 @@ export class GatewayService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private removePendingRequest<T>(
|
||||||
|
map: Map<string, Array<PendingRequest<T>>>,
|
||||||
|
key: string,
|
||||||
|
resolve: (value: T) => void,
|
||||||
|
reject: (error: Error) => void,
|
||||||
|
): void {
|
||||||
|
const pending = map.get(key);
|
||||||
|
if (pending) {
|
||||||
|
const index = pending.findIndex((r) => r.resolve === resolve || r.reject === reject);
|
||||||
|
if (index >= 0) {
|
||||||
|
pending.splice(index, 1);
|
||||||
|
this.pendingBatchRequestCount--;
|
||||||
|
if (pending.length === 0) {
|
||||||
|
map.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async canManageRoles({guildId, userId, targetUserId, roleId}: CanManageRolesParams): Promise<boolean> {
|
async canManageRoles({guildId, userId, targetUserId, roleId}: CanManageRolesParams): Promise<boolean> {
|
||||||
const result = await this.call<{can_manage: boolean}>('guild.can_manage_roles', {
|
const result = await this.call<{can_manage: boolean}>('guild.can_manage_roles', {
|
||||||
guild_id: guildId.toString(),
|
guild_id: guildId.toString(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export class SnowflakeReservationService {
|
|||||||
private initialized = false;
|
private initialized = false;
|
||||||
private reloadPromise: Promise<void> | null = null;
|
private reloadPromise: Promise<void> | null = null;
|
||||||
private kvSubscription: IKVSubscription | null = null;
|
private kvSubscription: IKVSubscription | null = null;
|
||||||
|
private messageHandler: ((channel: string) => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private repository: SnowflakeReservationRepository,
|
private repository: SnowflakeReservationRepository,
|
||||||
@@ -50,13 +51,14 @@ export class SnowflakeReservationService {
|
|||||||
this.kvSubscription = subscription;
|
this.kvSubscription = subscription;
|
||||||
await subscription.connect();
|
await subscription.connect();
|
||||||
await subscription.subscribe(SNOWFLAKE_RESERVATION_REFRESH_CHANNEL);
|
await subscription.subscribe(SNOWFLAKE_RESERVATION_REFRESH_CHANNEL);
|
||||||
subscription.on('message', (channel) => {
|
this.messageHandler = (channel: string) => {
|
||||||
if (channel === SNOWFLAKE_RESERVATION_REFRESH_CHANNEL) {
|
if (channel === SNOWFLAKE_RESERVATION_REFRESH_CHANNEL) {
|
||||||
this.reload().catch((error) => {
|
this.reload().catch((error) => {
|
||||||
Logger.error({error}, 'Failed to reload snowflake reservations');
|
Logger.error({error}, 'Failed to reload snowflake reservations');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
subscription.on('message', this.messageHandler);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error({error}, 'Failed to subscribe to snowflake reservation refresh channel');
|
Logger.error({error}, 'Failed to subscribe to snowflake reservation refresh channel');
|
||||||
}
|
}
|
||||||
@@ -99,9 +101,13 @@ export class SnowflakeReservationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
|
if (this.kvSubscription && this.messageHandler) {
|
||||||
|
this.kvSubscription.removeAllListeners('message');
|
||||||
|
}
|
||||||
if (this.kvSubscription) {
|
if (this.kvSubscription) {
|
||||||
this.kvSubscription.disconnect();
|
this.kvSubscription.disconnect();
|
||||||
this.kvSubscription = null;
|
this.kvSubscription = null;
|
||||||
}
|
}
|
||||||
|
this.messageHandler = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export class LimitConfigService {
|
|||||||
private kvSubscription: IKVSubscription | null = null;
|
private kvSubscription: IKVSubscription | null = null;
|
||||||
private subscriberInitialized = false;
|
private subscriberInitialized = false;
|
||||||
private readonly cacheKey: string;
|
private readonly cacheKey: string;
|
||||||
|
private messageHandler: ((channel: string) => void) | null = null;
|
||||||
|
|
||||||
constructor(repository: InstanceConfigRepository, cacheService: ICacheService, kvClient: IKVProvider | null = null) {
|
constructor(repository: InstanceConfigRepository, cacheService: ICacheService, kvClient: IKVProvider | null = null) {
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
@@ -144,17 +145,21 @@ export class LimitConfigService {
|
|||||||
const subscription = this.kvClient.duplicate();
|
const subscription = this.kvClient.duplicate();
|
||||||
this.kvSubscription = subscription;
|
this.kvSubscription = subscription;
|
||||||
|
|
||||||
|
this.messageHandler = (channel: string) => {
|
||||||
|
if (channel === LIMIT_CONFIG_REFRESH_CHANNEL) {
|
||||||
|
this.refreshCache().catch((err) => {
|
||||||
|
Logger.error({err}, 'Failed to refresh limit config from pubsub');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
subscription
|
subscription
|
||||||
.connect()
|
.connect()
|
||||||
.then(() => subscription.subscribe(LIMIT_CONFIG_REFRESH_CHANNEL))
|
.then(() => subscription.subscribe(LIMIT_CONFIG_REFRESH_CHANNEL))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
subscription.on('message', (channel) => {
|
if (this.messageHandler) {
|
||||||
if (channel === LIMIT_CONFIG_REFRESH_CHANNEL) {
|
subscription.on('message', this.messageHandler);
|
||||||
this.refreshCache().catch((err) => {
|
}
|
||||||
Logger.error({err}, 'Failed to refresh limit config from pubsub');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
Logger.error({error}, 'Failed to subscribe to limit config refresh channel');
|
Logger.error({error}, 'Failed to subscribe to limit config refresh channel');
|
||||||
@@ -164,12 +169,16 @@ export class LimitConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
|
if (this.kvSubscription && this.messageHandler) {
|
||||||
|
this.kvSubscription.removeAllListeners('message');
|
||||||
|
}
|
||||||
if (this.kvSubscription) {
|
if (this.kvSubscription) {
|
||||||
this.kvSubscription.quit().catch((err) => {
|
this.kvSubscription.quit().catch((err) => {
|
||||||
Logger.error({err}, 'Failed to close KV subscription');
|
Logger.error({err}, 'Failed to close KV subscription');
|
||||||
});
|
});
|
||||||
this.kvSubscription = null;
|
this.kvSubscription = null;
|
||||||
}
|
}
|
||||||
|
this.messageHandler = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class IpBanCache {
|
|||||||
private kvClient: IKVProvider | null = null;
|
private kvClient: IKVProvider | null = null;
|
||||||
private kvSubscription: IKVSubscription | null = null;
|
private kvSubscription: IKVSubscription | null = null;
|
||||||
private subscriberInitialized = false;
|
private subscriberInitialized = false;
|
||||||
|
private messageHandler: ((channel: string) => void) | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.singleIpBans = this.createFamilyMaps();
|
this.singleIpBans = this.createFamilyMaps();
|
||||||
@@ -78,23 +79,27 @@ class IpBanCache {
|
|||||||
const subscription = this.kvClient.duplicate();
|
const subscription = this.kvClient.duplicate();
|
||||||
this.kvSubscription = subscription;
|
this.kvSubscription = subscription;
|
||||||
|
|
||||||
|
this.messageHandler = (channel: string) => {
|
||||||
|
if (channel === IP_BAN_REFRESH_CHANNEL) {
|
||||||
|
this.refresh().catch((err) => {
|
||||||
|
this.consecutiveFailures++;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
||||||
|
Logger.error({error: message}, 'Failed to refresh IP ban cache after notification');
|
||||||
|
} else {
|
||||||
|
Logger.warn({error: message}, 'Failed to refresh IP ban cache after notification');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
subscription
|
subscription
|
||||||
.connect()
|
.connect()
|
||||||
.then(() => subscription.subscribe(IP_BAN_REFRESH_CHANNEL))
|
.then(() => subscription.subscribe(IP_BAN_REFRESH_CHANNEL))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
subscription.on('message', (channel) => {
|
if (this.messageHandler) {
|
||||||
if (channel === IP_BAN_REFRESH_CHANNEL) {
|
subscription.on('message', this.messageHandler);
|
||||||
this.refresh().catch((err) => {
|
}
|
||||||
this.consecutiveFailures++;
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
if (this.consecutiveFailures >= this.maxConsecutiveFailures) {
|
|
||||||
Logger.error({error: message}, 'Failed to refresh IP ban cache after notification');
|
|
||||||
} else {
|
|
||||||
Logger.warn({error: message}, 'Failed to refresh IP ban cache after notification');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
Logger.error({error}, 'Failed to subscribe to IP ban refresh channel');
|
Logger.error({error}, 'Failed to subscribe to IP ban refresh channel');
|
||||||
@@ -203,10 +208,14 @@ class IpBanCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
|
if (this.kvSubscription && this.messageHandler) {
|
||||||
|
this.kvSubscription.removeAllListeners('message');
|
||||||
|
}
|
||||||
if (this.kvSubscription) {
|
if (this.kvSubscription) {
|
||||||
this.kvSubscription.disconnect();
|
this.kvSubscription.disconnect();
|
||||||
this.kvSubscription = null;
|
this.kvSubscription = null;
|
||||||
}
|
}
|
||||||
|
this.messageHandler = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ import {createMiddleware} from 'hono/factory';
|
|||||||
|
|
||||||
const errorI18nService = new ErrorI18nService();
|
const errorI18nService = new ErrorI18nService();
|
||||||
|
|
||||||
|
let _reportService: ReportService | null = null;
|
||||||
|
|
||||||
|
export function shutdownReportService(): void {
|
||||||
|
if (_reportService) {
|
||||||
|
_reportService.shutdown();
|
||||||
|
_reportService = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _testEmailService: TestEmailService | null = null;
|
let _testEmailService: TestEmailService | null = null;
|
||||||
function getTestEmailService(): TestEmailService {
|
function getTestEmailService(): TestEmailService {
|
||||||
if (!_testEmailService) {
|
if (!_testEmailService) {
|
||||||
@@ -617,19 +626,21 @@ export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
|||||||
const desktopHandoffService = new DesktopHandoffService(cacheService);
|
const desktopHandoffService = new DesktopHandoffService(cacheService);
|
||||||
const authRequestService = new AuthRequestService(authService, ssoService, cacheService, desktopHandoffService);
|
const authRequestService = new AuthRequestService(authService, ssoService, cacheService, desktopHandoffService);
|
||||||
|
|
||||||
const reportSearchService = getReportSearchService();
|
if (!_reportService) {
|
||||||
const reportService = new ReportService(
|
_reportService = new ReportService(
|
||||||
reportRepository,
|
reportRepository,
|
||||||
channelRepository,
|
channelRepository,
|
||||||
guildRepository,
|
guildRepository,
|
||||||
userRepository,
|
userRepository,
|
||||||
inviteRepository,
|
inviteRepository,
|
||||||
emailService,
|
emailService,
|
||||||
emailDnsValidationService,
|
emailDnsValidationService,
|
||||||
snowflakeService,
|
snowflakeService,
|
||||||
storageService,
|
storageService,
|
||||||
reportSearchService,
|
getReportSearchService(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
const reportService = _reportService;
|
||||||
const reportRequestService = new ReportRequestService(reportService);
|
const reportRequestService = new ReportRequestService(reportService);
|
||||||
|
|
||||||
const adminService = new AdminService(
|
const adminService = new AdminService(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class VoiceTopology {
|
|||||||
private subscribers: Set<Subscriber> = new Set();
|
private subscribers: Set<Subscriber> = new Set();
|
||||||
private serverRotationIndex: Map<string, number> = new Map();
|
private serverRotationIndex: Map<string, number> = new Map();
|
||||||
private kvSubscription: IKVSubscription | null = null;
|
private kvSubscription: IKVSubscription | null = null;
|
||||||
|
private messageHandler: ((channel: string) => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private voiceRepository: IVoiceRepository,
|
private voiceRepository: IVoiceRepository,
|
||||||
@@ -53,13 +54,14 @@ export class VoiceTopology {
|
|||||||
this.kvSubscription = subscription;
|
this.kvSubscription = subscription;
|
||||||
await subscription.connect();
|
await subscription.connect();
|
||||||
await subscription.subscribe(VOICE_CONFIGURATION_CHANNEL);
|
await subscription.subscribe(VOICE_CONFIGURATION_CHANNEL);
|
||||||
subscription.on('message', (channel) => {
|
this.messageHandler = (channel: string) => {
|
||||||
if (channel === VOICE_CONFIGURATION_CHANNEL) {
|
if (channel === VOICE_CONFIGURATION_CHANNEL) {
|
||||||
this.reload().catch((error) => {
|
this.reload().catch((error) => {
|
||||||
Logger.error({error}, 'Failed to reload voice topology from KV notification');
|
Logger.error({error}, 'Failed to reload voice topology from KV notification');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
subscription.on('message', this.messageHandler);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error({error}, 'Failed to subscribe to voice configuration channel');
|
Logger.error({error}, 'Failed to subscribe to voice configuration channel');
|
||||||
}
|
}
|
||||||
@@ -239,9 +241,13 @@ export class VoiceTopology {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
|
if (this.kvSubscription && this.messageHandler) {
|
||||||
|
this.kvSubscription.removeAllListeners('message');
|
||||||
|
}
|
||||||
if (this.kvSubscription) {
|
if (this.kvSubscription) {
|
||||||
this.kvSubscription.disconnect();
|
this.kvSubscription.disconnect();
|
||||||
this.kvSubscription = null;
|
this.kvSubscription = null;
|
||||||
}
|
}
|
||||||
|
this.messageHandler = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export class JetStreamWorkerQueue {
|
|||||||
} catch {
|
} catch {
|
||||||
await jsm.consumers.add(STREAM_NAME, {
|
await jsm.consumers.add(STREAM_NAME, {
|
||||||
durable_name: CONSUMER_NAME,
|
durable_name: CONSUMER_NAME,
|
||||||
|
filter_subject: `${SUBJECT_PREFIX}>`,
|
||||||
ack_policy: AckPolicy.Explicit,
|
ack_policy: AckPolicy.Explicit,
|
||||||
max_deliver: MAX_DELIVER,
|
max_deliver: MAX_DELIVER,
|
||||||
ack_wait: nanos(ACK_WAIT_MS),
|
ack_wait: nanos(ACK_WAIT_MS),
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ export class NSFWDetectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.access(this.modelPath);
|
||||||
|
} catch {
|
||||||
|
this.session = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const modelBuffer = await fs.readFile(this.modelPath);
|
const modelBuffer = await fs.readFile(this.modelPath);
|
||||||
this.session = await ort.InferenceSession.create(modelBuffer);
|
this.session = await ort.InferenceSession.create(modelBuffer);
|
||||||
}
|
}
|
||||||
@@ -60,7 +66,7 @@ export class NSFWDetectionService {
|
|||||||
|
|
||||||
async checkNSFWBuffer(buffer: Buffer): Promise<NSFWCheckResult> {
|
async checkNSFWBuffer(buffer: Buffer): Promise<NSFWCheckResult> {
|
||||||
if (!this.session) {
|
if (!this.session) {
|
||||||
throw new Error('NSFW Detection service not initialized');
|
return {isNSFW: false, probability: 0};
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedImage = await this.preprocessImage(buffer);
|
const processedImage = await this.preprocessImage(buffer);
|
||||||
|
|||||||
Reference in New Issue
Block a user