mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091755fe78 | ||
|
|
1fb2790bb9 | ||
|
|
798e64b224 | ||
|
|
a2d6477b42 | ||
|
|
a2ca24eeb4 | ||
|
|
8dcd00a8fe | ||
|
|
be8a52c823 | ||
|
|
43e420b0ab | ||
|
|
f8947adf62 | ||
|
|
81fccaf0ab | ||
|
|
7a42291baf | ||
|
|
d9f983b08e | ||
|
|
2f159852a7 | ||
|
|
5ef402b8ee | ||
|
|
a8d6e5ab73 | ||
|
|
e805a3797f | ||
|
|
8f4fa82a9e | ||
|
|
d2438b2fdd | ||
|
|
133640ef2b | ||
|
|
8e0516a8c3 | ||
|
|
d784c0692e | ||
|
|
fffa265117 | ||
|
|
5367c0ab42 | ||
|
|
7f8f09ee51 | ||
|
|
a70924d4b0 | ||
|
|
1a5925f9cb | ||
|
|
43c778aae4 | ||
|
|
34cf8f821f | ||
|
|
226cfd062e | ||
|
|
e8f4e35c32 | ||
|
|
ef559f3d8c | ||
|
|
ee7206ac66 | ||
|
|
1ba9592308 | ||
|
|
d07f520b13 | ||
|
|
632f4c7b6c | ||
|
|
1f627c9cc5 | ||
|
|
cc110b9f5a | ||
|
|
f06d65db54 | ||
|
|
f8a04b8985 | ||
|
|
908e1b8bd4 | ||
|
|
f392636857 | ||
|
|
150115cc0c | ||
|
|
710a6f1c5d | ||
|
|
7c3e722085 | ||
|
|
d8f2aa3184 | ||
|
|
e828398e06 | ||
|
|
31d7cb81d6 | ||
|
|
214d19d45a | ||
|
|
d3170fc320 | ||
|
|
9a229c1b73 | ||
|
|
a036d9a2e1 | ||
|
|
d5bfa5a73d | ||
|
|
4534822355 | ||
|
|
bff29d8f07 | ||
|
|
bec34ea147 | ||
|
|
3be4171256 | ||
|
|
5bcaa7cfac | ||
|
|
ea93ef5352 | ||
|
|
8734956d86 | ||
|
|
519b3a6127 | ||
|
|
9b3773c1e6 | ||
|
|
e12b60078a | ||
|
|
7cb8f9f4ae | ||
|
|
622bd124b9 |
@@ -243,7 +243,6 @@ services:
|
||||
volume:
|
||||
nocopy: true
|
||||
- pnpm-store:/home/vscode/.local/share/pnpm/store
|
||||
- docs-venv:/workspaces/fluxer/fluxer_docs/.venv
|
||||
- cargo-registry:/home/vscode/.cargo/registry
|
||||
- cargo-git:/home/vscode/.cargo/git
|
||||
- rust-target:/workspaces/fluxer/target
|
||||
@@ -351,7 +350,6 @@ services:
|
||||
|
||||
volumes:
|
||||
pnpm-store:
|
||||
docs-venv:
|
||||
root-node-modules:
|
||||
fluxer-api-node-modules:
|
||||
fluxer-app-node-modules:
|
||||
|
||||
@@ -28,8 +28,7 @@ for path in \
|
||||
/home/vscode/.cargo/registry \
|
||||
/home/vscode/.cargo/git \
|
||||
/home/vscode/.local \
|
||||
/home/vscode/.local/share/pnpm/store \
|
||||
/workspaces/fluxer/fluxer_docs/.venv; do
|
||||
/home/vscode/.local/share/pnpm/store; do
|
||||
repair_tree "$path"
|
||||
done
|
||||
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@
|
||||
**/node_modules/
|
||||
**/target/
|
||||
**/test-results.json
|
||||
/fluxer_docs/site/
|
||||
/fluxer_docs/dist/
|
||||
/fluxer_docs/.astro/
|
||||
/fluxer_app/.devserver-cache.json
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
|
||||
@@ -33,5 +33,4 @@ jobs:
|
||||
with:
|
||||
image: fluxer-docs
|
||||
dockerfile: fluxer_docs/Dockerfile
|
||||
context: fluxer_docs
|
||||
build-version: ${{ inputs['build-version'] }}
|
||||
|
||||
@@ -460,6 +460,31 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --filter fluxer_docs...
|
||||
|
||||
- name: Verify documentation matches the live API
|
||||
run: pnpm --filter fluxer_docs verify
|
||||
|
||||
- name: Build documentation
|
||||
run: pnpm --filter fluxer_docs build
|
||||
|
||||
fonts:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
+11
@@ -115,6 +115,17 @@
|
||||
}
|
||||
},
|
||||
"assist": {"actions": {"source": {"organizeImports": "on"}}},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
|
||||
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
"assist": {"actions": {"source": {"organizeImports": "off"}}}
|
||||
}
|
||||
],
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
|
||||
Vendored
+2
-3
@@ -89,7 +89,6 @@ FLUXER_GATEWAY_PRESENCE_PUSH_BUFFER_MAX_BYTES=1048576
|
||||
FLUXER_ADMIN_PORT=3020
|
||||
FLUXER_ADMIN_BASE_PATH=/admin
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=dev-admin-secret-key-base
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_ID=1234567890123456789
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=dev-admin-oauth-secret
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
|
||||
FLUXER_MARKETING_PORT=3010
|
||||
@@ -99,8 +98,8 @@ FLUXER_MARKETING_SECRET_KEY_BASE=dev-marketing-secret-key-base
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
|
||||
FLUXER_VAPID_PUBLIC_KEY=dev-vapid-public-key
|
||||
FLUXER_VAPID_PRIVATE_KEY=dev-vapid-private-key
|
||||
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
|
||||
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
|
||||
FLUXER_VAPID_EMAIL=dev@localhost
|
||||
FLUXER_PASSKEY_RP_NAME='Fluxer Dev'
|
||||
FLUXER_PASSKEY_RP_ID=localhost
|
||||
|
||||
@@ -1,14 +1,60 @@
|
||||
# Every variable docker-compose.yml reads from this file is named here:
|
||||
# uncommented when it has no default, commented with its default when it has one.
|
||||
# A name absent from this file is one Compose does not forward, and it reaches a
|
||||
# service only through a Compose override file that adds it to that service's
|
||||
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
|
||||
# a Compose edit forgets the matching line here.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}
|
||||
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
|
||||
|
||||
# How browsers reach this instance.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
# Point DNS at this host and there is nothing else to configure.
|
||||
#
|
||||
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
|
||||
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
|
||||
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
|
||||
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
|
||||
# proxy serves, not this local port.
|
||||
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||
|
||||
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
|
||||
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
|
||||
# is on another machine, and firewall the port to that machine.
|
||||
#FLUXER_EDGE_BIND=127.0.0.1:8080
|
||||
|
||||
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
|
||||
# this to the real client address, so IP bans, rate limits and abuse detection
|
||||
# see the caller rather than the proxy. The default covers proxies on private or
|
||||
# loopback addresses, which is every same-host setup. Set it to your proxy's
|
||||
# address if it reaches Fluxer from a public IP.
|
||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||
|
||||
# The public origin browsers use, without a trailing slash. Derived from the three
|
||||
# values above and correct for the usual https-on-443 setup, so leave it alone
|
||||
# unless you serve Fluxer on a non-default port, where the port must appear here.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
|
||||
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
|
||||
# file win, so a value here is discarded under the proxy overlay with no warning.
|
||||
# Set it only for an unusual default-mode layout, such as serving several
|
||||
# hostnames or binding a non-default TLS port.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
|
||||
|
||||
# The old name for the value above. It is read only when
|
||||
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
|
||||
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
|
||||
#FLUXER_CADDY_SITE_ADDRESS=
|
||||
|
||||
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
|
||||
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
|
||||
# origin written with a default port never matches a browser Origin header.
|
||||
# Serving on any other port means setting all three, plus the published port
|
||||
# below, and pointing FLUXER_CADDY_SITE_ADDRESS at the same scheme and host.
|
||||
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
|
||||
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
|
||||
# stay below the two values it reads. Above them it silently expands to a bare
|
||||
# host with a trailing colon.
|
||||
@@ -48,6 +94,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
|
||||
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
|
||||
FLUXER_ERLANG_COOKIE=CHANGE_ME
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||
@@ -55,7 +102,10 @@ FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_EMAIL=[email protected]
|
||||
|
||||
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
|
||||
# only if that mailbox does not exist.
|
||||
#[email protected]
|
||||
|
||||
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
|
||||
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
|
||||
@@ -68,10 +118,22 @@ [email protected]
|
||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
|
||||
# only when a browser must reach an origin the defaults do not cover, such as a
|
||||
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
|
||||
# sources with spaces or commas.
|
||||
# sources with spaces or commas. Every one of them is empty by default, and the
|
||||
# three carrying a value below are illustrations, not defaults.
|
||||
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
|
||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
||||
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
||||
#FLUXER_CSP_EXTRA_MEDIA_SRC=
|
||||
#FLUXER_CSP_EXTRA_FONT_SRC=
|
||||
#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com
|
||||
#FLUXER_CSP_EXTRA_STYLE_SRC=
|
||||
#FLUXER_CSP_EXTRA_FRAME_SRC=
|
||||
#FLUXER_CSP_EXTRA_WORKER_SRC=
|
||||
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
|
||||
|
||||
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
|
||||
# directive off the header.
|
||||
#FLUXER_CSP_REPORT_URI=
|
||||
|
||||
# Allow the SSO identity provider to resolve to a private or internal address.
|
||||
# Off by default: the API refuses to call non-public addresses so a misconfigured
|
||||
@@ -80,23 +142,21 @@ [email protected]
|
||||
# identity provider, and only when you trust everyone who can configure SSO.
|
||||
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
||||
|
||||
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
|
||||
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# Ports LiveKit publishes on the host for voice and video media. They take the
|
||||
# same optional bind address as the Caddy ports above. This media does not pass
|
||||
# through Caddy or through a tunnel, so it needs these ports reachable from
|
||||
# clients. LiveKit advertises the port numbers from livekit.yaml, so publishing
|
||||
# them on different host ports means changing that file too.
|
||||
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
|
||||
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
|
||||
# Set it only when LiveKit is served from another host.
|
||||
#FLUXER_LIVEKIT_URL=
|
||||
|
||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
||||
# forward the same numbers.
|
||||
#FLUXER_LIVEKIT_TCP_PORT=7881
|
||||
#FLUXER_LIVEKIT_UDP_PORT=7882
|
||||
|
||||
# The voice server URL clients connect to. It defaults to FLUXER_PUBLIC_ORIGIN
|
||||
# plus /livekit, which the bundled Caddy proxies to the LiveKit container. Set
|
||||
# it only when LiveKit lives on its own host, and add that origin to
|
||||
# FLUXER_CSP_EXTRA_CONNECT_SRC when you do.
|
||||
#FLUXER_LIVEKIT_URL=wss://voice.example.com
|
||||
|
||||
FLUXER_KLIPY_API_KEY=
|
||||
|
||||
FLUXER_EMAIL_ENABLED=false
|
||||
@@ -112,20 +172,60 @@ FLUXER_EMAIL_SMTP_SECURE=true
|
||||
|
||||
FLUXER_CAPTCHA_ENABLED=false
|
||||
FLUXER_CAPTCHA_PROVIDER=none
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY=
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. Every service limit and reservation below has a default that
|
||||
# assumes a host with at least 16 GB of RAM. Limits are per-container ceilings, so
|
||||
# their sum may exceed host RAM; the reservations are what protect the services
|
||||
# whose death takes the whole instance down. Lower these on a smaller host.
|
||||
# Container memory. The 25 limits sum to 16.75 GiB, which is a sum of ceilings and
|
||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
||||
# The four reservations are cgroup memory.low, which biases the kernel away from
|
||||
# reclaiming from the services whose death takes the whole instance down. They do
|
||||
# not reserve anything. Lower the limits on a smaller host.
|
||||
#FLUXER_CADDY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_NATS_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
# LiveKit finds the address browsers dial by asking a STUN server. A host that
|
||||
# cannot reach one over UDP stops with "could not resolve external IP", and the
|
||||
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and turn the
|
||||
# lookup off. Point the two STUN entries at another server to keep the lookup
|
||||
# and leave Google out of it.
|
||||
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=true
|
||||
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
||||
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
|
||||
#FLUXER_LIVEKIT_STUN_SECONDARY=stun1.l.google.com:19302
|
||||
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=512mb
|
||||
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
|
||||
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
|
||||
#FLUXER_API_MEMORY_LIMIT=2560mb
|
||||
#FLUXER_API_MEMORY_RESERVATION=1gb
|
||||
#FLUXER_WORKER_MEMORY_LIMIT=2560mb
|
||||
#FLUXER_WORKER_MEMORY_RESERVATION=1gb
|
||||
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
#FLUXER_GATEWAY_MEMORY_RESERVATION=384mb
|
||||
#FLUXER_MEDIA_PROXY_MEMORY_LIMIT=512mb
|
||||
#FLUXER_STATIC_PROXY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_APP_PROXY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_SNOWFLAKES_MEMORY_LIMIT=128mb
|
||||
#FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_USERS_MEMORY_LIMIT=128mb
|
||||
#FLUXER_USERS_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_GIFS_MEMORY_LIMIT=128mb
|
||||
#FLUXER_GIFS_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MESSAGES_MEMORY_LIMIT=128mb
|
||||
#FLUXER_MESSAGES_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_UNFURL_MEMORY_LIMIT=128mb
|
||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
|
||||
# which is the container ceiling the indexer shares with the search process.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
|
||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
||||
@@ -145,13 +245,16 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
||||
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
#FLUXER_POSTGRES_WORK_MEM=8MB
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache: the bulk message
|
||||
# deletion queue, the account deletion queue and every distributed lock, none of
|
||||
# which carry an expiry. It therefore runs with an append-only file on a named
|
||||
# volume and with noeviction, so an over-limit write fails loudly instead of
|
||||
# silently deleting queued work. Only change the policy if you have moved that
|
||||
# durable state elsewhere.
|
||||
# The bundled Valkey holds durable state as well as cache. The bulk message
|
||||
# deletion queue and the account deletion queue are sorted sets with no expiry,
|
||||
# and nothing else stores the first of the two. It therefore runs with an
|
||||
# append-only file on a named volume and with noeviction, so an over-limit write
|
||||
# fails loudly instead of silently deleting queued work. Distributed locks all
|
||||
# carry a TTL and are not what the durability is for. Only change the policy if
|
||||
# you have moved that durable state elsewhere.
|
||||
#FLUXER_VALKEY_MAXMEMORY=192mb
|
||||
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
|
||||
|
||||
@@ -162,6 +265,12 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
||||
# users and messages routers and their shards. The Rust built-in defaults are 192
|
||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
# outlives the transaction, so set this to false if you put a transaction-pooling
|
||||
|
||||
@@ -1,58 +1,85 @@
|
||||
{
|
||||
servers {
|
||||
trusted_proxies static private_ranges
|
||||
trusted_proxies static {$FLUXER_EDGE_TRUSTED_PROXIES:private_ranges}
|
||||
trusted_proxies_strict
|
||||
}
|
||||
}
|
||||
|
||||
{$FLUXER_CADDY_SITE_ADDRESS} {
|
||||
{$FLUXER_EDGE_SITE_ADDRESS} {
|
||||
encode zstd gzip
|
||||
|
||||
handle /_health {
|
||||
respond "OK" 200
|
||||
}
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8080
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle /gateway {
|
||||
rewrite * /
|
||||
reverse_proxy gateway:8080
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /gateway/* {
|
||||
reverse_proxy gateway:8080
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /media/* {
|
||||
reverse_proxy media-proxy:8080
|
||||
reverse_proxy media-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /livekit/* {
|
||||
reverse_proxy livekit:7880
|
||||
reverse_proxy livekit:7880 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle /admin {
|
||||
rewrite * /
|
||||
reverse_proxy admin:8080
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle_path /admin/* {
|
||||
reverse_proxy admin:8080
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
|
||||
handle @staticAssets {
|
||||
reverse_proxy static-proxy:8080
|
||||
reverse_proxy static-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy app-proxy:8080
|
||||
reverse_proxy app-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:8088 {
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8080
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Overlay for running Fluxer behind your own reverse proxy.
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
|
||||
#
|
||||
# Or set this once in .env and keep using plain `docker compose up -d`:
|
||||
#
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||
#
|
||||
# Fluxer stops binding 80 and 443 and serves plain HTTP on one port instead.
|
||||
# That port already does all internal routing, so the proxy in front needs a
|
||||
# single rule: send everything to it. Terminate TLS there.
|
||||
services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_EDGE_BIND:-127.0.0.1:8080}:8080"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ":8080"
|
||||
@@ -44,8 +44,8 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
|
||||
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
|
||||
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?}
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
|
||||
@@ -73,6 +73,10 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_SMS_ENABLED: "false"
|
||||
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
|
||||
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
|
||||
FLUXER_STRIPE_ENABLED: "false"
|
||||
FLUXER_NCMEC_ENABLED: "false"
|
||||
FLUXER_CLAMAV_ENABLED: "false"
|
||||
@@ -114,7 +118,7 @@ x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
start_interval: 1s
|
||||
|
||||
services:
|
||||
caddy:
|
||||
edge:
|
||||
image: caddy:2.10-alpine
|
||||
deploy:
|
||||
resources:
|
||||
@@ -127,11 +131,12 @@ services:
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443"
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
|
||||
environment:
|
||||
FLUXER_CADDY_SITE_ADDRESS: ${FLUXER_CADDY_SITE_ADDRESS:?set FLUXER_CADDY_SITE_ADDRESS in .env}
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
- edge-data:/data
|
||||
- edge-config:/config
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
|
||||
interval: 10s
|
||||
@@ -160,8 +165,8 @@ services:
|
||||
-c shared_buffers=${FLUXER_POSTGRES_SHARED_BUFFERS:-512MB}
|
||||
-c effective_cache_size=${FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE:-2GB}
|
||||
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
|
||||
-c maintenance_work_mem=256MB
|
||||
-c autovacuum_work_mem=128MB
|
||||
-c maintenance_work_mem=${FLUXER_POSTGRES_MAINTENANCE_WORK_MEM:-256MB}
|
||||
-c autovacuum_work_mem=${FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM:-128MB}
|
||||
-c random_page_cost=1.1
|
||||
-c effective_io_concurrency=200
|
||||
-c default_statistics_target=200
|
||||
@@ -238,7 +243,7 @@ services:
|
||||
environment:
|
||||
MEILI_ENV: production
|
||||
MEILI_NO_ANALYTICS: "true"
|
||||
MEILI_MAX_INDEXING_MEMORY: 384mb
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
@@ -260,10 +265,11 @@ services:
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/"]
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 60s
|
||||
|
||||
seaweedfs-init:
|
||||
image: chrislusf/seaweedfs:4.34
|
||||
@@ -275,6 +281,9 @@ services:
|
||||
depends_on:
|
||||
seaweedfs: {condition: service_healthy}
|
||||
restart: "no"
|
||||
environment:
|
||||
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
@@ -292,6 +301,10 @@ services:
|
||||
echo "$$listed" | grep -q "^[[:space:]]*$$b[[:space:]]" || missing="$${missing:+$$missing }$$b";
|
||||
done;
|
||||
if [ -z "$$missing" ]; then
|
||||
if ! echo "s3.configure -user=fluxer -access_key=$$FLUXER_S3_ACCESS_KEY -secret_key=$$FLUXER_S3_SECRET_KEY -actions=Admin,Read,Write,List,Tagging -apply" | timeout 10 weed shell -master=seaweedfs:9333 >/dev/null 2>&1; then
|
||||
echo "seaweedfs-init could not configure the S3 identity" >&2;
|
||||
exit 1;
|
||||
fi;
|
||||
echo "buckets ready";
|
||||
exit 0;
|
||||
fi;
|
||||
@@ -311,14 +324,26 @@ services:
|
||||
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
command: ["--config", "/etc/livekit.yaml"]
|
||||
environment:
|
||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}"
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||
LIVEKIT_CONFIG: |
|
||||
port: 7880
|
||||
log_level: info
|
||||
rtc:
|
||||
tcp_port: ${FLUXER_LIVEKIT_TCP_PORT:-7881}
|
||||
udp_port: ${FLUXER_LIVEKIT_UDP_PORT:-7882}
|
||||
use_external_ip: ${FLUXER_LIVEKIT_USE_EXTERNAL_IP:-true}
|
||||
node_ip: "${FLUXER_LIVEKIT_NODE_IP:-}"
|
||||
stun_servers:
|
||||
- ${FLUXER_LIVEKIT_STUN_PRIMARY:-stun.l.google.com:19302}
|
||||
- ${FLUXER_LIVEKIT_STUN_SECONDARY:-stun1.l.google.com:19302}
|
||||
webhook:
|
||||
api_key: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
urls:
|
||||
- http://api:8080/webhooks/livekit
|
||||
ports:
|
||||
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:7881"
|
||||
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:7882/udp"
|
||||
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:${FLUXER_LIVEKIT_TCP_PORT:-7881}"
|
||||
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:${FLUXER_LIVEKIT_UDP_PORT:-7882}/udp"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
|
||||
interval: 10s
|
||||
@@ -372,7 +397,7 @@ services:
|
||||
reservations:
|
||||
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
|
||||
working_dir: /usr/src/app/fluxer_api
|
||||
command: ["node", "dist/WorkerEntrypoint.js"]
|
||||
command: ["sh", "-c", "if [ -f dist/WorkerEntrypoint.js ]; then exec node dist/WorkerEntrypoint.js; else exec ./node_modules/.bin/tsx src/WorkerEntrypoint.ts; fi"]
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
|
||||
@@ -411,6 +436,9 @@ services:
|
||||
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_GATEWAY_LOGGER_LEVEL: info
|
||||
FLUXER_ERLANG_COOKIE: ${FLUXER_ERLANG_COOKIE:?set FLUXER_ERLANG_COOKIE in .env}
|
||||
FLUXER_ERLANG_SCHEDULERS_MIN: "${FLUXER_ERLANG_SCHEDULERS_MIN:-2}"
|
||||
FLUXER_ERLANG_SCHEDULERS_MAX: "${FLUXER_ERLANG_SCHEDULERS_MAX:-16}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
|
||||
interval: 10s
|
||||
@@ -435,6 +463,7 @@ services:
|
||||
FLUXER_MEDIA_PROXY_MODE: upload
|
||||
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_S3_READ_SIGNED: "true"
|
||||
depends_on:
|
||||
seaweedfs-init: {condition: service_completed_successfully}
|
||||
nats: {condition: service_healthy}
|
||||
@@ -462,7 +491,7 @@ services:
|
||||
environment:
|
||||
FLUXER_APP_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
|
||||
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
|
||||
@@ -478,7 +507,7 @@ services:
|
||||
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
caddy: {condition: service_healthy}
|
||||
edge: {condition: service_healthy}
|
||||
|
||||
snowflakes:
|
||||
<<: *fluxer-service
|
||||
@@ -520,6 +549,7 @@ services:
|
||||
memory: ${FLUXER_USERS_MEMORY_LIMIT:-128mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
@@ -535,10 +565,11 @@ services:
|
||||
memory: ${FLUXER_USERS_SHARD_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -606,7 +637,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -621,6 +652,7 @@ services:
|
||||
memory: ${FLUXER_UNFURL_MEMORY_LIMIT:-128mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: unfurl
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
@@ -637,6 +669,7 @@ services:
|
||||
memory: ${FLUXER_UNFURL_SHARD_MEMORY_LIMIT:-256mb}
|
||||
environment:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: unfurl
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
|
||||
@@ -678,8 +711,8 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
edge-data:
|
||||
edge-config:
|
||||
postgres-data:
|
||||
valkey-data:
|
||||
nats-data:
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
port: 7880
|
||||
log_level: info
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
udp_port: 7882
|
||||
use_external_ip: true
|
||||
stun_servers:
|
||||
- stun.l.google.com:19302
|
||||
- stun1.l.google.com:19302
|
||||
|
||||
webhook:
|
||||
api_key: fluxer
|
||||
urls:
|
||||
- http://api:8080/webhooks/livekit
|
||||
@@ -1,4 +1,4 @@
|
||||
services:
|
||||
caddy:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
|
||||
|
||||
+7980
-8554
File diff suppressed because it is too large
Load Diff
@@ -122,6 +122,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
ARCHIVE_TRIGGER_GUILD,
|
||||
ARCHIVE_TRIGGER_USER,
|
||||
ARCHIVE_VIEW_ALL,
|
||||
ASSET_PURGE,
|
||||
AUDIT_LOG_VIEW,
|
||||
AUTHENTICATE,
|
||||
JOBS_VIEW,
|
||||
|
||||
@@ -12,7 +12,7 @@ impl AdminApiClient {
|
||||
acls: &[String],
|
||||
) -> ApiResult<CreateAdminApiKeyResponse> {
|
||||
let body = generated_types::CreateAdminApiKeyRequest {
|
||||
acls: acls.to_vec(),
|
||||
acls: parse_acls(acls)?,
|
||||
expires_in_days: None,
|
||||
name: generated_types::CreateAdminApiKeyRequestName::try_from(name)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
@@ -35,10 +35,20 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn revoke_api_key(&self, key_id: &str) -> ApiResult<()> {
|
||||
let key_id = generated_types::SnowflakeType::from(key_id.to_owned());
|
||||
self.generated()
|
||||
.delete_admin_api_key(key_id)
|
||||
.delete_admin_api_key(&key_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_acls(acls: &[String]) -> ApiResult<Vec<generated_types::AdminAclType>> {
|
||||
acls.iter()
|
||||
.map(|acl| {
|
||||
generated_types::AdminAclType::try_from(acl.as_str())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -4,35 +4,36 @@ use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LookupApplicationRequest<'a> {
|
||||
application_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ListUserApplicationsRequest<'a> {
|
||||
user_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TransferApplicationOwnershipRequest<'a> {
|
||||
application_id: &'a str,
|
||||
new_owner_id: &'a str,
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn lookup_application(&self, application_id: &str) -> ApiResult<Option<Application>> {
|
||||
let body = LookupApplicationRequest { application_id };
|
||||
let resp: LookupApplicationResponse =
|
||||
self.post_typed("/admin/applications/lookup", &body).await?;
|
||||
let resp: LookupApplicationResponse = self
|
||||
.get(
|
||||
&format!(
|
||||
"/admin/applications/{}",
|
||||
urlencoding::encode(application_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.application)
|
||||
}
|
||||
|
||||
pub async fn list_user_applications(&self, user_id: &str) -> ApiResult<Vec<Application>> {
|
||||
let body = ListUserApplicationsRequest { user_id };
|
||||
let resp: super::types::ListUserApplicationsResponse = self
|
||||
.post_typed("/admin/applications/list-by-owner", &body)
|
||||
.await?;
|
||||
let query_params = [("owner_id", user_id)];
|
||||
let resp: super::types::ListUserApplicationsResponse =
|
||||
self.get("/admin/applications", Some(&query_params)).await?;
|
||||
Ok(resp.applications)
|
||||
}
|
||||
|
||||
pub async fn list_guild_applications(&self, guild_id: &str) -> ApiResult<Vec<Application>> {
|
||||
let query_params = [("guild_id", guild_id)];
|
||||
let resp: super::types::ListUserApplicationsResponse =
|
||||
self.get("/admin/applications", Some(&query_params)).await?;
|
||||
Ok(resp.applications)
|
||||
}
|
||||
|
||||
@@ -41,11 +42,15 @@ impl AdminApiClient {
|
||||
application_id: &str,
|
||||
new_owner_id: &str,
|
||||
) -> ApiResult<ApplicationUpdateResponse> {
|
||||
let body = TransferApplicationOwnershipRequest {
|
||||
application_id,
|
||||
new_owner_id,
|
||||
};
|
||||
self.post_typed("/admin/applications/transfer-ownership", &body)
|
||||
.await
|
||||
let body = TransferApplicationOwnershipRequest { new_owner_id };
|
||||
self.patch_typed_with_reason(
|
||||
&format!(
|
||||
"/admin/applications/{}",
|
||||
urlencoding::encode(application_id)
|
||||
),
|
||||
&body,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::TriggerUserArchiveRequest {
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.trigger_user_archive(&body)
|
||||
.create_admin_user_archive(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -28,13 +27,12 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
include_attachments: bool,
|
||||
) -> ApiResult<Archive> {
|
||||
let body = generated_types::TriggerGuildArchiveRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
let body = generated_types::AdminArchiveCreateRequest {
|
||||
include_attachments: include_attachments.then_some(true),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.trigger_guild_archive(&body)
|
||||
.create_admin_guild_archive(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -47,22 +45,31 @@ impl AdminApiClient {
|
||||
include_expired: bool,
|
||||
requested_by: Option<&str>,
|
||||
) -> ApiResult<ListArchivesResponse> {
|
||||
let body = generated_types::ListArchivesRequest {
|
||||
include_expired: Some(include_expired),
|
||||
limit: None,
|
||||
requested_by: requested_by.map(snowflake),
|
||||
subject_id: subject_id.map(snowflake),
|
||||
subject_type: Some(
|
||||
generated_types::ListArchivesRequestSubjectType::try_from(subject_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
),
|
||||
let subject_id = subject_id.filter(|id| !id.is_empty());
|
||||
let search_every_subject_type = subject_type == "all" && subject_id.is_some();
|
||||
let subject_types: &[&str] = if search_every_subject_type {
|
||||
&["user", "guild"]
|
||||
} else {
|
||||
std::slice::from_ref(&subject_type)
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_archives(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let mut archives = Vec::new();
|
||||
for &subject_type in subject_types {
|
||||
let query_params = [
|
||||
("subject_type", subject_type),
|
||||
("subject_id", subject_id.unwrap_or_default()),
|
||||
("requested_by", requested_by.unwrap_or_default()),
|
||||
(
|
||||
"include_expired",
|
||||
if include_expired { "true" } else { "false" },
|
||||
),
|
||||
];
|
||||
match self.get("/admin/archives", Some(&query_params)).await {
|
||||
Ok(ListArchivesResponse { archives: page }) => archives.extend(page),
|
||||
Err(ApiError::Http { status: 403, .. }) if search_every_subject_type => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(ListArchivesResponse { archives })
|
||||
}
|
||||
|
||||
pub async fn get_archive_download_url(
|
||||
@@ -73,7 +80,7 @@ impl AdminApiClient {
|
||||
) -> ApiResult<ArchiveDownloadUrlResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_archive_download_url(subject_type, subject_id, archive_id)
|
||||
.get_admin_archive_download(subject_type, subject_id, archive_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -5,11 +5,18 @@ use crate::api::generated::types as generated_types;
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn purge_assets(&self, ids: &[String]) -> ApiResult<serde_json::Value> {
|
||||
pub async fn purge_assets(
|
||||
&self,
|
||||
guild_id: &str,
|
||||
ids: &[String],
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
let body = generated_types::PurgeGuildAssetsRequest { ids: ids.to_vec() };
|
||||
let response = self
|
||||
.generated()
|
||||
.purge_guild_assets(&body)
|
||||
.purge_admin_guild_assets(
|
||||
&generated_types::SnowflakeType::from(guild_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -23,26 +23,47 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &SearchAuditLogsParams,
|
||||
) -> ApiResult<AuditLogsListResponse> {
|
||||
let body = generated_types::SearchAuditLogsRequest {
|
||||
admin_user_id: nonempty_string(params.admin_user_id.as_deref())
|
||||
.map(generated_types::SnowflakeType::from),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(params.limit, "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
let sort_by = params
|
||||
.sort_by
|
||||
.as_deref()
|
||||
.map(audit_sort_by)
|
||||
.transpose()?
|
||||
.map(|value| value.to_string());
|
||||
let sort_order = params
|
||||
.sort_order
|
||||
.as_deref()
|
||||
.map(audit_sort_order)
|
||||
.transpose()?
|
||||
.map(|value| value.to_string());
|
||||
let limit = params.limit.to_string();
|
||||
let offset = params.offset.to_string();
|
||||
let query_params = [
|
||||
(
|
||||
"q",
|
||||
nonempty_string(params.query.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
offset: Some(i64::from(params.offset)),
|
||||
query: nonempty_string(params.query.as_deref()),
|
||||
sort_by: params.sort_by.as_deref().map(audit_sort_by).transpose()?,
|
||||
sort_order: params
|
||||
.sort_order
|
||||
.as_deref()
|
||||
.map(audit_sort_order)
|
||||
.transpose()?,
|
||||
target_id: nonempty_string(params.target_id.as_deref()),
|
||||
target_type: nonempty_string(params.target_type.as_deref()),
|
||||
};
|
||||
let body = serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
self.post("/admin/audit-logs/search", Some(&body)).await
|
||||
(
|
||||
"admin_user_id",
|
||||
nonempty_string(params.admin_user_id.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_type",
|
||||
nonempty_string(params.target_type.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"target_id",
|
||||
nonempty_string(params.target_id.as_deref()).unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by.unwrap_or_default()),
|
||||
("sort_order", sort_order.unwrap_or_default()),
|
||||
("limit", limit),
|
||||
("offset", offset),
|
||||
];
|
||||
let query_params: Vec<(&str, &str)> = query_params
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect();
|
||||
self.get("/admin/audit-logs", Some(&query_params)).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +79,7 @@ fn audit_logs_response(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem) -> AuditLogEntry {
|
||||
fn audit_log_entry(entry: generated_types::AdminAuditLogResponseSchema) -> AuditLogEntry {
|
||||
AuditLogEntry {
|
||||
log_id: String::from(entry.log_id),
|
||||
admin_user_id: String::from(entry.admin_user_id),
|
||||
@@ -78,17 +99,17 @@ fn audit_log_entry(entry: generated_types::AuditLogsListResponseSchemaLogsItem)
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::SearchAuditLogsRequestSortBy> {
|
||||
fn audit_sort_by(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortBy> {
|
||||
let value = match value {
|
||||
"created_at" => "createdAt",
|
||||
value => value,
|
||||
};
|
||||
generated_types::SearchAuditLogsRequestSortBy::try_from(value)
|
||||
generated_types::ListAdminAuditLogsSortBy::try_from(value)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn audit_sort_order(value: &str) -> ApiResult<generated_types::SearchAuditLogsRequestSortOrder> {
|
||||
generated_types::SearchAuditLogsRequestSortOrder::try_from(value)
|
||||
fn audit_sort_order(value: &str) -> ApiResult<generated_types::ListAdminAuditLogsSortOrder> {
|
||||
generated_types::ListAdminAuditLogsSortOrder::try_from(value)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
+176
-216
@@ -7,209 +7,123 @@ use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
};
|
||||
self.generated()
|
||||
.add_email_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"email",
|
||||
generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_email(&self, email: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
};
|
||||
self.generated()
|
||||
.remove_email_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn check_email_ban(&self, email: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanEmailRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_email_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_ip(&self, ip: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
|
||||
self.generated()
|
||||
.add_ip_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() }.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_ip(&self, ip: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
|
||||
self.generated()
|
||||
.remove_ip_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("ip", ip, None).await
|
||||
}
|
||||
|
||||
pub async fn check_ip_ban(&self, ip: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanIpRequest { ip: ip.to_owned() };
|
||||
let response = self
|
||||
.generated()
|
||||
.check_ip_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("ip", ip, None).await
|
||||
}
|
||||
|
||||
pub async fn add_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
let body = suspicious_email_domain_request(domain)?;
|
||||
self.generated()
|
||||
.add_suspicious_email_domain(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
SUSPICIOUS_EMAIL_DOMAIN_LIST,
|
||||
suspicious_email_domain_request(domain)?.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_suspicious_email_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
let body = suspicious_email_domain_request(domain)?;
|
||||
self.generated()
|
||||
.remove_suspicious_email_domain(&body)
|
||||
self.delete_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_suspicious_email_domain(&self, domain: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = suspicious_email_domain_request(domain)?;
|
||||
let response = self
|
||||
.generated()
|
||||
.check_suspicious_email_domain(&body)
|
||||
self.check_blocklist_entry(SUSPICIOUS_EMAIL_DOMAIN_LIST, domain, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.add_phrase_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"phrase",
|
||||
generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_phrase(&self, phrase: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.remove_phrase_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("phrase", phrase, None).await
|
||||
}
|
||||
|
||||
pub async fn check_phrase_ban(&self, phrase: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanPhraseRequest {
|
||||
phrase: phrase.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_phrase_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("phrase", phrase, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_url(&self, url: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.add_url_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"url",
|
||||
generated_types::BanUrlRequest {
|
||||
category: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
url: url.to_owned(),
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_url(&self, url: &str) -> ApiResult<()> {
|
||||
let body = generated_types::UnbanUrlRequest {
|
||||
url: url.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.remove_url_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.delete_blocklist_entry("url", url, None).await
|
||||
}
|
||||
|
||||
pub async fn check_url_ban(&self, url: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::CheckUrlBlocklistRequest {
|
||||
url: url.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_url_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("url", url, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_url_domain(&self, domain: &str, match_subdomains: bool) -> ApiResult<()> {
|
||||
let body = generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
};
|
||||
self.generated()
|
||||
.add_url_domain_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"url-domain",
|
||||
generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_url_domain(&self, domain: &str) -> ApiResult<()> {
|
||||
let body = generated_types::UnbanUrlDomainRequest {
|
||||
domain: domain.to_owned(),
|
||||
};
|
||||
self.generated()
|
||||
.remove_url_domain_ban(&body)
|
||||
self.delete_blocklist_entry("url-domain", domain, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_url_domain_ban(&self, domain: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::BanUrlDomainRequest {
|
||||
category: None,
|
||||
domain: domain.to_owned(),
|
||||
match_subdomains: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_url_domain_ban_status(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
self.check_blocklist_entry("url-domain", domain, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_file_sha(
|
||||
@@ -217,16 +131,22 @@ impl AdminApiClient {
|
||||
sha256_hex: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
};
|
||||
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/add", &body, audit_log_reason)
|
||||
.await
|
||||
let body = generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanFileShaRequest {
|
||||
category: None,
|
||||
content_type: None,
|
||||
notes: None,
|
||||
severity: None,
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
source_url: None,
|
||||
},
|
||||
);
|
||||
self.post_void_with_reason(
|
||||
"/admin/blocklists/file-sha/entries",
|
||||
Some(&serde_json::to_value(&body).map_err(|e| ApiError::Parse(e.to_string()))?),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_file_sha(
|
||||
@@ -234,23 +154,17 @@ impl AdminApiClient {
|
||||
sha256_hex: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::UnbanFileShaRequest {
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
};
|
||||
self.post_typed_with_reason::<(), _>("/admin/bans/file-sha/remove", &body, audit_log_reason)
|
||||
.await
|
||||
self.delete_void_with_reason(
|
||||
&blocklist_entry_path("file-sha", sha256_hex),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn check_file_sha_ban(&self, sha256_hex: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::CheckFileShaRequest {
|
||||
sha256_hex: sha256_hex.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_file_sha_ban_status(&body)
|
||||
self.check_blocklist_entry("file-sha", sha256_hex, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn bulk_ban_file_shas(
|
||||
@@ -261,54 +175,45 @@ impl AdminApiClient {
|
||||
let body = generated_types::BulkBanFileShasRequest {
|
||||
sha256_list: sha256_list.to_vec(),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bans/file-sha/bulk-add", &body, audit_log_reason)
|
||||
.await
|
||||
self.put_typed_with_reason(
|
||||
"/admin/blocklists/file-sha/entries",
|
||||
&body,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
};
|
||||
self.generated()
|
||||
.add_avatar_hash_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
"avatar-hash",
|
||||
generated_types::BanAvatarHashRequest {
|
||||
category: None,
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
notes: None,
|
||||
reason: None,
|
||||
severity: None,
|
||||
source_url: None,
|
||||
}
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_avatar_hash(&self, hash_short: &str) -> ApiResult<()> {
|
||||
let body = generated_types::CheckAvatarHashRequest {
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
};
|
||||
self.generated()
|
||||
.remove_avatar_hash_ban(&body)
|
||||
self.delete_blocklist_entry("avatar-hash", hash_short, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_avatar_hash_ban(&self, hash_short: &str) -> ApiResult<BanCheckResult> {
|
||||
let body = generated_types::CheckAvatarHashRequest {
|
||||
hashes: vec![hash_short.to_owned()],
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.check_avatar_hash_ban_status(&body)
|
||||
self.check_blocklist_entry("avatar-hash", hash_short, None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
|
||||
let body = generated_types::BanUserAvatarRequest::default();
|
||||
let response = self
|
||||
.generated()
|
||||
.ban_user_avatar(
|
||||
.ban_admin_user_avatar(
|
||||
&generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
&body,
|
||||
)
|
||||
@@ -318,21 +223,16 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn ban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
||||
let body = profile_substring_request(scope, substring)?;
|
||||
self.generated()
|
||||
.add_profile_substring_ban(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
self.create_blocklist_entry(
|
||||
PROFILE_SUBSTRING_LIST,
|
||||
profile_substring_request(scope, substring)?.into(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn unban_profile_substring(&self, scope: &str, substring: &str) -> ApiResult<()> {
|
||||
let body = profile_substring_request(scope, substring)?;
|
||||
self.generated()
|
||||
.remove_profile_substring_ban(&body)
|
||||
self.delete_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn check_profile_substring_ban(
|
||||
@@ -340,16 +240,76 @@ impl AdminApiClient {
|
||||
scope: &str,
|
||||
substring: &str,
|
||||
) -> ApiResult<BanCheckResult> {
|
||||
let body = profile_substring_request(scope, substring)?;
|
||||
self.check_blocklist_entry(PROFILE_SUBSTRING_LIST, substring, Some(scope))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_blocklist_entry(
|
||||
&self,
|
||||
list_type: &str,
|
||||
body: generated_types::AdminBlocklistEntryCreateRequest,
|
||||
) -> ApiResult<()> {
|
||||
self.generated()
|
||||
.create_admin_blocklist_entry(list_type, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_blocklist_entry(
|
||||
&self,
|
||||
list_type: &str,
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let scope = scope.map(blocklist_delete_scope).transpose()?;
|
||||
self.generated()
|
||||
.delete_admin_blocklist_entry(list_type, entry_value, scope)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_blocklist_entry(
|
||||
&self,
|
||||
list_type: &str,
|
||||
entry_value: &str,
|
||||
scope: Option<&str>,
|
||||
) -> ApiResult<BanCheckResult> {
|
||||
let scope = scope.map(blocklist_get_scope).transpose()?;
|
||||
let response = self
|
||||
.generated()
|
||||
.check_profile_substring_ban_status(&body)
|
||||
.get_admin_blocklist_entry(list_type, entry_value, scope)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
const SUSPICIOUS_EMAIL_DOMAIN_LIST: &str = "email-domain-suspicious";
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
fn blocklist_entry_path(list_type: &str, entry_value: &str) -> String {
|
||||
format!(
|
||||
"/admin/blocklists/{}/entries/{}",
|
||||
urlencoding::encode(list_type),
|
||||
urlencoding::encode(entry_value)
|
||||
)
|
||||
}
|
||||
|
||||
fn blocklist_get_scope(scope: &str) -> ApiResult<generated_types::GetAdminBlocklistEntryScope> {
|
||||
generated_types::GetAdminBlocklistEntryScope::try_from(scope)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn blocklist_delete_scope(
|
||||
scope: &str,
|
||||
) -> ApiResult<generated_types::DeleteAdminBlocklistEntryScope> {
|
||||
generated_types::DeleteAdminBlocklistEntryScope::try_from(scope)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn suspicious_email_domain_request(
|
||||
domain: &str,
|
||||
) -> ApiResult<generated_types::SuspiciousEmailDomainRequest> {
|
||||
|
||||
@@ -13,12 +13,16 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateUserFlagsRequest {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/update-user-flags", &body, audit_log_reason)
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequest {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
task:
|
||||
generated_types::UpdateUserFlagsAdminBulkJobCreateRequestTask::UpdateUserFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -29,17 +33,16 @@ impl AdminApiClient {
|
||||
remove_flags: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateSuspiciousActivityFlagsRequest {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason(
|
||||
"/admin/bulk/update-suspicious-activity-flags",
|
||||
&body,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequest {
|
||||
add_flags: add_flags.to_vec(),
|
||||
remove_flags: remove_flags.to_vec(),
|
||||
task: generated_types::UpdateSuspiciousActivityFlagsAdminBulkJobCreateRequestTask::UpdateSuspiciousActivityFlags,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_update_guild_features(
|
||||
@@ -49,12 +52,15 @@ impl AdminApiClient {
|
||||
remove_features: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkUpdateGuildFeaturesRequest {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/update-guild-features", &body, audit_log_reason)
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequest {
|
||||
add_features: guild_features(add_features),
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
task: generated_types::UpdateGuildFeaturesAdminBulkJobCreateRequestTask::UpdateGuildFeatures,
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -64,11 +70,15 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkAddGuildMembersRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/add-guild-members", &body, audit_log_reason)
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
task:
|
||||
generated_types::AddGuildMembersAdminBulkJobCreateRequestTask::AddGuildMembers,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -77,10 +87,14 @@ impl AdminApiClient {
|
||||
user_ids: &[String],
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkDeleteUserMessagesRequest {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk/delete-user-messages", &body, audit_log_reason)
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequest {
|
||||
task:
|
||||
generated_types::DeleteUserMessagesAdminBulkJobCreateRequestTask::DeleteUserMessages,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -92,21 +106,24 @@ impl AdminApiClient {
|
||||
public_reason: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<BulkJobResponse> {
|
||||
let body = generated_types::BulkScheduleUserDeletionRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason(
|
||||
"/admin/bulk/schedule-user-deletion",
|
||||
&body,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
let body = generated_types::AdminBulkJobCreateRequest::from(
|
||||
generated_types::ScheduleUserDeletionAdminBulkJobCreateRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code: crate::api::generated::deletion_reason_code(
|
||||
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
"reason_code",
|
||||
)
|
||||
.map_err(ApiError::Parse)?,
|
||||
task: generated_types::ScheduleUserDeletionAdminBulkJobCreateRequestTask::ScheduleUserDeletion,
|
||||
user_ids: snowflakes(user_ids),
|
||||
},
|
||||
);
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,17 +188,105 @@ impl AdminApiClient {
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
let builder = Self::with_json_body(self.request(Method::PATCH, path, None), body);
|
||||
let response = Self::send_request(builder).await?;
|
||||
self.patch_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn patch_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
let builder = Self::with_json_body(self.request(Method::DELETE, path, None), body);
|
||||
let response = Self::send_request(builder).await?;
|
||||
pub async fn patch_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PATCH, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(builder.json(body)).await?;
|
||||
self.parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::PUT, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn delete_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let builder =
|
||||
Self::with_audit_log_reason(self.request(Method::DELETE, path, None), audit_log_reason);
|
||||
let response = Self::send_request(Self::with_json_body(builder, body)).await?;
|
||||
self.parse_response(response).await
|
||||
}
|
||||
|
||||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
|
||||
@@ -26,7 +26,7 @@ impl AdminApiClient {
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.generate_gift_codes(&body)
|
||||
.create_admin_gift_codes(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -13,7 +13,7 @@ impl AdminApiClient {
|
||||
) -> ApiResult<Vec<DiscoveryPendingApplication>> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_pending_discovery_applications()
|
||||
.list_admin_discovery_applications()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
@@ -26,7 +26,7 @@ impl AdminApiClient {
|
||||
pub async fn list_discovery_listed_guilds(&self) -> ApiResult<Vec<DiscoveryListedGuild>> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_discovery_listed_guilds()
|
||||
.list_admin_discovery_listings()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
response
|
||||
@@ -42,15 +42,18 @@ impl AdminApiClient {
|
||||
reason: Option<&str>,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminReviewRequest {
|
||||
reason: reason
|
||||
.map(generated_types::DiscoveryAdminReviewRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::ApprovedDiscoveryAdminApplicationUpdateRequest {
|
||||
reason: reason
|
||||
.map(generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestReason::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status: generated_types::ApprovedDiscoveryAdminApplicationUpdateRequestStatus::Approved,
|
||||
},
|
||||
);
|
||||
let response = self
|
||||
.generated()
|
||||
.approve_discovery_application(&guild_id, &body)
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -62,13 +65,20 @@ impl AdminApiClient {
|
||||
reason: &str,
|
||||
) -> ApiResult<DiscoveryApplicationResponse> {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let body = generated_types::DiscoveryAdminRejectRequest {
|
||||
reason: generated_types::DiscoveryAdminRejectRequestReason::try_from(reason)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let body = generated_types::DiscoveryAdminApplicationUpdateRequest::from(
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequest {
|
||||
reason:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestReason::try_from(
|
||||
reason,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status:
|
||||
generated_types::RejectedDiscoveryAdminApplicationUpdateRequestStatus::Rejected,
|
||||
},
|
||||
);
|
||||
let response = self
|
||||
.generated()
|
||||
.reject_discovery_application(&guild_id, &body)
|
||||
.update_admin_discovery_application(&guild_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -86,7 +96,7 @@ impl AdminApiClient {
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.remove_from_discovery(&guild_id, &body)
|
||||
.delete_admin_discovery_listing(&guild_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -32,6 +32,14 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
|
||||
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
|
||||
}
|
||||
|
||||
pub(crate) fn deletion_reason_code(
|
||||
value: i32,
|
||||
field: &str,
|
||||
) -> Result<types::DeletionReasonCode, String> {
|
||||
types::DeletionReasonCode::try_from(value)
|
||||
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{number_to_u64, types::*};
|
||||
|
||||
@@ -10,7 +10,7 @@ impl AdminApiClient {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_list_guild_emojis(&guild_id)
|
||||
.list_admin_guild_emojis(&guild_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -23,7 +23,7 @@ impl AdminApiClient {
|
||||
let guild_id = generated_types::SnowflakeType::from(guild_id.to_owned());
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_list_guild_stickers(&guild_id)
|
||||
.list_admin_guild_stickers(&guild_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
+77
-134
@@ -17,28 +17,20 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchGuildsResponse> {
|
||||
let body = generated_types::SearchGuildsRequest {
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: Some(query.to_owned()),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.search_guilds(&body)
|
||||
.list_admin_guilds(Some(limit.as_str()), Some(offset.as_str()), Some(query))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
search_guilds_response(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_guild_by_id(&self, guild_id: &str) -> ApiResult<GuildInfo> {
|
||||
let body = generated_types::LookupGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_guild(&body)
|
||||
.get_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -51,12 +43,9 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn lookup_guild(&self, guild_id: &str) -> ApiResult<Option<GuildDetailInfo>> {
|
||||
let body = generated_types::LookupGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_guild(&body)
|
||||
.get_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupGuildResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -69,26 +58,23 @@ impl AdminApiClient {
|
||||
add_features: &[String],
|
||||
remove_features: &[String],
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::UpdateGuildFeaturesRequest {
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
add_features: guild_features(add_features),
|
||||
guild_id: snowflake(guild_id),
|
||||
remove_features: guild_features(remove_features),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_features(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::DeleteGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_delete_guild(&body)
|
||||
.delete_admin_guild(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -99,13 +85,13 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
new_owner_id: &str,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::TransferGuildOwnershipRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
new_owner_id: snowflake(new_owner_id),
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
new_owner_id: Some(snowflake(new_owner_id)),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_transfer_guild_ownership(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -117,44 +103,32 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<ListGuildMembersResponse> {
|
||||
let body = generated_types::ListGuildMembersRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_list_guild_members(&body)
|
||||
.list_admin_guild_members(
|
||||
&snowflake(guild_id),
|
||||
Some(limit.as_str()),
|
||||
Some(offset.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::BanGuildMemberRequest {
|
||||
ban_duration_seconds: None,
|
||||
delete_message_days: None,
|
||||
delete_message_seconds: None,
|
||||
guild_id: snowflake(guild_id),
|
||||
reason: None,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::BanGuildMemberBody::default();
|
||||
self.generated()
|
||||
.admin_ban_guild_member(&body)
|
||||
.ban_admin_guild_member(&snowflake(guild_id), &snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kick_guild_member(&self, guild_id: &str, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::KickGuildMemberRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.kick_guild_member(&body)
|
||||
.kick_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -166,21 +140,22 @@ impl AdminApiClient {
|
||||
limit: Option<u32>,
|
||||
before: Option<&str>,
|
||||
) -> ApiResult<GuildAuditLogResponse> {
|
||||
let body = generated_types::ListGuildAuditLogsRequest {
|
||||
action_type: None,
|
||||
after: None,
|
||||
before: before.map(snowflake),
|
||||
guild_id: snowflake(guild_id),
|
||||
limit: limit
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.map(generated_types::Int32Type::from),
|
||||
user_id: None,
|
||||
};
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit
|
||||
.map(i32::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.map(generated_types::Int32Type::from);
|
||||
let response = self
|
||||
.generated()
|
||||
.list_guild_audit_logs_admin(&body)
|
||||
.list_admin_guild_audit_logs(
|
||||
&snowflake(guild_id),
|
||||
None,
|
||||
None,
|
||||
before.as_ref(),
|
||||
limit.as_ref(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -189,15 +164,15 @@ impl AdminApiClient {
|
||||
pub async fn clear_guild_fields(&self, guild_id: &str, fields: &[String]) -> ApiResult<()> {
|
||||
let fields = fields
|
||||
.iter()
|
||||
.map(generated_types::ClearGuildFieldsRequestFieldsItem::try_from)
|
||||
.map(|field| generated_types::UpdateGuildRequestFieldsItem::try_from(field.as_str()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::ClearGuildFieldsRequest {
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
fields,
|
||||
guild_id: snowflake(guild_id),
|
||||
..Default::default()
|
||||
};
|
||||
self.generated()
|
||||
.clear_guild_fields(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -208,10 +183,10 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
settings: &serde_json::Value,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = guild_settings_request(guild_id, settings)?;
|
||||
let body = guild_settings_request(settings)?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_settings(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
guild_update_response(response.into_inner())
|
||||
@@ -222,13 +197,13 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
name: &str,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::UpdateGuildNameRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
name: name.to_owned(),
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
name: Some(name.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_name(&body)
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -239,37 +214,27 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
vanity: Option<&str>,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let body = generated_types::UpdateGuildVanityRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
vanity_url_code: vanity.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_guild_vanity(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let body = serde_json::json!({"vanity_url_code": vanity});
|
||||
self.patch(
|
||||
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
|
||||
Some(&body),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reload_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::ReloadGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.reload_guild(&body)
|
||||
.create_admin_guild_reload(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn shutdown_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::ShutdownGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.shutdown_guild(&body)
|
||||
.create_admin_guild_shutdown(&snowflake(guild_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -280,13 +245,9 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
guild_id: &str,
|
||||
) -> ApiResult<SuccessResponse> {
|
||||
let body = generated_types::ForceAddUserToGuildRequest {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.force_add_user_to_guild(&body)
|
||||
.add_admin_guild_member(&snowflake(guild_id), &snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -298,39 +259,23 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
let body = generated_types::SearchReportsRequest {
|
||||
category: None,
|
||||
guild_context_id: None,
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: None,
|
||||
report_type: None,
|
||||
reported_channel_id: None,
|
||||
reported_guild_id: Some(snowflake(guild_id)),
|
||||
reported_user_id: None,
|
||||
reporter_id: None,
|
||||
resolved_by_admin_id: None,
|
||||
sort_by: None,
|
||||
sort_order: None,
|
||||
status: None,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.search_reports(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let response = response.into_inner();
|
||||
Ok(SearchReportsResponse {
|
||||
reports: self.generated_value(response.reports)?,
|
||||
total: crate::api::generated::number_to_u64(response.total, "total")
|
||||
.map_err(ApiError::Parse)?,
|
||||
offset: crate::api::generated::number_to_u64(response.offset, "offset")
|
||||
.map_err(ApiError::Parse)?,
|
||||
limit: crate::api::generated::number_to_u64(response.limit, "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
})
|
||||
self.search_reports(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(guild_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,22 +362,21 @@ fn guild_update_response(
|
||||
}
|
||||
|
||||
fn guild_settings_request(
|
||||
guild_id: &str,
|
||||
settings: &serde_json::Value,
|
||||
) -> ApiResult<generated_types::UpdateGuildSettingsRequest> {
|
||||
) -> ApiResult<generated_types::UpdateGuildRequest> {
|
||||
let patch = serde_json::from_value::<GuildSettingsPatch>(settings.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
Ok(generated_types::UpdateGuildSettingsRequest {
|
||||
Ok(generated_types::UpdateGuildRequest {
|
||||
content_warning_level: patch.content_warning_level,
|
||||
content_warning_text: patch.content_warning_text,
|
||||
default_message_notifications: patch.default_message_notifications,
|
||||
disabled_operations: patch.disabled_operations,
|
||||
explicit_content_filter: patch.explicit_content_filter,
|
||||
guild_id: snowflake(guild_id),
|
||||
mfa_level: patch.mfa_level,
|
||||
nsfw: patch.nsfw,
|
||||
nsfw_level: patch.nsfw_level,
|
||||
verification_level: patch.verification_level,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -459,9 +403,8 @@ mod tests {
|
||||
"nsfw": true,
|
||||
"verification_level": 2,
|
||||
});
|
||||
let request = guild_settings_request("123", &settings).unwrap();
|
||||
let request = guild_settings_request(&settings).unwrap();
|
||||
let json = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(json["guild_id"], "123");
|
||||
assert_eq!(json["disabled_operations"], 5);
|
||||
assert_eq!(json["nsfw"], true);
|
||||
assert_eq!(json["verification_level"], 2);
|
||||
|
||||
@@ -4,19 +4,18 @@ use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
|
||||
PendingRegistrationActionRequest, RegistrationUrlActionRequest,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn get_instance_config(&self) -> ApiResult<InstanceConfigResponse> {
|
||||
self.post("/admin/instance-config/get", None).await
|
||||
self.get("/admin/instance/config", None).await
|
||||
}
|
||||
|
||||
pub async fn update_instance_config(
|
||||
&self,
|
||||
update: &InstanceConfigUpdateRequest,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
self.post_typed("/admin/instance-config/update", update)
|
||||
self.patch_typed_with_reason("/admin/instance/config", update, None)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -24,7 +23,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
request: &InstanceEmailSmtpTestRequest,
|
||||
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
||||
self.post_typed("/admin/instance-config/integrations/smtp/test", request)
|
||||
self.post_typed("/admin/instance/config/smtp-tests", request)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -32,40 +31,49 @@ impl AdminApiClient {
|
||||
&self,
|
||||
request: &CreateRegistrationUrlRequest,
|
||||
) -> ApiResult<CreateRegistrationUrlResponse> {
|
||||
self.post_typed("/admin/instance-config/registration-urls/create", request)
|
||||
self.post_typed("/admin/instance/registration-urls", request)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn revoke_registration_url(&self, id: &str) -> ApiResult<InstanceConfigResponse> {
|
||||
let request = RegistrationUrlActionRequest { id: id.to_owned() };
|
||||
self.post_typed("/admin/instance-config/registration-urls/revoke", &request)
|
||||
.await
|
||||
self.delete_with_reason(
|
||||
&format!(
|
||||
"/admin/instance/registration-urls/{}",
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn approve_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let request = PendingRegistrationActionRequest {
|
||||
user_id: user_id.to_owned(),
|
||||
};
|
||||
self.post_typed(
|
||||
"/admin/instance-config/pending-registrations/approve",
|
||||
&request,
|
||||
)
|
||||
.await
|
||||
self.decide_pending_registration(user_id, "approved").await
|
||||
}
|
||||
|
||||
pub async fn reject_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let request = PendingRegistrationActionRequest {
|
||||
user_id: user_id.to_owned(),
|
||||
};
|
||||
self.post_typed(
|
||||
"/admin/instance-config/pending-registrations/reject",
|
||||
&request,
|
||||
self.decide_pending_registration(user_id, "rejected").await
|
||||
}
|
||||
|
||||
async fn decide_pending_registration(
|
||||
&self,
|
||||
user_id: &str,
|
||||
status: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let body = serde_json::json!({"status": status});
|
||||
self.patch_with_reason(
|
||||
&format!(
|
||||
"/admin/instance/pending-registrations/{}",
|
||||
urlencoding::encode(user_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
|
||||
pub struct ListJobsParams {
|
||||
@@ -16,51 +14,32 @@ pub struct ListJobsParams {
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_jobs(&self, params: &ListJobsParams) -> ApiResult<ListJobsResponse> {
|
||||
let cursor = params
|
||||
.cursor
|
||||
.clone()
|
||||
.map(serde_json::from_value::<generated_types::ListJobsRequestCursor>)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let status = params
|
||||
.status
|
||||
.as_deref()
|
||||
.map(generated_types::ListJobsRequestStatus::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::ListJobsRequest {
|
||||
cursor,
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(params.limit, "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
let cursor = params.cursor.as_ref();
|
||||
let cursor_bucket_day = cursor_field(cursor, "bucket_day");
|
||||
let cursor_created_at = cursor_field(cursor, "created_at");
|
||||
let cursor_job_id = cursor_field(cursor, "job_id");
|
||||
let limit = params.limit.to_string();
|
||||
let max_lookback_days = params.max_lookback_days.to_string();
|
||||
let query_params = [
|
||||
("limit", limit.as_str()),
|
||||
("cursor_bucket_day", cursor_bucket_day.as_str()),
|
||||
("cursor_created_at", cursor_created_at.as_str()),
|
||||
("cursor_job_id", cursor_job_id.as_str()),
|
||||
("max_lookback_days", max_lookback_days.as_str()),
|
||||
("status", params.status.as_deref().unwrap_or_default()),
|
||||
("task_type", params.task_type.as_deref().unwrap_or_default()),
|
||||
(
|
||||
"requested_by_user_id",
|
||||
params.requested_by_user_id.as_deref().unwrap_or_default(),
|
||||
),
|
||||
max_lookback_days: Some(
|
||||
crate::api::generated::nonzero_u32(params.max_lookback_days, "max_lookback_days")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
requested_by_user_id: params
|
||||
.requested_by_user_id
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from),
|
||||
status,
|
||||
task_type: params.task_type.clone(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_jobs(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
];
|
||||
self.get("/admin/jobs", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_job(&self, job_id: &str) -> ApiResult<GetJobResponse> {
|
||||
let body = generated_types::GetJobRequest {
|
||||
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_job(&body)
|
||||
.get_admin_job(job_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -71,19 +50,28 @@ impl AdminApiClient {
|
||||
job_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<CancelJobResponse> {
|
||||
let body = generated_types::CancelJobRequest {
|
||||
job_id: generated_types::SnowflakeType::from(job_id.to_owned()),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/jobs/cancel", &body, audit_log_reason)
|
||||
.await
|
||||
self.put_with_reason(
|
||||
&format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_active_jobs(&self) -> ApiResult<ActiveJobsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_active_jobs()
|
||||
.list_admin_active_jobs()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_field(cursor: Option<&serde_json::Value>, field: &str) -> String {
|
||||
cursor
|
||||
.and_then(|cursor| cursor.get(field))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ use super::types::{LimitConfigResponse, LimitConfigUpdateRequest};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn get_limit_config(&self) -> ApiResult<LimitConfigResponse> {
|
||||
self.post("/admin/limit-config/get", Some(&serde_json::json!({})))
|
||||
.await
|
||||
self.get("/admin/limit-config", None).await
|
||||
}
|
||||
|
||||
pub async fn update_limit_config(
|
||||
&self,
|
||||
request: &LimitConfigUpdateRequest,
|
||||
) -> ApiResult<LimitConfigResponse> {
|
||||
self.post_typed("/admin/limit-config/update", request).await
|
||||
self.put_typed_with_reason("/admin/limit-config", request, None)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,16 @@ impl AdminApiClient {
|
||||
message_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::DeleteMessageRequest {
|
||||
channel_id: snowflake(channel_id),
|
||||
message_id: snowflake(message_id),
|
||||
};
|
||||
let _: serde_json::Value = self
|
||||
.post_typed_with_reason("/admin/messages/delete", &body, audit_log_reason)
|
||||
.delete_with_reason(
|
||||
&format!(
|
||||
"/admin/channels/{}/messages/{}",
|
||||
urlencoding::encode(channel_id),
|
||||
urlencoding::encode(message_id)
|
||||
),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -50,7 +54,7 @@ impl AdminApiClient {
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.report_message_attachment_to_ncmec(&body)
|
||||
.create_admin_ncmec_report(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -62,17 +66,14 @@ impl AdminApiClient {
|
||||
message_id: &str,
|
||||
context_limit: u32,
|
||||
) -> ApiResult<LookupMessageResponse> {
|
||||
let body = generated_types::LookupMessageRequest {
|
||||
channel_id: snowflake(channel_id),
|
||||
context_limit: Some(
|
||||
crate::api::generated::nonzero_u32(context_limit, "context_limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
message_id: snowflake(message_id),
|
||||
};
|
||||
let context_limit = context_limit.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_message(&body)
|
||||
.get_admin_message(
|
||||
&snowflake(channel_id),
|
||||
&snowflake(message_id),
|
||||
Some(context_limit.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -86,16 +87,13 @@ impl AdminApiClient {
|
||||
let entries = entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(serde_json::from_value::<generated_types::MessageShredRequestEntriesItem>)
|
||||
.map(serde_json::from_value::<generated_types::AdminUserMessageShredRequestEntriesItem>)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::MessageShredRequest {
|
||||
entries,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserMessageShredRequest { entries };
|
||||
let response = self
|
||||
.generated()
|
||||
.queue_message_shred(&body)
|
||||
.shred_admin_user_messages(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -106,13 +104,10 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
dry_run: bool,
|
||||
) -> ApiResult<DeleteAllUserMessagesResponse> {
|
||||
let body = generated_types::DeleteAllUserMessagesRequest {
|
||||
dry_run: Some(dry_run),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let dry_run = if dry_run { "true" } else { "false" };
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_all_user_messages(&body)
|
||||
.delete_admin_user_messages(&snowflake(user_id), Some(dry_run))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -122,12 +117,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
job_id: &str,
|
||||
) -> ApiResult<MessageShredStatusResponse> {
|
||||
let body = generated_types::MessageShredStatusRequest {
|
||||
job_id: job_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_message_shred_status(&body)
|
||||
.get_admin_message_shred(job_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -140,18 +132,18 @@ impl AdminApiClient {
|
||||
filename: &str,
|
||||
context_limit: u32,
|
||||
) -> ApiResult<LookupMessageResponse> {
|
||||
let body = generated_types::LookupMessageByAttachmentRequest {
|
||||
attachment_id: snowflake(attachment_id),
|
||||
channel_id: snowflake(channel_id),
|
||||
context_limit: Some(
|
||||
crate::api::generated::nonzero_u32(context_limit, "context_limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
filename: filename.to_owned(),
|
||||
};
|
||||
let context_limit = context_limit.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_message_by_attachment(&body)
|
||||
.search_admin_messages(
|
||||
Some(&snowflake(attachment_id)),
|
||||
&snowflake(channel_id),
|
||||
Some(context_limit.as_str()),
|
||||
Some(filename),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -164,18 +156,17 @@ impl AdminApiClient {
|
||||
after: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<BrowseChannelResponse> {
|
||||
let body = generated_types::BrowseChannelRequest {
|
||||
after: after.map(snowflake),
|
||||
before: before.map(snowflake),
|
||||
channel_id: snowflake(channel_id),
|
||||
limit: limit
|
||||
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
|
||||
.transpose()
|
||||
.map_err(ApiError::Parse)?,
|
||||
};
|
||||
let after = after.map(snowflake);
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit.map(|value| value.to_string());
|
||||
let response = self
|
||||
.generated()
|
||||
.browse_channel_messages(&body)
|
||||
.list_admin_channel_messages(
|
||||
&snowflake(channel_id),
|
||||
after.as_ref(),
|
||||
before.as_ref(),
|
||||
limit.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -187,18 +178,18 @@ impl AdminApiClient {
|
||||
query: &str,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<SearchChannelMessagesResponse> {
|
||||
let body = generated_types::SearchChannelMessagesRequest {
|
||||
channel_id: snowflake(channel_id),
|
||||
limit: limit
|
||||
.map(|value| crate::api::generated::nonzero_u32(value, "limit"))
|
||||
.transpose()
|
||||
.map_err(ApiError::Parse)?,
|
||||
query: generated_types::SearchChannelMessagesRequestQuery::try_from(query)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let limit = limit.map(|value| value.to_string());
|
||||
let response = self
|
||||
.generated()
|
||||
.search_channel_messages(&body)
|
||||
.search_admin_messages(
|
||||
None,
|
||||
&snowflake(channel_id),
|
||||
None,
|
||||
None,
|
||||
limit.as_deref(),
|
||||
None,
|
||||
Some(query),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
@@ -14,28 +12,21 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: Option<u32>,
|
||||
) -> ApiResult<ListReportsResponse> {
|
||||
let body = generated_types::ListReportsRequest {
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: offset.map(i64::from),
|
||||
status: status
|
||||
.map(generated_types::ReportStatus::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_reports(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
|
||||
let query_params = [
|
||||
("status", status),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_report(report_id)
|
||||
.get_admin_report(report_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -47,12 +38,16 @@ impl AdminApiClient {
|
||||
public_comment: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<ResolveReportResponse> {
|
||||
let body = generated_types::ResolveReportRequest {
|
||||
public_comment: public_comment.map(std::borrow::ToOwned::to_owned),
|
||||
report_id: generated_types::SnowflakeType::from(report_id.to_owned()),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/reports/resolve", &body, audit_log_reason)
|
||||
.await
|
||||
let mut body = serde_json::json!({"status": "resolved"});
|
||||
if let Some(public_comment) = public_comment {
|
||||
body["public_comment"] = serde_json::Value::from(public_comment);
|
||||
}
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -73,48 +68,37 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchReportsResponse> {
|
||||
let body = generated_types::SearchReportsRequest {
|
||||
category: nonempty_string(category),
|
||||
guild_context_id: nonempty_snowflake(guild_context_id),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let report_type = report_type
|
||||
.map(report_type_name)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let sort_by = sort_by.map(report_sort_by).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let query_params = [
|
||||
("q", query.unwrap_or_default()),
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", category.unwrap_or_default()),
|
||||
("reporter_id", reporter_id.unwrap_or_default()),
|
||||
("reported_user_id", reported_user_id.unwrap_or_default()),
|
||||
("reported_guild_id", reported_guild_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_channel_id",
|
||||
reported_channel_id.unwrap_or_default(),
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: nonempty_string(query),
|
||||
report_type: report_type
|
||||
.map(generated_types::ReportType::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
reported_channel_id: nonempty_snowflake(reported_channel_id),
|
||||
reported_guild_id: nonempty_snowflake(reported_guild_id),
|
||||
reported_user_id: nonempty_snowflake(reported_user_id),
|
||||
reporter_id: nonempty_snowflake(reporter_id),
|
||||
resolved_by_admin_id: nonempty_snowflake(resolved_by_admin_id),
|
||||
sort_by: sort_by
|
||||
.map(generated_types::SearchReportsRequestSortBy::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
sort_order: sort_order
|
||||
.map(generated_types::SearchReportsRequestSortOrder::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
status: status
|
||||
.map(generated_types::ReportStatus::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.search_reports(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let response = response.into_inner();
|
||||
Ok(SearchReportsResponse {
|
||||
reports: self.generated_value(response.reports)?,
|
||||
total: response.total as u64,
|
||||
offset: response.offset as u64,
|
||||
limit: response.limit as u64,
|
||||
})
|
||||
("guild_context_id", guild_context_id.unwrap_or_default()),
|
||||
(
|
||||
"resolved_by_admin_id",
|
||||
resolved_by_admin_id.unwrap_or_default(),
|
||||
),
|
||||
("sort_by", sort_by),
|
||||
("sort_order", sort_order.unwrap_or_default()),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn search_reports_by_reporter(
|
||||
@@ -168,12 +152,30 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
fn report_status(value: i32) -> ApiResult<&'static str> {
|
||||
match value {
|
||||
0 => Ok("pending"),
|
||||
1 => Ok("resolved"),
|
||||
other => Err(ApiError::Parse(format!("unknown report status: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty_snowflake(value: Option<&str>) -> Option<generated_types::SnowflakeType> {
|
||||
nonempty_string(value).map(generated_types::SnowflakeType::from)
|
||||
fn report_type_name(value: i32) -> ApiResult<&'static str> {
|
||||
match value {
|
||||
0 => Ok("message"),
|
||||
1 => Ok("user"),
|
||||
2 => Ok("guild"),
|
||||
other => Err(ApiError::Parse(format!("unknown report type: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn report_sort_by(value: &str) -> ApiResult<&'static str> {
|
||||
match value {
|
||||
"created_at" | "createdAt" => Ok("created_at"),
|
||||
"reported_at" | "reportedAt" => Ok("reported_at"),
|
||||
"resolved_at" | "resolvedAt" => Ok("resolved_at"),
|
||||
other => Err(ApiError::Parse(format!(
|
||||
"unknown report sort field: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,11 @@ impl AdminApiClient {
|
||||
) -> ApiResult<RefreshSearchIndexResponse> {
|
||||
let body = generated_types::RefreshSearchIndexRequest {
|
||||
guild_id: guild_id.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
index_type: generated_types::RefreshSearchIndexRequestIndexType::try_from(index_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_id: None,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.refresh_search_index(&body)
|
||||
.create_admin_search_index_refresh(index_type, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -29,14 +27,90 @@ impl AdminApiClient {
|
||||
&self,
|
||||
job_id: &str,
|
||||
) -> ApiResult<IndexRefreshStatusResponse> {
|
||||
let body = generated_types::GetIndexRefreshStatusRequest {
|
||||
job_id: job_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_search_index_refresh_status(&body)
|
||||
.get_admin_search_index_refresh(job_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
index_refresh_status(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn index_refresh_status(
|
||||
response: generated_types::IndexRefreshStatusResponse,
|
||||
) -> ApiResult<IndexRefreshStatusResponse> {
|
||||
match response {
|
||||
generated_types::IndexRefreshStatusResponse::Variant0 { status } => {
|
||||
Ok(IndexRefreshStatusResponse::NotFound {
|
||||
status: status.to_string(),
|
||||
})
|
||||
}
|
||||
generated_types::IndexRefreshStatusResponse::Variant1 {
|
||||
status,
|
||||
index_type,
|
||||
total,
|
||||
indexed,
|
||||
started_at,
|
||||
completed_at,
|
||||
failed_at,
|
||||
error,
|
||||
} => Ok(IndexRefreshStatusResponse::Progress {
|
||||
status: status.to_string(),
|
||||
index_type: Some(index_type),
|
||||
total: total
|
||||
.map(|value| float_to_u64(value, "total"))
|
||||
.transpose()?,
|
||||
indexed: indexed
|
||||
.map(|value| float_to_u64(value, "indexed"))
|
||||
.transpose()?,
|
||||
started_at,
|
||||
completed_at,
|
||||
failed_at,
|
||||
error,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
|
||||
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_a_running_refresh_to_progress() {
|
||||
let json = r#"{"status":"in_progress","index_type":"users","total":50000,"indexed":1200,"started_at":"2026-09-06T00:00:00Z"}"#;
|
||||
let response: generated_types::IndexRefreshStatusResponse =
|
||||
serde_json::from_str(json).unwrap();
|
||||
match index_refresh_status(response).unwrap() {
|
||||
IndexRefreshStatusResponse::Progress {
|
||||
status,
|
||||
index_type,
|
||||
total,
|
||||
indexed,
|
||||
started_at,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(status, "in_progress");
|
||||
assert_eq!(index_type.as_deref(), Some("users"));
|
||||
assert_eq!(total, Some(50_000));
|
||||
assert_eq!(indexed, Some(1_200));
|
||||
assert_eq!(started_at.as_deref(), Some("2026-09-06T00:00:00Z"));
|
||||
}
|
||||
other => panic!("expected a progress status, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_a_missing_refresh_to_not_found() {
|
||||
let json = r#"{"status":"not_found"}"#;
|
||||
let response: generated_types::IndexRefreshStatusResponse =
|
||||
serde_json::from_str(json).unwrap();
|
||||
match index_refresh_status(response).unwrap() {
|
||||
IndexRefreshStatusResponse::NotFound { status } => assert_eq!(status, "not_found"),
|
||||
other => panic!("expected a not found status, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse,
|
||||
ReloadAllGuildsResponse,
|
||||
@@ -10,12 +10,10 @@ use super::types::{
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn get_guild_memory_stats(&self, limit: u32) -> ApiResult<GuildMemoryStatsResponse> {
|
||||
let body = generated_types::GetProcessMemoryStatsRequest {
|
||||
limit: Some(i32::try_from(limit).map_err(|e| ApiError::Parse(e.to_string()))?),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.get_guild_memory_statistics(&body)
|
||||
.get_admin_gateway_memory_stats(Some(limit.as_str()))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -34,7 +32,7 @@ impl AdminApiClient {
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.reload_all_specified_guilds(&body)
|
||||
.create_admin_gateway_reload(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -43,7 +41,7 @@ impl AdminApiClient {
|
||||
pub async fn get_node_stats(&self) -> ApiResult<NodeStatsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_gateway_node_statistics()
|
||||
.get_admin_gateway_stats()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -54,7 +52,7 @@ impl AdminApiClient {
|
||||
) -> ApiResult<GatewayVoiceStateCountsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_gateway_voice_state_counts()
|
||||
.get_admin_gateway_voice_state_counts()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -22,7 +22,7 @@ impl AdminApiClient {
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.send_system_dm(&body)
|
||||
.create_admin_system_dm(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
|
||||
@@ -826,13 +826,3 @@ pub struct CreateRegistrationUrlResponse {
|
||||
pub code: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct RegistrationUrlActionRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PendingRegistrationActionRequest {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
+111
-183
@@ -17,18 +17,19 @@ impl AdminApiClient {
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> ApiResult<SearchUsersResponse> {
|
||||
let body = generated_types::SearchUsersRequest {
|
||||
email: nonempty_string(email),
|
||||
last_active_ip: nonempty_string(last_active_ip),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit, "limit").map_err(ApiError::Parse)?,
|
||||
),
|
||||
offset: Some(i64::from(offset)),
|
||||
query: nonempty_string(query),
|
||||
};
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.search_users(&body)
|
||||
.list_admin_users(
|
||||
nonempty(email),
|
||||
nonempty(last_active_ip),
|
||||
Some(limit.as_str()),
|
||||
Some(offset.as_str()),
|
||||
nonempty(query),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let response = response.into_inner();
|
||||
@@ -39,10 +40,9 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
|
||||
let body = generated_types::LookupUserRequest::Query(query.to_owned());
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_user(&body)
|
||||
.list_admin_users(None, None, None, None, None, Some(query), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -53,27 +53,18 @@ impl AdminApiClient {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let body = generated_types::LookupUserRequest::UserIds(
|
||||
user_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::SnowflakeType::from)
|
||||
.collect(),
|
||||
);
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_user(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
let query_params: Vec<(&str, &str)> = user_ids
|
||||
.iter()
|
||||
.map(|user_id| ("user_id", user_id.as_str()))
|
||||
.collect();
|
||||
let resp: LookupUserResponse = self.get("/admin/users", Some(&query_params)).await?;
|
||||
Ok(resp.users)
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::LookupUserRequest::Query(user_id.to_owned());
|
||||
let response = self
|
||||
.generated()
|
||||
.lookup_user(&body)
|
||||
.get_admin_user(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -89,7 +80,7 @@ impl AdminApiClient {
|
||||
pub async fn get_current_admin(&self) -> ApiResult<AdminUser> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_authenticated_admin_user()
|
||||
.get_current_admin_user()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: AdminUserMeResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -102,14 +93,13 @@ impl AdminApiClient {
|
||||
add_flags: &[String],
|
||||
remove_flags: &[String],
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdateUserFlagsRequest {
|
||||
let body = generated_types::AdminUserFlagsUpdateRequest {
|
||||
add_flags: user_flags(add_flags),
|
||||
remove_flags: user_flags(remove_flags),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_user_flags(&body)
|
||||
.update_admin_user_flags(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -124,19 +114,19 @@ impl AdminApiClient {
|
||||
after: Option<&str>,
|
||||
with_counts: Option<bool>,
|
||||
) -> ApiResult<Vec<GuildInfo>> {
|
||||
let body = generated_types::ListUserGuildsRequest {
|
||||
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit.unwrap_or(200), "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
with_counts: Some(with_counts.unwrap_or(true)),
|
||||
};
|
||||
let after = after.map(snowflake);
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit.unwrap_or(200).to_string();
|
||||
let with_counts = bool_param(with_counts.unwrap_or(true));
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_guilds(&body)
|
||||
.list_admin_user_guilds(
|
||||
&snowflake(user_id),
|
||||
after.as_ref(),
|
||||
before.as_ref(),
|
||||
Some(limit.as_str()),
|
||||
Some(with_counts),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: ListUserGuildsResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -147,12 +137,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::ListUserSessionsResponse> {
|
||||
let body = generated_types::ListUserSessionsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_sessions(&body)
|
||||
.list_admin_user_sessions(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -162,12 +149,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<TerminateSessionsResponse> {
|
||||
let body = generated_types::TerminateSessionsRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.terminate_user_sessions(&body)
|
||||
.terminate_admin_user_sessions(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -177,12 +161,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::ListUserRelationshipsResponse> {
|
||||
let body = generated_types::ListUserRelationshipsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_list_user_relationships(&body)
|
||||
.list_admin_user_relationships(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -195,18 +176,18 @@ impl AdminApiClient {
|
||||
after: Option<&str>,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<super::types::ListUserDmChannelsResponse> {
|
||||
let body = generated_types::ListUserDmChannelsRequest {
|
||||
after: after.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
before: before.map(|id| generated_types::SnowflakeType::from(id.to_owned())),
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let after = after.map(snowflake);
|
||||
let before = before.map(snowflake);
|
||||
let limit = limit.unwrap_or(50).to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_dm_channels(&body)
|
||||
.list_admin_user_dm_channels(
|
||||
&snowflake(user_id),
|
||||
after.as_ref(),
|
||||
before.as_ref(),
|
||||
Some(limit.as_str()),
|
||||
Some(generated_types::AdminUserDmChannelType::Dm),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -216,12 +197,15 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::ListUserGroupDmChannelsResponse> {
|
||||
let body = generated_types::ListUserGroupDmChannelsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_group_dm_channels(&body)
|
||||
.list_admin_user_dm_channels(
|
||||
&snowflake(user_id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(generated_types::AdminUserDmChannelType::GroupDm),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -233,14 +217,13 @@ impl AdminApiClient {
|
||||
add_flags: &[i32],
|
||||
remove_flags: &[i32],
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdatePremiumFlagsRequest {
|
||||
let body = generated_types::AdminUserPremiumFlagsUpdateRequest {
|
||||
add_flags: premium_flags(add_flags),
|
||||
remove_flags: premium_flags(remove_flags),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_user_premium_flags(&body)
|
||||
.update_admin_user_premium_flags(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -248,13 +231,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn update_suspicious_flags(&self, user_id: &str, flags: i32) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdateSuspiciousActivityFlagsRequest {
|
||||
let body = generated_types::AdminUserSuspiciousActivityFlagsRequest {
|
||||
flags: generated_types::SuspiciousActivityFlags::from(flags),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.update_suspicious_activity_flags(&body)
|
||||
.update_admin_user_suspicious_activity_flags(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -262,13 +244,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_user_acls(&self, user_id: &str, acls: &[String]) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserAclsRequest {
|
||||
acls: acls.to_vec(),
|
||||
user_id: snowflake(user_id),
|
||||
let body = generated_types::AdminUserAclsRequest {
|
||||
acls: super::admin_api_keys::parse_acls(acls)?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_acls(&body)
|
||||
.set_admin_user_acls(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -276,13 +257,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_user_traits(&self, user_id: &str, traits: &[String]) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserTraitsRequest {
|
||||
let body = generated_types::AdminUserTraitsRequest {
|
||||
traits: traits.to_vec(),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_traits(&body)
|
||||
.set_admin_user_traits(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -290,34 +270,25 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn disable_mfa(&self, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::DisableMfaRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.disable_user_mfa(&body)
|
||||
.disable_admin_user_mfa(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resend_verification_email(&self, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::ResendVerificationEmailRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.admin_resend_verification_email(&body)
|
||||
.resend_admin_user_verification_email(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn verify_email(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::VerifyUserEmailRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.verify_user_email(&body)
|
||||
.verify_admin_user_email(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -329,13 +300,10 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
has_verified_phone: bool,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::UpdateHasVerifiedPhoneRequest {
|
||||
has_verified_phone,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserPhoneVerificationRequest { has_verified_phone };
|
||||
let response = self
|
||||
.generated()
|
||||
.update_user_has_verified_phone(&body)
|
||||
.update_admin_user_phone_verification(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -349,16 +317,15 @@ impl AdminApiClient {
|
||||
) -> ApiResult<AdminUser> {
|
||||
let fields = fields
|
||||
.iter()
|
||||
.map(generated_types::ClearUserFieldsRequestFieldsItem::try_from)
|
||||
.map(|field| {
|
||||
generated_types::AdminUserClearFieldsRequestFieldsItem::try_from(field.as_str())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let body = generated_types::ClearUserFieldsRequest {
|
||||
fields,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserClearFieldsRequest { fields };
|
||||
let response = self
|
||||
.generated()
|
||||
.clear_user_fields(&body)
|
||||
.clear_admin_user_profile_fields(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -366,13 +333,10 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserBotStatusRequest {
|
||||
bot: is_bot,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_bot_status(&body)
|
||||
.set_admin_user_bot_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -380,13 +344,10 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::SetUserSystemStatusRequest {
|
||||
system: is_system,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_user_system_status(&body)
|
||||
.set_admin_user_system_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -399,18 +360,17 @@ impl AdminApiClient {
|
||||
username: &str,
|
||||
discriminator: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ChangeUsernameRequest {
|
||||
let body = generated_types::AdminUserUsernameUpdateRequest {
|
||||
discriminator: discriminator
|
||||
.map(generated_types::DiscriminatorType::try_from)
|
||||
.transpose()
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_id: snowflake(user_id),
|
||||
username: generated_types::UsernameType::try_from(username)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.change_user_username(&body)
|
||||
.update_admin_user_username(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -418,13 +378,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn change_email(&self, user_id: &str, email: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ChangeEmailRequest {
|
||||
let body = generated_types::AdminUserEmailUpdateRequest {
|
||||
email: generated_types::EmailType::from(email.to_owned()),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.change_user_email(&body)
|
||||
.update_admin_user_email(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -438,25 +397,25 @@ impl AdminApiClient {
|
||||
reason: Option<&str>,
|
||||
private_reason: Option<&str>,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::TempBanUserRequest {
|
||||
let body = generated_types::AdminUserBanRequest {
|
||||
duration_hours: i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
.post_typed_with_reason("/admin/users/temp-ban", &body, private_reason)
|
||||
.put_typed_with_reason(
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
&body,
|
||||
private_reason,
|
||||
)
|
||||
.await?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn unban_user(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::DisableMfaRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.unban_user(&body)
|
||||
.unban_admin_user(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -470,18 +429,18 @@ impl AdminApiClient {
|
||||
public_reason: Option<&str>,
|
||||
days_until_deletion: u32,
|
||||
) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ScheduleAccountDeletionRequest {
|
||||
let body = generated_types::AdminUserDeletionScheduleRequest {
|
||||
days_until_deletion: Some(
|
||||
crate::api::generated::nonzero_u32(days_until_deletion, "days_until_deletion")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
|
||||
reason_code,
|
||||
user_id: snowflake(user_id),
|
||||
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
|
||||
.map_err(ApiError::Parse)?,
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.schedule_account_deletion(&body)
|
||||
.schedule_admin_user_deletion(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -489,12 +448,9 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn cancel_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::DisableMfaRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.cancel_account_deletion(&body)
|
||||
.cancel_admin_user_deletion(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -502,13 +458,12 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn change_dob(&self, user_id: &str, dob: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::ChangeDobRequest {
|
||||
let body = generated_types::AdminUserDobUpdateRequest {
|
||||
date_of_birth: dob.to_owned(),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.change_user_dob(&body)
|
||||
.update_admin_user_date_of_birth(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -516,11 +471,8 @@ impl AdminApiClient {
|
||||
}
|
||||
|
||||
pub async fn send_password_reset(&self, user_id: &str) -> ApiResult<()> {
|
||||
let body = generated_types::SendPasswordResetRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.send_password_reset(&body)
|
||||
.send_admin_user_password_reset(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -532,14 +484,10 @@ impl AdminApiClient {
|
||||
target_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::RemoveUserRelationshipRequest {
|
||||
category: generated_types::RemoveUserRelationshipRequestCategory::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
target_user_id: snowflake(target_id),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let category = generated_types::RemoveAdminUserRelationshipCategory::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
self.generated()
|
||||
.remove_user_relationship(&body)
|
||||
.remove_admin_user_relationship(&snowflake(user_id), target_id, category)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -550,16 +498,11 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
category: &str,
|
||||
) -> ApiResult<super::types::RemoveRelationshipsResponse> {
|
||||
let body = generated_types::RemoveUserRelationshipsByCategoryRequest {
|
||||
category: generated_types::RemoveUserRelationshipsByCategoryRequestCategory::try_from(
|
||||
category,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let category = generated_types::ClearAdminUserRelationshipsCategory::try_from(category)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.remove_user_relationships_by_category(&body)
|
||||
.clear_admin_user_relationships(&snowflake(user_id), category)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -570,12 +513,8 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
credential_id: &str,
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::DeleteWebAuthnCredentialRequest {
|
||||
credential_id: credential_id.to_owned(),
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
self.generated()
|
||||
.delete_user_webauthn_credential(&body)
|
||||
.delete_admin_user_webauthn_credential(&snowflake(user_id), credential_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
Ok(())
|
||||
@@ -586,17 +525,10 @@ impl AdminApiClient {
|
||||
user_id: &str,
|
||||
limit: Option<u32>,
|
||||
) -> ApiResult<super::types::ListUserChangeLogResponse> {
|
||||
let body = generated_types::ListUserChangeLogRequest {
|
||||
limit: Some(
|
||||
crate::api::generated::nonzero_u32(limit.unwrap_or(50), "limit")
|
||||
.map_err(ApiError::Parse)?,
|
||||
),
|
||||
page_token: None,
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let limit = limit.unwrap_or(50).to_string();
|
||||
let response = self
|
||||
.generated()
|
||||
.get_user_change_log(&body)
|
||||
.list_admin_user_change_log(&snowflake(user_id), Some(limit.as_str()), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -606,24 +538,18 @@ impl AdminApiClient {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> ApiResult<super::types::WebAuthnCredentialListResponse> {
|
||||
let body = generated_types::ListWebAuthnCredentialsRequest {
|
||||
user_id: generated_types::SnowflakeType::from(user_id.to_owned()),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_user_webauthn_credentials(&body)
|
||||
.list_admin_user_webauthn_credentials(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn cancel_bulk_message_deletion(&self, user_id: &str) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::CancelBulkMessageDeletionRequest {
|
||||
user_id: snowflake(user_id),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.admin_cancel_bulk_message_deletion(&body)
|
||||
.cancel_admin_user_message_deletion(&snowflake(user_id))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
@@ -631,10 +557,12 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn snowflake(value: &str) -> generated_types::SnowflakeType {
|
||||
|
||||
@@ -14,12 +14,9 @@ impl AdminApiClient {
|
||||
&self,
|
||||
include_servers: bool,
|
||||
) -> ApiResult<ListVoiceRegionsResponse> {
|
||||
let body = generated_types::ListVoiceRegionsRequest {
|
||||
include_servers: Some(include_servers),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_voice_regions(&body)
|
||||
.list_admin_voice_regions(Some(bool_param(include_servers)))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -30,13 +27,9 @@ impl AdminApiClient {
|
||||
id: &str,
|
||||
include_servers: bool,
|
||||
) -> ApiResult<GetVoiceRegionResponse> {
|
||||
let body = generated_types::GetVoiceRegionRequest {
|
||||
id: id.to_owned(),
|
||||
include_servers: Some(include_servers),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_voice_region(&body)
|
||||
.get_admin_voice_region(id, Some(bool_param(include_servers)))
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -51,7 +44,7 @@ impl AdminApiClient {
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.create_voice_region(&body)
|
||||
.create_admin_voice_region(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -61,34 +54,31 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||
let region_id = required_field(params, "id")?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_voice_region(&body)
|
||||
.update_admin_voice_region(®ion_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
|
||||
let body = generated_types::DeleteVoiceRegionRequest { id: id.to_owned() };
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_voice_region(&body)
|
||||
.delete_admin_voice_region(id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn list_voice_servers(&self, region_id: &str) -> ApiResult<ListVoiceServersResponse> {
|
||||
let body = generated_types::ListVoiceServersRequest {
|
||||
region_id: region_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.list_voice_servers(&body)
|
||||
.list_admin_voice_servers(region_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -99,13 +89,9 @@ impl AdminApiClient {
|
||||
region_id: &str,
|
||||
server_id: &str,
|
||||
) -> ApiResult<GetVoiceServerResponse> {
|
||||
let body = generated_types::GetVoiceServerRequest {
|
||||
region_id: region_id.to_owned(),
|
||||
server_id: server_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.get_voice_server(&body)
|
||||
.get_admin_voice_server(region_id, server_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -115,12 +101,14 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<CreateVoiceServerResponse> {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::CreateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.create_voice_server(&body)
|
||||
.create_admin_voice_server(®ion_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -130,12 +118,15 @@ impl AdminApiClient {
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceServerResponse> {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
let server_id = required_field(params, "server_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_voice_server(&body)
|
||||
.update_admin_voice_server(®ion_id, &server_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
@@ -146,15 +137,34 @@ impl AdminApiClient {
|
||||
region_id: &str,
|
||||
server_id: &str,
|
||||
) -> ApiResult<DeleteVoiceResponse> {
|
||||
let body = generated_types::DeleteVoiceServerRequest {
|
||||
region_id: region_id.to_owned(),
|
||||
server_id: server_id.to_owned(),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.delete_voice_server(&body)
|
||||
.delete_admin_voice_server(region_id, server_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
|
||||
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
||||
if has_coordinate("latitude") == has_coordinate("longitude") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::Parse(
|
||||
"latitude and longitude must both be set or both be left empty".to_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn required_field(params: &serde_json::Value, field: &str) -> ApiResult<String> {
|
||||
params
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
.ok_or_else(|| ApiError::Parse(format!("{field} is required")))
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub enum RuntimeEnv {
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
pub fn from_env() -> Self {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
|
||||
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
@@ -51,14 +51,19 @@ impl AdminConfig {
|
||||
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
|
||||
&format!("{admin_endpoint}/oauth2_callback"),
|
||||
));
|
||||
let secret_key_base = read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "");
|
||||
anyhow::ensure!(
|
||||
!secret_key_base.trim().is_empty(),
|
||||
"FLUXER_ADMIN_SECRET_KEY_BASE is required"
|
||||
);
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
|
||||
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
|
||||
port: read_env("FLUXER_ADMIN_PORT", "3020")
|
||||
.parse()
|
||||
.unwrap_or(3020),
|
||||
secret_key_base: read_env("FLUXER_ADMIN_SECRET_KEY_BASE", "development-admin-secret"),
|
||||
secret_key_base,
|
||||
base_path,
|
||||
api_endpoint: trim_trailing_slash(&read_env(
|
||||
"FLUXER_API_ENDPOINT",
|
||||
@@ -110,7 +115,7 @@ impl AdminConfig {
|
||||
.trim()
|
||||
.to_ascii_lowercase(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_dev(&self) -> bool {
|
||||
@@ -120,6 +125,10 @@ impl AdminConfig {
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.env == RuntimeEnv::Production
|
||||
}
|
||||
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
@@ -193,10 +202,11 @@ mod tests {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
|
||||
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
|
||||
for (name, value) in vars {
|
||||
unsafe { env::set_var(name, value) };
|
||||
}
|
||||
let config = AdminConfig::from_env();
|
||||
let config = AdminConfig::from_env().expect("config loads with a secret");
|
||||
for (name, _) in vars {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = AdminConfig::from_env();
|
||||
let config = AdminConfig::from_env()?;
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let router = build_router(config);
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ async fn fetch_admin_user(
|
||||
config: &crate::config::AdminConfig,
|
||||
session: &Session,
|
||||
) -> AdminFetchResult {
|
||||
let url = format!("{}/admin/users/me", config.api_endpoint);
|
||||
let url = format!("{}/admin/users/@me", config.api_endpoint);
|
||||
let response =
|
||||
match crate::api::client::with_proxy_client_ip_header(http_client.get(&url), config)
|
||||
.header("Authorization", format!("Bearer {}", session.access_token))
|
||||
|
||||
@@ -28,7 +28,7 @@ pub async fn csrf_protection(
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let is_production = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
|
||||
let user_id = request
|
||||
.extensions()
|
||||
@@ -76,17 +76,17 @@ pub async fn csrf_protection(
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
let cookie_name = if is_production {
|
||||
let cookie_name = if secure_cookies {
|
||||
HOST_CSRF_COOKIE_NAME
|
||||
} else {
|
||||
CSRF_COOKIE_NAME
|
||||
};
|
||||
let secure = if is_production { "; Secure" } else { "" };
|
||||
let secure = if secure_cookies { "; Secure" } else { "" };
|
||||
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
|
||||
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
if is_production
|
||||
if secure_cookies
|
||||
&& let Ok(value) = HeaderValue::from_str(&format!(
|
||||
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
|
||||
))
|
||||
@@ -199,6 +199,81 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
|
||||
use crate::state::AppState;
|
||||
use axum::{Router, middleware::from_fn_with_state, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
|
||||
AppState::new(AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: String::new(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn csrf_cookies(admin_endpoint: &str) -> Vec<String> {
|
||||
let state = state_with_admin_endpoint(admin_endpoint);
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "ok" }))
|
||||
.layer(from_fn_with_state(state, csrf_protection));
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.expect("router responds");
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.map(|value| value.to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_admin_endpoint_sets_a_host_prefixed_secure_cookie() {
|
||||
let cookies = csrf_cookies("https://example.com/admin").await;
|
||||
assert!(
|
||||
cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("__Host-csrf_token=")
|
||||
&& cookie.contains("; Secure")),
|
||||
"expected a secure __Host- cookie, got {cookies:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_admin_endpoint_sets_a_plain_cookie_without_secure() {
|
||||
let cookies = csrf_cookies("http://example.com/admin").await;
|
||||
assert!(
|
||||
cookies
|
||||
.iter()
|
||||
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("Secure")),
|
||||
"expected a plain csrf_token cookie, got {cookies:?}"
|
||||
);
|
||||
assert!(
|
||||
!cookies.iter().any(|cookie| cookie.contains("__Host-")),
|
||||
"expected no __Host- cookie, got {cookies:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
|
||||
@@ -92,9 +92,9 @@ pub fn clear_flash_cookie(response: &mut Response) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, is_production: bool) -> Response {
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
|
||||
let encoded = serialize_flash(&flash);
|
||||
let secure_flag = if is_production { "; Secure" } else { "" };
|
||||
let secure_flag = if secure { "; Secure" } else { "" };
|
||||
let cookie_value = format!(
|
||||
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn admin_api_keys_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async fn admin_api_keys_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/admin-api-keys"),
|
||||
FlashData::success(format!("API key action '{action}' completed.")),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ fn admin_api_key_flash_response(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{}/admin-api-keys", config.base_path),
|
||||
flash_data,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ async fn application_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/applications/{application_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -178,6 +178,6 @@ async fn application_detail_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/applications/{application_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn oauth2_callback_finish(
|
||||
|
||||
let session_cookie_value =
|
||||
session::create_session(&user.id, &token.access_token, &config.secret_key_base);
|
||||
let secure = if config.is_production() {
|
||||
let secure = if config.secure_cookies() {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn gift_codes_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -99,14 +99,14 @@ async fn gift_codes_post(
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.unwrap_or(1);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let is_prod = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
match client.generate_gift_codes(count, dur_type, dur_qty).await {
|
||||
Ok(result) => {
|
||||
let codes = result.codes.join(",");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes?codes={codes}"),
|
||||
FlashData::success(format!("{} gift code(s) generated", result.codes.len())),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -114,7 +114,7 @@ async fn gift_codes_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/gift-codes"),
|
||||
FlashData::error("Failed to generate gift codes"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ async fn discovery_approve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -115,7 +115,7 @@ async fn discovery_approve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -131,7 +131,7 @@ async fn discovery_approve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ async fn discovery_reject(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -159,7 +159,7 @@ async fn discovery_reject(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -173,7 +173,7 @@ async fn discovery_reject(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn discovery_remove(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -201,7 +201,7 @@ async fn discovery_remove(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -215,6 +215,6 @@ async fn discovery_remove(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=listed"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ pub async fn render(
|
||||
return None;
|
||||
}
|
||||
let apps = client
|
||||
.list_user_applications(guild_id)
|
||||
.list_guild_applications(guild_id)
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications"))
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn guild_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/guilds/{guild_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -203,7 +203,7 @@ async fn guild_detail_post(
|
||||
} else {
|
||||
format!("{base}/guilds/{guild_id}?tab={tab}")
|
||||
};
|
||||
flash::redirect_with_flash(&redirect, flash, config.is_production())
|
||||
flash::redirect_with_flash(&redirect, flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn dispatch_guild_action(
|
||||
@@ -415,7 +415,7 @@ async fn dispatch_guild_action(
|
||||
return FlashData::error("Emoji ID is required");
|
||||
};
|
||||
action_result(
|
||||
client.purge_assets(&[emoji_id]).await,
|
||||
client.purge_assets(guild_id, &[emoji_id]).await,
|
||||
"Emoji deleted",
|
||||
"Failed to delete emoji",
|
||||
)
|
||||
@@ -425,7 +425,7 @@ async fn dispatch_guild_action(
|
||||
return FlashData::error("Sticker ID is required");
|
||||
};
|
||||
action_result(
|
||||
client.purge_assets(&[sticker_id]).await,
|
||||
client.purge_assets(guild_id, &[sticker_id]).await,
|
||||
"Sticker deleted",
|
||||
"Failed to delete sticker",
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn job_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/jobs/{job_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -214,7 +214,7 @@ async fn job_detail_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/jobs/{job_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ pub(crate) async fn messages_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/messages"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -164,7 +164,7 @@ pub(crate) async fn system_dms_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/system-dms"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -184,7 +184,11 @@ pub(crate) async fn system_dms_post(
|
||||
} else {
|
||||
FlashData::error("Recipients and content are required")
|
||||
};
|
||||
flash::redirect_with_flash(&format!("{base}/system-dms"), flash, config.is_production())
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/system-dms"),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn bulk_actions_post(
|
||||
@@ -201,7 +205,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -247,7 +251,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
.bulk_add_guild_members(&guild_id, &user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
"bulk-schedule-user-deletion" => {
|
||||
"bulk-schedule-user-deletion" | "bulk_delete_users" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
let reason_code = form.parse_u32("reason_code").unwrap_or(2);
|
||||
let days = form.parse_u32("days_until_deletion").unwrap_or(14);
|
||||
@@ -268,17 +272,11 @@ pub(crate) async fn bulk_actions_post(
|
||||
.bulk_delete_user_messages(&user_ids, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
"bulk_delete_users" => {
|
||||
let user_ids = form.list_values_any(&["user_ids[]", "user_ids"]);
|
||||
client
|
||||
.bulk_schedule_user_deletion(&user_ids, 0, 30, None, audit_log_reason.as_deref())
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Unknown bulk action"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -290,7 +288,7 @@ pub(crate) async fn bulk_actions_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::success("Bulk action submitted"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -298,8 +296,8 @@ pub(crate) async fn bulk_actions_post(
|
||||
tracing::warn!(%error, action, "admin API request failed: submit bulk action");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/bulk-actions"),
|
||||
FlashData::error("Failed to submit bulk action"),
|
||||
config.is_production(),
|
||||
FlashData::error(format!("Failed to submit bulk action: {error}")),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -405,14 +403,14 @@ pub(crate) async fn archives_download(
|
||||
Ok(_) => flash::redirect_with_flash(
|
||||
&format!("{base}/archives"),
|
||||
FlashData::error("Archive download URL was empty"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: get archive download URL");
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/archives"),
|
||||
FlashData::error("Failed to create archive download URL"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf,
|
||||
@@ -22,6 +23,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const MAX_REPORT_OFFSET: u32 = 10_000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReportsQuery {
|
||||
q: Option<String>,
|
||||
@@ -70,6 +73,14 @@ async fn reports_list(
|
||||
let page = query.page.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(25).clamp(1, 200);
|
||||
let offset = page.saturating_mul(limit);
|
||||
if offset > MAX_REPORT_OFFSET {
|
||||
return reports_error_page(
|
||||
config,
|
||||
&auth.0,
|
||||
"That page is out of range. The reports search returns at most the first 10000 reports, so narrow the filters and start again.",
|
||||
);
|
||||
}
|
||||
let search_query = query.q.as_deref().and_then(clean_string);
|
||||
let (sort_by, sort_order) = decode_sort(query.sort.as_deref());
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let status = query.status.as_deref().and_then(|s| s.parse::<i32>().ok());
|
||||
@@ -79,7 +90,7 @@ async fn reports_list(
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
let reports = client
|
||||
.search_reports(
|
||||
query.q.as_deref(),
|
||||
search_query.as_deref(),
|
||||
status,
|
||||
report_type,
|
||||
query.category.as_deref(),
|
||||
@@ -102,7 +113,7 @@ async fn reports_list(
|
||||
&auth.0,
|
||||
reports.as_ref(),
|
||||
&templates::pages::reports_list::ReportFilters {
|
||||
query: query.q.as_deref(),
|
||||
query: search_query.as_deref(),
|
||||
status: query.status.as_deref(),
|
||||
report_type: query.report_type.as_deref(),
|
||||
category: query.category.as_deref(),
|
||||
@@ -120,6 +131,18 @@ async fn reports_list(
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
fn reports_error_page(config: &AdminConfig, auth: &AuthContext, message: &str) -> Response {
|
||||
let markup = templates::layout::admin_layout(
|
||||
config,
|
||||
auth,
|
||||
"Reports",
|
||||
"reports",
|
||||
None,
|
||||
templates::components::error_display::error_alert(message),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn report_detail(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -195,7 +218,7 @@ async fn report_resolve(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -212,7 +235,7 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::success("Report resolved"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -223,7 +246,7 @@ async fn report_resolve(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/reports/{report_id}"),
|
||||
FlashData::error("Failed to resolve report"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ pub struct ActionQuery {
|
||||
pub rule: Option<String>,
|
||||
}
|
||||
|
||||
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, prod: bool) -> Response {
|
||||
flash::redirect_with_flash(&format!("{base}{path}"), fd, prod)
|
||||
pub fn redirect_back_with_flash(base: &str, path: &str, fd: FlashData, secure: bool) -> Response {
|
||||
flash::redirect_with_flash(&format!("{base}{path}"), fd, secure)
|
||||
}
|
||||
|
||||
pub async fn gateway_post(
|
||||
@@ -63,7 +63,7 @@ pub async fn gateway_post(
|
||||
base,
|
||||
"/gateway",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -80,7 +80,7 @@ pub async fn gateway_post(
|
||||
} else {
|
||||
FlashData::error("Unknown gateway action")
|
||||
};
|
||||
redirect_back_with_flash(base, "/gateway", flash, config.is_production())
|
||||
redirect_back_with_flash(base, "/gateway", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
pub async fn search_index_post(
|
||||
@@ -97,7 +97,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -114,7 +114,7 @@ pub async fn search_index_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/search-index?job_id={job_id}"),
|
||||
FlashData::success("Search index refresh started"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -123,7 +123,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Failed to start search index refresh"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -132,7 +132,7 @@ pub async fn search_index_post(
|
||||
base,
|
||||
"/search-index",
|
||||
FlashData::error("Index type is required"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ pub async fn instance_config_post(
|
||||
base,
|
||||
"/instance-config",
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -320,7 +320,7 @@ pub async fn instance_config_post(
|
||||
if htmx::is_htmx_request(&headers) {
|
||||
return htmx::toast_response(&flash);
|
||||
}
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.is_production())
|
||||
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
fn render_registration_url_list_response(
|
||||
@@ -866,13 +866,13 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Invalid form data"),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let action = aq.action.as_deref().unwrap_or("");
|
||||
let is_prod = config.is_production();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
let current = match client.get_limit_config().await {
|
||||
Ok(current) => current,
|
||||
Err(error) => {
|
||||
@@ -881,7 +881,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Failed to fetch current limit configuration"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -895,7 +895,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -908,7 +908,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
let fallback = current
|
||||
@@ -923,7 +923,7 @@ pub async fn limit_config_post(
|
||||
"Limit configuration updated",
|
||||
"Failed to update limit configuration",
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"delete" => {
|
||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||
@@ -933,7 +933,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -942,7 +942,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("The default rule cannot be deleted"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let old_len = limit_config.rules.len();
|
||||
@@ -952,14 +952,14 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash =
|
||||
limit_config_result(result, "Limit rule deleted", "Failed to delete limit rule");
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"create" => {
|
||||
let rule_id = match form.clean("rule_id") {
|
||||
@@ -969,7 +969,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule ID is required"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -978,7 +978,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("The default rule ID is reserved"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
if limit_config.rules.iter().any(|rule| rule.id == rule_id) {
|
||||
@@ -986,7 +986,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule ID already exists"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
);
|
||||
}
|
||||
let limits = current.defaults.get("default").cloned().unwrap_or_default();
|
||||
@@ -1000,7 +1000,7 @@ pub async fn limit_config_post(
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash =
|
||||
limit_config_result(result, "Limit rule created", "Failed to create limit rule");
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, is_prod);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1008,7 +1008,7 @@ pub async fn limit_config_post(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::success("Limit config updated"),
|
||||
is_prod,
|
||||
secure_cookies,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use crate::{
|
||||
acl,
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResult, ApiResultExt},
|
||||
types::AdminUser,
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
|
||||
routes::user_tabs,
|
||||
state::AppState,
|
||||
@@ -18,6 +21,8 @@ use axum::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
const USER_ID_LOOKUP_BATCH: usize = 100;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserListQuery {
|
||||
q: Option<String>,
|
||||
@@ -55,7 +60,6 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/users", get(users_list))
|
||||
.route("/users/{user_id}", get(user_detail).post(user_detail_post))
|
||||
.route("/users/{user_id}/tabs/{tab}", get(user_tab))
|
||||
.route("/users/{user_id}/peek", get(user_peek))
|
||||
.route("/users/{user_id}/fragment", get(user_peek))
|
||||
}
|
||||
|
||||
@@ -84,14 +88,10 @@ async fn users_list(
|
||||
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let results = if params.has_id_lookup() {
|
||||
let users = client
|
||||
.lookup_users_by_ids(¶ms.requested_ids)
|
||||
lookup_users_in_batches(&client, ¶ms.requested_ids)
|
||||
.await
|
||||
.map_err(
|
||||
|error| tracing::warn!(%error, "admin API request failed: lookup users by ids"),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
Some((users, false))
|
||||
.log_error("lookup users by ids")
|
||||
.map(|users| (users, false))
|
||||
} else if params.has_search() {
|
||||
let offset = params.page.saturating_mul(params.limit);
|
||||
client
|
||||
@@ -125,6 +125,17 @@ async fn users_list(
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn lookup_users_in_batches(
|
||||
client: &AdminApiClient,
|
||||
user_ids: &[String],
|
||||
) -> ApiResult<Vec<AdminUser>> {
|
||||
let mut users = Vec::new();
|
||||
for batch in user_ids.chunks(USER_ID_LOOKUP_BATCH) {
|
||||
users.extend(client.lookup_users_by_ids(batch).await?);
|
||||
}
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
async fn user_detail(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -189,7 +200,7 @@ async fn user_detail_post(
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/users/{user_id}"),
|
||||
flash,
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -208,7 +219,7 @@ async fn user_detail_post(
|
||||
{
|
||||
return htmx::toast_response(&outcome.flash);
|
||||
}
|
||||
flash::redirect_with_flash(&redirect, outcome.flash, config.is_production())
|
||||
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
|
||||
}
|
||||
|
||||
async fn user_tab(
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(crate) async fn voice_regions_post(
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/voice-regions"),
|
||||
flash_from_level(level, &msg),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ pub(crate) async fn voice_servers_post(
|
||||
flash::redirect_with_flash(
|
||||
&redirect_url,
|
||||
flash_from_level(level, &msg),
|
||||
config.is_production(),
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ fn filter_bar(base: &str, p: &JobsListParams) -> Markup {
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
|
||||
(select_input("status", "Status", &[
|
||||
("", "Any"), ("queued", "Queued"), ("running", "Running"),
|
||||
("succeeded", "Succeeded"), ("failed", "Failed"),
|
||||
("cancelled", "Cancelled"), ("deadletter", "Dead-letter"),
|
||||
("succeeded", "Succeeded"), ("cancelled", "Cancelled"),
|
||||
("deadletter", "Dead-letter"),
|
||||
], p.status_filter))
|
||||
div class="flex flex-col gap-2" {
|
||||
label for="task_type" class=(FORM_LABEL_CLASS) { "Task type" }
|
||||
|
||||
@@ -218,6 +218,16 @@ async fn user_fragment_alias_returns_drawer_fragment() {
|
||||
assert!(fragment.contains("SearchedUser"), "{fragment}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_peek_alias_is_gone() {
|
||||
let app = setup().await;
|
||||
|
||||
assert_eq!(
|
||||
get_status(&app, "/users/1500000000000000001/peek").await,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drawer_triggers_use_htmx_and_native_popover() {
|
||||
let app = setup().await;
|
||||
@@ -644,6 +654,23 @@ async fn get(app: &TestApp, uri: &str, headers: &[(&str, &str)]) -> String {
|
||||
get_with_headers(app, uri, headers).await.1
|
||||
}
|
||||
|
||||
async fn get_status(app: &TestApp, uri: &str) -> StatusCode {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(uri)
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
}
|
||||
|
||||
async fn get_with_headers(
|
||||
app: &TestApp,
|
||||
uri: &str,
|
||||
@@ -708,11 +735,11 @@ fn csrf_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|pair| pair.strip_prefix("csrf_token="))
|
||||
.map(str::to_owned)
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -752,8 +779,9 @@ async fn spawn_mock_api() -> String {
|
||||
}
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/me") => json_response(json!({ "user": admin_user() })),
|
||||
let path = uri.path().to_owned();
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => json_response(json!({ "user": admin_user() })),
|
||||
(Method::GET, "/admin/api-keys") => json_response(json!([])),
|
||||
(Method::POST, "/admin/api-keys") => json_response(json!({
|
||||
"key_id": "1900000000000000001",
|
||||
@@ -763,60 +791,56 @@ async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
"expires_at": null,
|
||||
"acls": ["*"]
|
||||
})),
|
||||
(Method::POST, "/admin/users/search") => {
|
||||
(Method::GET, "/admin/users") => {
|
||||
json_response(json!({ "users": [searched_user()], "total": 1 }))
|
||||
}
|
||||
(Method::POST, "/admin/users/lookup") => {
|
||||
(Method::GET, "/admin/users/1500000000000000001") => {
|
||||
json_response(json!({ "users": [searched_user()] }))
|
||||
}
|
||||
(Method::POST, "/admin/users/update-has-verified-phone") => {
|
||||
(Method::PUT, "/admin/users/1500000000000000001/phone-verification") => {
|
||||
json_response(json!({ "user": searched_user() }))
|
||||
}
|
||||
(Method::POST, "/admin/guilds/search") => {
|
||||
(Method::GET, "/admin/guilds") => {
|
||||
json_response(json!({ "guilds": [searched_guild()], "total": 1 }))
|
||||
}
|
||||
(Method::POST, "/admin/guilds/lookup") => {
|
||||
(Method::GET, "/admin/guilds/1600000000000000001") => {
|
||||
json_response(json!({ "guild": searched_guild_detail() }))
|
||||
}
|
||||
(Method::POST, "/admin/applications/lookup") => {
|
||||
json_response(json!({ "application": searched_application() }))
|
||||
}
|
||||
(Method::POST, "/admin/applications/list-by-owner") => {
|
||||
(Method::GET, "/admin/applications") => {
|
||||
json_response(json!({ "applications": [searched_application()] }))
|
||||
}
|
||||
(Method::POST, "/admin/reports/search") => json_response(
|
||||
(Method::GET, "/admin/reports") => json_response(
|
||||
json!({ "reports": [searched_report()], "total": 1, "offset": 0, "limit": 25 }),
|
||||
),
|
||||
(Method::GET, "/admin/reports/1800000000000000001") => json_response(searched_report()),
|
||||
(Method::GET, "/admin/reports/1800000000000000002") => {
|
||||
json_response(searched_message_report())
|
||||
}
|
||||
(Method::POST, "/admin/reports/resolve") => json_response(json!({
|
||||
(Method::PATCH, "/admin/reports/1800000000000000001") => json_response(json!({
|
||||
"report_id": "1800000000000000001",
|
||||
"status": 1,
|
||||
"resolved_at": "2026-05-26T12:03:00.000Z",
|
||||
"public_comment": "done"
|
||||
})),
|
||||
(Method::POST, "/admin/jobs/list") => {
|
||||
(Method::GET, "/admin/jobs") => {
|
||||
json_response(json!({ "jobs": [searched_job()], "next_cursor": null, "cursor": null }))
|
||||
}
|
||||
(Method::POST, "/admin/jobs/get") => json_response(json!({ "job": searched_job() })),
|
||||
(Method::POST, "/admin/instance-config/get") => json_response(instance_config()),
|
||||
(Method::POST, "/admin/instance-config/registration-urls/create") => json_response(json!({
|
||||
(Method::GET, "/admin/jobs/1900000000000000001") => {
|
||||
json_response(json!({ "job": searched_job() }))
|
||||
}
|
||||
(Method::GET, "/admin/instance/config") => json_response(instance_config()),
|
||||
(Method::POST, "/admin/instance/registration-urls") => json_response(json!({
|
||||
"registration_url": registration_url_fixture(),
|
||||
"code": "11111111-1111-4111-8111-111111111111",
|
||||
"url": "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111"
|
||||
})),
|
||||
(Method::POST, "/admin/instance-config/registration-urls/revoke") => {
|
||||
(Method::DELETE, path) if path.starts_with("/admin/instance/registration-urls/") => {
|
||||
json_response(instance_config_without_registration_urls())
|
||||
}
|
||||
(Method::POST, "/admin/instance-config/pending-registrations/approve") => {
|
||||
(Method::PATCH, path) if path.starts_with("/admin/instance/pending-registrations/") => {
|
||||
json_response(instance_config_without_pending_registrations())
|
||||
}
|
||||
(Method::POST, "/admin/instance-config/pending-registrations/reject") => {
|
||||
json_response(instance_config_without_pending_registrations())
|
||||
}
|
||||
(Method::POST, "/admin/limit-config/get") => json_response(limit_config()),
|
||||
(Method::GET, "/admin/limit-config") => json_response(limit_config()),
|
||||
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/admin/users/me",
|
||||
"path": "/admin/users/@me",
|
||||
"body_file": "admin_user_me.json"
|
||||
},
|
||||
{
|
||||
@@ -21,28 +21,28 @@
|
||||
"body": "{}"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/users/search",
|
||||
"method": "GET",
|
||||
"path": "/admin/users",
|
||||
"body_file": "search_users.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/users/lookup",
|
||||
"method": "GET",
|
||||
"path": "/admin/users/1508576042312688531",
|
||||
"body_file": "lookup_user.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/guilds/search",
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds",
|
||||
"body_file": "search_guilds.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/guilds/lookup",
|
||||
"method": "GET",
|
||||
"path": "/admin/guilds/1600000000000000001",
|
||||
"body_file": "lookup_guild.json"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/admin/reports/search",
|
||||
"method": "GET",
|
||||
"path": "/admin/reports",
|
||||
"body_file": "search_reports.json"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -62,4 +62,5 @@ export interface WorkerJobOptions {
|
||||
requestedByUserId?: bigint | undefined;
|
||||
auditLogReason?: string | undefined;
|
||||
skipLedger?: boolean | undefined;
|
||||
requireLedger?: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {createServer} from '@fluxer/hono/src/Server';
|
||||
import {Hono} from 'hono';
|
||||
import {afterAll, afterEach, describe, expect, test, vi} from 'vitest';
|
||||
import {afterAll, afterEach, beforeAll, describe, expect, it, test, vi} from 'vitest';
|
||||
import {buildAPIConfigFromMaster, buildAPIServerOptions} from './Config';
|
||||
|
||||
interface ListeningServer {
|
||||
@@ -63,3 +64,50 @@ describe('buildAPIServerOptions', () => {
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
});
|
||||
});
|
||||
|
||||
function withUploadRelaySecret(master: MasterConfig, secretBase64: string): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
services: {
|
||||
...master.services,
|
||||
media_proxy: {
|
||||
...master.services.media_proxy,
|
||||
upload_relay: {
|
||||
...master.services.media_proxy.upload_relay,
|
||||
secret_base64: secretBase64,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('refuses to build without FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64', () => {
|
||||
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, ''))).toThrow(
|
||||
/FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64/,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a secret that decodes to fewer than 32 bytes', () => {
|
||||
const secret = Buffer.alloc(16, 7).toString('base64');
|
||||
expect(() => buildAPIConfigFromMaster(withUploadRelaySecret(master, secret))).toThrow(/at least 32 bytes/);
|
||||
});
|
||||
|
||||
it('accepts a secret that decodes to 32 bytes', () => {
|
||||
const secret = Buffer.alloc(32, 7).toString('base64');
|
||||
expect(
|
||||
buildAPIConfigFromMaster(withUploadRelaySecret(master, secret)).mediaProxy.uploadRelay.relaySecretBase64,
|
||||
).toBe(secret);
|
||||
});
|
||||
|
||||
it('reads the relay secret from the loaded config rather than the environment', () => {
|
||||
expect(buildAPIConfigFromMaster(master).mediaProxy.uploadRelay.relaySecretBase64).toBe(
|
||||
master.services.media_proxy.upload_relay.secret_base64,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,17 +113,18 @@ function mapPushProviderApps(
|
||||
project_id?: string;
|
||||
}>
|
||||
| undefined,
|
||||
configName: string,
|
||||
): APIConfig['push']['apns']['apps'] {
|
||||
return (apps ?? []).flatMap((app) => {
|
||||
if (!app.app_id) return [];
|
||||
return [
|
||||
{
|
||||
appId: app.app_id,
|
||||
topic: app.topic,
|
||||
environment: app.environment,
|
||||
projectId: app.project_id,
|
||||
},
|
||||
];
|
||||
return (apps ?? []).map((app) => {
|
||||
if (!app.app_id) {
|
||||
throw new Error(`${configName} contains an entry with no app_id`);
|
||||
}
|
||||
return {
|
||||
appId: app.app_id,
|
||||
topic: app.topic,
|
||||
environment: app.environment,
|
||||
projectId: app.project_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +140,13 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
serviceName: 'api',
|
||||
});
|
||||
const uploadRelayConfig = master.services.media_proxy.upload_relay;
|
||||
const uploadRelaySecretBase64 = process.env.FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 ?? '';
|
||||
const uploadRelaySecretBase64 = uploadRelayConfig.secret_base64;
|
||||
if (uploadRelaySecretBase64.length === 0) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API');
|
||||
}
|
||||
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
|
||||
}
|
||||
if (!s3Config) {
|
||||
throw new Error('S3 configuration is required for the API');
|
||||
}
|
||||
@@ -149,7 +156,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
downloads: '',
|
||||
reports: '',
|
||||
harvests: '',
|
||||
static: '',
|
||||
};
|
||||
if (master.database.backend === 'cassandra' && !cassandraSource) {
|
||||
throw new Error('Cassandra configuration is required.');
|
||||
@@ -494,7 +500,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
privateKey: master.integrations.push.apns.private_key,
|
||||
privateKeyPath: master.integrations.push.apns.private_key_path,
|
||||
defaultEnvironment: master.integrations.push.apns.default_environment ?? 'production',
|
||||
apps: mapPushProviderApps(master.integrations.push.apns.apps),
|
||||
apps: mapPushProviderApps(master.integrations.push.apns.apps, 'FLUXER_PUSH_APNS_APPS'),
|
||||
},
|
||||
fcm: {
|
||||
enabled: master.integrations.push.fcm.enabled,
|
||||
@@ -504,7 +510,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
privateKeyPath: master.integrations.push.fcm.private_key_path,
|
||||
serviceAccountJsonPath: master.integrations.push.fcm.service_account_json_path,
|
||||
tokenUri: master.integrations.push.fcm.token_uri ?? 'https://oauth2.googleapis.com/token',
|
||||
apps: mapPushProviderApps(master.integrations.push.fcm.apps),
|
||||
apps: mapPushProviderApps(master.integrations.push.fcm.apps, 'FLUXER_PUSH_FCM_APPS'),
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import type {ZodError, ZodTypeAny} from 'zod';
|
||||
import {readRequestJsonBody} from './utils/RequestJsonBody';
|
||||
import {requireRequestJsonBody} from './utils/RequestJsonBody';
|
||||
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
||||
|
||||
initializeFluxerErrorMap();
|
||||
@@ -200,7 +200,7 @@ export const Validator = <
|
||||
let value: unknown;
|
||||
switch (target) {
|
||||
case 'json':
|
||||
value = (await readRequestJsonBody(c.req)).value;
|
||||
value = await requireRequestJsonBody(c.req);
|
||||
break;
|
||||
case 'form': {
|
||||
const formData = await c.req.formData();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {beforeAll, describe, expect, it} from 'vitest';
|
||||
import {z} from 'zod';
|
||||
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
||||
|
||||
function firstIssueMessage(schema: z.ZodType, value: unknown): string | undefined {
|
||||
const result = schema.safeParse(value);
|
||||
return result.success ? undefined : result.error.issues[0]?.message;
|
||||
}
|
||||
|
||||
describe('ZodErrorMap', () => {
|
||||
beforeAll(() => {
|
||||
initializeFluxerErrorMap();
|
||||
});
|
||||
|
||||
it('maps a date below its minimum to INVALID_FORMAT', () => {
|
||||
expect(
|
||||
firstIssueMessage(z.date().min(new Date('2000-01-01T00:00:00.000Z')), new Date('1999-12-31T00:00:00.000Z')),
|
||||
).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
|
||||
it('maps a date above its maximum to INVALID_FORMAT', () => {
|
||||
expect(
|
||||
firstIssueMessage(z.date().max(new Date('2000-01-01T00:00:00.000Z')), new Date('2000-01-02T00:00:00.000Z')),
|
||||
).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
|
||||
it('maps both numeric bounds to INVALID_FORMAT', () => {
|
||||
expect(firstIssueMessage(z.number().min(1), 0)).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
expect(firstIssueMessage(z.number().max(1), 2)).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
|
||||
it('maps a string longer than its maximum to CONTENT_EXCEEDS_MAX_LENGTH', () => {
|
||||
expect(firstIssueMessage(z.string().max(1), 'ab')).toBe(ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH);
|
||||
});
|
||||
|
||||
it('maps a string shorter than its minimum to INVALID_FORMAT', () => {
|
||||
expect(firstIssueMessage(z.string().min(2), 'a')).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
});
|
||||
});
|
||||
@@ -54,12 +54,7 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
|
||||
break;
|
||||
}
|
||||
case 'too_small': {
|
||||
const origin = 'origin' in issue ? String(issue.origin) : undefined;
|
||||
if (origin === 'date') {
|
||||
errorCode = ValidationErrorCodes.INVALID_DATE_OF_BIRTH_FORMAT;
|
||||
} else {
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
}
|
||||
errorCode = ValidationErrorCodes.INVALID_FORMAT;
|
||||
break;
|
||||
}
|
||||
case 'too_big': {
|
||||
|
||||
@@ -42,9 +42,11 @@ const LOAD_ALL_BANNED_IPS_QUERY = BannedIps.select();
|
||||
const IS_EMAIL_BANNED_QUERY = BannedEmails.select({
|
||||
where: BannedEmails.where.eq('email_lower'),
|
||||
});
|
||||
const LOAD_ALL_BANNED_EMAILS_QUERY = BannedEmails.select();
|
||||
const IS_EMAIL_DOMAIN_SUSPICIOUS_QUERY = SuspiciousEmailDomains.select({
|
||||
where: SuspiciousEmailDomains.where.eq('domain'),
|
||||
});
|
||||
const LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY = SuspiciousEmailDomains.select();
|
||||
const IS_EMAIL_DOMAIN_DISPOSABLE_QUERY = DisposableEmailDomains.select({
|
||||
where: DisposableEmailDomains.where.eq('domain'),
|
||||
});
|
||||
@@ -246,6 +248,13 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(BannedEmails.deleteByPk({email_lower: emailLower}));
|
||||
}
|
||||
|
||||
async loadAllBannedEmails(): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{
|
||||
email_lower: string;
|
||||
}>(LOAD_ALL_BANNED_EMAILS_QUERY.bind({}));
|
||||
return rows.map((row) => row.email_lower);
|
||||
}
|
||||
|
||||
async isEmailDomainSuspicious(domain: string): Promise<boolean> {
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
@@ -265,6 +274,13 @@ export class AdminRepository implements IAdminRepository {
|
||||
await deleteOneOrMany(SuspiciousEmailDomains.deleteByPk({domain: domainLower}));
|
||||
}
|
||||
|
||||
async loadAllSuspiciousEmailDomains(): Promise<Array<string>> {
|
||||
const rows = await fetchMany<{
|
||||
domain: string;
|
||||
}>(LOAD_ALL_SUSPICIOUS_EMAIL_DOMAINS_QUERY.bind({}));
|
||||
return rows.map((row) => row.domain);
|
||||
}
|
||||
|
||||
async isEmailDomainDisposable(domain: string): Promise<boolean> {
|
||||
const domainLower = domain.toLowerCase();
|
||||
if (isAccountPolicyContactDomainReputationExempt(domainLower)) return false;
|
||||
|
||||
@@ -188,10 +188,14 @@ export class AdminService {
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<SendSystemDmResponse> {
|
||||
await this.apiContext.services.worker.addJob('sendSystemDm', {
|
||||
content: data.content,
|
||||
user_ids: data.userIds,
|
||||
});
|
||||
await this.apiContext.services.worker.addJob(
|
||||
'sendSystemDm',
|
||||
{
|
||||
content: data.content,
|
||||
user_ids: data.userIds,
|
||||
},
|
||||
{requireLedger: true},
|
||||
);
|
||||
const metadata = new Map<string, string>([
|
||||
['recipient_count', data.userIds.length.toString()],
|
||||
['content_length', data.content.length.toString()],
|
||||
|
||||
@@ -57,12 +57,16 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract unbanEmail(email: string): Promise<void>;
|
||||
|
||||
abstract loadAllBannedEmails(): Promise<Array<string>>;
|
||||
|
||||
abstract isEmailDomainSuspicious(domain: string): Promise<boolean>;
|
||||
|
||||
abstract addSuspiciousEmailDomain(domain: string): Promise<void>;
|
||||
|
||||
abstract removeSuspiciousEmailDomain(domain: string): Promise<void>;
|
||||
|
||||
abstract loadAllSuspiciousEmailDomains(): Promise<Array<string>>;
|
||||
|
||||
abstract isEmailDomainDisposable(domain: string): Promise<boolean>;
|
||||
|
||||
abstract addDisposableEmailDomain(domain: string): Promise<void>;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DeleteApiKeyResponse,
|
||||
ListAdminApiKeyResponse,
|
||||
type ListAdminApiKeyResponse as ListAdminApiKeyResponseType,
|
||||
UpdateAdminApiKeyRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {KeyIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {z} from 'zod';
|
||||
@@ -17,6 +18,19 @@ import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
import type {AdminApiKeyView} from '../services/AdminApiKeyService';
|
||||
|
||||
function toApiKeyResponse(key: AdminApiKeyView): ListAdminApiKeyResponseType {
|
||||
return {
|
||||
key_id: key.keyId,
|
||||
name: key.name,
|
||||
created_at: key.createdAt.toISOString(),
|
||||
last_used_at: key.lastUsedAt?.toISOString() ?? null,
|
||||
expires_at: key.expiresAt?.toISOString() ?? null,
|
||||
created_by_user_id: String(key.createdById),
|
||||
acls: Array.from(key.acls),
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
@@ -53,6 +67,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
);
|
||||
app.get(
|
||||
'/admin/api-keys',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_api_keys',
|
||||
@@ -68,26 +83,66 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
const user = ctx.get('user');
|
||||
const keys = await adminApiKeyService.listKeys(user.id);
|
||||
const response: Array<ListAdminApiKeyResponseType> = keys.map((key) => ({
|
||||
key_id: key.keyId,
|
||||
name: key.name,
|
||||
created_at: key.createdAt.toISOString(),
|
||||
last_used_at: key.lastUsedAt?.toISOString() ?? null,
|
||||
expires_at: key.expiresAt?.toISOString() ?? null,
|
||||
created_by_user_id: String(key.createdById),
|
||||
acls: Array.from(key.acls),
|
||||
}));
|
||||
const response: Array<ListAdminApiKeyResponseType> = keys.map(toApiKeyResponse);
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/api-keys/:key_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
|
||||
Validator('param', KeyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_api_key',
|
||||
summary: 'Get admin API key',
|
||||
responseSchema: ListAdminApiKeyResponse,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Retrieves a single API key created by the authenticated admin. Returns metadata including creation time, last used time, and assigned permissions. The actual key material is never returned.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
const user = ctx.get('user');
|
||||
const keyId = ctx.req.valid('param').key_id;
|
||||
const key = await adminApiKeyService.getKey(keyId, user.id);
|
||||
return ctx.json(toApiKeyResponse(key));
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/api-keys/:key_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
|
||||
Validator('param', KeyIdParam),
|
||||
Validator('json', UpdateAdminApiKeyRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_admin_api_key',
|
||||
summary: 'Update admin API key',
|
||||
responseSchema: ListAdminApiKeyResponse,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
const user = ctx.get('user');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const keyId = ctx.req.valid('param').key_id;
|
||||
const key = await adminApiKeyService.updateKey(keyId, user.id, ctx.req.valid('json'), adminUserAcls);
|
||||
return ctx.json(toApiKeyResponse(key));
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/api-keys/:keyId',
|
||||
'/admin/api-keys/:key_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.ADMIN_API_KEY_MANAGE),
|
||||
Validator('param', KeyIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_admin_api_key',
|
||||
summary: 'Delete admin API key',
|
||||
summary: 'Revoke admin API key',
|
||||
responseSchema: DeleteApiKeyResponse,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
@@ -98,7 +153,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
const user = ctx.get('user');
|
||||
const keyId = ctx.req.valid('param').keyId;
|
||||
const keyId = ctx.req.valid('param').key_id;
|
||||
await adminApiKeyService.revokeKey(keyId, user.id);
|
||||
return ctx.json({success: true}, 200);
|
||||
},
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {
|
||||
AdminApplicationIdParam,
|
||||
ApplicationUpdateResponse,
|
||||
ListGuildApplicationsRequest,
|
||||
ListGuildApplicationsResponse,
|
||||
ListUserApplicationsRequest,
|
||||
ListUserApplicationsResponse,
|
||||
LookupApplicationRequest,
|
||||
ListApplicationsQuery,
|
||||
ListApplicationsResponse,
|
||||
LookupApplicationResponse,
|
||||
TransferApplicationOwnershipRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {createApplicationID, createGuildID, createUserID} from '../../BrandedTypes';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
@@ -18,15 +20,73 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
function requireRequestAdminACL(granted: ReadonlySet<string>, required: string): void {
|
||||
if (!granted.has(required) && !granted.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(required);
|
||||
}
|
||||
}
|
||||
|
||||
export function ApplicationAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/applications/lookup',
|
||||
app.get(
|
||||
'/admin/applications',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.APPLICATION_LOOKUP, AdminACLs.APPLICATION_LIST_BY_OWNER]),
|
||||
Validator('query', ListApplicationsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_applications',
|
||||
summary: 'List applications',
|
||||
description:
|
||||
'Lists OAuth2 applications and bots. Pass owner_id to list the applications a user owns, or guild_id to list the applications whose bot users are members of a guild. Exactly one of the two is required. owner_id requires APPLICATION_LIST_BY_OWNER permission, guild_id requires APPLICATION_LOOKUP permission.',
|
||||
responseSchema: ListApplicationsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {owner_id: ownerId, guild_id: guildId} = ctx.req.valid('query');
|
||||
if (guildId != null && ownerId != null) {
|
||||
throw InputValidationError.create('guild_id', 'Only one of owner_id and guild_id may be supplied');
|
||||
}
|
||||
if (guildId != null) {
|
||||
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LOOKUP);
|
||||
return ctx.json(await adminService.applicationService.listGuildApplications(createGuildID(guildId)));
|
||||
}
|
||||
if (ownerId != null) {
|
||||
requireRequestAdminACL(ctx.get('adminUserAcls'), AdminACLs.APPLICATION_LIST_BY_OWNER);
|
||||
return ctx.json(await adminService.applicationService.listUserApplications(createUserID(ownerId)));
|
||||
}
|
||||
throw InputValidationError.create('owner_id', 'One of owner_id and guild_id is required');
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/users/:user_id/applications',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.APPLICATION_LIST_BY_OWNER),
|
||||
Validator('param', UserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_user_applications',
|
||||
summary: 'List user applications',
|
||||
description: 'Lists the OAuth2 applications and bots a user owns. Requires APPLICATION_LIST_BY_OWNER permission.',
|
||||
responseSchema: ListApplicationsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const userId = createUserID(ctx.req.valid('param').user_id);
|
||||
return ctx.json(await adminService.applicationService.listUserApplications(userId));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/applications/:application_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.APPLICATION_LOOKUP),
|
||||
Validator('json', LookupApplicationRequest),
|
||||
Validator('param', AdminApplicationIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_application',
|
||||
summary: 'Look up application',
|
||||
operationId: 'get_admin_application',
|
||||
summary: 'Get application',
|
||||
description:
|
||||
'Retrieves complete application details including ownership, bot user, OAuth2 redirect URIs, and credential status. Requires APPLICATION_LOOKUP permission.',
|
||||
responseSchema: LookupApplicationResponse,
|
||||
@@ -36,59 +96,21 @@ export function ApplicationAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.applicationService.lookupApplication(ctx.req.valid('json')));
|
||||
const applicationId = createApplicationID(ctx.req.valid('param').application_id);
|
||||
return ctx.json(await adminService.applicationService.lookupApplication(applicationId));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/applications/list-by-owner',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.APPLICATION_LIST_BY_OWNER),
|
||||
Validator('json', ListUserApplicationsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'admin_list_user_applications',
|
||||
summary: 'List applications owned by a user',
|
||||
description:
|
||||
'Lists all applications (OAuth2 clients and bots) owned by a specific user. Requires APPLICATION_LIST_BY_OWNER permission.',
|
||||
responseSchema: ListUserApplicationsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.applicationService.listUserApplications(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/applications/list-by-guild',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.APPLICATION_LOOKUP, AdminACLs.APPLICATION_LIST_BY_OWNER]),
|
||||
Validator('json', ListGuildApplicationsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'admin_list_guild_applications',
|
||||
summary: 'List applications installed in a guild',
|
||||
description:
|
||||
'Lists OAuth2 applications whose bot users are members of a guild. Requires APPLICATION_LOOKUP or APPLICATION_LIST_BY_OWNER permission.',
|
||||
responseSchema: ListGuildApplicationsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.applicationService.listGuildApplications(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/applications/transfer-ownership',
|
||||
app.patch(
|
||||
'/admin/applications/:application_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.APPLICATION_TRANSFER_OWNERSHIP),
|
||||
Validator('param', AdminApplicationIdParam),
|
||||
Validator('json', TransferApplicationOwnershipRequest),
|
||||
OpenAPI({
|
||||
operationId: 'transfer_application_ownership',
|
||||
summary: 'Transfer application ownership',
|
||||
operationId: 'update_admin_application',
|
||||
summary: 'Update application',
|
||||
description:
|
||||
'Transfers application ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.',
|
||||
'Updates an application. Transfers ownership to the user given by new_owner_id, which is used when the owner is inactive or for administrative recovery. Logged to audit log. Requires APPLICATION_TRANSFER_OWNERSHIP permission.',
|
||||
responseSchema: ApplicationUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -98,8 +120,10 @@ export function ApplicationAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const applicationId = createApplicationID(ctx.req.valid('param').application_id);
|
||||
return ctx.json(
|
||||
await adminService.applicationService.transferApplicationOwnership(
|
||||
applicationId,
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {
|
||||
AdminArchiveCreateRequest,
|
||||
AdminArchiveResponseSchema,
|
||||
DownloadUrlResponseSchema,
|
||||
GetArchiveResponseSchema,
|
||||
ListArchivesRequest,
|
||||
ListArchivesQuery,
|
||||
ListArchivesResponseSchema,
|
||||
TriggerGuildArchiveRequest,
|
||||
TriggerUserArchiveRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {ArchivePathParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ArchivePathParam, GuildIdParam, UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {createGuildID, createUserID} from '../../BrandedTypes';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
@@ -26,15 +25,34 @@ function canViewArchive(adminAcls: Set<string>, subjectType: 'user' | 'guild'):
|
||||
return adminAcls.has(AdminACLs.ARCHIVE_TRIGGER_GUILD);
|
||||
}
|
||||
|
||||
function requireArchiveSubjectAccess(adminAcls: Set<string>, subjectType: 'user' | 'guild'): void {
|
||||
if (canViewArchive(adminAcls, subjectType) || adminAcls.has(AdminACLs.WILDCARD)) return;
|
||||
throw new MissingACLError(subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD);
|
||||
}
|
||||
|
||||
function resolveListSubjectType(adminAcls: Set<string>, requested: 'all' | 'user' | 'guild'): 'all' | 'user' | 'guild' {
|
||||
if (requested !== 'all') {
|
||||
requireArchiveSubjectAccess(adminAcls, requested);
|
||||
return requested;
|
||||
}
|
||||
const viewUser = canViewArchive(adminAcls, 'user');
|
||||
const viewGuild = canViewArchive(adminAcls, 'guild');
|
||||
if (viewUser && viewGuild) return 'all';
|
||||
if (viewUser) return 'user';
|
||||
if (viewGuild) return 'guild';
|
||||
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
|
||||
}
|
||||
|
||||
export function ArchiveAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/archives/user',
|
||||
'/admin/users/:user_id/archives',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER),
|
||||
Validator('json', TriggerUserArchiveRequest),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminArchiveCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'trigger_user_archive',
|
||||
summary: 'Trigger user archive',
|
||||
operationId: 'create_admin_user_archive',
|
||||
summary: 'Create user archive',
|
||||
responseSchema: AdminArchiveResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
@@ -45,23 +63,23 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await adminArchiveService.triggerUserArchive(
|
||||
createUserID(body.user_id),
|
||||
createUserID(ctx.req.valid('param').user_id),
|
||||
adminUserId,
|
||||
body.include_attachments,
|
||||
ctx.req.valid('json').include_attachments,
|
||||
);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/archives/guild',
|
||||
'/admin/guilds/:guild_id/archives',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_GUILD),
|
||||
Validator('json', TriggerGuildArchiveRequest),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', AdminArchiveCreateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'trigger_guild_archive',
|
||||
summary: 'Trigger guild archive',
|
||||
operationId: 'create_admin_guild_archive',
|
||||
summary: 'Create guild archive',
|
||||
responseSchema: AdminArchiveResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
@@ -72,22 +90,21 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await adminArchiveService.triggerGuildArchive(
|
||||
createGuildID(body.guild_id),
|
||||
createGuildID(ctx.req.valid('param').guild_id),
|
||||
adminUserId,
|
||||
body.include_attachments,
|
||||
ctx.req.valid('json').include_attachments,
|
||||
);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/archives/list',
|
||||
app.get(
|
||||
'/admin/archives',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
Validator('json', ListArchivesRequest),
|
||||
Validator('query', ListArchivesQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_archives',
|
||||
operationId: 'list_admin_archives',
|
||||
summary: 'List archives',
|
||||
responseSchema: ListArchivesResponseSchema,
|
||||
statusCode: 200,
|
||||
@@ -99,40 +116,24 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const body = ctx.req.valid('json') as ListArchivesRequest;
|
||||
if (
|
||||
body.subject_type === 'all' &&
|
||||
!adminAcls.has(AdminACLs.ARCHIVE_VIEW_ALL) &&
|
||||
!adminAcls.has(AdminACLs.WILDCARD)
|
||||
) {
|
||||
throw new MissingACLError(AdminACLs.ARCHIVE_VIEW_ALL);
|
||||
}
|
||||
if (
|
||||
body.subject_type !== 'all' &&
|
||||
!canViewArchive(adminAcls, body.subject_type) &&
|
||||
!adminAcls.has(AdminACLs.WILDCARD)
|
||||
) {
|
||||
throw new MissingACLError(
|
||||
body.subject_type === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
const query = ctx.req.valid('query');
|
||||
const result = await adminArchiveService.listArchives({
|
||||
subjectType: body.subject_type as 'user' | 'guild' | 'all',
|
||||
subjectId: body.subject_id ?? undefined,
|
||||
requestedBy: body.requested_by ?? undefined,
|
||||
limit: body.limit,
|
||||
includeExpired: body.include_expired,
|
||||
subjectType: resolveListSubjectType(adminAcls, query.subject_type),
|
||||
subjectId: query.subject_id ?? undefined,
|
||||
requestedBy: query.requested_by ?? undefined,
|
||||
limit: query.limit,
|
||||
includeExpired: query.include_expired,
|
||||
});
|
||||
return ctx.json({archives: result}, 200);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/archives/:subjectType/:subjectId/:archiveId',
|
||||
'/admin/archives/:subject_type/:subject_id/:archive_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
Validator('param', ArchivePathParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_archive_details',
|
||||
operationId: 'get_admin_archive',
|
||||
summary: 'Get archive details',
|
||||
responseSchema: GetArchiveResponseSchema,
|
||||
statusCode: 200,
|
||||
@@ -145,25 +146,18 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const params = ctx.req.valid('param');
|
||||
const subjectType = params.subjectType;
|
||||
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(
|
||||
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
const subjectId = params.subjectId;
|
||||
const archiveId = params.archiveId;
|
||||
const archive = await adminArchiveService.getArchive(subjectType, subjectId, archiveId);
|
||||
requireArchiveSubjectAccess(adminAcls, params.subject_type);
|
||||
const archive = await adminArchiveService.getArchive(params.subject_type, params.subject_id, params.archive_id);
|
||||
return ctx.json({archive}, 200);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/archives/:subjectType/:subjectId/:archiveId/download',
|
||||
'/admin/archives/:subject_type/:subject_id/:archive_id/download',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAnyAdminACL([AdminACLs.ARCHIVE_VIEW_ALL, AdminACLs.ARCHIVE_TRIGGER_USER, AdminACLs.ARCHIVE_TRIGGER_GUILD]),
|
||||
Validator('param', ArchivePathParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_archive_download_url',
|
||||
operationId: 'get_admin_archive_download',
|
||||
summary: 'Get archive download URL',
|
||||
responseSchema: DownloadUrlResponseSchema,
|
||||
statusCode: 200,
|
||||
@@ -176,15 +170,12 @@ export function ArchiveAdminController(app: HonoApp) {
|
||||
const adminArchiveService = ctx.get('adminArchiveService');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const params = ctx.req.valid('param');
|
||||
const subjectType = params.subjectType;
|
||||
if (!canViewArchive(adminAcls, subjectType) && !adminAcls.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(
|
||||
subjectType === 'user' ? AdminACLs.ARCHIVE_TRIGGER_USER : AdminACLs.ARCHIVE_TRIGGER_GUILD,
|
||||
);
|
||||
}
|
||||
const subjectId = params.subjectId;
|
||||
const archiveId = params.archiveId;
|
||||
const result = await adminArchiveService.getDownloadUrl(subjectType, subjectId, archiveId);
|
||||
requireArchiveSubjectAccess(adminAcls, params.subject_type);
|
||||
const result = await adminArchiveService.getDownloadUrl(
|
||||
params.subject_type,
|
||||
params.subject_id,
|
||||
params.archive_id,
|
||||
);
|
||||
return ctx.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {PurgeGuildAssetsRequest, PurgeGuildAssetsResponseSchema} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
@@ -10,20 +12,21 @@ import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
export function AssetAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/assets/purge',
|
||||
app.delete(
|
||||
'/admin/guilds/:guild_id/assets',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', PurgeGuildAssetsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'purge_guild_assets',
|
||||
operationId: 'purge_admin_guild_assets',
|
||||
summary: 'Purge guild assets',
|
||||
responseSchema: PurgeGuildAssetsResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Delete and clean up all assets belonging to a guild, including icons, banners, and other media. This is a destructive operation used for cleanup during guild management or compliance actions.',
|
||||
'Delete and clean up emoji and sticker assets belonging to a guild, including their stored media. An ID owned by another guild is reported in errors and left untouched, and an ID with no record still queues its media for removal. This is a destructive operation used for cleanup during guild management or compliance actions.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
@@ -32,6 +35,7 @@ export function AssetAdminController(app: HonoApp) {
|
||||
const data = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.assetPurgeService.purgeGuildAssets({
|
||||
guildId: createGuildID(ctx.req.valid('param').guild_id),
|
||||
ids: data.ids,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
|
||||
import {
|
||||
AdminAuditLogResponseSchema,
|
||||
AuditLogIdParam,
|
||||
AuditLogsListResponseSchema,
|
||||
ListAuditLogsRequest,
|
||||
SearchAuditLogsRequest,
|
||||
ListAdminAuditLogsQuery,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
@@ -14,44 +17,66 @@ import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
export function AuditLogAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
app.get(
|
||||
'/admin/audit-logs',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
|
||||
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
|
||||
Validator('json', ListAuditLogsRequest),
|
||||
Validator('query', ListAdminAuditLogsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_audit_logs',
|
||||
summary: 'List audit logs',
|
||||
operationId: 'list_admin_audit_logs',
|
||||
summary: 'List admin audit logs',
|
||||
responseSchema: AuditLogsListResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Retrieve a paginated list of audit logs with optional filtering by date range, action type, or actor. Used for tracking administrative operations and compliance auditing.',
|
||||
'Retrieve a paginated page of audit logs with optional filtering by acting admin, target type, or target ID. Passing q runs a full-text search across the audit log index instead of paging through the log in order, and sort_by with sort_order then order the matches. Used for tracking administrative operations, compliance auditing, and incident response.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.auditService.listAuditLogs(ctx.req.valid('json')));
|
||||
const {q, admin_user_id, target_type, target_id, sort_by, sort_order, limit, offset} = ctx.req.valid('query');
|
||||
if (q === undefined) {
|
||||
return ctx.json(
|
||||
await adminService.auditService.listAuditLogs({admin_user_id, target_type, target_id, limit, offset}),
|
||||
);
|
||||
}
|
||||
return ctx.json(
|
||||
await adminService.auditService.searchAuditLogs({
|
||||
query: q,
|
||||
admin_user_id,
|
||||
target_type,
|
||||
target_id,
|
||||
sort_by,
|
||||
sort_order,
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/audit-logs/search',
|
||||
app.get(
|
||||
'/admin/audit-logs/:log_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_AUDIT_LOG),
|
||||
requireAdminACL(AdminACLs.AUDIT_LOG_VIEW),
|
||||
Validator('json', SearchAuditLogsRequest),
|
||||
Validator('param', AuditLogIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'search_audit_logs',
|
||||
summary: 'Search audit logs',
|
||||
responseSchema: AuditLogsListResponseSchema,
|
||||
operationId: 'get_admin_audit_log',
|
||||
summary: 'Get admin audit log entry',
|
||||
responseSchema: AdminAuditLogResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Perform a full-text search across audit logs for specific events or changes. Allows targeted queries for compliance investigations or incident response.',
|
||||
'Retrieve a single admin audit log entry by ID, with the same resolved user, guild, and channel summaries the listing returns. Used to inspect one administrative operation during compliance investigations or incident response.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.auditService.searchAuditLogs(ctx.req.valid('json')));
|
||||
const {log_id} = ctx.req.valid('param');
|
||||
const log = await adminService.auditService.getAuditLog(log_id);
|
||||
if (!log) {
|
||||
throw new NotFoundError({code: APIErrorCodes.NOT_FOUND});
|
||||
}
|
||||
return ctx.json(log);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
BulkAddGuildMembersRequest,
|
||||
BulkUpdateGuildFeaturesRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {BulkDeleteUserMessagesRequest} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {AdminBulkJobCreateRequest, AdminBulkTaskType} from '@fluxer/schema/src/domains/admin/AdminBulkSchemas';
|
||||
import {BulkJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
BulkUpdateSuspiciousActivityFlagsRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import {requireAnyAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
import {getWorkerService} from '../../middleware/ServiceRegistry';
|
||||
@@ -20,27 +13,25 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
export function BulkAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/bulk/update-user-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_UPDATE_USER_FLAGS),
|
||||
Validator('json', BulkUpdateUserFlagsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_update_user_flags',
|
||||
summary: 'Bulk update user flags',
|
||||
description:
|
||||
'Enqueue a background job that modifies user flags (e.g., verified, bot, system) for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const jobId = await getWorkerService().addJob(
|
||||
const BULK_TASK_ACLS: Record<AdminBulkTaskType, string> = {
|
||||
[AdminBulkTaskType.UPDATE_USER_FLAGS]: AdminACLs.BULK_UPDATE_USER_FLAGS,
|
||||
[AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS]: AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
[AdminBulkTaskType.UPDATE_GUILD_FEATURES]: AdminACLs.BULK_UPDATE_GUILD_FEATURES,
|
||||
[AdminBulkTaskType.ADD_GUILD_MEMBERS]: AdminACLs.BULK_ADD_GUILD_MEMBERS,
|
||||
[AdminBulkTaskType.SCHEDULE_USER_DELETION]: AdminACLs.BULK_DELETE_USERS,
|
||||
[AdminBulkTaskType.DELETE_USER_MESSAGES]: AdminACLs.BULK_DELETE_USER_MESSAGES,
|
||||
};
|
||||
|
||||
async function queueBulkJob(
|
||||
body: AdminBulkJobCreateRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<bigint> {
|
||||
const workerService = getWorkerService();
|
||||
const options = {requestedByUserId: adminUserId, requireLedger: true, ...(auditLogReason && {auditLogReason})};
|
||||
switch (body.task) {
|
||||
case AdminBulkTaskType.UPDATE_USER_FLAGS:
|
||||
return await workerService.addJob(
|
||||
'bulkUpdateUserFlags',
|
||||
{
|
||||
user_ids: body.user_ids.map((id) => id.toString()),
|
||||
@@ -49,31 +40,10 @@ export function BulkAdminController(app: HonoApp) {
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
|
||||
options,
|
||||
);
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/bulk/update-suspicious-activity-flags',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY),
|
||||
Validator('json', BulkUpdateSuspiciousActivityFlagsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_update_suspicious_activity_flags',
|
||||
summary: 'Bulk update suspicious activity flags',
|
||||
description:
|
||||
'Enqueue a background job that modifies suspicious activity flags for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const jobId = await getWorkerService().addJob(
|
||||
case AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS:
|
||||
return await workerService.addJob(
|
||||
'bulkUpdateSuspiciousActivityFlags',
|
||||
{
|
||||
user_ids: body.user_ids.map((id) => id.toString()),
|
||||
@@ -82,31 +52,10 @@ export function BulkAdminController(app: HonoApp) {
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
|
||||
options,
|
||||
);
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/bulk/update-guild-features',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_UPDATE_GUILD_FEATURES),
|
||||
Validator('json', BulkUpdateGuildFeaturesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_update_guild_features',
|
||||
summary: 'Bulk update guild features',
|
||||
description:
|
||||
'Enqueue a background job that modifies guild features across multiple servers. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const jobId = await getWorkerService().addJob(
|
||||
case AdminBulkTaskType.UPDATE_GUILD_FEATURES:
|
||||
return await workerService.addJob(
|
||||
'bulkUpdateGuildFeatures',
|
||||
{
|
||||
guild_ids: body.guild_ids.map((id) => id.toString()),
|
||||
@@ -115,31 +64,10 @@ export function BulkAdminController(app: HonoApp) {
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
|
||||
options,
|
||||
);
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/bulk/add-guild-members',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_ADD_GUILD_MEMBERS),
|
||||
Validator('json', BulkAddGuildMembersRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_add_guild_members',
|
||||
summary: 'Bulk add guild members',
|
||||
description:
|
||||
'Enqueue a background job that adds multiple users to a guild. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const jobId = await getWorkerService().addJob(
|
||||
case AdminBulkTaskType.ADD_GUILD_MEMBERS:
|
||||
return await workerService.addJob(
|
||||
'bulkAddGuildMembers',
|
||||
{
|
||||
guild_id: body.guild_id.toString(),
|
||||
@@ -147,31 +75,10 @@ export function BulkAdminController(app: HonoApp) {
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
|
||||
options,
|
||||
);
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/bulk/schedule-user-deletion',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_DELETE_USERS),
|
||||
Validator('json', BulkScheduleUserDeletionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'schedule_bulk_user_deletion',
|
||||
summary: 'Schedule bulk user deletion',
|
||||
description:
|
||||
'Enqueue a background job that schedules account deletions for multiple users. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the worker version skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const jobId = await getWorkerService().addJob(
|
||||
case AdminBulkTaskType.SCHEDULE_USER_DELETION:
|
||||
return await workerService.addJob(
|
||||
'bulkScheduleUserDeletion',
|
||||
{
|
||||
user_ids: body.user_ids.map((id) => id.toString()),
|
||||
@@ -181,21 +88,39 @@ export function BulkAdminController(app: HonoApp) {
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
|
||||
options,
|
||||
);
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
case AdminBulkTaskType.DELETE_USER_MESSAGES:
|
||||
return await workerService.addJob(
|
||||
'bulkDeleteMessagesForUsers',
|
||||
{
|
||||
user_ids: body.user_ids.map((id) => id.toString()),
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function BulkAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/bulk/delete-user-messages',
|
||||
'/admin/bulk-jobs',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_BULK_OPERATION),
|
||||
requireAdminACL(AdminACLs.BULK_DELETE_USER_MESSAGES),
|
||||
Validator('json', BulkDeleteUserMessagesRequest),
|
||||
Validator('json', AdminBulkJobCreateRequest),
|
||||
requireAnyAdminACL([
|
||||
AdminACLs.BULK_UPDATE_USER_FLAGS,
|
||||
AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY,
|
||||
AdminACLs.BULK_UPDATE_GUILD_FEATURES,
|
||||
AdminACLs.BULK_ADD_GUILD_MEMBERS,
|
||||
AdminACLs.BULK_DELETE_USERS,
|
||||
AdminACLs.BULK_DELETE_USER_MESSAGES,
|
||||
]),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_delete_user_messages',
|
||||
summary: 'Bulk delete user messages',
|
||||
operationId: 'create_admin_bulk_job',
|
||||
summary: 'Queue a bulk job',
|
||||
description:
|
||||
'Enqueue a background job that deletes every message authored by each of the given users across all channels. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
|
||||
'Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the schedule_user_deletion worker skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -203,17 +128,14 @@ export function BulkAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const adminAcls = ctx.get('adminUserAcls');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const jobId = await getWorkerService().addJob(
|
||||
'bulkDeleteMessagesForUsers',
|
||||
{
|
||||
user_ids: body.user_ids.map((id) => id.toString()),
|
||||
admin_user_id: adminUserId.toString(),
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
{requestedByUserId: adminUserId, ...(auditLogReason && {auditLogReason})},
|
||||
);
|
||||
const requiredAcl = BULK_TASK_ACLS[body.task];
|
||||
if (!adminAcls.has(requiredAcl) && !adminAcls.has(AdminACLs.WILDCARD)) {
|
||||
throw new MissingACLError(requiredAcl);
|
||||
}
|
||||
const jobId = await queueBulkJob(body, adminUserId, auditLogReason);
|
||||
return ctx.json({job_id: jobId.toString()});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -17,15 +17,15 @@ function trimTrailingSlash(value: string): string {
|
||||
|
||||
export function CodesAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/codes/gift',
|
||||
'/admin/gift-codes',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_CODE_GENERATION),
|
||||
requireAdminACL(AdminACLs.GIFT_CODES_GENERATE),
|
||||
Validator('json', GenerateGiftCodesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'generate_gift_codes',
|
||||
summary: 'Generate gift codes',
|
||||
operationId: 'create_admin_gift_codes',
|
||||
summary: 'Issue gift codes',
|
||||
description:
|
||||
'Create one-use Plutonium gift codes with an explicit positive duration. Lifetime gifts are not supported.',
|
||||
'Create one-use Plutonium gift codes with an explicit positive duration and return their complete redemption links. Lifetime gifts are not supported. Not available on self-hosted instances. Requires GIFT_CODES_GENERATE permission.',
|
||||
responseSchema: CodesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
DiscoveryAdminApplicationUpdateRequest,
|
||||
DiscoveryAdminCategoryListingQuery,
|
||||
DiscoveryAdminListedGuildResponse,
|
||||
DiscoveryAdminListingBulkCategoryRequest,
|
||||
DiscoveryAdminListingBulkCategoryResponse,
|
||||
DiscoveryAdminPendingApplicationResponse,
|
||||
DiscoveryAdminRejectRequest,
|
||||
DiscoveryAdminRemoveRequest,
|
||||
DiscoveryAdminReviewRequest,
|
||||
DiscoveryApplicationPatchRequest,
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryIdParam,
|
||||
DiscoveryCategoryListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {z} from 'zod';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
@@ -41,8 +46,6 @@ function mapRowToApplicationResponse(row: GuildDiscoveryRow) {
|
||||
};
|
||||
}
|
||||
|
||||
const ADMIN_LIST_HARD_CAP = 1000;
|
||||
|
||||
interface GuildEnrichment {
|
||||
name: string;
|
||||
icon: string | null;
|
||||
@@ -131,8 +134,8 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
OpenAPI({
|
||||
operationId: 'list_pending_discovery_applications',
|
||||
summary: 'List all pending discovery applications',
|
||||
operationId: 'list_admin_discovery_applications',
|
||||
summary: 'List discovery applications',
|
||||
description:
|
||||
'Returns every pending discovery application, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: z.array(DiscoveryAdminPendingApplicationResponse),
|
||||
@@ -144,21 +147,106 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const guildService = ctx.get('guildService');
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const rows = await discoveryService.listByStatus({
|
||||
status: DiscoveryApplicationStatus.PENDING,
|
||||
limit: ADMIN_LIST_HARD_CAP,
|
||||
});
|
||||
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.PENDING});
|
||||
const enrichment = await enrichGuilds(rows, guildService, userRepository);
|
||||
return ctx.json(rows.map((row) => mapPendingResponse(row, enrichment.get(row.guild_id.toString()))));
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/discovery/applications/:guild_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryAdminApplicationUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_admin_discovery_application',
|
||||
summary: 'Review discovery application',
|
||||
description: 'Approve or reject a pending discovery application. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const row =
|
||||
data.status === DiscoveryApplicationStatus.APPROVED
|
||||
? await discoveryService.approve({guildId, adminUserId, reason: data.reason})
|
||||
: await discoveryService.reject({guildId, adminUserId, reason: data.reason});
|
||||
return ctx.json(mapRowToApplicationResponse(row));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/discovery/listed',
|
||||
'/admin/discovery/categories',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
OpenAPI({
|
||||
operationId: 'list_discovery_listed_guilds',
|
||||
summary: 'List all guilds currently listed in discovery',
|
||||
operationId: 'list_admin_discovery_categories',
|
||||
summary: 'List discovery categories',
|
||||
description:
|
||||
'Returns every discovery category a listing can be filed under. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: DiscoveryCategoryListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
return ctx.json(
|
||||
Object.entries(DiscoveryCategoryLabels).map(([id, name]) => ({
|
||||
id: Number(id),
|
||||
name,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/discovery/categories/:category_id/listings',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('param', DiscoveryCategoryIdParam),
|
||||
Validator('query', DiscoveryAdminCategoryListingQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_discovery_category_listings',
|
||||
summary: 'List guilds in a discovery category',
|
||||
description:
|
||||
'Returns an offset page of the guilds listed under one discovery category, most members first, enriched with guild metadata. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {category_id} = ctx.req.valid('param');
|
||||
const {limit, offset} = ctx.req.valid('query');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const guildService = ctx.get('guildService');
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED});
|
||||
const inCategory = rows.filter((row) => row.category_type === category_id);
|
||||
const enrichment = await enrichGuilds(inCategory, guildService, userRepository);
|
||||
const sorted = [...inCategory].sort(
|
||||
(left, right) =>
|
||||
(enrichment.get(right.guild_id.toString())?.member_count ?? 0) -
|
||||
(enrichment.get(left.guild_id.toString())?.member_count ?? 0),
|
||||
);
|
||||
return ctx.json(
|
||||
sorted
|
||||
.slice(offset, offset + limit)
|
||||
.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/discovery/listings',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_LIST),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_discovery_listings',
|
||||
summary: 'List discovery listings',
|
||||
description:
|
||||
'Returns every approved/listed discovery guild, enriched with guild metadata. No pagination. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: z.array(DiscoveryAdminListedGuildResponse),
|
||||
@@ -170,24 +258,59 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const guildService = ctx.get('guildService');
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const rows = await discoveryService.listByStatus({
|
||||
status: DiscoveryApplicationStatus.APPROVED,
|
||||
limit: ADMIN_LIST_HARD_CAP,
|
||||
});
|
||||
const rows = await discoveryService.listByStatus({status: DiscoveryApplicationStatus.APPROVED});
|
||||
const enrichment = await enrichGuilds(rows, guildService, userRepository);
|
||||
return ctx.json(rows.map((row) => mapListedResponse(row, enrichment.get(row.guild_id.toString()))));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/discovery/applications/:guild_id/approve',
|
||||
app.patch(
|
||||
'/admin/discovery/listings',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('json', DiscoveryAdminListingBulkCategoryRequest),
|
||||
OpenAPI({
|
||||
operationId: 'bulk_update_admin_discovery_listing_category',
|
||||
summary: 'Move discovery listings to a category',
|
||||
description:
|
||||
'Files every named discovery listing under one category. Every guild is attempted and the ones that could not be moved are reported. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: DiscoveryAdminListingBulkCategoryResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const guildIds = [...new Set(data.guild_ids)];
|
||||
const failed: Array<string> = [];
|
||||
let updated = 0;
|
||||
for (const rawGuildId of guildIds) {
|
||||
try {
|
||||
await discoveryService.editApplication({
|
||||
guildId: createGuildID(rawGuildId),
|
||||
userId: adminUserId,
|
||||
data: {category_type: data.category_type},
|
||||
});
|
||||
updated += 1;
|
||||
} catch {
|
||||
failed.push(rawGuildId.toString());
|
||||
}
|
||||
}
|
||||
return ctx.json({updated, failed_guild_ids: failed});
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/discovery/listings/:guild_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryAdminReviewRequest),
|
||||
Validator('json', DiscoveryApplicationPatchRequest),
|
||||
OpenAPI({
|
||||
operationId: 'approve_discovery_application',
|
||||
summary: 'Approve discovery application',
|
||||
description: 'Approve a pending discovery application. Requires DISCOVERY_REVIEW permission.',
|
||||
operationId: 'update_admin_discovery_listing',
|
||||
summary: 'Update discovery listing',
|
||||
description:
|
||||
'Edit the description, category, language, or tags of a discovery listing without delisting the guild. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -199,44 +322,19 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const row = await discoveryService.approve({guildId, adminUserId, reason: data.reason});
|
||||
const row = await discoveryService.editApplication({guildId, userId: adminUserId, data});
|
||||
return ctx.json(mapRowToApplicationResponse(row));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/discovery/applications/:guild_id/reject',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REVIEW),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryAdminRejectRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reject_discovery_application',
|
||||
summary: 'Reject discovery application',
|
||||
description: 'Reject a pending discovery application. Requires DISCOVERY_REVIEW permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const {guild_id} = ctx.req.valid('param');
|
||||
const guildId = createGuildID(guild_id);
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const row = await discoveryService.reject({guildId, adminUserId, reason: data.reason});
|
||||
return ctx.json(mapRowToApplicationResponse(row));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/discovery/guilds/:guild_id/remove',
|
||||
app.delete(
|
||||
'/admin/discovery/listings/:guild_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_ADMIN_ACTION),
|
||||
requireAdminACL(AdminACLs.DISCOVERY_REMOVE),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', DiscoveryAdminRemoveRequest),
|
||||
OpenAPI({
|
||||
operationId: 'remove_from_discovery',
|
||||
summary: 'Remove guild from discovery',
|
||||
operationId: 'delete_admin_discovery_listing',
|
||||
summary: 'Remove discovery listing',
|
||||
description: 'Remove an approved guild from discovery. Requires DISCOVERY_REMOVE permission.',
|
||||
responseSchema: DiscoveryApplicationResponse,
|
||||
statusCode: 200,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {GetProcessMemoryStatsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {GetProcessMemoryStatsQuery} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {
|
||||
GatewayVoiceStateCountsResponse,
|
||||
GuildMemoryStatsResponse,
|
||||
@@ -18,54 +18,12 @@ import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
export function GatewayAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/gateway/memory-stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
Validator('json', GetProcessMemoryStatsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_guild_memory_statistics',
|
||||
summary: 'Get guild memory statistics',
|
||||
description: 'Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.',
|
||||
responseSchema: GuildMemoryStatsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(body.limit));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/gateway/reload-all',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD),
|
||||
requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL),
|
||||
Validator('json', ReloadGuildsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reload_all_specified_guilds',
|
||||
summary: 'Reload specified guilds',
|
||||
description:
|
||||
'Reconnects to the database and re-syncs guild state. Used for recovery after data inconsistencies. Requires GATEWAY_RELOAD_ALL permission.',
|
||||
responseSchema: ReloadAllGuildsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildIds = body.guild_ids.map((id) => createGuildID(id));
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/gateway/stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
OpenAPI({
|
||||
operationId: 'get_gateway_node_statistics',
|
||||
operationId: 'get_admin_gateway_stats',
|
||||
summary: 'Get gateway node statistics',
|
||||
description:
|
||||
'Returns uptime, process memory, and guild count. Used to monitor gateway health and performance. Requires GATEWAY_MEMORY_STATS permission.',
|
||||
@@ -79,12 +37,32 @@ export function GatewayAdminController(app: HonoApp) {
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.getNodeStats());
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/gateway/memory-stats',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
Validator('query', GetProcessMemoryStatsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_gateway_memory_stats',
|
||||
summary: 'Get guild memory statistics',
|
||||
description: 'Returns heap and resident memory usage per guild. Requires GATEWAY_MEMORY_STATS permission.',
|
||||
responseSchema: GuildMemoryStatsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {limit} = ctx.req.valid('query');
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.getGuildMemoryStats(limit));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/gateway/voice-state-counts',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GATEWAY_MEMORY_STATS),
|
||||
OpenAPI({
|
||||
operationId: 'get_gateway_voice_state_counts',
|
||||
operationId: 'get_admin_gateway_voice_state_counts',
|
||||
summary: 'Get gateway voice state counts',
|
||||
description:
|
||||
'Returns active voice state counts grouped by voice region and voice server. Requires GATEWAY_MEMORY_STATS permission.',
|
||||
@@ -98,4 +76,26 @@ export function GatewayAdminController(app: HonoApp) {
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.getVoiceStateCounts());
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/gateway/reloads',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GATEWAY_RELOAD),
|
||||
requireAdminACL(AdminACLs.GATEWAY_RELOAD_ALL),
|
||||
Validator('json', ReloadGuildsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_admin_gateway_reload',
|
||||
summary: 'Reload gateway guilds',
|
||||
description:
|
||||
'Reconnects to the database and re-syncs guild state. Used for recovery after data inconsistencies. Requires GATEWAY_RELOAD_ALL permission.',
|
||||
responseSchema: ReloadAllGuildsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildIds = body.guild_ids.map((id) => createGuildID(id));
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {
|
||||
BanGuildMemberRequest,
|
||||
ClearGuildFieldsRequest,
|
||||
DeleteGuildRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
ListGuildAuditLogsRequest,
|
||||
BanGuildMemberBody,
|
||||
ListGuildAuditLogsResponse,
|
||||
ListGuildMembersRequest,
|
||||
LookupGuildRequest,
|
||||
ReloadGuildRequest,
|
||||
ShutdownGuildRequest,
|
||||
TransferGuildOwnershipRequest,
|
||||
UpdateGuildFeaturesRequest,
|
||||
UpdateGuildNameRequest,
|
||||
UpdateGuildSettingsRequest,
|
||||
UpdateGuildVanityRequest,
|
||||
ListGuildMembersQuery,
|
||||
ListGuildsQuery,
|
||||
UpdateGuildRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {
|
||||
GuildUpdateResponse,
|
||||
@@ -25,29 +16,117 @@ import {
|
||||
ListGuildMembersResponse,
|
||||
ListGuildStickersResponse,
|
||||
LookupGuildResponse,
|
||||
SearchGuildsResponse,
|
||||
SuccessResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildIdParam, GuildIdUserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildAuditLogListQuery} from '@fluxer/schema/src/domains/guild/GuildAuditLogSchemas';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {requireAdminACL, requireAnyAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import {AdminRateLimitConfigs} from '../../rate_limit_configs/AdminRateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
const GUILD_UPDATE_ACLS = [
|
||||
AdminACLs.GUILD_UPDATE_NAME,
|
||||
AdminACLs.GUILD_UPDATE_SETTINGS,
|
||||
AdminACLs.GUILD_UPDATE_FEATURES,
|
||||
AdminACLs.GUILD_UPDATE_VANITY,
|
||||
AdminACLs.GUILD_TRANSFER_OWNERSHIP,
|
||||
];
|
||||
|
||||
function hasGuildSettingsUpdate(body: UpdateGuildRequest): boolean {
|
||||
return (
|
||||
body.verification_level !== undefined ||
|
||||
body.mfa_level !== undefined ||
|
||||
body.nsfw_level !== undefined ||
|
||||
body.nsfw !== undefined ||
|
||||
body.content_warning_level !== undefined ||
|
||||
body.content_warning_text !== undefined ||
|
||||
body.explicit_content_filter !== undefined ||
|
||||
body.default_message_notifications !== undefined ||
|
||||
body.disabled_operations !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function hasGuildFeatureUpdate(body: UpdateGuildRequest): boolean {
|
||||
return body.add_features !== undefined || body.remove_features !== undefined;
|
||||
}
|
||||
|
||||
function selectGuildUpdateACLs(body: UpdateGuildRequest): Array<string> {
|
||||
const required: Array<string> = [];
|
||||
if (body.name !== undefined) {
|
||||
required.push(AdminACLs.GUILD_UPDATE_NAME);
|
||||
}
|
||||
if (body.fields !== undefined || hasGuildSettingsUpdate(body)) {
|
||||
required.push(AdminACLs.GUILD_UPDATE_SETTINGS);
|
||||
}
|
||||
if (hasGuildFeatureUpdate(body)) {
|
||||
required.push(AdminACLs.GUILD_UPDATE_FEATURES);
|
||||
}
|
||||
if (body.vanity_url_code !== undefined) {
|
||||
required.push(AdminACLs.GUILD_UPDATE_VANITY);
|
||||
}
|
||||
if (body.new_owner_id !== undefined) {
|
||||
required.push(AdminACLs.GUILD_TRANSFER_OWNERSHIP);
|
||||
}
|
||||
if (required.length > 0) {
|
||||
return required;
|
||||
}
|
||||
return Object.keys(body).length === 0 ? [] : [AdminACLs.WILDCARD];
|
||||
}
|
||||
|
||||
function requireAllAdminACLs(granted: ReadonlySet<string>, required: ReadonlyArray<string>): void {
|
||||
if (granted.has(AdminACLs.WILDCARD)) {
|
||||
return;
|
||||
}
|
||||
for (const acl of required) {
|
||||
if (!granted.has(acl)) {
|
||||
throw new MissingACLError(acl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function GuildAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/guilds/lookup',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
app.get(
|
||||
'/admin/guilds',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', LookupGuildRequest),
|
||||
Validator('query', ListGuildsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_guild',
|
||||
summary: 'Look up guild',
|
||||
operationId: 'list_admin_guilds',
|
||||
summary: 'List guilds',
|
||||
description:
|
||||
'Retrieves complete guild details including metadata, settings, and statistics. Look up by guild ID or vanity slug. Requires GUILD_LOOKUP permission.',
|
||||
'Searches guilds by name, ID, and other criteria. Supports full-text search and pagination through limit and offset. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: SearchGuildsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.searchService.searchGuilds({
|
||||
query: query.q,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_guild',
|
||||
summary: 'Get guild',
|
||||
description:
|
||||
'Retrieves complete guild details including metadata, settings, channels, roles, and statistics. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: LookupGuildResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -55,16 +134,138 @@ export function GuildAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.guildServiceAggregate.lookupService.lookupGuild(ctx.req.valid('json')));
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.lookupService.lookupGuild({
|
||||
guild_id: ctx.req.valid('param').guild_id,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/list-members',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS),
|
||||
Validator('json', ListGuildMembersRequest),
|
||||
app.patch(
|
||||
'/admin/guilds/:guild_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAnyAdminACL(GUILD_UPDATE_ACLS),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('json', UpdateGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'admin_list_guild_members',
|
||||
operationId: 'update_admin_guild',
|
||||
summary: 'Update guild',
|
||||
description:
|
||||
'Partially updates a guild. The permissions required are selected by the fields present in the body and are evaluated with all-of semantics: name requires GUILD_UPDATE_NAME, vanity_url_code requires GUILD_UPDATE_VANITY, new_owner_id requires GUILD_TRANSFER_OWNERSHIP, add_features and remove_features require GUILD_UPDATE_FEATURES, and fields together with every other setting requires GUILD_UPDATE_SETTINGS. A body with no fields applies no change. Every applied change is logged to the audit log.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const guildIdRaw = ctx.req.valid('param').guild_id;
|
||||
const body = ctx.req.valid('json');
|
||||
requireAllAdminACLs(ctx.get('adminUserAcls'), selectGuildUpdateACLs(body));
|
||||
const {updateService, vanityService, lookupService} = adminService.guildServiceAggregate;
|
||||
if (body.fields !== undefined) {
|
||||
await updateService.clearGuildFields({guild_id: guildIdRaw, fields: body.fields}, adminUserId, auditLogReason);
|
||||
}
|
||||
if (hasGuildSettingsUpdate(body)) {
|
||||
await updateService.updateGuildSettings(
|
||||
{
|
||||
guild_id: guildIdRaw,
|
||||
verification_level: body.verification_level,
|
||||
mfa_level: body.mfa_level,
|
||||
nsfw_level: body.nsfw_level,
|
||||
nsfw: body.nsfw,
|
||||
content_warning_level: body.content_warning_level,
|
||||
content_warning_text: body.content_warning_text,
|
||||
explicit_content_filter: body.explicit_content_filter,
|
||||
default_message_notifications: body.default_message_notifications,
|
||||
disabled_operations: body.disabled_operations,
|
||||
},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
}
|
||||
if (hasGuildFeatureUpdate(body)) {
|
||||
await updateService.updateGuildFeatures({
|
||||
guildId: createGuildID(guildIdRaw),
|
||||
addFeatures: body.add_features ?? [],
|
||||
removeFeatures: body.remove_features ?? [],
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
});
|
||||
}
|
||||
if (body.name !== undefined) {
|
||||
await updateService.updateGuildName({guild_id: guildIdRaw, name: body.name}, adminUserId, auditLogReason);
|
||||
}
|
||||
if (body.vanity_url_code !== undefined) {
|
||||
await vanityService.updateGuildVanity(
|
||||
{guild_id: guildIdRaw, vanity_url_code: body.vanity_url_code},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
}
|
||||
if (body.new_owner_id !== undefined) {
|
||||
await updateService.transferGuildOwnership(
|
||||
{guild_id: guildIdRaw, new_owner_id: body.new_owner_id},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
}
|
||||
const {guild} = await lookupService.lookupGuild({guild_id: guildIdRaw});
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
}
|
||||
return ctx.json({
|
||||
guild: {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
features: guild.features,
|
||||
owner_id: guild.owner_id,
|
||||
icon: guild.icon,
|
||||
banner: guild.banner,
|
||||
member_count: guild.member_count,
|
||||
nsfw_level: guild.nsfw_level,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/guilds/:guild_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_DELETE),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_admin_guild',
|
||||
summary: 'Delete guild',
|
||||
description:
|
||||
'Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation with no recovery window. Logged to audit log. Requires GUILD_DELETE permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.managementService.deleteGuild(
|
||||
ctx.req.valid('param').guild_id,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/members',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LIST_MEMBERS),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('query', ListGuildMembersQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_guild_members',
|
||||
summary: 'List guild members',
|
||||
description:
|
||||
'Lists all guild members with pagination. Returns member IDs, join dates, and roles. Requires GUILD_LIST_MEMBERS permission.',
|
||||
@@ -75,7 +276,102 @@ export function GuildAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildMembers(ctx.req.valid('json')));
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.lookupService.listGuildMembers({
|
||||
guild_id: ctx.req.valid('param').guild_id,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/guilds/:guild_id/members/:user_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER),
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'add_admin_guild_member',
|
||||
summary: 'Add guild member',
|
||||
description:
|
||||
'Forcefully adds a user to a guild. Bypasses normal invite flow for administrative account recovery. Logged to audit log. Requires GUILD_FORCE_ADD_MEMBER permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const params = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.membershipService.forceAddUserToGuild({
|
||||
data: {guild_id: params.guild_id, user_id: params.user_id},
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/guilds/:guild_id/members/:user_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_KICK_MEMBER),
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'kick_admin_guild_member',
|
||||
summary: 'Remove guild member',
|
||||
description:
|
||||
'Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const params = ctx.req.valid('param');
|
||||
await adminService.guildServiceAggregate.membershipService.kickMember(
|
||||
{guild_id: params.guild_id, user_id: params.user_id},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/guilds/:guild_id/bans/:user_id',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_BAN_MEMBER),
|
||||
Validator('param', GuildIdUserIdParam),
|
||||
Validator('json', BanGuildMemberBody),
|
||||
OpenAPI({
|
||||
operationId: 'ban_admin_guild_member',
|
||||
summary: 'Ban guild member',
|
||||
description:
|
||||
'Bans a user from a guild, optionally deleting their recent messages. Prevents the user from joining until the ban expires or is removed. Logged to audit log. Requires GUILD_BAN_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const params = ctx.req.valid('param');
|
||||
await adminService.guildServiceAggregate.membershipService.banMember(
|
||||
{...ctx.req.valid('json'), guild_id: params.guild_id, user_id: params.user_id},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -84,7 +380,7 @@ export function GuildAdminController(app: HonoApp) {
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'admin_list_guild_emojis',
|
||||
operationId: 'list_admin_guild_emojis',
|
||||
summary: 'List guild emojis',
|
||||
description:
|
||||
'Lists all custom emojis in a guild. Returns ID, name, and creation date. Used for asset inventory and purge operations. Requires ASSET_PURGE permission.',
|
||||
@@ -105,7 +401,7 @@ export function GuildAdminController(app: HonoApp) {
|
||||
requireAdminACL(AdminACLs.ASSET_PURGE),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'admin_list_guild_stickers',
|
||||
operationId: 'list_admin_guild_stickers',
|
||||
summary: 'List guild stickers',
|
||||
description:
|
||||
'Lists all stickers in a guild. Returns ID, name, and asset information. Used for asset inventory and purge operations. Requires ASSET_PURGE permission.',
|
||||
@@ -120,13 +416,14 @@ export function GuildAdminController(app: HonoApp) {
|
||||
return ctx.json(await adminService.guildServiceAggregate.lookupService.listGuildStickers(guildId));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/audit-logs',
|
||||
app.get(
|
||||
'/admin/guilds/:guild_id/audit-logs',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_AUDIT_LOG_VIEW),
|
||||
Validator('json', ListGuildAuditLogsRequest),
|
||||
Validator('param', GuildIdParam),
|
||||
Validator('query', GuildAuditLogListQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_guild_audit_logs_admin',
|
||||
operationId: 'list_admin_guild_audit_logs',
|
||||
summary: 'List guild audit logs',
|
||||
description:
|
||||
'Returns in-app guild audit log entries for a guild without requiring VIEW_AUDIT_LOG membership permission. Supports pagination via before/after log IDs and filtering by user_id or action_type. Requires GUILD_AUDIT_LOG_VIEW permission.',
|
||||
@@ -137,271 +434,26 @@ export function GuildAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.guildServiceAggregate.listGuildAuditLogs(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/clear-fields',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
|
||||
Validator('json', ClearGuildFieldsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'clear_guild_fields',
|
||||
summary: 'Clear guild fields',
|
||||
description:
|
||||
'Clears specified optional guild fields such as icon, banner, or description. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.guildServiceAggregate.updateService.clearGuildFields(
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/update-features',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_FEATURES),
|
||||
Validator('json', UpdateGuildFeaturesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_features',
|
||||
summary: 'Update guild features',
|
||||
description:
|
||||
'Enables or disables guild feature flags. Modifies verification levels and community settings. Changes are logged to audit log. Requires GUILD_UPDATE_FEATURES permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildId = createGuildID(body.guild_id);
|
||||
const query = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.updateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures: body.add_features,
|
||||
removeFeatures: body.remove_features,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
await adminService.guildServiceAggregate.listGuildAuditLogs({
|
||||
guild_id: ctx.req.valid('param').guild_id,
|
||||
limit: query.limit,
|
||||
before: query.before,
|
||||
after: query.after,
|
||||
user_id: query.user_id,
|
||||
action_type: query.action_type,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/update-name',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_NAME),
|
||||
Validator('json', UpdateGuildNameRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_name',
|
||||
summary: 'Update guild name',
|
||||
description:
|
||||
'Changes a guild name. Used for removing inappropriate names or correcting display issues. Logged to audit log. Requires GUILD_UPDATE_NAME permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.updateService.updateGuildName(
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/update-settings',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_SETTINGS),
|
||||
Validator('json', UpdateGuildSettingsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_settings',
|
||||
summary: 'Update guild settings',
|
||||
description:
|
||||
'Modifies guild configuration including description, region, language and other settings. Logged to audit log. Requires GUILD_UPDATE_SETTINGS permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.updateService.updateGuildSettings(
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/transfer-ownership',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_TRANSFER_OWNERSHIP),
|
||||
Validator('json', TransferGuildOwnershipRequest),
|
||||
OpenAPI({
|
||||
operationId: 'admin_transfer_guild_ownership',
|
||||
summary: 'Transfer guild ownership',
|
||||
description:
|
||||
'Transfers guild ownership to another user. Used when owner is inactive or for administrative recovery. Logged to audit log. Requires GUILD_TRANSFER_OWNERSHIP permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.updateService.transferGuildOwnership(
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/update-vanity',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_UPDATE_VANITY),
|
||||
Validator('json', UpdateGuildVanityRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_guild_vanity',
|
||||
summary: 'Update guild vanity',
|
||||
description:
|
||||
'Updates a guild vanity URL slug. Sets custom short URL and prevents duplicate slugs. Logged to audit log. Requires GUILD_UPDATE_VANITY permission.',
|
||||
responseSchema: GuildUpdateResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.vanityService.updateGuildVanity(
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/force-add-user',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_FORCE_ADD_MEMBER),
|
||||
Validator('json', ForceAddUserToGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'force_add_user_to_guild',
|
||||
summary: 'Force add user to guild',
|
||||
description:
|
||||
'Forcefully adds a user to a guild. Bypasses normal invite flow for administrative account recovery. Logged to audit log. Requires GUILD_FORCE_ADD_MEMBER permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const requestCache = ctx.get('requestCache');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.membershipService.forceAddUserToGuild({
|
||||
data: ctx.req.valid('json'),
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/ban-member',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_BAN_MEMBER),
|
||||
Validator('json', BanGuildMemberRequest),
|
||||
OpenAPI({
|
||||
operationId: 'admin_ban_guild_member',
|
||||
summary: 'Ban guild member',
|
||||
description:
|
||||
'Permanently bans a user from a guild. Prevents user from joining. Logged to audit log. Requires GUILD_BAN_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.guildServiceAggregate.membershipService.banMember(
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/kick-member',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_KICK_MEMBER),
|
||||
Validator('json', KickGuildMemberRequest),
|
||||
OpenAPI({
|
||||
operationId: 'kick_guild_member',
|
||||
summary: 'Kick guild member',
|
||||
description:
|
||||
'Temporarily removes a user from a guild. User can rejoin. Logged to audit log. Requires GUILD_KICK_MEMBER permission.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
await adminService.guildServiceAggregate.membershipService.kickMember(
|
||||
ctx.req.valid('json'),
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
);
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/reload',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
'/admin/guilds/:guild_id/reloads',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_RELOAD),
|
||||
Validator('json', ReloadGuildRequest),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'reload_guild',
|
||||
operationId: 'create_admin_guild_reload',
|
||||
summary: 'Reload guild',
|
||||
description:
|
||||
'Reloads a single guild state from database. Used to recover from corruption or sync issues. Logged to audit log. Requires GUILD_RELOAD permission.',
|
||||
@@ -414,10 +466,9 @@ export function GuildAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.managementService.reloadGuild(
|
||||
body.guild_id,
|
||||
ctx.req.valid('param').guild_id,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
@@ -425,13 +476,13 @@ export function GuildAdminController(app: HonoApp) {
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/shutdown',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
'/admin/guilds/:guild_id/shutdowns',
|
||||
RateLimitMiddleware(AdminRateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_SHUTDOWN),
|
||||
Validator('json', ShutdownGuildRequest),
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'shutdown_guild',
|
||||
summary: 'Shutdown guild',
|
||||
operationId: 'create_admin_guild_shutdown',
|
||||
summary: 'Shut down guild',
|
||||
description:
|
||||
'Shuts down and unloads a guild from the gateway. Guild data remains in database. Used for emergency resource cleanup. Logged to audit log. Requires GUILD_SHUTDOWN permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
@@ -443,39 +494,9 @@ export function GuildAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.managementService.shutdownGuild(
|
||||
body.guild_id,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/guilds/delete',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.GUILD_DELETE),
|
||||
Validator('json', DeleteGuildRequest),
|
||||
OpenAPI({
|
||||
operationId: 'admin_delete_guild',
|
||||
summary: 'Delete guild',
|
||||
description:
|
||||
'Permanently deletes a guild. Deletes all channels, messages, and settings. Irreversible operation. Logged to audit log. Requires GUILD_DELETE permission.',
|
||||
responseSchema: SuccessResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.guildServiceAggregate.managementService.deleteGuild(
|
||||
body.guild_id,
|
||||
ctx.req.valid('param').guild_id,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
InstanceEmailSmtpTestRequest,
|
||||
InstanceEmailSmtpTestResponse,
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
@@ -166,12 +167,12 @@ async function grantSetupCompleterAdminACL(ctx: Context<HonoEnv>): Promise<void>
|
||||
|
||||
export function InstanceConfigAdminController(app: HonoApp) {
|
||||
const instanceConfigRepository = getInstanceConfigRepository();
|
||||
app.post(
|
||||
'/admin/instance-config/get',
|
||||
app.get(
|
||||
'/admin/instance/config',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'get_instance_config',
|
||||
operationId: 'get_admin_instance_config',
|
||||
summary: 'Get instance configuration',
|
||||
description:
|
||||
'Retrieves instance-wide configuration including webhooks and SSO configuration. Requires INSTANCE_CONFIG_VIEW permission.',
|
||||
@@ -184,13 +185,13 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/instance-config/update',
|
||||
app.patch(
|
||||
'/admin/instance/config',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('json', InstanceConfigUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_instance_config',
|
||||
operationId: 'update_admin_instance_config',
|
||||
summary: 'Update instance configuration',
|
||||
description:
|
||||
'Updates instance configuration settings including webhook URLs and SSO parameters. Changes apply immediately. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
@@ -379,13 +380,13 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/instance-config/branding-asset',
|
||||
'/admin/instance/config/branding-assets',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('json', BrandingAssetUploadRequest),
|
||||
OpenAPI({
|
||||
operationId: 'upload_instance_branding_asset',
|
||||
summary: 'Upload or clear an instance branding asset',
|
||||
operationId: 'create_admin_instance_branding_asset',
|
||||
summary: 'Upload an instance branding asset',
|
||||
description:
|
||||
'Uploads a branding image served by the media proxy and stores its URL, or clears it when no image is provided. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
responseSchema: InstanceConfigResponse,
|
||||
@@ -409,13 +410,13 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/instance-config/integrations/smtp/test',
|
||||
'/admin/instance/config/smtp-tests',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireSetupSessionOrAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('json', InstanceEmailSmtpTestRequest),
|
||||
OpenAPI({
|
||||
operationId: 'test_instance_smtp_config',
|
||||
summary: 'Validate SMTP configuration',
|
||||
operationId: 'create_admin_instance_smtp_test',
|
||||
summary: 'Run an SMTP configuration test',
|
||||
description:
|
||||
'Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
responseSchema: InstanceEmailSmtpTestResponse,
|
||||
@@ -444,12 +445,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/instance-config/registration-urls/create',
|
||||
'/admin/instance/registration-urls',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('json', CreateRegistrationUrlRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_registration_url',
|
||||
operationId: 'create_admin_registration_url',
|
||||
summary: 'Create an admin-issued registration URL',
|
||||
description:
|
||||
'Creates a one-time-display registration URL that can be sent manually by an administrator. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
@@ -474,13 +475,13 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/instance-config/registration-urls/revoke',
|
||||
app.delete(
|
||||
'/admin/instance/registration-urls/:registration_url_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('json', RegistrationUrlActionRequest),
|
||||
Validator('param', RegistrationUrlIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'revoke_registration_url',
|
||||
operationId: 'revoke_admin_registration_url',
|
||||
summary: 'Revoke an admin-issued registration URL',
|
||||
description:
|
||||
'Revokes an admin-issued registration URL so it can no longer be used. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
@@ -490,50 +491,30 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await instanceConfigRepository.revokeRegistrationUrl(ctx.req.valid('json').id);
|
||||
await instanceConfigRepository.revokeRegistrationUrl(ctx.req.valid('param').registration_url_id);
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/instance-config/pending-registrations/approve',
|
||||
app.patch(
|
||||
'/admin/instance/pending-registrations/:user_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', PendingRegistrationActionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'approve_pending_registration',
|
||||
summary: 'Approve a pending registration',
|
||||
operationId: 'update_admin_pending_registration',
|
||||
summary: 'Approve or reject a pending registration',
|
||||
description:
|
||||
'Approves a registration waiting for manual review by removing its pending registration trait. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
'Decides a registration waiting for manual review. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
responseSchema: InstanceConfigResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.req.valid('json').user_id;
|
||||
await updatePendingRegistrationUser(ctx, userId, 'approve');
|
||||
await instanceConfigRepository.removePendingRegistration(userId);
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/instance-config/pending-registrations/reject',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
||||
Validator('json', PendingRegistrationActionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'reject_pending_registration',
|
||||
summary: 'Reject a pending registration',
|
||||
description:
|
||||
'Rejects a registration waiting for manual review and prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.',
|
||||
responseSchema: InstanceConfigResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const userId = ctx.req.valid('json').user_id;
|
||||
await updatePendingRegistrationUser(ctx, userId, 'reject');
|
||||
const userId = ctx.req.valid('param').user_id.toString();
|
||||
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
|
||||
await updatePendingRegistrationUser(ctx, userId, decision);
|
||||
await instanceConfigRepository.removePendingRegistration(userId);
|
||||
return ctx.json(await buildInstanceConfigResponse());
|
||||
},
|
||||
@@ -570,11 +551,15 @@ async function applyInstancePolicyUpdate(
|
||||
patch.single_community_enabled = false;
|
||||
}
|
||||
}
|
||||
const unlockDirectMessages = policy.direct_messages_locked === false;
|
||||
if (unlockDirectMessages && current.direct_messages_locked) {
|
||||
patch.direct_messages_locked = false;
|
||||
}
|
||||
if (
|
||||
policy.direct_messages_disabled !== undefined &&
|
||||
policy.direct_messages_disabled !== current.direct_messages_disabled
|
||||
) {
|
||||
if (current.direct_messages_locked) {
|
||||
if (current.direct_messages_locked && !unlockDirectMessages) {
|
||||
throw new InstancePolicyTransitionNotAllowedError();
|
||||
}
|
||||
patch.direct_messages_disabled = policy.direct_messages_disabled;
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
ActiveJobsResponseSchema,
|
||||
CancelJobRequest,
|
||||
CancelJobResponseSchema,
|
||||
GetJobRequest,
|
||||
GetJobResponseSchema,
|
||||
ListJobsRequest,
|
||||
ListJobsQuery,
|
||||
type ListJobsRequest,
|
||||
ListJobsResponseSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/JobsSchemas';
|
||||
import {JobIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
@@ -17,34 +17,72 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
function toListJobsRequest(query: ListJobsQuery): ListJobsRequest {
|
||||
return {
|
||||
limit: query.limit,
|
||||
max_lookback_days: query.max_lookback_days,
|
||||
...(query.cursor_bucket_day !== undefined &&
|
||||
query.cursor_created_at !== undefined &&
|
||||
query.cursor_job_id !== undefined && {
|
||||
cursor: {
|
||||
bucket_day: query.cursor_bucket_day,
|
||||
created_at: query.cursor_created_at,
|
||||
job_id: query.cursor_job_id,
|
||||
},
|
||||
}),
|
||||
...(query.status !== undefined && {status: query.status}),
|
||||
...(query.task_type !== undefined && {task_type: query.task_type}),
|
||||
...(query.requested_by_user_id !== undefined && {requested_by_user_id: query.requested_by_user_id}),
|
||||
};
|
||||
}
|
||||
|
||||
export function JobsAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/jobs/list',
|
||||
app.get(
|
||||
'/admin/jobs',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW),
|
||||
requireAdminACL(AdminACLs.JOBS_VIEW),
|
||||
Validator('json', ListJobsRequest),
|
||||
Validator('query', ListJobsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_jobs',
|
||||
operationId: 'list_admin_jobs',
|
||||
summary: 'List jobs',
|
||||
responseSchema: ListJobsResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Paginated, filterable list of background jobs from the human-facing ledger. Walks back through day-buckets and applies status / task-type / requester filters in-process.',
|
||||
'Paginated, filterable list of background jobs from the human-facing ledger. Walks back through day-buckets and applies status / task-type / requester filters in-process. The three cursor query parameters come from the previous page `next_cursor` and must be supplied together.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.jobAdminService.listJobs(ctx.req.valid('json')));
|
||||
return ctx.json(await adminService.jobAdminService.listJobs(toListJobsRequest(ctx.req.valid('query'))));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/jobs/get',
|
||||
app.get(
|
||||
'/admin/jobs/active',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW),
|
||||
requireAdminACL(AdminACLs.JOBS_VIEW),
|
||||
Validator('json', GetJobRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_job',
|
||||
operationId: 'list_admin_active_jobs',
|
||||
summary: 'List active jobs',
|
||||
responseSchema: ActiveJobsResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Polling endpoint for the Jobs page. Returns only currently-active jobs (queued or running) from their own index, so the UI can refresh progress without scanning historical day-buckets.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.jobAdminService.listActiveJobs());
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/jobs/:job_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW),
|
||||
requireAdminACL(AdminACLs.JOBS_VIEW),
|
||||
Validator('param', JobIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_job',
|
||||
summary: 'Get job detail',
|
||||
responseSchema: GetJobResponseSchema,
|
||||
statusCode: 200,
|
||||
@@ -54,18 +92,18 @@ export function JobsAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const result = await adminService.jobAdminService.getJob(ctx.req.valid('json').job_id);
|
||||
const result = await adminService.jobAdminService.getJob(ctx.req.valid('param').job_id);
|
||||
if (!result) return ctx.json({error: 'job_not_found'}, 404);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/jobs/cancel',
|
||||
app.put(
|
||||
'/admin/jobs/:job_id/cancellation',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW),
|
||||
requireAdminACL(AdminACLs.JOBS_CANCEL),
|
||||
Validator('json', CancelJobRequest),
|
||||
Validator('param', JobIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'cancel_job',
|
||||
operationId: 'create_admin_job_cancellation',
|
||||
summary: 'Request cancellation of a running job',
|
||||
responseSchema: CancelJobResponseSchema,
|
||||
statusCode: 200,
|
||||
@@ -76,26 +114,7 @@ export function JobsAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.jobAdminService.cancelJob(ctx.req.valid('json').job_id));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/jobs/active',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_JOBS_VIEW),
|
||||
requireAdminACL(AdminACLs.JOBS_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'list_active_jobs',
|
||||
summary: 'List active (queued + running) jobs',
|
||||
responseSchema: ActiveJobsResponseSchema,
|
||||
statusCode: 200,
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Polling endpoint for the Jobs page. Returns only currently-active jobs (queued or running) so the UI can refresh progress without scanning historical data.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.jobAdminService.listActiveJobs());
|
||||
return ctx.json(await adminService.jobAdminService.cancelJob(ctx.req.valid('param').job_id));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,12 +91,12 @@ function findModifiedLimits(
|
||||
}
|
||||
|
||||
export function LimitConfigAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/limit-config/get',
|
||||
app.get(
|
||||
'/admin/limit-config',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.INSTANCE_LIMIT_CONFIG_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'get_limit_config',
|
||||
operationId: 'get_admin_limit_config',
|
||||
summary: 'Get limit configuration',
|
||||
description:
|
||||
'Retrieves rate limit configuration including message limits, upload limits, and request throttles. Shows defaults, metadata, and any modifications from defaults. Requires INSTANCE_LIMIT_CONFIG_VIEW permission.',
|
||||
@@ -111,16 +111,16 @@ export function LimitConfigAdminController(app: HonoApp) {
|
||||
return ctx.json(formatConfig(snapshot));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/limit-config/update',
|
||||
app.put(
|
||||
'/admin/limit-config',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.INSTANCE_LIMIT_CONFIG_UPDATE),
|
||||
Validator('json', LimitConfigUpdateRequest),
|
||||
OpenAPI({
|
||||
operationId: 'update_limit_config',
|
||||
summary: 'Update limit configuration',
|
||||
operationId: 'replace_admin_limit_config',
|
||||
summary: 'Replace limit configuration',
|
||||
description:
|
||||
'Updates rate limit configuration including message throughput, upload sizes, and request throttles. Changes apply immediately to all new operations. Requires INSTANCE_LIMIT_CONFIG_UPDATE permission.',
|
||||
'Replaces the stored limit configuration, which covers message throughput, upload sizes, and request throttles, with the supplied document. Changes apply immediately to all new operations. Requires INSTANCE_LIMIT_CONFIG_UPDATE permission.',
|
||||
responseSchema: LimitConfigGetResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {
|
||||
BrowseChannelRequest,
|
||||
AdminChannelMessageListQuery,
|
||||
AdminMessageSearchQuery,
|
||||
AdminMessageSearchResponse,
|
||||
BrowseChannelResponse,
|
||||
SearchChannelMessagesRequest,
|
||||
SearchChannelMessagesResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminMessageBrowseSchemas';
|
||||
import {
|
||||
DeleteAllUserMessagesRequest,
|
||||
AdminMessageDetailQuery,
|
||||
AdminUserMessageDeleteQuery,
|
||||
AdminUserMessageShredRequest,
|
||||
DeleteAllUserMessagesResponse,
|
||||
DeleteMessageRequest,
|
||||
LookupMessageByAttachmentRequest,
|
||||
LookupMessageRequest,
|
||||
MessageShredRequest,
|
||||
MessageShredJobIdParam,
|
||||
MessageShredResponse,
|
||||
MessageShredStatusRequest,
|
||||
ReportAttachmentToNcmecRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminMessageSchemas';
|
||||
import {
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
MessageShredStatusResponse,
|
||||
NcmecAttachmentSubmitResultResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
ChannelIdMessageIdParam,
|
||||
ChannelIdParam,
|
||||
UserIdParam,
|
||||
} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {createAttachmentID, createChannelID, createMessageID, createReportID} from '../../BrandedTypes';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
@@ -33,48 +38,57 @@ import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
export function MessageAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/messages/lookup',
|
||||
app.get(
|
||||
'/admin/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', LookupMessageRequest),
|
||||
Validator('query', AdminMessageSearchQuery),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_message',
|
||||
summary: 'Look up message details',
|
||||
operationId: 'search_admin_messages',
|
||||
summary: 'Search messages',
|
||||
description:
|
||||
'Retrieves complete message details including content, attachments, edits, and metadata. Look up by message ID and channel. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: LookupMessageResponse,
|
||||
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: AdminMessageSearchResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.messageService.lookupMessage(ctx.req.valid('json')));
|
||||
const query = ctx.req.valid('query');
|
||||
if (query.message_id != null) {
|
||||
return ctx.json(
|
||||
await adminService.messageService.lookupMessage({
|
||||
channel_id: query.channel_id,
|
||||
message_id: query.message_id,
|
||||
context_limit: query.context_limit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (query.attachment_id != null) {
|
||||
if (query.filename == null) {
|
||||
throw InputValidationError.fromCode('filename', ValidationErrorCodes.INVALID_FORMAT);
|
||||
}
|
||||
return ctx.json(
|
||||
await adminService.messageService.lookupMessageByAttachment({
|
||||
channel_id: query.channel_id,
|
||||
attachment_id: query.attachment_id,
|
||||
filename: query.filename,
|
||||
context_limit: query.context_limit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return ctx.json(
|
||||
await adminService.messageService.searchChannelMessages({
|
||||
channel_id: query.channel_id,
|
||||
query: query.q ?? '',
|
||||
limit: query.limit,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/lookup-by-attachment',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', LookupMessageByAttachmentRequest),
|
||||
OpenAPI({
|
||||
operationId: 'lookup_message_by_attachment',
|
||||
summary: 'Look up message by attachment',
|
||||
description:
|
||||
'Finds and retrieves message containing a specific attachment by ID. Used to locate messages with sensitive or illegal content. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: LookupMessageResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.messageService.lookupMessageByAttachment(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/report-to-ncmec',
|
||||
'/admin/messages/ncmec-reports',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.CSAM_SUBMIT_NCMEC),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE),
|
||||
@@ -82,8 +96,8 @@ export function MessageAdminController(app: HonoApp) {
|
||||
requireAdminACL(AdminACLs.ARCHIVE_TRIGGER_USER),
|
||||
Validator('json', ReportAttachmentToNcmecRequest),
|
||||
OpenAPI({
|
||||
operationId: 'report_message_attachment_to_ncmec',
|
||||
summary: 'Report an image attachment to NCMEC',
|
||||
operationId: 'create_admin_ncmec_report',
|
||||
summary: 'Report an attachment to NCMEC',
|
||||
description:
|
||||
'Submits a specific image attachment to NCMEC, creates an audit log entry, silently disables the user, triggers one archive for the user, and schedules content deletion after the archive completes.',
|
||||
responseSchema: NcmecAttachmentSubmitResultResponse,
|
||||
@@ -107,16 +121,96 @@ export function MessageAdminController(app: HonoApp) {
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/delete',
|
||||
app.get(
|
||||
'/admin/messages/shreds/:job_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_SHRED),
|
||||
Validator('param', MessageShredJobIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_message_shred',
|
||||
summary: 'Get message shred job',
|
||||
description:
|
||||
'Returns the progress of a queued message shred job, including whether it is complete. Requires MESSAGE_SHRED permission.',
|
||||
responseSchema: MessageShredStatusResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {job_id} = ctx.req.valid('param');
|
||||
return ctx.json(await adminService.messageShredService.getMessageShredStatus(job_id.toString()));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/channels/:channel_id/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('param', ChannelIdParam),
|
||||
Validator('query', AdminChannelMessageListQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_channel_messages',
|
||||
summary: 'List channel messages',
|
||||
description:
|
||||
'Pages through the messages of a channel, newest first, with cursor-based pagination. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: BrowseChannelResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {channel_id} = ctx.req.valid('param');
|
||||
const {limit, before, after} = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.messageService.browseChannel({
|
||||
channel_id,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/channels/:channel_id/messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('param', ChannelIdMessageIdParam),
|
||||
Validator('query', AdminMessageDetailQuery),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_message',
|
||||
summary: 'Get message',
|
||||
description:
|
||||
'Retrieves complete message details including content, attachments, edits, and metadata, together with the messages surrounding it. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: LookupMessageResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
const {context_limit} = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.messageService.lookupMessage({
|
||||
channel_id,
|
||||
message_id,
|
||||
context_limit,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.delete(
|
||||
'/admin/channels/:channel_id/messages/:message_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE),
|
||||
Validator('json', DeleteMessageRequest),
|
||||
Validator('param', ChannelIdMessageIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'admin_delete_message',
|
||||
summary: 'Delete single message',
|
||||
operationId: 'delete_admin_message',
|
||||
summary: 'Delete message',
|
||||
description:
|
||||
'Deletes a single message permanently. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.',
|
||||
'Deletes a single message permanently and purges its attachments. Used for removing inappropriate or harmful content. Logged to audit log. Requires MESSAGE_DELETE permission.',
|
||||
responseSchema: DeleteMessageResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -126,21 +220,23 @@ export function MessageAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {channel_id, message_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.messageService.deleteMessage(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
await adminService.messageService.deleteMessage({channel_id, message_id}, adminUserId, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/shred',
|
||||
'/admin/users/:user_id/message-shreds',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_SHRED),
|
||||
Validator('json', MessageShredRequest),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserMessageShredRequest),
|
||||
OpenAPI({
|
||||
operationId: 'queue_message_shred',
|
||||
summary: 'Queue message shred operation',
|
||||
operationId: 'shred_admin_user_messages',
|
||||
summary: 'Shred user messages',
|
||||
description:
|
||||
'Queues bulk message shredding with attachment deletion. Returns job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.',
|
||||
'Queues bulk shredding of the given messages of a user, with attachment deletion. Returns a job ID to track progress asynchronously. Used for large-scale content removal. Requires MESSAGE_SHRED permission.',
|
||||
responseSchema: MessageShredResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -150,21 +246,24 @@ export function MessageAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_id} = ctx.req.valid('param');
|
||||
const {entries} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.messageShredService.queueMessageShred(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
await adminService.messageShredService.queueMessageShred({user_id, entries}, adminUserId, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/delete-all',
|
||||
app.delete(
|
||||
'/admin/users/:user_id/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_DELETE_ALL),
|
||||
Validator('json', DeleteAllUserMessagesRequest),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('query', AdminUserMessageDeleteQuery),
|
||||
OpenAPI({
|
||||
operationId: 'delete_all_user_messages',
|
||||
operationId: 'delete_admin_user_messages',
|
||||
summary: 'Delete all user messages',
|
||||
description:
|
||||
'Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Requires MESSAGE_DELETE_ALL permission.',
|
||||
'Deletes all messages from a specific user across all channels. Permanent operation used for account suspension or policy violation. Pass dry_run=false to delete; the default counts without deleting. Requires MESSAGE_DELETE_ALL permission.',
|
||||
responseSchema: DeleteAllUserMessagesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -174,73 +273,15 @@ export function MessageAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {user_id} = ctx.req.valid('param');
|
||||
const {dry_run} = ctx.req.valid('query');
|
||||
return ctx.json(
|
||||
await adminService.messageDeletionService.deleteAllUserMessages(
|
||||
ctx.req.valid('json'),
|
||||
{user_id, dry_run},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/shred-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_SHRED),
|
||||
Validator('json', MessageShredStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_message_shred_status',
|
||||
summary: 'Get message shred status',
|
||||
description:
|
||||
'Polls status of a queued message shred operation. Returns progress percentage and whether the job is complete. Requires MESSAGE_SHRED permission.',
|
||||
responseSchema: MessageShredStatusResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.messageShredService.getMessageShredStatus(body.job_id));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/browse',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', BrowseChannelRequest),
|
||||
OpenAPI({
|
||||
operationId: 'browse_channel_messages',
|
||||
summary: 'Browse channel messages',
|
||||
description:
|
||||
'Browses messages in a channel with cursor-based pagination. Returns messages in reverse chronological order. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: BrowseChannelResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.messageService.browseChannel(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/messages/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.MESSAGE_LOOKUP),
|
||||
Validator('json', SearchChannelMessagesRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_channel_messages',
|
||||
summary: 'Search channel messages',
|
||||
description: 'Searches messages within a channel by content. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: SearchChannelMessagesResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.messageService.searchChannelMessages(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
ListReportsRequest,
|
||||
ListReportsResponse,
|
||||
AdminReportListResponse,
|
||||
ListReportsQuery,
|
||||
ReportAdminResponseSchema,
|
||||
ResolveReportRequest,
|
||||
ResolveReportResponse,
|
||||
SearchReportsRequest,
|
||||
SearchReportsResponse,
|
||||
type SearchReportsRequest,
|
||||
UpdateReportRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {ReportIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {createReportID} from '../../BrandedTypes';
|
||||
@@ -19,18 +18,68 @@ import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
const REPORT_STATUS_BY_FILTER = {
|
||||
pending: 0,
|
||||
resolved: 1,
|
||||
} as const;
|
||||
|
||||
const REPORT_TYPE_BY_FILTER = {
|
||||
message: 0,
|
||||
user: 1,
|
||||
guild: 2,
|
||||
} as const;
|
||||
|
||||
const REPORT_SORT_FIELD_BY_QUERY = {
|
||||
created_at: 'createdAt',
|
||||
reported_at: 'reportedAt',
|
||||
resolved_at: 'resolvedAt',
|
||||
} as const;
|
||||
|
||||
function usesReportSearchIndex(query: ListReportsQuery): boolean {
|
||||
return (
|
||||
query.q !== undefined ||
|
||||
query.report_type !== undefined ||
|
||||
query.category !== undefined ||
|
||||
query.reporter_id !== undefined ||
|
||||
query.reported_user_id !== undefined ||
|
||||
query.reported_guild_id !== undefined ||
|
||||
query.reported_channel_id !== undefined ||
|
||||
query.guild_context_id !== undefined ||
|
||||
query.resolved_by_admin_id !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function toSearchReportsRequest(query: ListReportsQuery): SearchReportsRequest {
|
||||
return {
|
||||
query: query.q,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
reporter_id: query.reporter_id,
|
||||
status: query.status === undefined ? undefined : REPORT_STATUS_BY_FILTER[query.status],
|
||||
report_type: query.report_type === undefined ? undefined : REPORT_TYPE_BY_FILTER[query.report_type],
|
||||
category: query.category,
|
||||
reported_user_id: query.reported_user_id,
|
||||
reported_guild_id: query.reported_guild_id,
|
||||
reported_channel_id: query.reported_channel_id,
|
||||
guild_context_id: query.guild_context_id,
|
||||
resolved_by_admin_id: query.resolved_by_admin_id,
|
||||
sort_by: REPORT_SORT_FIELD_BY_QUERY[query.sort_by],
|
||||
sort_order: query.sort_order,
|
||||
};
|
||||
}
|
||||
|
||||
export function ReportAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/reports/list',
|
||||
app.get(
|
||||
'/admin/reports',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('json', ListReportsRequest),
|
||||
Validator('query', ListReportsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_reports',
|
||||
operationId: 'list_admin_reports',
|
||||
summary: 'List reports',
|
||||
description:
|
||||
'Lists user and content reports with optional status filtering and pagination. Requires REPORT_VIEW permission.',
|
||||
responseSchema: ListReportsResponse,
|
||||
'Lists user and content reports with pagination. Filtering by status alone reads them straight from the database; supplying a free-text query or any of the entity, category and resolver filters searches the report index instead and adds the total, offset and limit of the page to the response. Reporter contact details are redacted unless the caller also holds REPORT_VIEW_REPORTER_PII. Requires REPORT_VIEW permission.',
|
||||
responseSchema: AdminReportListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
@@ -38,8 +87,16 @@ export function ReportAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {status, limit, offset} = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.reportServiceAggregate.listReports(status ?? 0, adminUserAcls, limit, offset));
|
||||
const query = ctx.req.valid('query');
|
||||
if (query.status === undefined || usesReportSearchIndex(query)) {
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.searchReports(toSearchReportsRequest(query), adminUserAcls),
|
||||
);
|
||||
}
|
||||
const status = REPORT_STATUS_BY_FILTER[query.status];
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.listReports(status, adminUserAcls, query.limit, query.offset),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -48,10 +105,10 @@ export function ReportAdminController(app: HonoApp) {
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('param', ReportIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_report',
|
||||
summary: 'Get report details',
|
||||
operationId: 'get_admin_report',
|
||||
summary: 'Get report',
|
||||
description:
|
||||
'Retrieves detailed information about a specific report including content, reporter, and reason. Requires REPORT_VIEW permission.',
|
||||
'Retrieves detailed information about a specific report including content, reporter, reason, and the message context captured when it was filed. Requires REPORT_VIEW permission.',
|
||||
responseSchema: ReportAdminResponseSchema,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -65,16 +122,17 @@ export function ReportAdminController(app: HonoApp) {
|
||||
return ctx.json(report);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/reports/resolve',
|
||||
app.patch(
|
||||
'/admin/reports/:report_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||
Validator('json', ResolveReportRequest),
|
||||
Validator('param', ReportIdParam),
|
||||
Validator('json', UpdateReportRequest),
|
||||
OpenAPI({
|
||||
operationId: 'resolve_report',
|
||||
summary: 'Resolve report',
|
||||
operationId: 'update_admin_report',
|
||||
summary: 'Update report',
|
||||
description:
|
||||
'Closes and resolves a report with optional public comment. Marks report as handled and creates audit log entry. Requires REPORT_RESOLVE permission.',
|
||||
'Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter, and creates an audit log entry. Requires REPORT_RESOLVE permission.',
|
||||
responseSchema: ResolveReportResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -84,7 +142,8 @@ export function ReportAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {report_id, public_comment} = ctx.req.valid('json');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const {public_comment} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.reportServiceAggregate.resolveReport(
|
||||
createReportID(report_id),
|
||||
@@ -95,26 +154,4 @@ export function ReportAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/reports/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
Validator('json', SearchReportsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_reports',
|
||||
summary: 'Search reports',
|
||||
description:
|
||||
'Searches and filters reports by user, content, reason, and status criteria. Supports full-text search and advanced filtering. Requires REPORT_VIEW permission.',
|
||||
responseSchema: SearchReportsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.reportServiceAggregate.searchReports(body, adminUserAcls));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {SearchGuildsRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
import {
|
||||
GetIndexRefreshStatusRequest,
|
||||
IndexRefreshStatusResponse,
|
||||
RefreshSearchIndexRequest,
|
||||
RefreshSearchIndexResponse,
|
||||
SearchGuildsResponse,
|
||||
SearchUsersResponse,
|
||||
SearchIndexNameParam,
|
||||
SearchIndexRefreshIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {SearchUsersRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
@@ -20,58 +17,16 @@ import {Validator} from '../../Validator';
|
||||
|
||||
export function SearchAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/guilds/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', SearchGuildsRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_guilds',
|
||||
summary: 'Search guilds',
|
||||
description:
|
||||
'Searches guilds by name, ID, and other criteria. Supports full-text search and filtering. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: SearchGuildsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchService.searchGuilds(body));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/users/search',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.USER_LOOKUP),
|
||||
Validator('json', SearchUsersRequest),
|
||||
OpenAPI({
|
||||
operationId: 'search_users',
|
||||
summary: 'Search users',
|
||||
description:
|
||||
'Searches users by username, email, ID, last active IP, and other criteria. Supports full-text search and filtering by account status. Requires USER_LOOKUP permission.',
|
||||
responseSchema: SearchUsersResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchService.searchUsers(body, adminUserAcls));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/search/refresh-index',
|
||||
'/admin/search/indexes/:index_name/refreshes',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('param', SearchIndexNameParam),
|
||||
Validator('json', RefreshSearchIndexRequest),
|
||||
OpenAPI({
|
||||
operationId: 'refresh_search_index',
|
||||
summary: 'Refresh search index',
|
||||
operationId: 'create_admin_search_index_refresh',
|
||||
summary: 'Refresh a search index',
|
||||
description:
|
||||
'Trigger full or partial search index rebuild. Creates background job to reindex guilds and users. Returns job ID for status tracking. Requires GUILD_LOOKUP permission.',
|
||||
'Trigger a full or partial rebuild of the named search index. Creates a background job and returns its refresh ID for status tracking. The channel_messages and guild_members indexes are rebuilt one guild at a time and require guild_id, and favorite_memes requires user_id. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: RefreshSearchIndexResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -81,20 +36,27 @@ export function SearchAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchService.refreshSearchIndex(body, adminUserId, auditLogReason));
|
||||
const {index_name} = ctx.req.valid('param');
|
||||
const {guild_id, user_id} = ctx.req.valid('json');
|
||||
return ctx.json(
|
||||
await adminService.searchService.refreshSearchIndex(
|
||||
{index_type: index_name, guild_id, user_id},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/search/refresh-status',
|
||||
app.get(
|
||||
'/admin/search/index-refreshes/:job_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.GUILD_LOOKUP),
|
||||
Validator('json', GetIndexRefreshStatusRequest),
|
||||
Validator('param', SearchIndexRefreshIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_search_index_refresh_status',
|
||||
summary: 'Get search index refresh status',
|
||||
operationId: 'get_admin_search_index_refresh',
|
||||
summary: 'Get search index refresh',
|
||||
description:
|
||||
'Polls status of a search index refresh job. Returns completion percentage and current phase. Requires GUILD_LOOKUP permission.',
|
||||
'Reads the progress of a queued search index refresh. Returns the completion counts and current phase, or a not_found status once the record has expired. Requires GUILD_LOOKUP permission.',
|
||||
responseSchema: IndexRefreshStatusResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -102,8 +64,8 @@ export function SearchAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const body = ctx.req.valid('json');
|
||||
return ctx.json(await adminService.searchService.getIndexRefreshStatus(body.job_id));
|
||||
const {job_id} = ctx.req.valid('param');
|
||||
return ctx.json(await adminService.searchService.getIndexRefreshStatus(job_id));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ import type {HonoApp} from '../../types/HonoEnv';
|
||||
|
||||
export function SystemAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/system/heap-snapshot',
|
||||
'/admin/system/heap-snapshots',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_SYSTEM_HEAP_SNAPSHOT),
|
||||
requireAdminACL(AdminACLs.SYSTEM_HEAP_SNAPSHOT),
|
||||
OpenAPI({
|
||||
operationId: 'take_heap_snapshot',
|
||||
summary: 'Take a V8 heap snapshot',
|
||||
operationId: 'create_admin_system_heap_snapshot',
|
||||
summary: 'Create a V8 heap snapshot',
|
||||
description:
|
||||
'Triggers a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.',
|
||||
'Writes a V8 heap snapshot of the current process and returns the snapshot file. Used for diagnosing memory leaks. Requires SYSTEM_HEAP_SNAPSHOT permission.',
|
||||
responseSchema: HeapSnapshotResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -11,19 +11,19 @@ import {Validator} from '../../Validator';
|
||||
|
||||
export function SystemDmAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/system-dm/send',
|
||||
'/admin/system-dms',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_MESSAGE_OPERATION),
|
||||
requireAdminACL(AdminACLs.SYSTEM_DM_SEND),
|
||||
Validator('json', SendSystemDmRequest),
|
||||
OpenAPI({
|
||||
operationId: 'send_system_dm',
|
||||
summary: 'Send system DM',
|
||||
operationId: 'create_admin_system_dm',
|
||||
summary: 'Send a system direct message',
|
||||
responseSchema: SendSystemDmResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Queue a worker job that sends the same system DM content to each provided user ID. Progress is observable via the Jobs admin page (task_type=sendSystemDm). Requires SYSTEM_DM_SEND permission.',
|
||||
'Queue a worker job that delivers the same content to every listed user as a direct message from the system account. Progress is observable through the Jobs admin resource (task_type=sendSystemDm), and an in-flight broadcast is stopped by cancelling that job. Requires SYSTEM_DM_SEND permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,37 +6,40 @@ import {
|
||||
CreateVoiceRegionResponse,
|
||||
CreateVoiceServerRequest,
|
||||
CreateVoiceServerResponse,
|
||||
DeleteVoiceRegionRequest,
|
||||
DeleteVoiceResponse,
|
||||
DeleteVoiceServerRequest,
|
||||
GetVoiceRegionRequest,
|
||||
GetVoiceRegionQuery,
|
||||
GetVoiceRegionResponse,
|
||||
GetVoiceServerRequest,
|
||||
GetVoiceServerResponse,
|
||||
ListVoiceRegionsRequest,
|
||||
ListVoiceRegionsQuery,
|
||||
ListVoiceRegionsResponse,
|
||||
ListVoiceServersRequest,
|
||||
ListVoiceServersResponse,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceRegionResponse,
|
||||
UpdateVoiceServerRequest,
|
||||
UpdateVoiceServerResponse,
|
||||
VoiceRegionIdParam,
|
||||
VoiceServerIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
|
||||
import type {Context} from 'hono';
|
||||
import {requireAdminACL} from '../../middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
import type {HonoApp} from '../../types/HonoEnv';
|
||||
import type {HonoApp, HonoEnv} from '../../types/HonoEnv';
|
||||
import {Validator} from '../../Validator';
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function VoiceAdminController(app: HonoApp) {
|
||||
app.post(
|
||||
'/admin/voice/regions/list',
|
||||
app.get(
|
||||
'/admin/voice/regions',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
|
||||
Validator('json', ListVoiceRegionsRequest),
|
||||
Validator('query', ListVoiceRegionsQuery),
|
||||
OpenAPI({
|
||||
operationId: 'list_voice_regions',
|
||||
operationId: 'list_admin_voice_regions',
|
||||
summary: 'List voice regions',
|
||||
responseSchema: ListVoiceRegionsResponse,
|
||||
statusCode: 200,
|
||||
@@ -47,36 +50,16 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.voiceService.listVoiceRegions(ctx.req.valid('json')));
|
||||
return ctx.json(await adminService.voiceService.listVoiceRegions(ctx.req.valid('query')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/regions/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
|
||||
Validator('json', GetVoiceRegionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_voice_region',
|
||||
summary: 'Get voice region',
|
||||
responseSchema: GetVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Gets detailed information about a voice region including assigned servers, capacity, and server details. Requires VOICE_REGION_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.voiceService.getVoiceRegion(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/regions/create',
|
||||
'/admin/voice/regions',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_CREATE),
|
||||
Validator('json', CreateVoiceRegionRequest),
|
||||
OpenAPI({
|
||||
operationId: 'create_voice_region',
|
||||
operationId: 'create_admin_voice_region',
|
||||
summary: 'Create voice region',
|
||||
responseSchema: CreateVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
@@ -94,13 +77,45 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/regions/update',
|
||||
app.get(
|
||||
'/admin/voice/regions/:region_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_LIST),
|
||||
Validator('param', VoiceRegionIdParam),
|
||||
Validator('query', GetVoiceRegionQuery),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_voice_region',
|
||||
summary: 'Get voice region',
|
||||
responseSchema: GetVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Gets detailed information about a voice region including assigned servers, capacity, and server details. Requires VOICE_REGION_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(
|
||||
await adminService.voiceService.getVoiceRegion({
|
||||
id: ctx.req.valid('param').region_id,
|
||||
include_servers: ctx.req.valid('query').include_servers,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/voice/regions/:region_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_UPDATE),
|
||||
Validator('json', UpdateVoiceRegionRequest),
|
||||
Validator('param', VoiceRegionIdParam),
|
||||
Validator('json', UpdateVoiceRegionRequest, {
|
||||
pre: (value: unknown, ctx: Context<HonoEnv>) => ({
|
||||
...(isPlainObject(value) ? value : {}),
|
||||
id: ctx.req.param('region_id'),
|
||||
}),
|
||||
}),
|
||||
OpenAPI({
|
||||
operationId: 'update_voice_region',
|
||||
operationId: 'update_admin_voice_region',
|
||||
summary: 'Update voice region',
|
||||
responseSchema: UpdateVoiceRegionResponse,
|
||||
statusCode: 200,
|
||||
@@ -118,13 +133,13 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/regions/delete',
|
||||
app.delete(
|
||||
'/admin/voice/regions/:region_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_REGION_DELETE),
|
||||
Validator('json', DeleteVoiceRegionRequest),
|
||||
Validator('param', VoiceRegionIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_voice_region',
|
||||
operationId: 'delete_admin_voice_region',
|
||||
summary: 'Delete voice region',
|
||||
responseSchema: DeleteVoiceResponse,
|
||||
statusCode: 200,
|
||||
@@ -138,57 +153,47 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
return ctx.json(
|
||||
await adminService.voiceService.deleteVoiceRegion(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
await adminService.voiceService.deleteVoiceRegion(
|
||||
{id: ctx.req.valid('param').region_id},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/servers/list',
|
||||
app.get(
|
||||
'/admin/voice/regions/:region_id/servers',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
|
||||
Validator('json', ListVoiceServersRequest),
|
||||
Validator('param', VoiceRegionIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'list_voice_servers',
|
||||
operationId: 'list_admin_voice_servers',
|
||||
summary: 'List voice servers',
|
||||
responseSchema: ListVoiceServersResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Lists all voice servers with connection counts and capacity. Shows server status, region assignment, and load information. Supports filtering and pagination. Requires VOICE_SERVER_LIST permission.',
|
||||
'Lists all voice servers in a region with connection counts and capacity. Shows server status, region assignment, and load information. Requires VOICE_SERVER_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.voiceService.listVoiceServers(ctx.req.valid('json')));
|
||||
return ctx.json(await adminService.voiceService.listVoiceServers({region_id: ctx.req.valid('param').region_id}));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/servers/get',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
|
||||
Validator('json', GetVoiceServerRequest),
|
||||
OpenAPI({
|
||||
operationId: 'get_voice_server',
|
||||
summary: 'Get voice server',
|
||||
responseSchema: GetVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Gets detailed voice server information including active connections and configuration. Requires VOICE_SERVER_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
return ctx.json(await adminService.voiceService.getVoiceServer(ctx.req.valid('json')));
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/servers/create',
|
||||
'/admin/voice/regions/:region_id/servers',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_CREATE),
|
||||
Validator('json', CreateVoiceServerRequest),
|
||||
Validator('param', VoiceRegionIdParam),
|
||||
Validator('json', CreateVoiceServerRequest, {
|
||||
pre: (value: unknown, ctx: Context<HonoEnv>) => ({
|
||||
...(isPlainObject(value) ? value : {}),
|
||||
region_id: ctx.req.param('region_id'),
|
||||
}),
|
||||
}),
|
||||
OpenAPI({
|
||||
operationId: 'create_voice_server',
|
||||
operationId: 'create_admin_voice_server',
|
||||
summary: 'Create voice server',
|
||||
responseSchema: CreateVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
@@ -206,13 +211,41 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/servers/update',
|
||||
app.get(
|
||||
'/admin/voice/regions/:region_id/servers/:server_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_LIST),
|
||||
Validator('param', VoiceServerIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_admin_voice_server',
|
||||
summary: 'Get voice server',
|
||||
responseSchema: GetVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Gets detailed voice server information including active connections and configuration. Requires VOICE_SERVER_LIST permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {region_id, server_id} = ctx.req.valid('param');
|
||||
return ctx.json(await adminService.voiceService.getVoiceServer({region_id, server_id}));
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/voice/regions/:region_id/servers/:server_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_UPDATE),
|
||||
Validator('json', UpdateVoiceServerRequest),
|
||||
Validator('param', VoiceServerIdParam),
|
||||
Validator('json', UpdateVoiceServerRequest, {
|
||||
pre: (value: unknown, ctx: Context<HonoEnv>) => ({
|
||||
...(isPlainObject(value) ? value : {}),
|
||||
region_id: ctx.req.param('region_id'),
|
||||
server_id: ctx.req.param('server_id'),
|
||||
}),
|
||||
}),
|
||||
OpenAPI({
|
||||
operationId: 'update_voice_server',
|
||||
operationId: 'update_admin_voice_server',
|
||||
summary: 'Update voice server',
|
||||
responseSchema: UpdateVoiceServerResponse,
|
||||
statusCode: 200,
|
||||
@@ -230,13 +263,13 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/voice/servers/delete',
|
||||
app.delete(
|
||||
'/admin/voice/regions/:region_id/servers/:server_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_GUILD_MODIFY),
|
||||
requireAdminACL(AdminACLs.VOICE_SERVER_DELETE),
|
||||
Validator('json', DeleteVoiceServerRequest),
|
||||
Validator('param', VoiceServerIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'delete_voice_server',
|
||||
operationId: 'delete_admin_voice_server',
|
||||
summary: 'Delete voice server',
|
||||
responseSchema: DeleteVoiceResponse,
|
||||
statusCode: 200,
|
||||
@@ -249,8 +282,9 @@ export function VoiceAdminController(app: HonoApp) {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const {region_id, server_id} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.voiceService.deleteVoiceServer(ctx.req.valid('json'), adminUserId, auditLogReason),
|
||||
await adminService.voiceService.deleteVoiceServer({region_id, server_id}, adminUserId, auditLogReason),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {AdminApiKeyRow} from '../../database/types/AdminAuthTypes';
|
||||
import {AdminApiKey} from '../../models/AdminApiKey';
|
||||
import {AdminApiKeys, AdminApiKeysByCreator} from '../../Tables';
|
||||
import {hashPassword} from '../../utils/PasswordUtils';
|
||||
import type {CreateAdminApiKeyData, IAdminApiKeyRepository} from './IAdminApiKeyRepository';
|
||||
import type {CreateAdminApiKeyData, IAdminApiKeyRepository, UpdateAdminApiKeyData} from './IAdminApiKeyRepository';
|
||||
|
||||
function computeTtlSeconds(expiresAt: Date): number {
|
||||
const diffSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
|
||||
@@ -79,6 +79,38 @@ export class AdminApiKeyRepository implements IAdminApiKeyRepository {
|
||||
return new AdminApiKey(row);
|
||||
}
|
||||
|
||||
async update(apiKey: AdminApiKey, data: UpdateAdminApiKeyData): Promise<AdminApiKey> {
|
||||
const row = apiKey.toRow();
|
||||
const updatedRow: AdminApiKeyRow = {
|
||||
...row,
|
||||
name: data.name ?? row.name,
|
||||
acls: data.acls ?? row.acls,
|
||||
};
|
||||
const patch = {
|
||||
name: Db.set(updatedRow.name),
|
||||
acls: Db.set(updatedRow.acls ?? new Set<string>()),
|
||||
};
|
||||
const batch = new BatchBuilder();
|
||||
if (apiKey.expiresAt) {
|
||||
const ttlSeconds = computeTtlSeconds(apiKey.expiresAt);
|
||||
batch.addPrepared(AdminApiKeys.patchByPkWithTtl({key_id: apiKey.keyId}, patch, ttlSeconds));
|
||||
batch.addPrepared(
|
||||
AdminApiKeysByCreator.patchByPkWithTtl(
|
||||
{created_by_user_id: apiKey.createdById, key_id: apiKey.keyId},
|
||||
patch,
|
||||
ttlSeconds,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
batch.addPrepared(AdminApiKeys.patchByPk({key_id: apiKey.keyId}, patch));
|
||||
batch.addPrepared(
|
||||
AdminApiKeysByCreator.patchByPk({created_by_user_id: apiKey.createdById, key_id: apiKey.keyId}, patch),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
return new AdminApiKey(updatedRow);
|
||||
}
|
||||
|
||||
async listByCreator(createdBy: UserID): Promise<Array<AdminApiKey>> {
|
||||
const query = AdminApiKeysByCreator.select({
|
||||
where: AdminApiKeysByCreator.where.eq('created_by_user_id'),
|
||||
|
||||
@@ -86,6 +86,8 @@ export class AdminArchiveRepository {
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
failed_at: Db.clear(),
|
||||
error_message: Db.clear(),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
@@ -101,6 +103,8 @@ export class AdminArchiveRepository {
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
failed_at: Db.clear(),
|
||||
error_message: Db.clear(),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
@@ -116,6 +120,8 @@ export class AdminArchiveRepository {
|
||||
},
|
||||
{
|
||||
started_at: Db.set(new Date()),
|
||||
failed_at: Db.clear(),
|
||||
error_message: Db.clear(),
|
||||
progress_percent: Db.set(0),
|
||||
progress_step: Db.set(progressStep),
|
||||
},
|
||||
|
||||
@@ -9,9 +9,15 @@ export interface CreateAdminApiKeyData {
|
||||
acls: Set<string>;
|
||||
}
|
||||
|
||||
export interface UpdateAdminApiKeyData {
|
||||
name?: string;
|
||||
acls?: Set<string>;
|
||||
}
|
||||
|
||||
export interface IAdminApiKeyRepository {
|
||||
create(data: CreateAdminApiKeyData, createdBy: UserID, keyId: bigint, rawKey: string): Promise<AdminApiKey>;
|
||||
findById(keyId: bigint): Promise<AdminApiKey | null>;
|
||||
update(apiKey: AdminApiKey, data: UpdateAdminApiKeyData): Promise<AdminApiKey>;
|
||||
listByCreator(createdBy: UserID): Promise<Array<AdminApiKey>>;
|
||||
updateLastUsed(keyId: bigint, expiresAt: Date | null): Promise<void>;
|
||||
revoke(keyId: bigint, createdBy: UserID): Promise<void>;
|
||||
|
||||
@@ -4,10 +4,11 @@ import {randomInt} from 'node:crypto';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminApiKeyNotFoundError} from '@fluxer/errors/src/domains/admin/AdminApiKeyNotFoundError';
|
||||
import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import type {CreateAdminApiKeyRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {CreateAdminApiKeyRequest, UpdateAdminApiKeyRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
|
||||
import type {AdminApiKey} from '../../models/AdminApiKey';
|
||||
import {verifyPassword} from '../../utils/PasswordUtils';
|
||||
import type {IAdminApiKeyRepository} from '../repositories/IAdminApiKeyRepository';
|
||||
|
||||
@@ -26,6 +27,16 @@ interface CreateApiKeyResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminApiKeyView {
|
||||
keyId: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
createdById: UserID;
|
||||
acls: Set<string>;
|
||||
}
|
||||
|
||||
export class AdminApiKeyService {
|
||||
constructor(
|
||||
private readonly adminApiKeyRepository: IAdminApiKeyRepository,
|
||||
@@ -65,12 +76,7 @@ export class AdminApiKeyService {
|
||||
const keyId = await this.snowflakeService.generate();
|
||||
const rawKey = this.generateRawKey(keyId);
|
||||
const expiresAt = request.expires_in_days ? new Date(Date.now() + request.expires_in_days * ms('1 day')) : null;
|
||||
if (creatorAcls) {
|
||||
const invalidACLs = request.acls.filter((acl) => !creatorAcls.has(acl) && !creatorAcls.has(AdminACLs.WILDCARD));
|
||||
if (invalidACLs.length > 0) {
|
||||
throw new MissingACLError(invalidACLs[0]);
|
||||
}
|
||||
}
|
||||
this.assertGrantableAcls(request.acls, creatorAcls);
|
||||
const aclsSet = new Set(request.acls);
|
||||
const apiKey = await this.adminApiKeyRepository.create(
|
||||
{
|
||||
@@ -120,41 +126,68 @@ export class AdminApiKeyService {
|
||||
};
|
||||
}
|
||||
|
||||
async listKeys(createdBy: UserID): Promise<
|
||||
Array<{
|
||||
keyId: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
createdById: UserID;
|
||||
acls: Set<string>;
|
||||
}>
|
||||
> {
|
||||
async listKeys(createdBy: UserID): Promise<Array<AdminApiKeyView>> {
|
||||
const apiKeys = await this.adminApiKeyRepository.listByCreator(createdBy);
|
||||
return apiKeys.map((key) => ({
|
||||
keyId: key.keyId.toString(),
|
||||
name: key.name,
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
createdById: key.createdById,
|
||||
acls: key.acls ?? new Set(),
|
||||
}));
|
||||
return apiKeys.map((key) => this.toView(key));
|
||||
}
|
||||
|
||||
async revokeKey(keyId: string, createdBy: UserID): Promise<void> {
|
||||
if (!/^\d+$/.test(keyId)) {
|
||||
throw new AdminApiKeyNotFoundError();
|
||||
async getKey(keyId: bigint, createdBy: UserID): Promise<AdminApiKeyView> {
|
||||
const apiKey = await this.findOwnedKey(keyId, createdBy);
|
||||
return this.toView(apiKey);
|
||||
}
|
||||
|
||||
async updateKey(
|
||||
keyId: bigint,
|
||||
createdBy: UserID,
|
||||
request: UpdateAdminApiKeyRequest,
|
||||
creatorAcls?: Set<string>,
|
||||
): Promise<AdminApiKeyView> {
|
||||
const apiKey = await this.findOwnedKey(keyId, createdBy);
|
||||
if (request.acls) {
|
||||
this.assertGrantableAcls(request.acls, creatorAcls);
|
||||
}
|
||||
const keyIdBigInt = BigInt(keyId);
|
||||
const apiKey = await this.adminApiKeyRepository.findById(keyIdBigInt);
|
||||
const updated = await this.adminApiKeyRepository.update(apiKey, {
|
||||
name: request.name,
|
||||
acls: request.acls ? new Set(request.acls) : undefined,
|
||||
});
|
||||
return this.toView(updated);
|
||||
}
|
||||
|
||||
async revokeKey(keyId: bigint, createdBy: UserID): Promise<void> {
|
||||
const apiKey = await this.findOwnedKey(keyId, createdBy);
|
||||
await this.adminApiKeyRepository.revoke(apiKey.keyId, createdBy);
|
||||
}
|
||||
|
||||
private async findOwnedKey(keyId: bigint, createdBy: UserID): Promise<AdminApiKey> {
|
||||
const apiKey = await this.adminApiKeyRepository.findById(keyId);
|
||||
if (!apiKey) {
|
||||
throw new AdminApiKeyNotFoundError();
|
||||
}
|
||||
if (apiKey.createdById !== createdBy) {
|
||||
throw new AdminApiKeyNotFoundError();
|
||||
}
|
||||
await this.adminApiKeyRepository.revoke(keyIdBigInt, createdBy);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
private assertGrantableAcls(acls: ReadonlyArray<string>, creatorAcls?: Set<string>): void {
|
||||
if (!creatorAcls) {
|
||||
return;
|
||||
}
|
||||
const invalidACLs = acls.filter((acl) => !creatorAcls.has(acl) && !creatorAcls.has(AdminACLs.WILDCARD));
|
||||
if (invalidACLs.length > 0) {
|
||||
throw new MissingACLError(invalidACLs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
private toView(apiKey: AdminApiKey): AdminApiKeyView {
|
||||
return {
|
||||
keyId: apiKey.keyId.toString(),
|
||||
name: apiKey.name,
|
||||
createdAt: apiKey.createdAt,
|
||||
lastUsedAt: apiKey.lastUsedAt,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
createdById: apiKey.createdById,
|
||||
acls: apiKey.acls ?? new Set(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,12 @@ import {UnknownApplicationError} from '@fluxer/errors/src/domains/oauth/UnknownA
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
ApplicationAdminResponse,
|
||||
ListGuildApplicationsRequest,
|
||||
ListGuildApplicationsResponse,
|
||||
ListUserApplicationsRequest,
|
||||
ListUserApplicationsResponse,
|
||||
LookupApplicationRequest,
|
||||
ListApplicationsResponse,
|
||||
LookupApplicationResponse,
|
||||
TransferApplicationOwnershipRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminApplicationSchemas';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createApplicationID, createGuildID, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {type ApplicationID, createApplicationID, createUserID, type GuildID, type UserID} from '../../BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {Application} from '../../models/Application';
|
||||
import type {IApplicationRepository} from '../../oauth/repositories/IApplicationRepository';
|
||||
@@ -36,9 +32,8 @@ interface UserDisplay {
|
||||
export class AdminApplicationService {
|
||||
constructor(private readonly deps: AdminApplicationServiceDeps) {}
|
||||
|
||||
async lookupApplication(data: LookupApplicationRequest): Promise<LookupApplicationResponse> {
|
||||
async lookupApplication(applicationId: ApplicationID): Promise<LookupApplicationResponse> {
|
||||
const {applicationRepository} = this.deps;
|
||||
const applicationId = createApplicationID(data.application_id);
|
||||
const application = await applicationRepository.getApplication(applicationId);
|
||||
if (!application) {
|
||||
return {application: null};
|
||||
@@ -49,10 +44,9 @@ export class AdminApplicationService {
|
||||
};
|
||||
}
|
||||
|
||||
async listUserApplications(data: ListUserApplicationsRequest): Promise<ListUserApplicationsResponse> {
|
||||
async listUserApplications(ownerUserId: UserID): Promise<ListApplicationsResponse> {
|
||||
const {applicationRepository} = this.deps;
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const ownerUserId = createUserID(data.user_id);
|
||||
const owner = await userRepository.findUnique(ownerUserId);
|
||||
if (!owner) {
|
||||
throw new UnknownUserError();
|
||||
@@ -75,9 +69,8 @@ export class AdminApplicationService {
|
||||
};
|
||||
}
|
||||
|
||||
async listGuildApplications(data: ListGuildApplicationsRequest): Promise<ListGuildApplicationsResponse> {
|
||||
async listGuildApplications(guildId: GuildID): Promise<ListApplicationsResponse> {
|
||||
const {applicationRepository, guildRepository} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const guild = await guildRepository.findUnique(guildId);
|
||||
if (!guild) {
|
||||
throw new UnknownGuildError();
|
||||
@@ -100,13 +93,13 @@ export class AdminApplicationService {
|
||||
}
|
||||
|
||||
async transferApplicationOwnership(
|
||||
applicationId: ApplicationID,
|
||||
data: TransferApplicationOwnershipRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
) {
|
||||
const {applicationRepository, auditService} = this.deps;
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const applicationId = createApplicationID(data.application_id);
|
||||
const application = await applicationRepository.getApplication(applicationId);
|
||||
if (!application) {
|
||||
throw new UnknownApplicationError();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {HarvestExpiredError} from '@fluxer/errors/src/domains/moderation/HarvestExpiredError';
|
||||
import {HarvestFailedError} from '@fluxer/errors/src/domains/moderation/HarvestFailedError';
|
||||
@@ -130,7 +131,10 @@ export class AdminArchiveService {
|
||||
async listArchives(params: ListArchivesParams): Promise<Array<AdminArchiveResponse>> {
|
||||
const {subjectType = 'all', subjectId, requestedBy, limit = 50, includeExpired = false} = params;
|
||||
if (subjectId !== undefined && subjectType === 'all') {
|
||||
throw new Error('subject_type must be specified when subject_id is provided');
|
||||
throw InputValidationError.create(
|
||||
'subject_type',
|
||||
'subject_type must name user or guild when subject_id is supplied',
|
||||
);
|
||||
}
|
||||
if (subjectId !== undefined) {
|
||||
const archives = await this.adminArchiveRepository.listBySubject(
|
||||
@@ -175,12 +179,12 @@ export class AdminArchiveService {
|
||||
if (!archive) {
|
||||
throw new UnknownHarvestError();
|
||||
}
|
||||
if (!archive.completedAt || !archive.storageKey) {
|
||||
throw new HarvestNotReadyError();
|
||||
}
|
||||
if (archive.failedAt) {
|
||||
throw new HarvestFailedError();
|
||||
}
|
||||
if (!archive.completedAt || !archive.storageKey) {
|
||||
throw new HarvestNotReadyError();
|
||||
}
|
||||
if (archive.expiresAt && archive.expiresAt < new Date()) {
|
||||
throw new HarvestExpiredError();
|
||||
}
|
||||
|
||||
@@ -29,11 +29,12 @@ export class AdminAssetPurgeService {
|
||||
}
|
||||
|
||||
async purgeGuildAssets(args: {
|
||||
guildId: GuildID;
|
||||
ids: Array<string>;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
}): Promise<PurgeGuildAssetsResponse> {
|
||||
const {ids, adminUserId, auditLogReason} = args;
|
||||
const {guildId, ids, adminUserId, auditLogReason} = args;
|
||||
const processed: Array<PurgeGuildAssetResult> = [];
|
||||
const errors: Array<PurgeGuildAssetError> = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -51,7 +52,11 @@ export class AdminAssetPurgeService {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await this.processAssetId(numericId, trimmedId, adminUserId, auditLogReason);
|
||||
const result = await this.processAssetId(guildId, numericId, trimmedId, adminUserId, auditLogReason);
|
||||
if (result === null) {
|
||||
errors.push({id: trimmedId, error: 'Asset belongs to another guild'});
|
||||
continue;
|
||||
}
|
||||
processed.push(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message !== '' ? error.message : 'Failed to purge asset';
|
||||
@@ -62,15 +67,19 @@ export class AdminAssetPurgeService {
|
||||
}
|
||||
|
||||
private async processAssetId(
|
||||
guildId: GuildID,
|
||||
numericId: bigint,
|
||||
idString: string,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<PurgeGuildAssetResult> {
|
||||
): Promise<PurgeGuildAssetResult | null> {
|
||||
const {guildRepository} = this.deps;
|
||||
const emojiId = createEmojiID(numericId);
|
||||
const emoji = await guildRepository.getEmojiById(emojiId);
|
||||
if (emoji) {
|
||||
if (emoji.guildId !== guildId) {
|
||||
return null;
|
||||
}
|
||||
await guildRepository.deleteEmoji(emoji.guildId, emojiId);
|
||||
await this.dispatchGuildEmojisUpdate(emoji.guildId);
|
||||
await this.assetPurger.purgeEmoji(idString);
|
||||
@@ -97,6 +106,9 @@ export class AdminAssetPurgeService {
|
||||
const stickerId = createStickerID(numericId);
|
||||
const sticker = await guildRepository.getStickerById(stickerId);
|
||||
if (sticker) {
|
||||
if (sticker.guildId !== guildId) {
|
||||
return null;
|
||||
}
|
||||
await guildRepository.deleteSticker(sticker.guildId, stickerId);
|
||||
await this.dispatchGuildStickersUpdate(sticker.guildId);
|
||||
await this.assetPurger.purgeSticker(idString);
|
||||
|
||||
@@ -55,6 +55,15 @@ export class AdminAuditService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAuditLog(logId: bigint): Promise<AdminAuditLogResponse | null> {
|
||||
const log = await this.adminRepository.getAuditLog(logId);
|
||||
if (!log) {
|
||||
return null;
|
||||
}
|
||||
const [response] = await this.toResponses([log]);
|
||||
return response;
|
||||
}
|
||||
|
||||
async listAuditLogs(data: {
|
||||
admin_user_id?: bigint;
|
||||
target_type?: string;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
|
||||
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
@@ -46,6 +50,59 @@ interface AdminBanManagementServiceDeps {
|
||||
suspiciousIpRepository: ISuspiciousIpRepository;
|
||||
}
|
||||
|
||||
interface AdminBlocklistEntry {
|
||||
list_type: AdminBlocklistListType;
|
||||
value: string;
|
||||
scope: string | null;
|
||||
category: string | null;
|
||||
severity: number | null;
|
||||
source_url: string | null;
|
||||
notes: string | null;
|
||||
content_type: string | null;
|
||||
match_subdomains: boolean | null;
|
||||
reason: string | null;
|
||||
expires_at: string | null;
|
||||
created_at: string | null;
|
||||
created_by_user_id: string | null;
|
||||
}
|
||||
|
||||
interface AdminBlocklistEntryPage {
|
||||
items: Array<AdminBlocklistEntry>;
|
||||
has_more: boolean;
|
||||
next_after: string | null;
|
||||
}
|
||||
|
||||
function createBlocklistEntry(
|
||||
listType: AdminBlocklistListType,
|
||||
value: string,
|
||||
overrides: Partial<Omit<AdminBlocklistEntry, 'list_type' | 'value'>> = {},
|
||||
): AdminBlocklistEntry {
|
||||
return {
|
||||
list_type: listType,
|
||||
value,
|
||||
scope: null,
|
||||
category: null,
|
||||
severity: null,
|
||||
source_url: null,
|
||||
notes: null,
|
||||
content_type: null,
|
||||
match_subdomains: null,
|
||||
reason: null,
|
||||
expires_at: null,
|
||||
created_at: null,
|
||||
created_by_user_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoString(value: Date | null | undefined): string | null {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
||||
function toSnowflakeString(value: bigint | null | undefined): string | null {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
interface AdminBlocklistAuditParams {
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
@@ -91,7 +148,10 @@ export class AdminBanManagementService {
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
return;
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.IP_BAN_DECLINED,
|
||||
message: 'This IP address is on the instance exemption list',
|
||||
});
|
||||
}
|
||||
if (await this.shouldSkipIpBanForCgnat(data.ip)) {
|
||||
await auditService.createAuditLog({
|
||||
@@ -102,7 +162,10 @@ export class AdminBanManagementService {
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
return;
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.IP_BAN_DECLINED,
|
||||
message: 'This IP address is a high blast-radius carrier network',
|
||||
});
|
||||
}
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
@@ -393,7 +456,7 @@ export class AdminBanManagementService {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const canonical = canonicalizeUrl(data.url);
|
||||
if (!canonical) throw new Error('URL could not be canonicalized');
|
||||
if (!canonical) throw InputValidationError.fromCode('url', ValidationErrorCodes.INVALID_URL_FORMAT);
|
||||
await adminRepository.banUrl({
|
||||
url_canonical: canonical,
|
||||
category: data.category ?? ContentBlocklistCategory.MANUAL,
|
||||
@@ -427,7 +490,7 @@ export class AdminBanManagementService {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const canonical = canonicalizeUrl(data.url);
|
||||
if (!canonical) throw new Error('URL could not be canonicalized');
|
||||
if (!canonical) throw InputValidationError.fromCode('url', ValidationErrorCodes.INVALID_URL_FORMAT);
|
||||
await adminRepository.unbanUrl(canonical);
|
||||
urlBlocklistCache.removeExactUrl(canonical);
|
||||
await cacheService.publish(BANNED_URLS_REFRESH_CHANNEL, 'refresh');
|
||||
@@ -762,6 +825,124 @@ export class AdminBanManagementService {
|
||||
return {banned: false};
|
||||
}
|
||||
|
||||
async listBlocklistEntries(params: {
|
||||
listType: AdminBlocklistListType;
|
||||
limit: number;
|
||||
after: string | null;
|
||||
scope: BannedProfileSubstringScope | null;
|
||||
}): Promise<AdminBlocklistEntryPage> {
|
||||
const entries = await this.loadBlocklistEntries(params.listType, params.scope);
|
||||
entries.sort((left, right) => (left.value < right.value ? -1 : left.value > right.value ? 1 : 0));
|
||||
const after = params.after;
|
||||
const remaining = after == null ? entries : entries.filter((entry) => entry.value > after);
|
||||
const page = remaining.slice(0, params.limit);
|
||||
const hasMore = remaining.length > page.length;
|
||||
const lastEntry = page.at(-1);
|
||||
return {
|
||||
items: page,
|
||||
has_more: hasMore,
|
||||
next_after: hasMore && lastEntry ? lastEntry.value : null,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadBlocklistEntries(
|
||||
listType: AdminBlocklistListType,
|
||||
scope: BannedProfileSubstringScope | null,
|
||||
): Promise<Array<AdminBlocklistEntry>> {
|
||||
const {adminRepository} = this.deps;
|
||||
switch (listType) {
|
||||
case 'ip': {
|
||||
const rows = await adminRepository.loadAllBannedIpEntries();
|
||||
return rows.map((row) =>
|
||||
createBlocklistEntry(listType, row.ip, {
|
||||
reason: row.reason,
|
||||
expires_at: toIsoString(row.expiresAt),
|
||||
created_at: toIsoString(row.createdAt),
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'email': {
|
||||
const rows = await adminRepository.loadAllBannedEmails();
|
||||
return rows.map((value) => createBlocklistEntry(listType, value));
|
||||
}
|
||||
case 'email-domain-suspicious': {
|
||||
const rows = await adminRepository.loadAllSuspiciousEmailDomains();
|
||||
return rows.map((value) => createBlocklistEntry(listType, value));
|
||||
}
|
||||
case 'phrase': {
|
||||
const rows = await adminRepository.loadAllBannedPhrases();
|
||||
return rows.map((value) => createBlocklistEntry(listType, value));
|
||||
}
|
||||
case 'url': {
|
||||
const rows = await adminRepository.loadAllBannedUrls();
|
||||
return rows.map((row) =>
|
||||
createBlocklistEntry(listType, row.url_canonical, {
|
||||
category: row.category,
|
||||
severity: row.severity,
|
||||
source_url: row.source_url,
|
||||
notes: row.notes,
|
||||
created_at: toIsoString(row.added_at),
|
||||
created_by_user_id: toSnowflakeString(row.added_by),
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'url-domain': {
|
||||
const rows = await adminRepository.loadAllBannedUrlDomains();
|
||||
return rows.map((row) =>
|
||||
createBlocklistEntry(listType, row.domain, {
|
||||
category: row.category,
|
||||
severity: row.severity,
|
||||
source_url: row.source_url,
|
||||
notes: row.notes,
|
||||
match_subdomains: row.match_subdomains,
|
||||
created_at: toIsoString(row.added_at),
|
||||
created_by_user_id: toSnowflakeString(row.added_by),
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'file-sha': {
|
||||
const rows = await adminRepository.loadAllBannedFileShas();
|
||||
return rows.map((row) =>
|
||||
createBlocklistEntry(listType, row.sha256_hex, {
|
||||
category: row.category,
|
||||
severity: row.severity,
|
||||
source_url: row.source_url,
|
||||
notes: row.notes,
|
||||
content_type: row.content_type,
|
||||
created_at: toIsoString(row.added_at),
|
||||
created_by_user_id: toSnowflakeString(row.added_by),
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'avatar-hash': {
|
||||
const rows = await adminRepository.loadAllBannedAvatarHashes();
|
||||
return rows.map((row) =>
|
||||
createBlocklistEntry(listType, row.hash_short, {
|
||||
category: row.category,
|
||||
severity: row.severity,
|
||||
source_url: row.source_url,
|
||||
notes: row.notes,
|
||||
created_at: toIsoString(row.added_at),
|
||||
created_by_user_id: toSnowflakeString(row.added_by),
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'profile-substring': {
|
||||
const rows = await adminRepository.loadAllBannedProfileSubstrings();
|
||||
return rows
|
||||
.filter((row) => scope == null || row.scope === scope)
|
||||
.map((row) =>
|
||||
createBlocklistEntry(listType, row.substring, {
|
||||
scope: row.scope,
|
||||
notes: row.notes,
|
||||
created_at: toIsoString(row.added_at),
|
||||
created_by_user_id: toSnowflakeString(row.added_by),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
@@ -73,7 +74,7 @@ export class AdminReportService {
|
||||
const {reportService} = this.deps;
|
||||
const requestedLimit = limit || 50;
|
||||
const currentOffset = offset || 0;
|
||||
const reports = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
|
||||
const {reports, total} = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
|
||||
const requestCache = createRequestCache();
|
||||
const reportNsfwLookupCache = createReportNsfwLookupCache();
|
||||
const reportResponses = await Promise.all(
|
||||
@@ -83,6 +84,9 @@ export class AdminReportService {
|
||||
);
|
||||
return {
|
||||
reports: reportResponses,
|
||||
total,
|
||||
offset: currentOffset,
|
||||
limit: requestedLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,14 +156,7 @@ export class AdminReportService {
|
||||
publicComment: string;
|
||||
}): Promise<void> {
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const systemUser = await userRepository.findUnique(SYSTEM_USER_ID);
|
||||
if (!systemUser) {
|
||||
Logger.warn(
|
||||
{reportId: reportId.toString(), reporterId: reporter.id.toString()},
|
||||
'Skipping report review system DM because system user does not exist',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const systemUser = await userRepository.findUniqueAssert(SYSTEM_USER_ID);
|
||||
const template = getEmailTemplate('report_resolved', reporter.locale, {
|
||||
username: reporter.username,
|
||||
reportId: reportId.toString(),
|
||||
@@ -207,7 +204,7 @@ export class AdminReportService {
|
||||
async searchReports(data: SearchReportsRequest, acls: ReadonlySet<string>) {
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
throw new Error('Search is not enabled');
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const filters: Record<string, string | number> = {};
|
||||
if (data.reporter_id !== undefined) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user