Compare commits

...
Author SHA1 Message Date
Hampus da1842ecab fix(app): keep the red taskbar badge on windows (#3491) 2026-10-15 04:27:13 +02:00
Hampus 8c38e06a15 fix(voice): release the mic after a region hot-swap (#3490) 2026-10-15 04:26:44 +02:00
Hampus c454add5c8 fix(desktop): match KDE tray icon size to Breeze 22px grid (#3489) 2026-10-15 04:11:58 +02:00
Hampus 1f4f3318dc feat(api): add libredesk webhook adapter (#3488) 2026-10-15 03:54:32 +02:00
Hampus 105bd49c9e feat(desktop): let users install updates on demand (#3487) 2026-10-15 03:50:03 +02:00
Hampus 9213a765b1 fix(api): include personal notes in dm search scopes (#3486) 2026-10-15 02:43:35 +02:00
Hampus 7c4180c2be fix(app): show the search bar in personal notes (#3485) 2026-10-15 02:36:57 +02:00
Hampus fe4bb4386e fix(app): limit /tts playback to the viewed channel (#3484) 2026-10-15 00:35:06 +02:00
Hampus 674b4d26d1 feat(desktop): add a setting and flag to disable updates (#3483) 2026-10-15 00:33:11 +02:00
Hampus 3a74a6f97a fix(docs): finish the api_public origins footnote (#3482) 2026-10-14 23:58:05 +02:00
Hampus a7ba56b140 feat(app): add help for messaging paused accounts (#3481) 2026-10-14 22:45:26 +02:00
Hampus a39410623e fix(admin): reload pages when an auth proxy blocks navigation (#3480) 2026-10-14 21:18:48 +02:00
Hampus a4802c6004 docs(guilds): document template forum channel fields (#3479) 2026-10-14 20:49:00 +02:00
Hampus 812919759a fix(installer): stop upgrades hanging after the uploads copy (#3478) 2026-10-14 20:37:03 +02:00
Hampus b3f373c94c feat(desktop): tray unread dot and monochrome tray icon (#3477) 2026-10-14 20:00:40 +02:00
Hampus 36095ab66f feat(admin): upload instance branding images (#3476) 2026-10-14 19:26:31 +02:00
Hampus 52c19e67cb fix(app): underline embed links when links are underlined (#3475) 2026-10-14 19:21:31 +02:00
Hampus 4e3299a91c fix(app): make the channel welcome line generic (#3474) 2026-10-14 18:54:05 +02:00
Hampus 99be186fa1 fix(guild): import all template channel types, fetch on desktop (#3473) 2026-10-14 18:39:09 +02:00
Hampus 00249f9b4d fix(deploy): drop healthcheck start_interval for older engines (#3472) 2026-10-14 17:57:34 +02:00
Hampus 7c16d424ce fix(app): show download icon for shell updates (#3471) 2026-10-14 17:48:41 +02:00
Hampus 5f5e26a8d4 fix(config): default self-hosted instances to production (#3470) 2026-10-14 17:14:35 +02:00
Hampus 8c05497c37 fix(auth): stop flagging dev instance signups as staff (#3469) 2026-10-14 17:09:14 +02:00
Hampus d2f19660e5 fix(app): skip zod eval probe blocked by csp (#3468) 2026-10-14 16:41:47 +02:00
Hampus d5ca5d8293 fix(auth): fix approval signups and auto-join invites (#3467) 2026-10-14 16:41:33 +02:00
Hampus e403fb6cce feat(desktop): add FLUXER_DISABLE_MODULE_UPDATES opt-out (#3466) 2026-10-14 16:30:34 +02:00
Hampus e8dff6a8a1 fix(api): page postgres kv full scans by keyset (#3465) 2026-10-14 16:27:27 +02:00
Hampus 2b656a1186 chore(deps): bump rustls in desktop native and fuzz locks (#3464) 2026-10-14 16:07:15 +02:00
Hampus 381699623e fix(app): stop own profile preview trapping mobile web (#3463) 2026-10-14 16:04:09 +02:00
Hampus d7bbe933fa fix(app): unify guild acronyms and shrink them to fit (#3462) 2026-10-14 16:02:18 +02:00
Hampus 069b13c382 chore(deps): bump rustls to 0.23.46 (#3461) 2026-10-14 15:59:23 +02:00
Fenane 538b5d69bb fix(app): convert emoji shortcodes in a single transform pass (#3459) 2026-10-14 15:53:22 +02:00
Hampus 3c8fe74660 fix(app): refetch oauth bot destinations after account switch (#3460) 2026-10-14 15:39:33 +02:00
Hampus c0f825ae2a fix(app): explain software screen share encoding on linux (#3458) 2026-10-14 14:47:44 +02:00
Hampus 8d8a29377c fix(ci): retry the release history read in desktop builds (#3457) 2026-10-14 13:47:38 +02:00
ArcticWarmth abcb5cb814 feat(admin): move pending registrations to their own page (#1388) 2026-10-14 13:45:27 +02:00
Hampus 97b2ac91ca fix(desktop): capture screen share audio from chosen devices (#3456) 2026-10-14 02:48:19 +02:00
Hampus 123c4d5bbf fix(api): link github push commits by their full hash (#3455) 2026-10-14 02:28:06 +02:00
Hampus 6e2bf3cbe6 feat(api): purge media through the controller edge api (#3454) 2026-10-14 02:12:31 +02:00
Hampus f509bef1fb fix(app): keep push-to-talk release delays above 500 ms (#3453) 2026-10-14 00:37:40 +02:00
Hampus 959b03b5f4 fix(desktop): shell-compatible module updates, no linux walls (#3452) 2026-10-13 22:07:00 +02:00
Hampus f501d34179 fix(voice): allow self-mute while push-to-talk is on (#3451) 2026-10-13 22:02:40 +02:00
Fenane 3657438467 fix(app): show username in UserAreaAccountSwitchMenu (#3447)
Signed-off-by: dearfenane <[email protected]>
2026-10-13 21:00:19 +02:00
Hampus 2976b1842b fix(i18n): use емоджі consistently in Ukrainian (#3450) 2026-10-13 20:56:40 +02:00
Hampus 8cac3b35c9 fix(desktop): reuse window capture items on Windows 10 (#3449) 2026-10-13 17:57:09 +02:00
Hampus d514501d57 fix(app-proxy): tell the web client its release channel (#3448) 2026-10-13 15:15:11 +02:00
Hampus 83bb9d89c8 fix(media-proxy): serve transforms whole, never as ranges (#3446) 2026-10-13 05:26:21 +02:00
Hampus 0117605572 fix(api): copy objects without server-side copy on one bucket (#3445) 2026-10-13 04:42:53 +02:00
Hampus 72e14c17ba fix(voice): keep the playback boost while the window is hidden (#3444) 2026-10-13 04:36:23 +02:00
Hampus aa0ebdb97e fix(desktop): build mac native modules for macOS 13 (#3443) 2026-10-13 03:49:39 +02:00
Hampus 3d4b2933e1 fix(setup): show the setup token step in the wizard (#3442) 2026-10-13 03:22:42 +02:00
Hampus 922ff5ea64 fix(app): keep forum channels after a warm account switch (#3441) 2026-10-13 02:23:24 +02:00
Hampus fce3200265 fix(app): keep the AbortSignal polyfill in the bundle (#3440) 2026-10-13 01:50:06 +02:00
409 changed files with 22542 additions and 6613 deletions

No files matched your search

+14 -9
View File
@@ -16,18 +16,22 @@ on:
required: false
default: ""
type: string
allow_shell_drift:
description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes.
required: false
default: false
type: boolean
shell_contract:
description: "Did the shell contract change since the oldest shell these modules still reach? unchanged refuses if it did. compatible keeps older shells on these modules because the renderer still works on them. raise stops sending these modules to shells without the new contract: Linux and managed shells keep their current renderer, Windows and macOS update their shell first."
required: true
type: choice
options:
- unchanged
- compatible
- raise
default: unchanged
permissions:
contents: write
id-token: write
actions: read
concurrency:
group: desktop-modules-${{ inputs.channel }}
group: desktop-${{ inputs.channel }}
cancel-in-progress: false
env:
@@ -260,13 +264,14 @@ jobs:
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Check the live shell runs these modules
- name: Check which shells run these modules
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }}
SHELL_CONTRACT: ${{ inputs.shell_contract }}
DESKTOP_MODULES_ONLY_RELEASE: true
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step check_shell_drift
--step check_shell_contract
- name: Build the modules-only manifests
run: >-
+42 -1
View File
@@ -21,6 +21,15 @@ on:
required: false
default: ""
type: string
shell_contract:
description: "Did the shell contract change since the oldest shell these modules still reach? unchanged refuses if it did. compatible keeps older shells on these modules because the renderer still works on them. raise stops sending these modules to shells without the new contract: Linux and managed shells keep their current renderer, Windows and macOS update their shell first."
required: true
type: choice
options:
- unchanged
- compatible
- raise
default: unchanged
permissions:
contents: write
id-token: write
@@ -28,7 +37,7 @@ permissions:
concurrency:
group: desktop-${{ inputs.channel }}
cancel-in-progress: true
cancel-in-progress: false
env:
CHANNEL: ${{ inputs.channel }}
@@ -53,6 +62,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
fetch-depth: 0
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
@@ -78,6 +88,18 @@ jobs:
--step set_metadata
--channel "${{ inputs.channel }}"
- name: Check which shells run these modules
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
CHANNEL: ${{ steps.meta.outputs.build_channel }}
SOURCE_SHA: ${{ steps.meta.outputs.source_sha }}
VERSION: ${{ steps.meta.outputs.version }}
SHELL_CONTRACT: ${{ inputs.shell_contract }}
DESKTOP_MODULES_ONLY_RELEASE: false
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step check_shell_contract
matrix:
name: Resolve build matrix
runs-on: ubuntu-24.04-arm
@@ -745,6 +767,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
fetch-depth: 0
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
@@ -771,6 +794,24 @@ jobs:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Check which shells run these modules
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SHELL_CONTRACT: ${{ inputs.shell_contract }}
DESKTOP_MODULES_ONLY_RELEASE: false
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step check_shell_contract
- name: Build desktop module manifest
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
+2 -1
View File
@@ -117,6 +117,7 @@ jobs:
with:
toolchain: "1.98.1"
components: clippy, rustfmt
targets: x86_64-pc-windows-msvc
- name: Install pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
@@ -208,7 +209,7 @@ jobs:
- name: Clippy (warnings as errors)
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
- name: Clippy (desktop native workspaces on Linux and Windows backends, warnings as errors)
run: tools/ci/check-desktop-native-workspaces.sh clippy
- name: Verify the source-built ffmpeg CLI is on PATH
Generated
+4 -4
View File
@@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 2.0.119",
"syn 3.0.6",
]
[[package]]
@@ -3890,9 +3890,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
@@ -4613,7 +4613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
+3
View File
@@ -620,6 +620,9 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_CACHE_PURGE_HTTP_ENDPOINT=
#FLUXER_CACHE_PURGE_HTTP_TOKEN=
#FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS=10000
#FLUXER_CACHE_PURGE_CONTROLLER_ENDPOINT=
#FLUXER_CACHE_PURGE_CONTROLLER_TOKEN=
#FLUXER_CACHE_PURGE_CONTROLLER_TIMEOUT_MS=10000
# media-proxy limits and timeouts.
#FLUXER_MEDIA_PROXY_READ_ONLY=false
+8 -6
View File
@@ -97,6 +97,9 @@ x-fluxer-env: &fluxer-env
FLUXER_CACHE_PURGE_HTTP_ENDPOINT: ${FLUXER_CACHE_PURGE_HTTP_ENDPOINT:-}
FLUXER_CACHE_PURGE_HTTP_TOKEN: ${FLUXER_CACHE_PURGE_HTTP_TOKEN:-}
FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS: ${FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS:-}
FLUXER_CACHE_PURGE_CONTROLLER_ENDPOINT: ${FLUXER_CACHE_PURGE_CONTROLLER_ENDPOINT:-}
FLUXER_CACHE_PURGE_CONTROLLER_TOKEN: ${FLUXER_CACHE_PURGE_CONTROLLER_TOKEN:-}
FLUXER_CACHE_PURGE_CONTROLLER_TIMEOUT_MS: ${FLUXER_CACHE_PURGE_CONTROLLER_TIMEOUT_MS:-}
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
@@ -208,12 +211,11 @@ x-fluxer-app-healthcheck: &fluxer-app-healthcheck
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
start_interval: 1s
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
<<: *fluxer-app-healthcheck
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8090 && printf \"GET /_health HTTP/1.0\\r\\n\\r\\n\" >&3 && head -n 1 <&3 | grep -q \" 200 \"'"]
services:
edge:
@@ -467,7 +469,7 @@ services:
<<: *fluxer-env
FLUXER_API_PORT: "8080"
FLUXER_SETUP_TOKEN: ${FLUXER_SETUP_TOKEN:-}
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}${FLUXER_API_NODE_OPTIONS:+ $FLUXER_API_NODE_OPTIONS}
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=${FLUXER_API_NODE_HEAP_MB:-}}${FLUXER_API_NODE_OPTIONS:+ ${FLUXER_API_NODE_OPTIONS:-}}
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_API_POSTGRES_MAX_CONNECTIONS:-25}"
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "${FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED:-true}"
@@ -501,13 +503,13 @@ services:
command: ["node", "dist/WorkerEntrypoint.js"]
environment:
<<: *fluxer-env
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}${FLUXER_WORKER_NODE_OPTIONS:+ $FLUXER_WORKER_NODE_OPTIONS}
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=${FLUXER_WORKER_NODE_HEAP_MB:-}}${FLUXER_WORKER_NODE_OPTIONS:+ ${FLUXER_WORKER_NODE_OPTIONS:-}}
NODE_EXTRA_CA_CERTS: ${FLUXER_NODE_EXTRA_CA_CERTS:-/etc/ssl/certs/ca-certificates.crt}
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "${FLUXER_WORKER_POSTGRES_MAX_CONNECTIONS:-25}"
healthcheck:
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
test: ["CMD-SHELL", "node -e \"const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}\""]
<<: *fluxer-app-healthcheck
retries: ${FLUXER_WORKER_HEALTHCHECK_RETRIES:-3}
depends_on:
@@ -865,7 +867,7 @@ services:
FLUXER_API_ENDPOINT: http://api:8080
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /_health HTTP/1.0\\r\\n\\r\\n\" >&3 && head -n 1 <&3 | grep -q \" 200 \"'"]
<<: *fluxer-app-healthcheck
start_period: ${FLUXER_SVC_HEALTHCHECK_START_PERIOD:-60s}
depends_on:
+121 -48
View File
@@ -4152,15 +4152,17 @@
}
}
},
"/admin/instance/pending-registrations/{user_id}": {
"patch": {
"operationId": "update_admin_pending_registration",
"summary": "Approve or reject a pending registration",
"/admin/instance/pending-registrations": {
"get": {
"operationId": "list_admin_pending_registrations",
"summary": "List pending registrations",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}}
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationListResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
@@ -4201,7 +4203,62 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "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.",
"description": "Lists every registration waiting for manual review, oldest first. Requires REGISTRATION_REVIEW permission.",
"security": [{"adminApiKey": []}]
}
},
"/admin/instance/pending-registrations/{user_id}": {
"patch": {
"operationId": "update_admin_pending_registration",
"summary": "Approve or reject a pending registration",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationListResponse"}}
}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5887,7 +5944,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account. Requires AUTHENTICATE permission.",
"description": "Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account, plus the number of pending registrations when the admin holds REGISTRATION_REVIEW. Requires AUTHENTICATE permission.",
"security": [{"adminApiKey": []}]
}
},
@@ -10281,7 +10338,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 106,
"maxItems": 107,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -10297,8 +10354,11 @@
},
"AdminUsersMeResponse": {
"type": "object",
"properties": {"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}},
"required": ["user"],
"properties": {
"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"},
"pending_registration_count": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}
},
"required": ["user", "pending_registration_count"],
"additionalProperties": false
},
"SnowflakeStringType": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "format": "snowflake"},
@@ -11331,40 +11391,9 @@
],
"additionalProperties": false
}
},
"pending_registrations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"user_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
"username": {"type": "string"},
"discriminator": {"type": "integer", "minimum": 0, "maximum": 9999},
"global_name": {"nullable": true, "type": "string"},
"email": {"nullable": true, "type": "string"},
"requested_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"registration_url_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 128},
"client_ip": {"nullable": true, "type": "string"}
},
"required": [
"user_id",
"username",
"discriminator",
"global_name",
"email",
"requested_at",
"registration_url_id",
"client_ip"
],
"additionalProperties": false
}
}
},
"required": ["mode", "admin_registration_urls_enabled", "urls", "pending_registrations"],
"required": ["mode", "admin_registration_urls_enabled", "urls"],
"additionalProperties": false
},
"self_hosted": {"type": "boolean"},
@@ -11811,6 +11840,17 @@
},
"required": ["status"]
},
"PendingRegistrationListResponse": {
"type": "object",
"properties": {
"pending_registrations": {
"type": "array",
"items": {"$ref": "#/components/schemas/PendingRegistrationResponse"}
}
},
"required": ["pending_registrations"],
"additionalProperties": false
},
"InstanceEmailSmtpTestRequest": {
"type": "object",
"properties": {
@@ -13515,7 +13555,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 106,
"maxItems": 107,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -13533,7 +13573,7 @@
"type": "string"
},
"acls": {
"maxItems": 106,
"maxItems": 107,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -13563,7 +13603,7 @@
"maximum": 365
},
"acls": {
"maxItems": 106,
"maxItems": 107,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -13584,7 +13624,7 @@
"type": "string"
},
"acls": {
"maxItems": 106,
"maxItems": 107,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -13597,7 +13637,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 106,
"maxItems": 107,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -13679,6 +13719,7 @@
"message:delete",
"message:lookup",
"message:shred",
"registration:review",
"report:delete",
"report:resolve",
"report:view",
@@ -16471,6 +16512,36 @@
},
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
"StripePriceIdSchema": {"type": "string", "maxLength": 255, "pattern": "^price_[A-Za-z0-9]+$"},
"PendingRegistrationResponse": {
"type": "object",
"properties": {
"user_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
"username": {"type": "string"},
"discriminator": {"type": "integer", "minimum": 0, "maximum": 9999},
"global_name": {"nullable": true, "type": "string"},
"email": {"nullable": true, "type": "string"},
"requested_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"registration_url_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 128},
"client_ip": {"nullable": true, "type": "string"},
"invite_code": {"nullable": true, "type": "string", "maxLength": 256}
},
"required": [
"user_id",
"username",
"discriminator",
"global_name",
"email",
"requested_at",
"registration_url_id",
"client_ip",
"invite_code"
],
"additionalProperties": false
},
"InstanceBillingResponse": {
"type": "object",
"properties": {
@@ -16839,7 +16910,7 @@
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 107, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
@@ -17340,6 +17411,7 @@
"REACTION_EMAIL_VERIFICATION_REQUIRED",
"REPORT_EMAIL_VERIFICATION_REQUIRED",
"REGISTRATION_CLOSED",
"REGISTRATION_CREDENTIALS_REQUIRED",
"REGISTRATION_PENDING_APPROVAL",
"REGISTRATION_REJECTED",
"REGISTRATION_URL_INVALID",
@@ -17635,6 +17707,7 @@
"Email verification is required for this action.",
"Email verification is required for this action.",
"Registration is closed on this instance.",
"This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
"This registration is waiting for admin approval.",
"This registration request was rejected.",
"This registration link is invalid or has expired.",
+2
View File
@@ -70,6 +70,7 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all";
pub const MESSAGE_DELETE: &str = "message:delete";
pub const MESSAGE_LOOKUP: &str = "message:lookup";
pub const MESSAGE_SHRED: &str = "message:shred";
pub const REGISTRATION_REVIEW: &str = "registration:review";
pub const REPORT_DELETE: &str = "report:delete";
pub const REPORT_RESOLVE: &str = "report:resolve";
pub const REPORT_VIEW: &str = "report:view";
@@ -177,6 +178,7 @@ pub const ALL_ACLS: &[&str] = &[
MESSAGE_DELETE,
MESSAGE_LOOKUP,
MESSAGE_SHRED,
REGISTRATION_REVIEW,
REPORT_DELETE,
REPORT_RESOLVE,
REPORT_VIEW,
+40 -29
View File
@@ -4,9 +4,11 @@ use reqwest::Method;
use super::client::{AdminApiClient, ApiResult};
use super::types::{
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
AccountIdentitySettings, BrandingAssetKind, BrandingAssetUploadRequest,
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceAccountIdentityDiscovery,
InstanceConfigResponse, InstanceConfigUpdateRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery, PendingRegistrationListResponse, PendingRegistrationResponse,
PendingRegistrationStatus,
};
impl AdminApiClient {
@@ -36,6 +38,20 @@ impl AdminApiClient {
.await
}
pub async fn upload_branding_asset(
&self,
kind: BrandingAssetKind,
image: Option<String>,
) -> ApiResult<InstanceConfigResponse> {
self.send(
Method::POST,
"/admin/instance/config/branding-assets",
Some(&BrandingAssetUploadRequest { kind, image }),
None,
)
.await
}
pub async fn test_saved_instance_smtp_config(
&self,
) -> ApiResult<InstanceEmailSmtpTestResponse> {
@@ -74,35 +90,30 @@ impl AdminApiClient {
.await
}
pub async fn approve_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
self.decide_pending_registration(user_id, "approved").await
pub async fn list_pending_registrations(&self) -> ApiResult<Vec<PendingRegistrationResponse>> {
let response: PendingRegistrationListResponse = self
.get("/admin/instance/pending-registrations", None)
.await?;
Ok(response.pending_registrations)
}
pub async fn reject_pending_registration(
pub async fn decide_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
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.send(
Method::PATCH,
&format!(
"/admin/instance/pending-registrations/{}",
urlencoding::encode(user_id)
),
Some(&body),
None,
)
.await
status: PendingRegistrationStatus,
) -> ApiResult<Vec<PendingRegistrationResponse>> {
let body = serde_json::json!({"status": status.as_str()});
let response: PendingRegistrationListResponse = self
.send(
Method::PATCH,
&format!(
"/admin/instance/pending-registrations/{}",
urlencoding::encode(user_id)
),
Some(&body),
None,
)
.await?;
Ok(response.pending_registrations)
}
}
+1
View File
@@ -32,6 +32,7 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
#[derive(Clone, Debug, Deserialize)]
pub struct AdminUserMeResponse {
pub user: AdminUser,
pub pending_registration_count: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
+86 -3
View File
@@ -701,8 +701,6 @@ pub struct InstanceRegistrationResponse {
pub admin_registration_urls_enabled: bool,
#[serde(default)]
pub urls: Vec<RegistrationUrlResponse>,
#[serde(default)]
pub pending_registrations: Vec<PendingRegistrationResponse>,
}
impl Default for InstanceRegistrationResponse {
@@ -711,7 +709,6 @@ impl Default for InstanceRegistrationResponse {
mode: RegistrationMode::Open,
admin_registration_urls_enabled: true,
urls: Vec::new(),
pending_registrations: Vec::new(),
}
}
}
@@ -767,6 +764,92 @@ pub struct PendingRegistrationResponse {
pub client_ip: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PendingRegistrationStatus {
Approved,
Rejected,
}
impl PendingRegistrationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Approved => "approved",
Self::Rejected => "rejected",
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct PendingRegistrationListResponse {
pub pending_registrations: Vec<PendingRegistrationResponse>,
}
pub const BRANDING_ASSET_MAX_IMAGE_CHARS: usize = 16_000_000;
pub const BRANDING_ASSET_ACCEPT: &str = ".png,.jpeg,.webp,.gif,.apng,.avif,.heic,.heif,.jxl,.svg,\
image/png,image/jpeg,image/webp,image/gif,image/apng,image/avif,image/heic,image/heif,\
image/jxl,image/svg+xml";
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BrandingAssetKind {
Icon,
Symbol,
Logo,
Wordmark,
Favicon,
}
impl BrandingAssetKind {
pub const ALL: [Self; 5] = [
Self::Icon,
Self::Symbol,
Self::Logo,
Self::Wordmark,
Self::Favicon,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Icon => "icon",
Self::Symbol => "symbol",
Self::Logo => "logo",
Self::Wordmark => "wordmark",
Self::Favicon => "favicon",
}
}
pub fn parse(value: &str) -> Option<Self> {
Self::ALL.into_iter().find(|kind| kind.as_str() == value)
}
pub fn label(self) -> &'static str {
match self {
Self::Icon => "Icon",
Self::Symbol => "Symbol",
Self::Logo => "Logo",
Self::Wordmark => "Wordmark",
Self::Favicon => "Favicon",
}
}
pub fn url(self, branding: &AppBrandingConfigResponse) -> Option<&str> {
match self {
Self::Icon => branding.icon_url.as_deref(),
Self::Symbol => branding.symbol_url.as_deref(),
Self::Logo => branding.logo_url.as_deref(),
Self::Wordmark => branding.wordmark_url.as_deref(),
Self::Favicon => branding.favicon_url.as_deref(),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct BrandingAssetUploadRequest {
pub kind: BrandingAssetKind,
pub image: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
+15 -15
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{AdminUser, PremiumBranding},
api::types::{AdminUser, AdminUserMeResponse, PremiumBranding},
middleware::flash,
session::{self, Session},
state::AppState,
@@ -16,6 +16,7 @@ use axum::{
pub struct AuthContext {
pub session: Session,
pub admin_user: Option<AdminUser>,
pub pending_registration_count: Option<u32>,
pub branding: Option<PremiumBranding>,
}
@@ -29,17 +30,19 @@ pub async fn require_auth(
let Some(session) = session else {
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
};
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
AdminFetchResult::Unauthorized => {
let login_url = format!("{}/login", config.base_path);
return clear_session_and_redirect(&login_url, config);
}
AdminFetchResult::Ok(admin_user) => Some(*admin_user),
AdminFetchResult::None => None,
};
let (admin_user, pending_registration_count) =
match fetch_admin_user(state.http_client(), config, &session).await {
AdminFetchResult::Unauthorized => {
let login_url = format!("{}/login", config.base_path);
return clear_session_and_redirect(&login_url, config);
}
AdminFetchResult::Ok(me) => (Some(me.user), me.pending_registration_count),
AdminFetchResult::None => (None, None),
};
request.extensions_mut().insert(AuthContext {
session,
admin_user,
pending_registration_count,
branding: state.self_hosted_branding().await,
});
@@ -83,7 +86,7 @@ fn extract_session(request: &Request, secret_key: &str) -> Option<Session> {
}
enum AdminFetchResult {
Ok(Box<AdminUser>),
Ok(Box<AdminUserMeResponse>),
Unauthorized,
None,
}
@@ -109,11 +112,8 @@ async fn fetch_admin_user(
if !response.status().is_success() {
return AdminFetchResult::None;
}
match response
.json::<crate::api::types::AdminUserMeResponse>()
.await
{
Ok(resp) => AdminFetchResult::Ok(Box::new(resp.user)),
match response.json::<AdminUserMeResponse>().await {
Ok(me) => AdminFetchResult::Ok(Box::new(me)),
Err(_) => AdminFetchResult::None,
}
}
+2
View File
@@ -13,6 +13,7 @@ pub mod guilds;
pub mod jobs;
mod message_actions;
pub mod messages;
pub mod registrations;
pub mod reports;
pub mod system;
mod system_actions;
@@ -63,6 +64,7 @@ pub fn build_router(config: AdminConfig) -> Router {
.merge(applications::router())
.merge(codes::router())
.merge(discovery::router())
.merge(registrations::router())
.merge(jobs::router())
.merge(messages::router())
.merge(system::router())
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::{client::AdminApiClient, types::PendingRegistrationStatus},
middleware::{
auth::AuthContext,
csrf::CsrfToken,
flash::{self, FlashData},
htmx,
},
state::AppState,
templates::{self, layout_sidebar::pending_registration_count_markup},
utils::forms::clean_string,
};
use axum::{
Form, Router,
extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use serde::Deserialize;
#[derive(Deserialize)]
struct PendingRegistrationForm {
#[serde(default)]
user_id: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/pending-registrations", get(pending_registrations_page))
.route(
"/pending-registrations/approve",
post(approve_pending_registration),
)
.route(
"/pending-registrations/reject",
post(reject_pending_registration),
)
}
async fn pending_registrations_page(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let pending_registrations = match client.list_pending_registrations().await {
Ok(pending_registrations) => Some(pending_registrations),
Err(error) => {
tracing::warn!(%error, "admin API request failed: list pending registrations");
None
}
};
let markup = templates::pages::pending_registrations::pending_registrations_page(
config,
&auth.0,
&csrf.0.0,
pending_registrations.as_deref(),
);
Html(markup.into_string()).into_response()
}
async fn approve_pending_registration(
state: State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
form: Form<PendingRegistrationForm>,
) -> Response {
decide_pending_registration(
state,
headers,
auth,
csrf,
form,
PendingRegistrationStatus::Approved,
)
.await
}
async fn reject_pending_registration(
state: State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
form: Form<PendingRegistrationForm>,
) -> Response {
decide_pending_registration(
state,
headers,
auth,
csrf,
form,
PendingRegistrationStatus::Rejected,
)
.await
}
async fn decide_pending_registration(
State(state): State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
Form(form): Form<PendingRegistrationForm>,
status: PendingRegistrationStatus,
) -> Response {
let config = state.config();
let (success_message, error_message) = match status {
PendingRegistrationStatus::Approved => {
("Registration approved", "Failed to approve registration")
}
PendingRegistrationStatus::Rejected => {
("Registration rejected", "Failed to reject registration")
}
};
let flash = match clean_string(form.user_id.as_deref().unwrap_or("")) {
None => FlashData::error("User ID is required"),
Some(user_id) => {
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
match client.decide_pending_registration(&user_id, status).await {
Ok(pending_registrations) => {
let flash = FlashData::success(success_message);
if htmx::targets(&headers, "pending-registration-list") {
let markup = maud::html! {
(templates::pages::pending_registrations::pending_registration_list(
config,
&csrf.0.0,
&pending_registrations,
))
(pending_registration_count_markup(
u32::try_from(pending_registrations.len()).ok(),
true,
))
};
let mut response = Html(markup.into_string()).into_response();
htmx::add_toast_header(&mut response, &flash);
return response;
}
flash
}
Err(error) => {
tracing::warn!(%error, ?status, "admin API request failed: decide pending registration");
FlashData::error(error_message)
}
}
}
};
if htmx::is_htmx_request(&headers) {
return htmx::toast_response(&flash);
}
flash::redirect_with_flash(
&format!("{}/pending-registrations", config.base_path),
flash,
config.secure_cookies(),
)
}
+110 -73
View File
@@ -6,25 +6,27 @@ use crate::{
types::{
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
AppSetupConfigUpdateRequest, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigUpdateRequest,
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestResponse,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitKeyMetadata,
LimitRule, LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest,
RegistrationMode, SsoConfigUpdateRequest, VOICE_P2P_MAX_PARTICIPANTS_RANGE,
VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
},
},
config::AdminConfig,
middleware::{
auth::AuthContext,
csrf::CsrfToken,
flash::{self, FlashData},
htmx,
@@ -41,6 +43,8 @@ use axum::{
use maud::Markup;
use serde::Deserialize;
const BRANDING_ASSET_FORM_BYTES: usize = BRANDING_ASSET_MAX_IMAGE_CHARS * 3 + 64 * 1024;
#[derive(Deserialize)]
pub struct ActionQuery {
pub action: Option<String>,
@@ -147,6 +151,15 @@ pub async fn instance_config_post(
Query(aq): Query<ActionQuery>,
request: Request,
) -> Response {
match aq.action.as_deref() {
Some("upload_branding_asset") => {
return branding_asset_post(&state, &headers, &auth.0, true, request).await;
}
Some("remove_branding_asset") => {
return branding_asset_post(&state, &headers, &auth.0, false, request).await;
}
_ => {}
}
let config = state.config();
let base = &config.base_path;
let form = match MultiValueForm::from_request(request).await {
@@ -304,48 +317,6 @@ pub async fn instance_config_post(
},
None => FlashData::error("Registration URL ID is required"),
},
"approve_pending_registration" => match form.clean("user_id") {
Some(user_id) => match client.approve_pending_registration(&user_id).await {
Ok(instance_config) => {
let flash = FlashData::success("Registration approved");
if htmx::targets(&headers, "pending-registration-list") {
return render_pending_registration_list_response(
config,
&csrf.0.0,
&instance_config,
&flash,
);
}
flash
}
Err(error) => {
tracing::warn!(%error, "admin API request failed: approve pending registration");
FlashData::error("Failed to approve registration")
}
},
None => FlashData::error("User ID is required"),
},
"reject_pending_registration" => match form.clean("user_id") {
Some(user_id) => match client.reject_pending_registration(&user_id).await {
Ok(instance_config) => {
let flash = FlashData::success("Registration rejected");
if htmx::targets(&headers, "pending-registration-list") {
return render_pending_registration_list_response(
config,
&csrf.0.0,
&instance_config,
&flash,
);
}
flash
}
Err(error) => {
tracing::warn!(%error, "admin API request failed: reject pending registration");
FlashData::error("Failed to reject registration")
}
},
None => FlashData::error("User ID is required"),
},
_ => FlashData::error("Unknown instance config action"),
};
if htmx::is_htmx_request(&headers) {
@@ -354,6 +325,88 @@ pub async fn instance_config_post(
redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies())
}
async fn branding_asset_post(
state: &AppState,
headers: &HeaderMap,
auth: &AuthContext,
upload: bool,
request: Request,
) -> Response {
let config = state.config();
let form = MultiValueForm::from_request_with_limit(request, BRANDING_ASSET_FORM_BYTES).await;
let flash = match build_branding_asset_change(form.as_ref(), upload) {
Err(message) => FlashData::error(message),
Ok((kind, image)) => {
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
let result = client.upload_branding_asset(kind, image).await;
remember_premium_branding(state, &result);
match result {
Ok(instance_config) => {
let flash = branding_asset_success(kind, upload);
if htmx::targets(
headers,
&templates::pages::instance_config::branding_asset_field_id(kind),
) {
return render_fragment_with_toast(
templates::pages::instance_config::branding_asset_field(
kind,
kind.url(&instance_config.app_public.branding),
),
&flash,
);
}
flash
}
Err(error) => branding_asset_failure(kind, upload, &error),
}
}
};
if htmx::is_htmx_request(headers) {
return htmx::toast_response(&flash);
}
redirect_back_with_flash(
&config.base_path,
"/instance-config",
flash,
config.secure_cookies(),
)
}
fn build_branding_asset_change(
form: Option<&MultiValueForm>,
upload: bool,
) -> Result<(BrandingAssetKind, Option<String>), &'static str> {
let form = form.ok_or("Invalid form data")?;
let kind = form
.first("kind")
.and_then(BrandingAssetKind::parse)
.ok_or("Unknown branding asset")?;
if !upload {
return Ok((kind, None));
}
let image = form.clean("image").ok_or("Choose an image to upload")?;
Ok((kind, Some(image)))
}
fn branding_asset_success(kind: BrandingAssetKind, upload: bool) -> FlashData {
let verb = if upload { "uploaded" } else { "removed" };
FlashData::success(format!("{} {verb}", kind.label()))
}
fn branding_asset_failure(
kind: BrandingAssetKind,
upload: bool,
error: &crate::api::client::ApiError,
) -> FlashData {
let verb = if upload { "upload" } else { "remove" };
let label = kind.as_str();
tracing::warn!(%error, kind = label, "admin API request failed: {verb} branding asset");
match super::billing_actions::validation_message(error) {
Some(message) => FlashData::error(format!("Failed to {verb} the {label}: {message}")),
None => FlashData::error(format!("Failed to {verb} the {label}")),
}
}
fn remember_premium_branding(
state: &AppState,
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
@@ -381,22 +434,6 @@ fn render_registration_url_list_response(
)
}
fn render_pending_registration_list_response(
config: &AdminConfig,
csrf_token: &str,
instance_config: &crate::api::types::InstanceConfigResponse,
flash: &FlashData,
) -> Response {
render_fragment_with_toast(
templates::pages::instance_config::pending_registration_list(
config,
csrf_token,
&instance_config.registration.pending_registrations,
),
flash,
)
}
fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
let mut response = Html(markup.into_string()).into_response();
htmx::add_toast_header(&mut response, flash);
+1 -1
View File
@@ -104,7 +104,7 @@ pub fn admin_layout_ext(
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
a href="#main-content" class="skip-link" { "Skip to main content" }
div class="flex h-[100dvh]" {
(render_sidebar(config, admin_acls, active_page, options.inspected_voice_region_id))
(render_sidebar(config, admin_acls, active_page, options.inspected_voice_region_id, auth.pending_registration_count))
div data-sidebar-overlay="" class="pointer-events-none fixed inset-0 z-30 bg-black/50 opacity-0 transition-opacity duration-200 ease-in-out lg:hidden" aria-hidden="true" {}
div class="flex h-[100dvh] w-full flex-1 flex-col overflow-y-auto" {
(render_header(config, auth, csrf_token))
@@ -571,6 +571,12 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
showToast(failed ? 'error' : 'success', isLoginResponse(xhr) ? 'Session expired. Sign in again.' : failed ? 'Action failed.' : 'Done.');
markToastHandled(event.detail);
}, true);
document.addEventListener('htmx:sendError', function (event) {
if (requestVerb(event.detail) !== 'get') return;
var path = event.detail.pathInfo && event.detail.pathInfo.finalRequestPath;
if (path) window.location.assign(path);
}, true);
})();
"#;
+16 -2
View File
@@ -4,13 +4,14 @@ use crate::{acl, config::AdminConfig};
use maud::{Markup, PreEscaped, html};
use super::layout_scripts::SIDEBAR_ACTIVE_SCROLL_SCRIPT;
use super::layout_sidebar_nav::NAV_SECTIONS;
use super::layout_sidebar_nav::{NAV_SECTIONS, PENDING_REGISTRATIONS_KEY};
pub fn render_sidebar(
config: &AdminConfig,
admin_acls: &[String],
active_page: &str,
inspected_voice_region_id: Option<&str>,
pending_registration_count: Option<u32>,
) -> Markup {
let base = &config.base_path;
html! {
@@ -40,7 +41,7 @@ pub fn render_sidebar(
div class="space-y-1" {
@for item in &visible_items {
@let active = active_page == item.active_key;
@let base_classes = "block min-h-[44px] px-3 py-2.5 rounded text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900";
@let base_classes = "flex min-h-[44px] items-center justify-between gap-3 px-3 py-2.5 rounded text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900";
@let item_path = if item.active_key == "voice-servers" {
if let Some(region_id) = inspected_voice_region_id {
let encoded = urlencoding::encode(region_id);
@@ -58,6 +59,9 @@ pub fn render_sidebar(
};
a href={(base) (item_path)} class=(classes) aria-current=[active.then_some("page")] data-active=[active.then_some("")] {
(item.title)
@if item.active_key == PENDING_REGISTRATIONS_KEY {
(pending_registration_count_markup(pending_registration_count, false))
}
}
}
}
@@ -69,3 +73,13 @@ pub fn render_sidebar(
}
}
}
pub fn pending_registration_count_markup(count: Option<u32>, out_of_band: bool) -> Markup {
html! {
span id="pending-registration-count" class="tabular-nums text-neutral-400" hx-swap-oob=[out_of_band.then_some("true")] {
@if let Some(count) = count.filter(|count| *count > 0) {
(count)
}
}
}
}
@@ -9,6 +9,8 @@ pub struct NavItem {
pub required_acls: &'static [&'static str],
}
pub const PENDING_REGISTRATIONS_KEY: &str = "pending-registrations";
pub struct NavSection {
pub title: &'static str,
pub items: &'static [NavItem],
@@ -44,6 +46,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
"discovery",
[acl::DISCOVERY_REVIEW]
),
item!(
"Pending Registrations",
"/pending-registrations",
PENDING_REGISTRATIONS_KEY,
[acl::REGISTRATION_REVIEW]
),
item!(
"Bulk Actions",
"/bulk-actions",
@@ -3,14 +3,15 @@
use crate::{
api::types::{
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
BRANDING_ASSET_ACCEPT, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
InstanceRegistrationResponse, LimitConfigResponse, PremiumMode, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse, TagStyle, VOICE_P2P_DEFAULT_SALT,
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -30,7 +31,7 @@ use crate::{
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
use maud::{Markup, PreEscaped, html};
fn format_percent(value: f64) -> String {
if value.fract() == 0.0 {
@@ -956,36 +957,9 @@ fn app_public_config_section(
))
}
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"app_icon_url",
"Icon URL",
app_public.branding.icon_url.as_deref().unwrap_or(""),
"https://example.com/icon.png",
))
(text_input(
"app_symbol_url",
"Symbol URL",
app_public.branding.symbol_url.as_deref().unwrap_or(""),
"https://example.com/symbol.png",
))
(text_input(
"app_logo_url",
"Logo URL",
app_public.branding.logo_url.as_deref().unwrap_or(""),
"https://example.com/logo.png",
))
(text_input(
"app_wordmark_url",
"Wordmark URL",
app_public.branding.wordmark_url.as_deref().unwrap_or(""),
"https://example.com/wordmark.png",
))
(text_input(
"app_favicon_url",
"Favicon URL",
app_public.branding.favicon_url.as_deref().unwrap_or(""),
"https://example.com/favicon.ico",
))
@for kind in BrandingAssetKind::ALL {
(branding_asset_field(kind, kind.url(&app_public.branding)))
}
(text_input(
"app_status_page_url",
"Status page URL",
@@ -1014,6 +988,7 @@ fn app_public_config_section(
}))
}
}
script { (PreEscaped(BRANDING_ASSET_SCRIPT)) }
div class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Legal Documents" }
form method="post" action={(base) "/instance-config?action=update_app_legal"} {
@@ -1056,6 +1031,173 @@ fn app_public_config_section(
)
}
pub fn branding_asset_field_id(kind: BrandingAssetKind) -> String {
format!("branding-asset-{}", kind.as_str())
}
pub fn branding_asset_field(kind: BrandingAssetKind, url: Option<&str>) -> Markup {
let name = format!("app_{}_url", kind.as_str());
let placeholder = match kind {
BrandingAssetKind::Favicon => "https://example.com/favicon.ico".to_owned(),
_ => format!("https://example.com/{}.png", kind.as_str()),
};
html! {
div id=(branding_asset_field_id(kind)) class="flex flex-col gap-2"
data-branding-asset=(kind.as_str())
data-branding-asset-max-chars=(BRANDING_ASSET_MAX_IMAGE_CHARS) {
(text_input(&name, &format!("{} URL", kind.label()), url.unwrap_or(""), &placeholder))
div class="flex items-center gap-2" {
@if let Some(url) = url {
img src=(url) alt={(kind.label()) " preview"}
onerror="this.hidden=true"
class="h-8 w-8 shrink-0 rounded-md border border-neutral-200 bg-neutral-50 object-contain";
}
input type="file" accept=(BRANDING_ASSET_ACCEPT) class="hidden"
data-branding-asset-file
aria-label={"Upload " (kind.as_str())};
button type="button" data-branding-asset-upload
class=(BRANDING_ASSET_BUTTON_CLASS) {
"Upload"
}
@if url.is_some() {
button type="button" data-branding-asset-remove
class=(BRANDING_ASSET_BUTTON_CLASS) {
"Remove"
}
}
}
}
}
}
const BRANDING_ASSET_BUTTON_CLASS: &str = "inline-flex h-8 shrink-0 items-center justify-center \
rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium \
text-neutral-700 transition-all hover:border-neutral-400 hover:text-neutral-900 \
focus:outline-none focus:ring-2 focus:ring-brand-primary/20 \
disabled:cursor-not-allowed disabled:opacity-50";
const BRANDING_ASSET_SCRIPT: &str = r#"
(function () {
if (window.__adminBrandingAssetBound) return;
window.__adminBrandingAssetBound = true;
function toast(level, message) {
document.body.dispatchEvent(new CustomEvent('showFlash', {detail: {level: level, message: message}}));
}
function toastFromResponse(response) {
var raw = response.headers.get('X-Fluxer-Admin-Toast');
if (!raw) return null;
try {
var parsed = JSON.parse(raw);
return {
level: parsed && parsed.level ? String(parsed.level) : 'info',
message: parsed && parsed.message ? String(parsed.message) : ''
};
} catch (error) {
return null;
}
}
function fieldOf(element) {
return element.closest('[data-branding-asset]');
}
function setBusy(field, busy) {
field.setAttribute('aria-busy', busy ? 'true' : 'false');
field.querySelectorAll('button').forEach(function (button) {
button.disabled = busy;
});
}
function send(field, action, image, failure) {
var form = field.closest('form');
var csrfInput = form ? form.querySelector('input[name="_csrf"]') : null;
var token = csrfInput ? csrfInput.value || '' : '';
var url = new URL(form ? form.getAttribute('action') || window.location.href : window.location.href, window.location.href);
url.searchParams.set('action', action);
var body = new URLSearchParams();
body.set('_csrf', token);
body.set('kind', field.getAttribute('data-branding-asset') || '');
if (image !== null) body.set('image', image);
setBusy(field, true);
fetch(url.toString(), {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'HX-Request': 'true',
'HX-Target': field.id,
'X-CSRF-Token': token
},
body: body.toString()
}).then(function (response) {
var parsed = toastFromResponse(response);
if (response.status !== 200) {
setBusy(field, false);
toast(parsed ? parsed.level : 'error', parsed ? parsed.message : failure);
return;
}
return response.text().then(function (html) {
var template = document.createElement('template');
template.innerHTML = html.trim();
var replacement = template.content.firstElementChild;
if (replacement && replacement.id === field.id) {
field.replaceWith(replacement);
} else {
setBusy(field, false);
}
if (parsed) toast(parsed.level, parsed.message);
});
}).catch(function () {
setBusy(field, false);
toast('error', failure);
});
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!(target instanceof Element)) return;
var upload = target.closest('[data-branding-asset-upload]');
if (upload) {
var uploadField = fieldOf(upload);
var file = uploadField ? uploadField.querySelector('[data-branding-asset-file]') : null;
if (file) file.click();
return;
}
var remove = target.closest('[data-branding-asset-remove]');
if (remove) {
var removeField = fieldOf(remove);
if (removeField) send(removeField, 'remove_branding_asset', null, 'Failed to remove the image');
}
});
document.addEventListener('change', function (event) {
var input = event.target;
if (!(input instanceof HTMLInputElement) || !input.hasAttribute('data-branding-asset-file')) return;
var field = fieldOf(input);
var selected = input.files && input.files[0];
input.value = '';
if (!field || !selected) return;
var maxChars = Number(field.getAttribute('data-branding-asset-max-chars')) || 0;
setBusy(field, true);
var reader = new FileReader();
reader.onload = function () {
var dataUrl = typeof reader.result === 'string' ? reader.result : '';
if (!dataUrl) {
setBusy(field, false);
toast('error', 'Failed to read the image');
return;
}
if (maxChars > 0 && dataUrl.length > maxChars) {
setBusy(field, false);
toast('error', 'This image is too large to upload');
return;
}
send(field, 'upload_branding_asset', dataUrl, 'Failed to upload the image');
};
reader.onerror = function () {
setBusy(field, false);
toast('error', 'Failed to read the image');
};
reader.readAsDataURL(selected);
});
})();
"#;
fn gateway_rollout_section(
base: &str,
csrf_token: &str,
@@ -1640,7 +1782,7 @@ fn registration_config_section(
let base = &config.base_path;
section_card_with_description(
"Registration Controls",
"Control public registration, admin-issued registration URLs, and pending approval requests.",
"Control public registration and admin-issued registration URLs.",
html! {
div class="space-y-8" {
(registration_policy_form(base, csrf_token, registration))
@@ -1649,7 +1791,6 @@ fn registration_config_section(
}
(create_registration_url_form(base, csrf_token))
(registration_url_list(config, csrf_token, &registration.urls))
(pending_registration_list(config, csrf_token, &registration.pending_registrations))
}
},
)
@@ -1872,110 +2013,7 @@ fn registration_url_row(
}
}
pub fn pending_registration_list(
config: &AdminConfig,
csrf_token: &str,
pending_registrations: &[PendingRegistrationResponse],
) -> Markup {
let base = &config.base_path;
html! {
div id="pending-registration-list" class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Pending Registrations" }
@if pending_registrations.is_empty() {
p class="text-sm text-neutral-500" { "No pending registrations." }
} @else {
div class="overflow-x-auto rounded-lg border border-neutral-200" {
table class="min-w-[940px] divide-y divide-neutral-200 text-sm" {
thead class="bg-neutral-50" {
tr {
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Applicant" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Requested" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Registration URL" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "IP" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Action" }
}
}
tbody class="divide-y divide-neutral-200 bg-white" {
@for pending in pending_registrations {
(pending_registration_row(config, base, csrf_token, pending))
}
}
}
}
}
}
}
}
fn pending_registration_row(
config: &AdminConfig,
base: &str,
csrf_token: &str,
pending: &PendingRegistrationResponse,
) -> Markup {
let account_name = pending_registration_account_name(pending);
let requested_at = format_admin_timestamp(&pending.requested_at);
let email = pending.email.as_deref().unwrap_or("None");
let link_id = pending.registration_url_id.as_deref().unwrap_or("None");
let client_ip = pending.client_ip.as_deref().unwrap_or("None");
let approve_action = format!("{base}/instance-config?action=approve_pending_registration");
let reject_action = format!("{base}/instance-config?action=reject_pending_registration");
html! {
tr {
td class="max-w-[20rem] px-4 py-3 align-top" {
p class="font-medium text-neutral-900" { (account_name) }
@if let Some(global_name) = pending.global_name.as_deref() {
p class="truncate text-xs text-neutral-500" title=(global_name) { (global_name) }
}
p class="truncate text-xs text-neutral-500" title=(email) { (email) }
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (&pending.user_id) }
}
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
(requested_at)
}
td class="px-4 py-3 align-top text-neutral-700" {
@if let Some(registration_url_id) = pending.registration_url_id.as_deref() {
@let full_url = admin_issued_registration_url(config, registration_url_id);
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (registration_url_id) }
div class="mt-1" {
(compact_copy_button(&full_url, "Copy URL"))
}
} @else {
span class="text-neutral-500" { (link_id) }
}
}
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
(client_ip)
}
td class="px-4 py-3 align-top" {
div class="flex flex-nowrap gap-2" {
form method="post" action=(&approve_action)
hx-post=(&approve_action)
hx-target="#pending-registration-list"
hx-swap="outerHTML"
hx-push-url="false"
data-admin-allow-swap="true" {
(csrf_input(csrf_token))
input type="hidden" name="user_id" value=(&pending.user_id);
(compact_button("Approve", false))
}
form method="post" action=(&reject_action)
hx-post=(&reject_action)
hx-target="#pending-registration-list"
hx-swap="outerHTML"
hx-push-url="false"
data-admin-allow-swap="true" {
(csrf_input(csrf_token))
input type="hidden" name="user_id" value=(&pending.user_id);
(compact_button("Reject", true))
}
}
}
}
}
}
fn admin_issued_registration_url(config: &AdminConfig, code: &str) -> String {
pub(super) fn admin_issued_registration_url(config: &AdminConfig, code: &str) -> String {
format!(
"{}/register?registration_url={}",
config.web_app_endpoint.trim_end_matches('/'),
@@ -1994,7 +2032,7 @@ fn copyable_registration_url(value: &str, label: &str) -> Markup {
}
}
fn compact_copy_button(value: &str, label: &str) -> Markup {
pub(super) fn compact_copy_button(value: &str, label: &str) -> Markup {
html! {
button type="button"
class="inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium text-neutral-700 transition-all hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
@@ -2005,7 +2043,7 @@ fn compact_copy_button(value: &str, label: &str) -> Markup {
}
}
fn compact_button(label: &str, danger: bool) -> Markup {
pub(super) fn compact_button(label: &str, danger: bool) -> Markup {
let variant_class = if danger {
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500/30"
} else {
@@ -2091,14 +2129,6 @@ fn registration_url_uses_text(url: &RegistrationUrlResponse) -> String {
}
}
fn pending_registration_account_name(pending: &PendingRegistrationResponse) -> String {
if pending.discriminator == 0 {
pending.username.clone()
} else {
format!("{}#{:04}", pending.username, pending.discriminator)
}
}
fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) -> Markup {
let allowed_domains = sso.allowed_domains.join("\n");
let secret_caption = if sso.client_secret_set {
+1
View File
@@ -25,6 +25,7 @@ pub mod jobs_list_helpers;
pub mod limit_config;
pub mod login;
pub mod messages_page;
pub mod pending_registrations;
pub mod profile_substring_bans;
pub mod report_detail;
pub mod reports_list;
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::PendingRegistrationResponse,
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
components::{
error_display::error_alert, form::csrf_input, page_container::page_header,
table::empty_state,
},
layout::admin_layout,
layout_sidebar_nav::PENDING_REGISTRATIONS_KEY,
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
use super::instance_config::{admin_issued_registration_url, compact_button, compact_copy_button};
pub fn pending_registrations_page(
config: &AdminConfig,
auth: &AuthContext,
csrf_token: &str,
pending_registrations: Option<&[PendingRegistrationResponse]>,
) -> Markup {
let content = html! {
(page_header(
"Pending Registrations",
Some("Approve or reject accounts waiting for a registration decision."),
))
@match pending_registrations {
Some(pending_registrations) => {
(pending_registration_list(config, csrf_token, pending_registrations))
}
None => (error_alert("Failed to load pending registrations.")),
}
};
admin_layout(
config,
auth,
"Pending Registrations",
PENDING_REGISTRATIONS_KEY,
None,
content,
)
}
pub fn pending_registration_list(
config: &AdminConfig,
csrf_token: &str,
pending_registrations: &[PendingRegistrationResponse],
) -> Markup {
let base = &config.base_path;
html! {
div id="pending-registration-list" {
@if pending_registrations.is_empty() {
(empty_state("No pending registrations."))
} @else {
div class="overflow-x-auto rounded-lg border border-neutral-200" {
table class="min-w-[940px] divide-y divide-neutral-200 text-sm" {
thead class="bg-neutral-50" {
tr {
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Applicant" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Requested" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Registration URL" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "IP" }
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Action" }
}
}
tbody class="divide-y divide-neutral-200 bg-white" {
@for pending in pending_registrations {
(pending_registration_row(config, base, csrf_token, pending))
}
}
}
}
}
}
}
}
fn pending_registration_row(
config: &AdminConfig,
base: &str,
csrf_token: &str,
pending: &PendingRegistrationResponse,
) -> Markup {
let account_name = pending_registration_account_name(pending);
let requested_at = format_admin_timestamp(&pending.requested_at);
let email = pending.email.as_deref().unwrap_or("None");
let client_ip = pending.client_ip.as_deref().unwrap_or("None");
let approve_action = format!("{base}/pending-registrations/approve");
let reject_action = format!("{base}/pending-registrations/reject");
html! {
tr {
td class="max-w-[20rem] px-4 py-3 align-top" {
p class="font-medium text-neutral-900" { (account_name) }
@if let Some(global_name) = pending.global_name.as_deref() {
p class="truncate text-xs text-neutral-500" title=(global_name) { (global_name) }
}
p class="truncate text-xs text-neutral-500" title=(email) { (email) }
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (&pending.user_id) }
}
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
(requested_at)
}
td class="px-4 py-3 align-top text-neutral-700" {
@if let Some(registration_url_id) = pending.registration_url_id.as_deref() {
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (registration_url_id) }
div class="mt-1" {
(compact_copy_button(&admin_issued_registration_url(config, registration_url_id), "Copy URL"))
}
} @else {
span class="text-neutral-500" { "None" }
}
}
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
(client_ip)
}
td class="px-4 py-3 align-top" {
div class="flex flex-nowrap gap-2" {
(decision_form(&approve_action, csrf_token, &pending.user_id, "Approve", false))
(decision_form(&reject_action, csrf_token, &pending.user_id, "Reject", true))
}
}
}
}
}
fn decision_form(
action: &str,
csrf_token: &str,
user_id: &str,
label: &str,
danger: bool,
) -> Markup {
html! {
form method="post" action=(action)
hx-post=(action)
hx-target="#pending-registration-list"
hx-swap="outerHTML"
hx-push-url="false"
data-admin-allow-swap="true" {
(csrf_input(csrf_token))
input type="hidden" name="user_id" value=(user_id);
(compact_button(label, danger))
}
}
}
fn pending_registration_account_name(pending: &PendingRegistrationResponse) -> String {
if pending.discriminator == 0 {
pending.username.clone()
} else {
format!("{}#{:04}", pending.username, pending.discriminator)
}
}
+5 -3
View File
@@ -33,9 +33,11 @@ pub struct MultiValueForm {
impl MultiValueForm {
pub async fn from_request(request: Request) -> Option<Self> {
let bytes = body::to_bytes(request.into_body(), MAX_FORM_BYTES)
.await
.ok()?;
Self::from_request_with_limit(request, MAX_FORM_BYTES).await
}
pub async fn from_request_with_limit(request: Request, max_bytes: usize) -> Option<Self> {
let bytes = body::to_bytes(request.into_body(), max_bytes).await.ok()?;
Some(Self::parse(bytes.as_ref()))
}
+26 -2
View File
@@ -261,8 +261,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"registration": {
"mode": "open",
"admin_registration_urls_enabled": false,
"urls": [],
"pending_registrations": []
"urls": []
},
"self_hosted": false,
"account_identity": {"mode": "username", "locked": true, "tag_style": "none"},
@@ -1282,3 +1281,28 @@ fn deserialize_guild_threads_response() {
.is_some_and(|m| m.archived && !m.locked)
);
}
#[test]
fn branding_asset_upload_request_matches_the_generated_contract() {
for kind in types::BrandingAssetKind::ALL {
for image in [Some("data:image/png;base64,iVBORw0KGgo=".to_owned()), None] {
let request = types::BrandingAssetUploadRequest {
kind,
image: image.clone(),
};
let value = serde_json::to_value(&request).unwrap();
assert_eq!(
value,
serde_json::json!({"kind": kind.as_str(), "image": image})
);
let generated: generated_types::BrandingAssetUploadRequest =
serde_json::from_value(value.clone()).expect("generated branding asset contract");
assert_eq!(
serde_json::to_value(generated).unwrap()["kind"],
value["kind"]
);
assert_eq!(types::BrandingAssetKind::parse(kind.as_str()), Some(kind));
}
}
assert_eq!(types::BrandingAssetKind::parse("banner"), None);
}
+291
View File
@@ -5,6 +5,7 @@
use axum::{
Json, Router,
body::{Body, to_bytes},
extract::{DefaultBodyLimit, Path},
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
routing,
@@ -272,6 +273,283 @@ async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report()
);
}
#[tokio::test]
async fn pending_registration_decision_refreshes_the_list_and_sidebar_count() {
let received = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
let sink = received.clone();
let api = Router::new()
.route(
"/admin/users/@me",
routing::get(|| async {
json_response(json!({"user": admin_user(), "pending_registration_count": 2}))
}),
)
.route(
"/admin/instance/pending-registrations",
routing::get(|| async {
json_response(json!({"pending_registrations": [
pending_registration("1500000000000000002"),
pending_registration("1500000000000000003"),
]}))
}),
)
.route(
"/admin/instance/pending-registrations/{user_id}",
routing::patch(
move |Path(user_id): Path<String>, Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push((user_id, body));
json_response(json!({"pending_registrations": [
pending_registration("1500000000000000003"),
]}))
}
},
),
)
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/pending-registrations", &[]).await;
assert!(
page.contains(r#"<span id="pending-registration-count" class="tabular-nums text-neutral-400">2</span>"#),
"{page}"
);
assert!(page.contains("ID: 1500000000000000002"), "{page}");
let csrf_token = csrf_cookie(&headers).unwrap_or_else(|| {
panic!("pending registrations page did not set csrf_token cookie\n{page}")
});
assert_form_has_csrf(&page, "/pending-registrations/approve", &csrf_token);
assert_form_has_csrf(&page, "/pending-registrations/reject", &csrf_token);
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let (status, headers, fragment) = post_form_with_headers(
&app,
"/pending-registrations/approve",
&[
("HX-Request", "true"),
("HX-Target", "pending-registration-list"),
("Cookie", &cookie),
],
&format!("_csrf={csrf_token}&user_id=1500000000000000002"),
)
.await;
assert_eq!(status, StatusCode::OK, "{fragment}");
assert!(
fragment.contains(r#"id="pending-registration-list""#),
"{fragment}"
);
assert!(!fragment.contains("ID: 1500000000000000002"), "{fragment}");
assert!(fragment.contains("ID: 1500000000000000003"), "{fragment}");
assert!(
fragment.contains(r#"hx-swap-oob="true">1</span>"#),
"{fragment}"
);
let toast = headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(toast.contains("Registration approved"), "{toast}");
assert_eq!(
*received.lock().unwrap(),
vec![(
"1500000000000000002".to_owned(),
json!({"status": "approved"})
)]
);
}
#[tokio::test]
async fn branding_asset_upload_takes_a_large_image_and_swaps_the_field() {
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
let sink = received.clone();
let api = Router::new()
.route(
"/admin/instance/config",
routing::get(|| async {
json_response(instance_config(Some(
"https://media.example.test/branding/0/old.png",
)))
}),
)
.route(
"/admin/instance/config/branding-assets",
routing::post(move |Json(body): Json<Value>| {
let sink = sink.clone();
async move {
sink.lock().unwrap().push(body.clone());
if body["image"] == "data:image/png;base64,bad" {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"code": "INVALID_FORM_BODY",
"message": "Input form validation failed",
"errors": [{"path": "image", "message": "Unsupported image format"}]
})),
)
.into_response();
}
let logo_url = body["image"]
.is_string()
.then_some("https://media.example.test/branding/0/new.png");
json_response(instance_config(logo_url))
}
}),
)
.layer(DefaultBodyLimit::disable())
.fallback(mock_api);
let app = setup_with_api(api).await;
let (headers, page) = get_with_headers(&app, "/instance-config", &[]).await;
let csrf_token = csrf_cookie(&headers)
.unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{page}"));
assert_form_has_csrf(
&page,
"/instance-config?action=update_app_public",
&csrf_token,
);
assert!(page.contains(r#"id="branding-asset-logo""#), "{page}");
assert!(page.contains(r#"id="branding-asset-favicon""#), "{page}");
assert!(
page.contains(r#"src="https://media.example.test/branding/0/old.png""#),
"{page}"
);
assert!(page.contains(r#"accept=".png,.jpeg,.webp"#), "{page}");
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
let post = |action: &'static str, fields: Vec<(&'static str, String)>| {
let app = &app;
let cookie = cookie.clone();
let csrf_token = csrf_token.clone();
async move {
let mut body = url::form_urlencoded::Serializer::new(String::new());
body.append_pair("_csrf", &csrf_token);
for (name, value) in &fields {
body.append_pair(name, value);
}
post_form_with_headers(
app,
&format!("/instance-config?action={action}"),
&[
("HX-Request", "true"),
("HX-Target", "branding-asset-logo"),
("X-CSRF-Token", &csrf_token),
("Cookie", &cookie),
],
&body.finish(),
)
.await
}
};
let toast_of = |headers: &HeaderMap| {
headers
.get("X-Fluxer-Admin-Toast")
.and_then(|value| value.to_str().ok())
.unwrap_or_default()
.to_owned()
};
let image = format!(
"data:image/png;base64,{}",
"iVBO+w/A".repeat(2 * 1024 * 1024 / 8)
);
let (status, headers, fragment) = post(
"upload_branding_asset",
vec![("kind", "logo".to_owned()), ("image", image.clone())],
)
.await;
assert_eq!(status, StatusCode::OK, "{fragment}");
assert!(
fragment.starts_with(r#"<div id="branding-asset-logo""#),
"{fragment}"
);
assert!(
fragment.contains(r#"value="https://media.example.test/branding/0/new.png""#),
"{fragment}"
);
assert!(
fragment.contains("data-branding-asset-remove"),
"{fragment}"
);
assert!(toast_of(&headers).contains("Logo uploaded"));
let (status, headers, fragment) =
post("remove_branding_asset", vec![("kind", "logo".to_owned())]).await;
assert_eq!(status, StatusCode::OK, "{fragment}");
assert!(
fragment.contains(r#"name="app_logo_url" value="""#),
"{fragment}"
);
assert!(!fragment.contains("<img"), "{fragment}");
assert!(
!fragment.contains("data-branding-asset-remove"),
"{fragment}"
);
assert!(toast_of(&headers).contains("Logo removed"));
let (status, headers, _) = post(
"upload_branding_asset",
vec![
("kind", "logo".to_owned()),
("image", "data:image/png;base64,bad".to_owned()),
],
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT);
let toast = toast_of(&headers);
assert!(toast.contains("error"), "{toast}");
assert!(
toast.contains("Failed to upload the logo: image: Unsupported image format"),
"{toast}"
);
let (_, headers, _) = post(
"upload_branding_asset",
vec![("kind", "logo".to_owned()), ("image", " ".to_owned())],
)
.await;
assert!(toast_of(&headers).contains("Choose an image to upload"));
let (_, headers, _) = post(
"upload_branding_asset",
vec![("kind", "banner".to_owned()), ("image", image.clone())],
)
.await;
assert!(toast_of(&headers).contains("Unknown branding asset"));
let (_, headers, _) = post(
"update_app_public",
vec![
("app_product_name", "Fluxer".to_owned()),
("app_logo_url", image.clone()),
],
)
.await;
assert!(toast_of(&headers).contains("Invalid form data"));
assert_eq!(
*received.lock().unwrap(),
vec![
json!({"kind": "logo", "image": image}),
json!({"kind": "logo", "image": null}),
json!({"kind": "logo", "image": "data:image/png;base64,bad"}),
]
);
}
fn instance_config(logo_url: Option<&str>) -> Value {
json!({
"sso": {},
"gateway_rollout": {
"session_rollout_percentage": 100,
"session_rollout_mode": "modulo",
"guild_rollout_percentage": 100,
"rpc_request_timeout_ms": 10000,
"max_concurrent_session_starts": 512,
"max_concurrent_guild_starts": 256,
"voice_e2ee_scope": "guild_feature_only"
},
"self_hosted": true,
"app_public": {"branding": {"product_name": "Fluxer", "logo_url": logo_url}}
})
}
async fn setup_with_api(api: Router) -> TestApp {
let api_endpoint = spawn_mock_api(api).await;
let router = build_router(test_config(api_endpoint));
@@ -440,6 +718,19 @@ fn user(id: &str, username: &str) -> Value {
})
}
fn pending_registration(user_id: &str) -> Value {
json!({
"user_id": user_id,
"username": "applicant",
"discriminator": 0,
"global_name": null,
"email": "[email protected]",
"requested_at": "2026-10-14T12:00:00.000Z",
"registration_url_id": null,
"client_ip": "203.0.113.7"
})
}
fn searched_report() -> Value {
json!({
"report_id": "1800000000000000001",
+5
View File
@@ -374,6 +374,11 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
token: master.integrations.cache_purge.http.token,
timeoutMs: master.integrations.cache_purge.http.timeout_ms,
},
controller: {
endpoint: master.integrations.cache_purge.controller.endpoint,
token: master.integrations.cache_purge.controller.token,
timeoutMs: master.integrations.cache_purge.controller.timeout_ms,
},
},
clamav: {
enabled: master.integrations.clamav.enabled,
@@ -39,6 +39,7 @@ export const AdminAuditReadActions = {
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
LIST_GUILD_STICKERS: 'list_guild_stickers',
LIST_GUILD_THREADS: 'list_guild_threads',
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
LIST_REPORT_REASONS: 'list_report_reasons',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
@@ -3,6 +3,7 @@
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
import {startAttachmentDecayReconcile} from '@app/api/attachment/AttachmentDecayReconcileRun';
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
import {createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {
@@ -34,6 +35,7 @@ import {
InstanceEmailSmtpTestRequest,
InstanceEmailSmtpTestResponse,
PendingRegistrationActionRequest,
PendingRegistrationListResponse,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
@@ -98,7 +100,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
experimentDelivery,
registrationConfig,
registrationUrls,
pendingRegistrations,
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
@@ -109,7 +110,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
]);
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
await Promise.all([
@@ -148,7 +148,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
registration: {
...registrationConfig,
urls: registrationUrls,
pending_registrations: pendingRegistrations,
},
self_hosted: Config.instance.selfHosted,
account_identity: {
@@ -320,10 +319,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
targetType: 'instance_config',
targetId: 0n,
action: AdminAuditReadActions.GET_INSTANCE_CONFIG,
metadata: {
registration_url_count: response.registration.urls.length,
pending_registration_count: response.registration.pending_registrations.length,
},
metadata: {registration_url_count: response.registration.urls.length},
});
return ctx.json(response);
},
@@ -738,18 +734,43 @@ export function InstanceConfigAdminController(app: HonoApp) {
return ctx.json(await buildInstanceConfigResponse());
},
);
app.get(
'/admin/instance/pending-registrations',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REGISTRATION_REVIEW),
OpenAPI({
operationId: 'list_admin_pending_registrations',
summary: 'List pending registrations',
description:
'Lists every registration waiting for manual review, oldest first. Requires REGISTRATION_REVIEW permission.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const pendingRegistrations = await instanceConfigRepository.getPendingRegistrations();
await recordAdminRead(ctx, {
targetType: 'instance_config',
targetId: 0n,
action: AdminAuditReadActions.LIST_PENDING_REGISTRATIONS,
metadata: {pending_registration_count: pendingRegistrations.length},
});
return ctx.json({pending_registrations: pendingRegistrations});
},
);
app.patch(
'/admin/instance/pending-registrations/:user_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
requireAdminACL(AdminACLs.REGISTRATION_REVIEW),
Validator('param', UserIdParam),
Validator('json', PendingRegistrationActionRequest),
OpenAPI({
operationId: 'update_admin_pending_registration',
summary: 'Approve or reject a pending registration',
description:
'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,
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -757,9 +778,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
async (ctx) => {
const userId = ctx.req.valid('param').user_id.toString();
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await updatePendingRegistrationUser(ctx, userId, decision);
const pendingRegistration = (await instanceConfigRepository.getPendingRegistrations()).find(
(entry) => entry.user_id === userId,
);
await updatePendingRegistrationUser(ctx, userId, decision, pendingRegistration?.invite_code ?? null);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json(await buildInstanceConfigResponse());
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
},
);
}
@@ -855,6 +879,7 @@ async function updatePendingRegistrationUser(
ctx: Context<HonoEnv>,
userId: string,
decision: 'approve' | 'reject',
inviteCode: string | null,
): Promise<void> {
const userRepository = ctx.get('userRepository');
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
@@ -876,6 +901,11 @@ async function updatePendingRegistrationUser(
}
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
if (decision === 'approve' && wasPendingApproval) {
await maybeAutoJoinInvite(ctx.get('inviteService'), {
userId: user.id,
inviteCode,
requestCache: ctx.get('requestCache'),
});
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
}
await recordAdminWrite(ctx, {
@@ -8,9 +8,10 @@ import {
RequireEmailAccountIdentity,
RequireUsernameAccountIdentity,
} from '@app/api/middleware/AccountIdentityMiddleware';
import {requireAdminACL, requireGrantedAdminACLs} from '@app/api/middleware/AdminMiddleware';
import {hasAdminACL, requireAdminACL, requireGrantedAdminACLs} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {inputValidationErrorFromZodIssues, Validator} from '@app/api/Validator';
@@ -94,14 +95,15 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account. Requires AUTHENTICATE permission.',
'Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account, plus the number of pending registrations when the admin holds REGISTRATION_REVIEW. Requires AUTHENTICATE permission.',
}),
async (ctx) => {
const adminUser = ctx.get('user');
const cacheService = ctx.get('cacheService');
return ctx.json({
user: await mapUserToAdminResponse(adminUser, cacheService),
});
const canReviewRegistrations = hasAdminACL(ctx.get('adminUserAcls'), AdminACLs.REGISTRATION_REVIEW);
const [user, pendingRegistrations] = await Promise.all([
mapUserToAdminResponse(ctx.get('user'), ctx.get('cacheService')),
canReviewRegistrations ? getInstanceConfigRepository().getPendingRegistrations() : null,
]);
return ctx.json({user, pending_registration_count: pendingRegistrations?.length ?? null});
},
);
app.get(
@@ -6,19 +6,34 @@ import {
createUniqueUsername,
setUserACLs,
} from '@app/api/auth/tests/AuthTestUtils';
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {Config} from '@app/api/Config';
import {createChannelInvite, createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
import type {PendingRegistrationListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {AdminUsersMeResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface PendingRegistrationResponse {
user_id: string;
}
function invitedRegistrationBody(prefix: string, inviteCode?: string): Record<string, unknown> {
return {
email: createUniqueEmail(prefix),
username: createUniqueUsername(prefix),
global_name: 'Invited person',
password: 'invited-since-1999',
date_of_birth: '2000-01-01',
consent: true,
invite_code: inviteCode,
};
}
describe('pending registration approval and the stock community', () => {
let harness: ApiTestHarness;
@@ -37,7 +52,7 @@ describe('pending registration approval and the stock community', () => {
it('keeps a banned user out of the stock community on approval', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await getInstanceConfigRepository().setInstancePolicyConfig({
@@ -79,7 +94,7 @@ describe('pending registration approval and the stock community', () => {
it('does not add a user who was never pending to the stock community', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const outsider = await createTestAccount(harness);
@@ -99,4 +114,251 @@ describe('pending registration approval and the stock community', () => {
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('joins the invited community on approval', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedapproval', invite.code))
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
});
it('lists the invite code on a pending registration', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedlisted', invite.code))
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations).toEqual([
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
]);
});
it('does not join the invited community on rejection', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitedrejected', invite.code))
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'rejected'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('joins the auto-join community on approval of a registration without an invite', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('autojoinapproval'))
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations).toEqual([
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
]);
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
});
it('does not join the auto-join community when the pending entry has no invite', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('noinviteapproval'))
.execute();
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('approves and joins the single community when the invite is gone', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild: stockGuild} = await setupTestGuildWithMembers(harness, 0);
const invitedGuild = await createGuild(harness, owner.token, 'Invited Guild');
const invite = await createChannelInvite(harness, owner.token, invitedGuild.system_channel_id!);
await getInstanceConfigRepository().setInstancePolicyConfig({
single_community_enabled: true,
single_community_guild_id: stockGuild.id,
});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body(invitedRegistrationBody('invitegone', invite.code))
.execute();
await createBuilder(harness, owner.token)
.delete(`/invites/${invite.code}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${stockGuild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${invitedGuild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('lets the review ACL alone list and decide registrations', async () => {
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const configAdmin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('reviewonly'),
username: createUniqueUsername('reviewonly'),
global_name: 'Review only',
password: 'reviewing-since-1999',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations.map((entry) => entry.user_id)).toEqual([pending.user_id]);
const reviewerMe = await createBuilder<AdminUsersMeResponse>(harness, reviewer.token)
.get('/admin/users/@me')
.execute();
expect(reviewerMe.pending_registration_count).toBe(1);
const configAdminMe = await createBuilder<AdminUsersMeResponse>(harness, configAdmin.token)
.get('/admin/users/@me')
.execute();
expect(configAdminMe.pending_registration_count).toBeNull();
await createBuilder(harness, configAdmin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
await createBuilder(harness, configAdmin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
const decided = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'rejected'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.pending_registrations).toEqual([]);
});
});
@@ -35,7 +35,7 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
action: 'get_instance_config',
targetType: 'instance_config',
targetId: '0',
metadata: {registration_url_count: '1', pending_registration_count: '0'},
metadata: {registration_url_count: '1'},
},
};
},
@@ -129,6 +129,30 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
};
},
},
{
method: 'GET',
route: '/admin/instance/pending-registrations',
async prepare({harness}) {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
await registerUser(harness, {
email: createUniqueEmail('coverage-listed'),
username: createUniqueUsername('coveragelisted'),
global_name: 'Coverage Listed',
password: 'coverage-listed-password',
date_of_birth: '2000-01-01',
consent: true,
});
return {
request: {path: '/admin/instance/pending-registrations'},
expected: {
action: 'list_pending_registrations',
targetType: 'instance_config',
targetId: '0',
metadata: {pending_registration_count: '1'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/instance/pending-registrations/:user_id',
+14 -5
View File
@@ -52,9 +52,10 @@ import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
import {RegistrationCredentialsRequiredError} from '@fluxer/errors/src/domains/auth/RegistrationCredentialsRequiredError';
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -163,6 +164,9 @@ async function registerAccount(
const registrationAccess = setupTokenVerified
? {pendingApproval: false, registrationUrl: null}
: await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
if (registrationAccess.pendingApproval && !hasSignInCredentials(data, usernameMode)) {
throw new RegistrationCredentialsRequiredError();
}
const geoipResult = await lookupGeoip(clientIp);
const countryCode = geoipResult.countryCode;
const collectDateOfBirth = appPublicConfig.registration.collect_date_of_birth;
@@ -253,7 +257,6 @@ async function registerAccount(
const acceptLanguage = request.headers.get('accept-language');
const userLocale = parseAcceptLanguage(acceptLanguage);
const passwordHash = data.password ? await hashPassword(data.password) : null;
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
const userRow: UserRow = {
...EMPTY_USER_ROW,
user_id: userId,
@@ -271,7 +274,7 @@ async function registerAccount(
timezone_privacy_flags: ProfileFieldPrivacyFlags.EVERYONE,
date_of_birth: dateOfBirth,
locale: userLocale,
flags,
flags: 0n,
terms_agreed_at: requiresTermsConsent ? now : null,
privacy_agreed_at: requiresPrivacyConsent ? now : null,
last_active_at: now,
@@ -303,6 +306,7 @@ async function registerAccount(
requested_at: now.toISOString(),
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
client_ip: clientIp,
invite_code: data.invite_code?.trim() || config.instance.autoJoinInviteCode || null,
});
}
grantBootstrapAdmin = bootstrapAdminEligible && (await instanceConfigRepository.claimAdminBootstrap(userId));
@@ -405,6 +409,11 @@ function assertUsernameModeRegistration(data: RegisterRequest): void {
}
}
function hasSignInCredentials(data: RegisterRequest, usernameMode: boolean): boolean {
if (!data.password) return false;
return usernameMode || Boolean(data.email);
}
function requestedUsernameFromOlderApp(config: APIConfig, email: string | null | undefined): string | undefined {
if (email == null) return undefined;
const localPart = getLocalPartAtInstance(config, email.trim());
@@ -495,7 +504,7 @@ async function resolveRegistrationAccess(
};
}
async function maybeAutoJoinInvite(
export async function maybeAutoJoinInvite(
inviteService: InviteService,
params: {
userId: UserID;
@@ -513,7 +522,7 @@ async function maybeAutoJoinInvite(
requestCache,
});
} catch (error) {
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite on registration');
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite');
}
}
+12 -1
View File
@@ -2,6 +2,7 @@
import {createHash, randomBytes} from 'node:crypto';
import type {ApiContext} from '@app/api/ApiContext';
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
import * as AuthSession from '@app/api/auth/AuthSession';
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
import {SsoIdentityRepository} from '@app/api/auth/services/SsoIdentityRepository';
@@ -28,6 +29,7 @@ import {
isTestSsoProvider,
validateSsoPublicOutboundUrl,
} from '@app/api/instance/SsoConfigValidation';
import type {InviteService} from '@app/api/invite/InviteService';
import {Logger} from '@app/api/Logger';
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
@@ -265,6 +267,7 @@ export class SsoService {
private readonly discriminatorService: DiscriminatorService,
private readonly kvActivityTracker: KVActivityTracker,
private readonly singleCommunityService: SingleCommunityService,
private readonly inviteService: InviteService,
) {}
async getPublicStatus(): Promise<PublicSsoStatus> {
@@ -406,10 +409,16 @@ export class SsoService {
}
await assertEmailNotBlocklisted(emailLower, 'email');
const pendingApproval = registrationConfig.mode === 'approval';
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
const autoJoinInviteCode = this.apiContext.services.config.instance.autoJoinInviteCode?.trim() || null;
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval, autoJoinInviteCode});
if (pendingApproval) {
throw new RegistrationPendingApprovalError();
}
await maybeAutoJoinInvite(this.inviteService, {
userId: user.id,
inviteCode: autoJoinInviteCode,
requestCache,
});
await this.singleCommunityService.joinStockCommunity(user.id, requestCache);
return user;
}
@@ -468,6 +477,7 @@ export class SsoService {
config: ResolvedSsoConfig,
options?: {
pendingApproval?: boolean;
autoJoinInviteCode?: string | null;
},
): Promise<User> {
const {users, snowflake, cache} = this.apiContext.services;
@@ -535,6 +545,7 @@ export class SsoService {
requested_at: now.toISOString(),
registration_url_id: null,
client_ip: null,
invite_code: options.autoJoinInviteCode ?? null,
});
}
createAttempted = true;
@@ -23,6 +23,7 @@ import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {ms} from 'itty-time';
@@ -126,6 +127,19 @@ describe('Auth registration', () => {
expect(reg.user_id.length).toBeGreaterThan(0);
expect(Object.keys(reg).sort()).toEqual(['token', 'user', 'user_id']);
});
it('does not flag accounts registered on a development instance as staff', async () => {
expect(getConfig().nodeEnv).toBe('development');
const reg = await registerUser(harness, {
email: createUniqueEmail('devnotstaff'),
username: createUniqueUsername('devnotstaff'),
global_name: 'Dev User',
password: 'a-strong-password',
date_of_birth: '2000-01-01',
consent: true,
});
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(reg.user_id)));
expect(user.flags & UserFlags.STAFF).toBe(0n);
});
it('grants wildcard admin ACL to first accepted local dev registration', async () => {
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: false}, async () => {
@@ -91,4 +91,67 @@ describe('Auth registration policy', () => {
.expect(403, APIErrorCodes.REGISTRATION_PENDING_APPROVAL)
.execute();
});
it('refuses an approval registration without sign-in credentials', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({global_name: 'Nobody Signs In', date_of_birth: '2000-01-01', consent: true})
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('refuses an approval registration with an email but no password', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const {password: _password, ...body} = registrationBody('nopassword');
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body(body)
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('refuses an approval registration with a password but no email', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const {email: _email, ...body} = registrationBody('noemail');
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body(body)
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('keeps the registration URL seat when an approval registration is refused', async () => {
const repository = getInstanceConfigRepository();
await repository.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
const {code} = await repository.createRegistrationUrl({
label: 'Reviewed link',
createdByUserId: '1',
expiresAt: null,
maxUses: 1,
approvalRequired: true,
});
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({global_name: 'Seat Keeper', date_of_birth: '2000-01-01', consent: true, registration_url_code: code})
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
const registration = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body({...registrationBody('seatkeeper'), registration_url_code: code})
.execute();
expect(registration.registration_pending_approval).toBe(true);
});
it('still accepts an unclaimed registration in open mode', async () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'open'});
const registration = await createBuilderWithoutAuth<RegistrationTokenResponse>(harness)
.post('/auth/register')
.body({global_name: 'Open Unclaimed', date_of_birth: '2000-01-01', consent: true})
.execute();
expect(registration.token.length).toBeGreaterThan(0);
});
});
@@ -23,7 +23,10 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {
InstanceConfigResponse,
PendingRegistrationListResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -62,6 +65,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
});
@@ -82,6 +86,13 @@ describe('signups racing on registration URLs and pending approvals', () => {
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const readPendingRegistrations = async (): Promise<PendingRegistrationListResponse['pending_registrations']> =>
(
await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.execute()
).pending_registrations;
const completeSso = async (prefix: string) => {
const start = await createBuilderWithoutAuth<{state: string}>(harness)
.post('/auth/sso/start')
@@ -116,7 +127,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
};
const expectOnePendingAccount = async () => {
const pending = (await readAdminConfig()).registration.pending_registrations;
const pending = await readPendingRegistrations();
expect(pending).toHaveLength(1);
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
expect(account?.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)).toBe(true);
@@ -209,8 +220,8 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('seatreleased', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect(await readPendingRegistrations()).toEqual([]);
const withdrawn = await readAdminConfig();
expect(withdrawn.registration.pending_registrations).toEqual([]);
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
const retried = await register('seatreleasedretry', code);
@@ -307,7 +318,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`pending${index}`)));
expect(attempts.map((attempt) => attempt.json.registration_pending_approval)).toEqual(Array(5).fill(true));
const pending = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
const pending = (await readPendingRegistrations()).map((entry) => entry.user_id);
expect(pending.toSorted()).toEqual(attempts.map((attempt) => attempt.json.user_id).toSorted());
});
@@ -340,7 +351,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('pendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
expect(await readPendingRegistrations()).toHaveLength(1);
});
it('lists no pending approval for an approval-mode signup that failed before the account was created', async () => {
@@ -350,7 +361,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('pendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
expect(await readPendingRegistrations()).toEqual([]);
});
it('lists an SSO account provisioned in approval mode whose provisioning failed after the account was created', async () => {
@@ -383,7 +394,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await completeSso('ssopendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
expect(await readPendingRegistrations()).toHaveLength(1);
});
it('lists no pending approval for an SSO signup in approval mode that failed before the account was created', async () => {
@@ -394,7 +405,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await completeSso('ssopendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
expect(await readPendingRegistrations()).toEqual([]);
});
it('keeps a stored pending approval listed until an admin decides it', async () => {
@@ -417,16 +428,19 @@ describe('signups racing on registration URLs and pending approvals', () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const fresh = await register('pendingafter');
const listed = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
const pendingRegistrations = await readPendingRegistrations();
expect(pendingRegistrations.map((entry) => entry.user_id).toSorted()).toEqual(
[account.userId, fresh.json.user_id].toSorted(),
);
expect(pendingRegistrations.find((entry) => entry.user_id === account.userId)?.invite_code).toBeNull();
const decided = await createBuilder<InstanceConfigResponse>(harness, admin.token)
const decided = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${account.userId}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.registration.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(decided.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(
JSON.parse(
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
+77 -1
View File
@@ -9,10 +9,13 @@ import {
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {Config} from '@app/api/Config';
import {createChannelInvite, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {PendingRegistrationListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface SsoStartResponse {
@@ -503,6 +506,79 @@ describe('Auth SSO flow', () => {
.expect(200)
.execute();
});
it('joins a provisioned user to the auto-join invite community', async () => {
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await enableSso(harness, admin.token);
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
.post('/auth/sso/complete')
.body({
code: `sso-auto-join-${Date.now()}@example.com`,
state: startData.state,
})
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${completeData.user_id}`)
.expect(200)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
});
it('replays the auto-join invite when an SSO registration is approved', async () => {
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await enableSso(harness, admin.token);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
Config.instance.autoJoinInviteCode = invite.code;
try {
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
.post('/auth/sso/start')
.body({})
.execute();
await createBuilderWithoutAuth(harness)
.post('/auth/sso/complete')
.body({
code: `sso-auto-join-approval-${Date.now()}@example.com`,
state: startData.state,
})
.expect(403, 'REGISTRATION_PENDING_APPROVAL')
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.get('/admin/instance/pending-registrations')
.expect(200)
.execute();
expect(listed.pending_registrations).toEqual([expect.objectContaining({invite_code: invite.code})]);
const pendingUserId = listed.pending_registrations[0]!.user_id;
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
.expect(404)
.execute();
await createBuilder(harness, reviewer.token)
.patch(`/admin/instance/pending-registrations/${pendingUserId}`)
.body({status: 'approved'})
.expect(200)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
.expect(200)
.execute();
} finally {
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
}
});
});
describe('existing user login', () => {
let admin: TestAccount;
@@ -404,6 +404,29 @@ describe('Username sign-in', () => {
expect(user.username.toLowerCase().startsWith('sharedname')).toBe(true);
expect(user.isUnclaimedAccount()).toBe(true);
});
it('refuses a credential-less approval registration on a username instance', async () => {
await useUsernameMode();
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
await createBuilderWithoutAuth(harness)
.post('/auth/register')
.body({global_name: 'No Credentials', date_of_birth: '2000-01-01'})
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
.execute();
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
});
it('accepts an approval registration with a username and password', async () => {
await useUsernameMode();
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const registration = await createBuilderWithoutAuth<{registration_pending_approval: true; user_id: string}>(
harness,
)
.post('/auth/register')
.body({username: 'reviewedperson', password: PASSWORD, date_of_birth: '2000-01-01'})
.execute();
expect(registration).toEqual({registration_pending_approval: true, user_id: expect.any(String)});
});
});
describe('login', () => {
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {UserID} from '@app/api/BrandedTypes';
import {userIdToChannelId} from '@app/api/BrandedTypes';
import {isPersonalNotesChannelId} from '@app/api/channel/services/PersonalNotesChannelRepair';
import type {Channel} from '@app/api/models/Channel';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
@@ -15,6 +17,9 @@ interface DmScopeOptions {
}
export function isDmScopeChannelForUser(channel: Channel, userId: UserID): boolean {
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return isPersonalNotesChannelId({userId, channelId: channel.id});
}
const isDm = channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
return isDm && channel.recipientIds.has(userId);
}
@@ -26,7 +31,7 @@ export async function getDmChannelIdsForScope({
includeChannel,
}: DmScopeOptions): Promise<Array<string>> {
const summaryResults = await userRepository.listPrivateChannelSummaries(userId);
const channelIdStrings = new Set<string>();
const channelIdStrings = new Set<string>([userIdToChannelId(userId).toString()]);
for (const summary of summaryResults) {
const isDm =
summary.channelType === ChannelTypes.DM || summary.channelType === ChannelTypes.GROUP_DM || summary.isGroupDm;
+8 -5
View File
@@ -19,13 +19,16 @@ export interface TrustedCallerConfig {
buckets: Array<string>;
}
interface APICachePurgeTarget {
endpoint: string;
token: string;
timeoutMs: number;
}
export interface APICachePurgeConfig {
adapter: CachePurgeAdapterName;
http: {
endpoint: string;
token: string;
timeoutMs: number;
};
http: APICachePurgeTarget;
controller: APICachePurgeTarget;
}
interface APIGeoipFilesystemConfig {
+1 -1
View File
@@ -8,7 +8,7 @@
"billing.eu_withdrawal_waiver_checkout": "Якщо я є споживачем з ЄС/ЄЕЗ, я надаю явну згоду на негайне надання цифрового контенту {product_name} {premium_tier_name} і визнаю, що втрачаю своє законне право на відмову після отримання доступу. Це не впливає на інші обов'язкові права споживача. Див. [Умови надання послуг]({terms_url}).",
"bulk_message_deletion.complete": "Ми завершили видалення твоїх повідомлень. Видалено {message_count, plural, =0 {0 повідомлень} one {# повідомлення} few {# повідомлення} many {# повідомлень} other {# повідомлень}} з {channel_count, plural, =0 {0 місць} one {# місця} few {# місць} many {# місць} other {# місць}}.",
"content.virus_detected": "Цей файл позначено як потенційно небезпечний, і його вилучено.",
"guild.bulk_create.emoji_limit": "Досягнуто максимальної кількості емодзі ({limit}).",
"guild.bulk_create.emoji_limit": "Досягнуто максимальної кількості емоджі ({limit}).",
"guild.bulk_create.sticker_limit": "Досягнуто максимальної кількості наліпок ({limit}).",
"guild.bulk_create.unknown_error": "Невідома помилка.",
"guild.default_category_text": "Текстові канали",
@@ -0,0 +1,334 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {defineTable} from '@app/api/database/CassandraTableDsl';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
import {keyFromColumns, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
import {dockerAvailable, startPostgresKv, stopPostgresKv} from '@app/api/test/DockerTestContainer';
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const KV_TABLE = 'kv_keyset_scan';
type Row = Record<string, unknown>;
interface UserRow {
user_id: bigint;
name: string;
kind: number;
}
interface BucketRow {
channel_id: bigint;
bucket: number;
note: string;
}
interface NameRow {
name: string;
value: number;
}
const KeysetUsers = defineTable<UserRow, 'user_id'>({
name: 'keyset_users',
columns: ['user_id', 'name', 'kind'],
primaryKey: ['user_id'],
});
const KeysetBuckets = defineTable<BucketRow, 'channel_id' | 'bucket', 'channel_id'>({
name: 'keyset_buckets',
columns: ['channel_id', 'bucket', 'note'],
primaryKey: ['channel_id', 'bucket'],
partitionKey: ['channel_id'],
});
const KeysetNames = defineTable<NameRow, 'name'>({
name: 'keyset_names',
columns: ['name', 'value'],
primaryKey: ['name'],
});
interface Statement {
text: string;
rows: number;
}
function recordingClient(client: IPostgresClient, statements: Array<Statement>): IPostgresClient {
return {
query: (async (text: string, values?: Array<unknown>, name?: string) => {
const result = await client.query(text, values, name);
statements.push({text, rows: result.rows.length});
return result;
}) as IPostgresClient['query'],
connect: () => client.connect(),
shutdown: () => client.shutdown(),
transaction: (fn) => client.transaction(fn),
kvTable: () => client.kvTable(),
};
}
function pageState(state: Row): string {
return Buffer.from(JSON.stringify(state)).toString('base64url');
}
function readPageState(state: string): Row {
return JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as Row;
}
function userId(index: number): bigint {
return 1_000_000_000_000_000_000n + BigInt(index) * 7_919n;
}
describe.skipIf(!dockerAvailable)('Postgres KV keyset scans', () => {
let raw: IPostgresClient;
let statements: Array<Statement>;
let executor: PostgresKvQueryExecutor;
let postgresContainer: string;
beforeAll(async () => {
const started = await startPostgresKv({namePrefix: 'fluxer-kvkeyset', kvTable: KV_TABLE, maxConnections: 4});
postgresContainer = started.container;
raw = started.client;
}, 900_000);
beforeEach(async () => {
await raw.query(`DELETE FROM ${KV_TABLE}`);
statements = [];
executor = new PostgresKvQueryExecutor(recordingClient(raw, statements));
});
afterAll(async () => {
await stopPostgresKv(postgresContainer);
});
async function seed(queries: ReadonlyArray<PreparedQuery>): Promise<void> {
await executor.executeBatch(queries.map((query) => ({query: query.cql, params: query.params, meta: query.kvMeta})));
}
async function seedUsers(count: number): Promise<Array<UserRow>> {
const rows = Array.from({length: count}, (_, index) => ({
user_id: userId((index * 37) % count),
name: `user-${index}`,
kind: index % 7,
}));
await seed(rows.map((row) => KeysetUsers.insert(row)));
return rows;
}
async function seedBuckets(channels: number, buckets: number): Promise<void> {
const queries: Array<PreparedQuery> = [];
for (let channel = 0; channel < channels; channel += 1) {
for (let bucket = 0; bucket < buckets; bucket += 1) {
queries.push(
KeysetBuckets.insert({
channel_id: userId((channel * 13) % channels),
bucket: bucket * 11 - 20,
note: `note-${(channel + bucket) % 5}`,
}),
);
}
}
await seed(queries);
}
async function oracle(query: PreparedQuery): Promise<Array<Row>> {
const rows = await executor.executeQuery<Row>(query);
statements.length = 0;
return rows;
}
async function walk(
query: PreparedQuery,
pageSize: number,
start: string | null = null,
): Promise<{pages: Array<Array<Row>>; states: Array<string>}> {
const pages: Array<Array<Row>> = [];
const states: Array<string> = [];
let state = start;
do {
const page = await executor.executePagedQuery<Row>(query, {pageSize, pageState: state});
pages.push(page.rows);
state = page.pageState;
if (state !== null) states.push(state);
expect(pages.length).toBeLessThan(10_000);
} while (state !== null);
return {pages, states};
}
function expectBounded(batch: number): void {
expect(statements.length).toBeGreaterThan(0);
for (const statement of statements) {
expect(statement.rows).toBeLessThanOrEqual(batch);
if (statement.text.includes('row_data FROM')) expect(statement.text).toMatch(/LIMIT \$2$/);
}
}
function byKey(columns: ReadonlyArray<string>, rows: ReadonlyArray<Row>): Array<Row> {
return [...rows].sort((left, right) => {
const l = keyFromColumns(columns, left);
const r = keyFromColumns(columns, right);
return l < r ? -1 : l > r ? 1 : 0;
});
}
it('walks a numeric single key table in the same order without a full load', async () => {
await seedUsers(250);
const query = KeysetUsers.select({}).bind({});
const expected = await oracle(query);
expect(expected).toHaveLength(250);
const {pages, states} = await walk(query, 40);
expect(pages.flat()).toEqual(expected);
expect(pages.slice(0, -1).every((page) => page.length === 40)).toBe(true);
expect(readPageState(states[0]!)).toEqual({
offset: 40,
after: keyFromColumns(['user_id'], expected[39]!),
keyed: true,
});
expectBounded(41);
});
it('walks a composite key table by row key without reloading it per page', async () => {
await seedBuckets(30, 7);
const query = KeysetBuckets.select({}).bind({});
const expected = await oracle(query);
expect(expected).toHaveLength(210);
const {pages} = await walk(query, 25);
const rows = pages.flat();
expect(byKey(['channel_id', 'bucket'], rows)).toEqual(byKey(['channel_id', 'bucket'], expected));
expect(new Set(rows.map((row) => keyFromColumns(['channel_id', 'bucket'], row))).size).toBe(210);
const channelRuns = rows.filter((row, index) => index === 0 || rows[index - 1]!.channel_id !== row.channel_id);
expect(channelRuns).toHaveLength(30);
expect(statements.filter((statement) => statement.text.includes('row_data FROM'))).toHaveLength(pages.length);
expectBounded(26);
});
it('walks a text key table in the same order', async () => {
await seed(
Array.from({length: 90}, (_, index) => KeysetNames.insert({name: `name-${(index * 31) % 90}-x`, value: index})),
);
const query = KeysetNames.select({}).bind({});
const expected = await oracle(query);
const {pages} = await walk(query, 16);
expect(pages.flat()).toEqual(expected);
expectBounded(17);
});
it('fills filtered pages from bounded batches on numeric and composite tables', async () => {
await seedUsers(300);
await seedBuckets(20, 9);
const users = KeysetUsers.select({where: KeysetUsers.where.eq('kind')}).bind({kind: 3});
const expectedUsers = await oracle(users);
const userWalk = await walk(users, 10);
expect(userWalk.pages.flat()).toEqual(expectedUsers);
expect(userWalk.pages.slice(0, -1).every((page) => page.length === 10)).toBe(true);
expectBounded(11);
const buckets = KeysetBuckets.select({where: KeysetBuckets.where.eq('note')}).bind({note: 'note-2'});
const expectedBuckets = await oracle(buckets);
const bucketWalk = await walk(buckets, 6);
expect(byKey(['channel_id', 'bucket'], bucketWalk.pages.flat())).toEqual(
byKey(['channel_id', 'bucket'], expectedBuckets),
);
expect(bucketWalk.pages.slice(0, -1).every((page) => page.length === 6)).toBe(true);
expectBounded(7);
});
it('skips expired rows', async () => {
await seedUsers(60);
await seedBuckets(6, 5);
await raw.query(
`UPDATE ${KV_TABLE} SET expires_at = now() - interval '1 minute' WHERE (row_data ->> 'kind')::int = 2 OR row_data ->> 'note' = 'note-1'`,
);
const users = KeysetUsers.select({}).bind({});
const expectedUsers = await oracle(users);
expect(expectedUsers.length).toBeLessThan(60);
expect((await walk(users, 7)).pages.flat()).toEqual(expectedUsers);
const buckets = KeysetBuckets.select({}).bind({});
const expectedBuckets = await oracle(buckets);
expect(expectedBuckets.length).toBeLessThan(30);
expect(byKey(['channel_id', 'bucket'], (await walk(buckets, 7)).pages.flat())).toEqual(
byKey(['channel_id', 'bucket'], expectedBuckets),
);
expectBounded(8);
});
it('returns one empty page for an empty table', async () => {
for (const query of [KeysetUsers.select({}).bind({}), KeysetBuckets.select({}).bind({})]) {
expect(await executor.executePagedQuery(query, {pageSize: 10})).toEqual({rows: [], pageState: null});
}
expectBounded(1);
});
it('never drops rows with non-numeric keys from a single key table', async () => {
await seedUsers(40);
await executor.executeQuery(KeysetUsers.insert({user_id: 'stray' as unknown as bigint, name: 'stray', kind: 0}));
const {pages} = await walk(KeysetUsers.select({}).bind({}), 9);
const names = pages.flat().map((row) => row.name);
expect(names).toHaveLength(41);
expect(new Set(names).size).toBe(41);
expect(names).toContain('stray');
expectBounded(10);
});
it('resumes every page state shape the previous release produced', async () => {
await seedUsers(120);
await seedBuckets(12, 6);
const users = KeysetUsers.select({}).bind({});
const expectedUsers = await oracle(users);
const userKey = keyFromColumns(['user_id'], expectedUsers[49]!);
for (const state of [{offset: 50, after: userKey, keyed: true}, {offset: 50, after: userKey}, {offset: 50}]) {
expect((await walk(users, 15, pageState(state))).pages.flat()).toEqual(expectedUsers.slice(50));
}
const buckets = KeysetBuckets.select({}).bind({});
const expectedBuckets = await oracle(buckets);
const bucketKey = keyFromColumns(['channel_id', 'bucket'], expectedBuckets[29]!);
statements.length = 0;
const resumed = await walk(buckets, 11, pageState({offset: 30, after: bucketKey}));
expect(byKey(['channel_id', 'bucket'], resumed.pages.flat())).toEqual(
byKey(['channel_id', 'bucket'], expectedBuckets.slice(30)),
);
expect(readPageState(resumed.states[0]!)).toMatchObject({byRowKey: true, since: bucketKey});
expectBounded(12);
});
it('streams limited scans in key order instead of loading the table', async () => {
const seeded = await seedUsers(200);
const sorted = [...seeded].sort((left, right) => (left.user_id < right.user_id ? -1 : 1));
const first = KeysetUsers.select({limit: 20}).bind({});
const next = KeysetUsers.select({where: KeysetUsers.where.tokenGt('user_id', 'last_user_id'), limit: 20});
const expectedFirst = await oracle(KeysetUsers.select({}).bind({}));
expect(await executor.executeQuery(first)).toEqual(expectedFirst.slice(0, 20));
expectBounded(21);
statements.length = 0;
const rows = await executor.executeQuery(next.bind({last_user_id: sorted[99]!.user_id}));
expect(rows).toEqual(expectedFirst.slice(100, 120));
expect(statements.filter((statement) => statement.text.includes('row_data FROM'))).toHaveLength(1);
expectBounded(21);
statements.length = 0;
expect(await executor.executeQuery(next.bind({last_user_id: sorted[195]!.user_id}))).toEqual(
expectedFirst.slice(196),
);
expectBounded(21);
});
it('probes for non-numeric keys through the primary key while other tables hold string keys', async () => {
await raw.query(
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) SELECT 'keyset_users', k, k, '{}'::jsonb FROM generate_series(1, 20000) g, LATERAL (SELECT '{"__fluxer_type":"bigint","value":"' || g || '"}' AS k) s`,
);
await raw.query(
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) SELECT 'keyset_strings', g::text, '"' || lpad(g::text, 8, '0') || '"', '{}'::jsonb FROM generate_series(1, 200000) g`,
);
await raw.query(`ANALYZE ${KV_TABLE}`);
await executor.executePagedQuery(KeysetUsers.select({}).bind({}), {pageSize: 10});
const probe = statements.find((statement) => statement.text.endsWith('AS found'));
expect(probe).toBeDefined();
const plan = await raw.query<{'QUERY PLAN': string}>(`EXPLAIN (COSTS OFF) ${probe!.text}`, ['keyset_users']);
expect(plan.rows.map((row) => row['QUERY PLAN']).join('\n')).not.toContain('Seq Scan');
});
it('keeps the sorted load when a limited scan asks for an order the key walk cannot give', async () => {
await seedUsers(30);
const descending = KeysetUsers.select({orderBy: {col: 'user_id', direction: 'DESC'}, limit: 5}).bind({});
const rows = await executor.executeQuery<Row>(descending);
const all = await oracle(KeysetUsers.select({}).bind({}));
expect(rows).toEqual(all.slice(-5).reverse());
});
});
@@ -28,6 +28,19 @@ interface PageState {
offset: number;
after?: string;
keyed?: boolean;
byRowKey?: boolean;
since?: string;
}
interface ScanWalk {
numeric: boolean;
after: string | null;
since: string | null;
}
interface NumericFloor {
value: string;
inclusive: boolean;
}
interface PageEntry {
@@ -90,6 +103,12 @@ const EXPIRED_STORED_ROW = 'kv.expires_at IS NOT NULL AND kv.expires_at <= now()
const MERGED_ROW_DATA = `CASE WHEN ${EXPIRED_STORED_ROW} THEN EXCLUDED.row_data ELSE kv.row_data || EXCLUDED.row_data END`;
const KEPT_EXPIRES_AT = `CASE WHEN ${EXPIRED_STORED_ROW} THEN NULL ELSE kv.expires_at END`;
const NO_EXPIRY = 'infinity';
const BIGINT_ROW_KEY_PREFIX = `{"${ENCODED_TYPE_KEY}":"bigint","value":"`;
const NON_NUMERIC_ROW_KEY_RANGES = [
`kv.row_key COLLATE "C" < '-'`,
`kv.row_key COLLATE "C" >= ':' AND kv.row_key COLLATE "C" < '${BIGINT_ROW_KEY_PREFIX}'`,
`kv.row_key COLLATE "C" >= '${BIGINT_ROW_KEY_PREFIX}:'`,
];
export async function postgresKvPassIsFresh(
client: IPostgresClient,
@@ -676,15 +695,18 @@ function logError(details: Record<string, unknown>, message: string): void {
const fullScanLoggedAt = new Map<string, number>();
function logFullScan(meta: KvQueryMeta): void {
function logFullScan(meta: KvQueryMeta, walk?: 'numeric' | 'row_key'): void {
const shape = queryShape(meta);
const key = `${meta.table.name}|${meta.action}|${shape.summary}`;
const key = `${meta.table.name}|${meta.action}|${shape.summary}|${walk ?? 'load'}`;
const now = Date.now();
const last = fullScanLoggedAt.get(key);
if (last !== undefined && now - last < FULL_SCAN_LOG_INTERVAL_MS) return;
if (fullScanLoggedAt.size >= FULL_SCAN_LOG_KEY_LIMIT) fullScanLoggedAt.clear();
fullScanLoggedAt.set(key, now);
logWarn({table: meta.table.name, action: meta.action, where: shape.summary || 'none'}, 'Postgres KV full table scan');
logWarn(
{table: meta.table.name, action: meta.action, where: shape.summary || 'none', walk: walk ?? 'load'},
'Postgres KV full table scan',
);
}
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | typeof NO_EXPIRY | null | undefined {
@@ -715,7 +737,13 @@ function decodePageState(pageState: string | null | undefined): PageState {
throw new Error('Invalid Postgres KV page state');
}
if (typeof decoded.after !== 'string') return {offset: decoded.offset};
return {offset: decoded.offset, after: decoded.after, keyed: decoded.keyed === true};
return {
offset: decoded.offset,
after: decoded.after,
keyed: decoded.keyed === true,
byRowKey: decoded.byRowKey === true,
...(typeof decoded.since === 'string' ? {since: decoded.since} : {}),
};
}
function pageableSelect(meta: KvQueryMeta, pageSize: number): boolean {
@@ -734,22 +762,30 @@ function numericKeyValue(value: unknown): string | null {
return null;
}
function numericScanPlan(meta: KvQueryMeta, plan: QueryPlan): boolean {
return plan.exact && plan.candidates.kind === 'scan' && (meta.table.primaryKey as ReadonlyArray<string>).length === 1;
function numericRowKeyValue(key: string): string | null {
const values = decodeRowKey(key, 1);
return values === null ? null : numericKeyValue(values[0]);
}
function numericScanKeyed(meta: KvQueryMeta, plan: QueryPlan, entries: ReadonlyArray<PageEntry>): boolean {
if (!numericScanPlan(meta, plan)) return false;
const column = (meta.table.primaryKey as ReadonlyArray<string>)[0]!;
return entries.every((entry) => numericKeyValue(entry.row[column]) !== null);
function singlePrimaryKey(meta: KvQueryMeta): boolean {
return (meta.table.primaryKey as ReadonlyArray<string>).length === 1;
}
function numericScanCursor(state: PageState): {rowKey: string; value: string} | null {
if (state.keyed !== true || state.after === undefined) return null;
const values = decodeRowKey(state.after, 1);
if (values === null) return null;
const value = numericKeyValue(values[0]);
return value === null ? null : {rowKey: state.after, value};
function walksInPrimaryKeyOrder(meta: KvQueryMeta): boolean {
if (!singlePrimaryKey(meta)) return false;
if (meta.orderBy === undefined) return true;
return meta.orderBy.col === meta.table.primaryKey[0] && meta.orderBy.direction !== 'DESC';
}
function numericFloor(meta: KvQueryMeta, params: CassandraParams): NumericFloor | null {
const column = meta.table.primaryKey[0];
for (const clause of whereClauses(meta)) {
if (clause.col !== column) continue;
if (clause.kind !== 'gt' && clause.kind !== 'tokenGt' && clause.kind !== 'gte') continue;
const value = numericKeyValue(getParam(params, clause.param));
if (value !== null) return {value, inclusive: clause.kind === 'gte'};
}
return null;
}
function pageStart(meta: KvQueryMeta, entries: ReadonlyArray<PageEntry>, state: PageState): number {
@@ -1018,10 +1054,10 @@ export class PostgresKvQueryExecutor {
const meta = this.meta(query);
if (pageableSelect(meta, options.pageSize) && (state.after !== undefined || state.offset === 0)) {
const plan = buildCandidatePlan(meta, query.params);
const cursor = numericScanPlan(meta, plan) ? numericScanCursor(state) : null;
const page = cursor
? await this.numericScanPage(meta, query.params, cursor, state.offset, options.pageSize)
: await this.sortedPage(meta, query.params, plan, state, options.pageSize);
const page =
plan.candidates.kind === 'scan'
? await this.scanPage(meta, query.params, await this.scanWalk(meta, state), state.offset, options.pageSize)
: await this.sortedPage(meta, query.params, plan, state, options.pageSize);
return {rows: page.rows as Array<T>, pageState: page.pageState};
}
const rows = await this.executeQuery<T, P>(query);
@@ -1033,29 +1069,111 @@ export class PostgresKvQueryExecutor {
};
}
private async numericScanPage(
private async scanWalk(meta: KvQueryMeta, state: PageState): Promise<ScanWalk> {
if (state.byRowKey) return {numeric: false, after: state.after ?? null, since: state.since ?? null};
const after = state.after ?? null;
const numericAfter = after === null || numericRowKeyValue(after) !== null;
if (state.keyed && after !== null && numericAfter && singlePrimaryKey(meta)) {
return {numeric: true, after, since: null};
}
const numeric = singlePrimaryKey(meta) && !(await this.hasNonNumericRows(meta, this.client));
if (numeric && numericAfter) return {numeric: true, after, since: null};
return {numeric: false, after: null, since: after};
}
private async hasNonNumericRows(meta: KvQueryMeta, db: PostgresQueryable): Promise<boolean> {
const probes = NON_NUMERIC_ROW_KEY_RANGES.map(
(range) =>
`(SELECT kv.row_key FROM ${this.table} kv WHERE kv.table_name = $1 AND ${range} AND (kv.expires_at IS NULL OR kv.expires_at > now()) AND ${NUMERIC_ROW_KEY} IS NULL ORDER BY kv.row_key COLLATE "C" LIMIT 1) IS NOT NULL`,
);
const result = await db.query<{found: boolean}>(`SELECT ${probes.join(' OR ')} AS found`, [meta.table.name]);
return result.rows[0]?.found === true;
}
private async scanPage(
meta: KvQueryMeta,
params: CassandraParams,
cursor: {rowKey: string; value: string},
walk: ScanWalk,
offset: number,
pageSize: number,
): Promise<{rows: Array<Row>; pageState: string | null}> {
logFullScan(meta);
const result = await this.client.query<StoredRow>(
`SELECT kv.row_key, kv.row_data FROM ${this.table} kv WHERE kv.table_name = $1 AND ${NUMERIC_ROW_KEY} IS NOT NULL AND (${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C") > ($2::numeric, $3) AND (kv.expires_at IS NULL OR kv.expires_at > now()) ORDER BY ${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C" LIMIT $4`,
[meta.table.name, cursor.value, cursor.rowKey, pageSize + 1],
);
const entries = this.matchingEntries(meta, result.rows.slice(0, pageSize), params);
const last = entries[entries.length - 1];
const page = await this.walkScan(meta, params, walk, pageSize, this.client);
if (!page.more || page.after === null) return {rows: this.projected(meta, page.entries), pageState: null};
const position = {offset: offset + page.entries.length, after: page.after};
return {
rows: this.projected(meta, entries),
pageState:
result.rows.length > pageSize && last
? encodePageState({offset: offset + entries.length, after: last.key, keyed: true})
: null,
rows: this.projected(meta, page.entries),
pageState: encodePageState(
walk.numeric
? {...position, keyed: true}
: {...position, byRowKey: true, ...(walk.since === null ? {} : {since: walk.since})},
),
};
}
private async walkScan(
meta: KvQueryMeta,
params: CassandraParams,
walk: ScanWalk,
want: number,
db: PostgresQueryable,
): Promise<{entries: Array<PageEntry>; after: string | null; more: boolean}> {
logFullScan(meta, walk.numeric ? 'numeric' : 'row_key');
const floor = walk.numeric ? numericFloor(meta, params) : null;
const since = walk.since === null ? null : decodeRowKey(walk.since, meta.table.primaryKey.length);
if (walk.since !== null && since === null) throw new Error('Invalid Postgres KV page state');
const limit = want + 1;
const entries: Array<PageEntry> = [];
let after = walk.after;
while (entries.length < want) {
const batch = await this.scanBatch(meta, walk.numeric, floor, after, limit, db);
for (let index = 0; index < batch.length; index += 1) {
const stored = batch[index]!;
after = stored.row_key;
const entry = this.matchingEntry(meta, stored, params);
if (entry && (since === null || compareRowToKeyValues(meta, entry.row, since) > 0)) entries.push(entry);
if (entries.length === want) {
return {entries, after, more: index < batch.length - 1 || batch.length === limit};
}
}
if (batch.length < limit) return {entries, after, more: false};
}
return {entries, after, more: false};
}
private async scanBatch(
meta: KvQueryMeta,
numeric: boolean,
floor: NumericFloor | null,
after: string | null,
limit: number,
db: PostgresQueryable,
): Promise<Array<StoredRow>> {
const params: Array<unknown> = [meta.table.name, limit];
let predicate = '';
if (numeric) {
predicate = ` AND ${NUMERIC_ROW_KEY} IS NOT NULL`;
if (floor) {
params.push(floor.value);
predicate += ` AND ${NUMERIC_ROW_KEY} ${floor.inclusive ? '>=' : '>'} $${params.length}::numeric`;
}
if (after !== null) {
const value = numericRowKeyValue(after);
if (value === null) throw new Error('Invalid Postgres KV page state');
params.push(value, after);
predicate += ` AND (${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C") > ($${params.length - 1}::numeric, $${params.length})`;
}
} else if (after !== null) {
params.push(after);
predicate = ` AND kv.row_key COLLATE "C" > $${params.length}`;
}
const order = numeric ? `${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C"` : 'kv.row_key COLLATE "C"';
const result = await db.query<StoredRow>(
`SELECT kv.row_key, kv.row_data FROM ${this.table} kv WHERE kv.table_name = $1${predicate} AND (kv.expires_at IS NULL OR kv.expires_at > now()) ORDER BY ${order} LIMIT $2`,
params,
);
return result.rows;
}
private async sortedPage(
meta: KvQueryMeta,
params: CassandraParams,
@@ -1071,12 +1189,9 @@ export class PostgresKvQueryExecutor {
const last = page[page.length - 1];
const nextOffset = start + page.length;
if (nextOffset >= entries.length || !last) return {rows: this.projected(meta, page), pageState: null};
const keyed = numericScanKeyed(meta, plan, entries);
return {
rows: this.projected(meta, page),
pageState: encodePageState(
keyed ? {offset: nextOffset, after: last.key, keyed: true} : {offset: nextOffset, after: last.key},
),
pageState: encodePageState({offset: nextOffset, after: last.key}),
};
}
@@ -1146,17 +1261,22 @@ export class PostgresKvQueryExecutor {
stored: ReadonlyArray<StoredRow>,
params: CassandraParams,
): Array<PageEntry> {
const required = queryShape(meta).requiredColumns;
const entries: Array<PageEntry> = [];
for (const entry of stored) {
const row = required ? decodeRowColumns(entry.row_data, required) : decodeRow(entry.row_data);
if (matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params)) {
entries.push({key: entry.row_key, row});
}
const matched = this.matchingEntry(meta, entry, params);
if (matched) entries.push(matched);
}
return entries;
}
private matchingEntry(meta: KvQueryMeta, stored: StoredRow, params: CassandraParams): PageEntry | null {
const required = queryShape(meta).requiredColumns;
const row = required ? decodeRowColumns(stored.row_data, required) : decodeRow(stored.row_data);
return matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params)
? {key: stored.row_key, row}
: null;
}
private matchingRows(meta: KvQueryMeta, stored: ReadonlyArray<StoredRow>, params: CassandraParams): Array<Row> {
return this.matchingEntries(meta, stored, params).map((entry) => entry.row);
}
@@ -1185,6 +1305,16 @@ export class PostgresKvQueryExecutor {
) {
return this.unorderedScan(meta, meta.limit, db);
}
if (
typeof meta.limit === 'number' &&
Number.isInteger(meta.limit) &&
plan.candidates.kind === 'scan' &&
walksInPrimaryKeyOrder(meta) &&
!(await this.hasNonNumericRows(meta, db))
) {
const walk = {numeric: true, after: null, since: null};
return this.projected(meta, (await this.walkScan(meta, params, walk, meta.limit, db)).entries);
}
let rows = this.matchingRows(meta, await this.candidates(meta, plan, db), params);
rows = sortRows(meta, rows);
if (typeof meta.limit === 'number') rows = rows.slice(0, meta.limit);
@@ -55,15 +55,15 @@ describe('Postgres KV unordered scan', () => {
expect(rows).toEqual([{user_id: '2'}, {user_id: '1'}]);
});
it('keeps the sorted full scan when order is asked for', async () => {
it('keeps the sorted full scan when the order cannot be walked by key', async () => {
const statements: Array<{text: string; values: Array<unknown> | undefined}> = [];
const executor = new PostgresKvQueryExecutor(recordingClient(statements));
const rows = await executor.executeQuery({
cql: '__ordered_probe',
params: {},
kvMeta: selectMeta({limit: 1}),
kvMeta: selectMeta({limit: 1, orderBy: {col: 'user_id', direction: 'DESC'}}),
});
expect(statements[0]!.text).not.toContain('LIMIT');
expect(rows).toEqual([{user_id: '1'}]);
expect(rows).toEqual([{user_id: '2'}]);
});
});
@@ -18,6 +18,7 @@ import {
import type {ChannelService} from '@app/api/channel/services/ChannelService';
import {getContentMessage} from '@app/api/content_i18n/ContentI18n';
import {BatchBuilder} from '@app/api/database/CassandraQueryExecution';
import {Db, type DbOp} from '@app/api/database/CassandraTypes';
import type {PermissionOverwrite} from '@app/api/database/types/ChannelTypes';
import type {GuildRow} from '@app/api/database/types/GuildTypes';
import {isTainted} from '@app/api/experiment/ChannelThreadsGate';
@@ -25,6 +26,11 @@ import {mapGuildToGuildResponse, mapGuildToPartialResponse} from '@app/api/guild
import type {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {GuildDataHelpers} from '@app/api/guild/services/data/GuildDataHelpers';
import {
buildTemplateThreadParentConfig,
clipTemplateTopic,
mapOtherPlatformTemplateChannelType,
} from '@app/api/guild/services/data/TemplateChannelImport';
import {memberEndsInvitePause} from '@app/api/guild/services/GuildInvitePauseApplier';
import {emitGuildCreated, emitGuildFeaturesChanged} from '@app/api/infrastructure/activity/GuildEvents';
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
@@ -43,7 +49,16 @@ import {getGuildSearchService} from '@app/api/SearchFactory';
import type {GuildDiscoveryContext} from '@app/api/search/guild/GuildSearchSerializer';
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService';
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '@app/api/Tables';
import {
Channels,
ChannelsByGuild,
GuildMembers,
GuildMembersByUserId,
GuildRoles,
Guilds,
ThreadOnlyChannelsByGuild,
ThreadParentConfig as ThreadParentConfigTable,
} from '@app/api/Tables';
import type {UserRepository} from '@app/api/user/repositories/UserRepository';
import {mapUserSettingsToResponse} from '@app/api/user/UserMappers';
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '@app/api/user/utils/GuildFolderUtils';
@@ -71,6 +86,7 @@ import {
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
VOICE_CHANNEL_USER_LIMIT_MAX,
} from '@fluxer/constants/src/LimitConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {
DEFAULT_THREAD_PERMISSIONS,
THREAD_AWARE_ALL_PERMISSIONS,
@@ -190,7 +206,6 @@ const THREAD_PURGE_CONCURRENCY = 8;
const GUILD_DELETION_MEMBER_CONCURRENCY = 25;
const GUILD_DELETION_PENDING_FEATURES = [GuildFeatures.UNAVAILABLE_FOR_EVERYONE, GuildFeatures.UNAVAILABLE_HIDDEN];
const TEMPLATE_AFK_TIMEOUT_MAX_SECONDS = 3600;
const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13;
function isSystemChannelType(type: number): boolean {
return type === ChannelTypes.GUILD_TEXT || type === ChannelTypes.GUILD_ANNOUNCEMENT;
@@ -1024,7 +1039,7 @@ export class GuildOperationsService {
throw new GuildTemplateInvalidError();
}
seenChannelKeys.add(channelKey);
const fluxerType = this.mapOtherPlatformTemplateChannelTypeToFluxer(channel.type);
const fluxerType = mapOtherPlatformTemplateChannelType(channel.type);
if (fluxerType == null) {
skippedOtherPlatformChannelTypes.add(channel.type);
continue;
@@ -1102,7 +1117,9 @@ export class GuildOperationsService {
const parentKey = channel.parent_id != null ? this.getTemplateEntityKey(channel.parent_id) : null;
const parentType = parentKey != null ? channelTypeMap.get(parentKey) : null;
const parentId =
parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY ? (channelIdMap.get(parentKey) ?? null) : null;
fluxerType !== ChannelTypes.GUILD_CATEGORY && parentKey != null && parentType === ChannelTypes.GUILD_CATEGORY
? (channelIdMap.get(parentKey) ?? null)
: null;
const isVoice = fluxerType === ChannelTypes.GUILD_VOICE;
let permissionOverwrites: Map<RoleID | UserID, PermissionOverwrite> | null = null;
if (channel.permission_overwrites && channel.permission_overwrites.length > 0) {
@@ -1132,7 +1149,7 @@ export class GuildOperationsService {
guild_id: guildId,
type: fluxerType,
name: channel.name,
topic: channel.topic ?? null,
topic: clipTemplateTopic(channel.topic, fluxerType),
icon_hash: null,
url: null,
parent_id: parentId,
@@ -1160,11 +1177,25 @@ export class GuildOperationsService {
}),
);
batch.addPrepared(
ChannelsByGuild.upsertAll({
guild_id: guildId,
channel_id: channelId,
}),
THREAD_ONLY_CHANNEL_TYPES.has(fluxerType)
? ThreadOnlyChannelsByGuild.upsertAll({guild_id: guildId, channel_id: channelId})
: ChannelsByGuild.upsertAll({guild_id: guildId, channel_id: channelId}),
);
const parentConfig = await buildTemplateThreadParentConfig(channel, fluxerType, () =>
this.snowflakeService.generate(),
);
if (parentConfig) {
const ops: Record<string, DbOp<unknown>> = {};
for (const [key, value] of Object.entries(parentConfig)) {
if (value !== undefined) ops[key] = Db.set(value);
}
batch.addPrepared(
ThreadParentConfigTable.patchByPk(
{guild_id: guildId, channel_id: channelId},
ops as Parameters<typeof ThreadParentConfigTable.patchByPk>[1],
),
);
}
}
let systemChannelId: ChannelID | null = null;
if (template.system_channel_id != null) {
@@ -1241,21 +1272,6 @@ export class GuildOperationsService {
return roles.find((role) => this.getTemplateEntityKey(role.id) === '0') ?? null;
}
private mapOtherPlatformTemplateChannelTypeToFluxer(channelType: number): number | null {
if (
channelType === ChannelTypes.GUILD_TEXT ||
channelType === ChannelTypes.GUILD_ANNOUNCEMENT ||
channelType === ChannelTypes.GUILD_VOICE ||
channelType === ChannelTypes.GUILD_CATEGORY
) {
return channelType;
}
if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) {
return ChannelTypes.GUILD_VOICE;
}
return null;
}
private sanitiseTemplateGuildSettings(template?: TemplateSerializedGuild): TemplateGuildSettings {
return {
verificationLevel: this.clampTemplateSetting(
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ThreadParentConfigPatch} from '@app/api/channel/repositories/ThreadRepository';
import type {ForumTagUdt} from '@app/api/database/types/ThreadTypes';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {CHANNEL_TOPIC_MAX_LENGTH} from '@fluxer/constants/src/LimitConstants';
import {
ChannelFlags,
FORUM_TAG_NAME_MAX_LENGTH,
FORUM_TOPIC_MAX_LENGTH,
ForumLayoutTypes,
ForumSortOrderTypes,
ForumTagSettings,
MAX_FORUM_TAGS_PER_CHANNEL,
settableChannelFlags,
THREAD_AUTO_ARCHIVE_DURATIONS,
THREAD_ONLY_CHANNEL_TYPES,
THREAD_PARENT_CHANNEL_TYPES,
THREAD_RATE_LIMIT_PER_USER_MAX,
} from '@fluxer/constants/src/ThreadConstants';
import type {TemplateChannel} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
const THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE = 13;
const IMPORTED_CHANNEL_TYPES = new Set<number>([
ChannelTypes.GUILD_TEXT,
ChannelTypes.GUILD_VOICE,
ChannelTypes.GUILD_CATEGORY,
ChannelTypes.GUILD_ANNOUNCEMENT,
ChannelTypes.GUILD_FORUM,
ChannelTypes.GUILD_MEDIA,
]);
const FORUM_SORT_ORDERS = new Set<number>(Object.values(ForumSortOrderTypes));
const FORUM_LAYOUTS = new Set<number>(Object.values(ForumLayoutTypes));
const FORUM_TAG_SETTINGS = new Set<string>(Object.values(ForumTagSettings));
const AUTO_ARCHIVE_DURATIONS = new Set<number>(THREAD_AUTO_ARCHIVE_DURATIONS);
export function mapOtherPlatformTemplateChannelType(channelType: number): number | null {
if (IMPORTED_CHANNEL_TYPES.has(channelType)) return channelType;
if (channelType === THE_OTHER_PLATFORM_GUILD_STAGE_VOICE_CHANNEL_TYPE) return ChannelTypes.GUILD_VOICE;
return null;
}
function clipText(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
const last = value.charCodeAt(maxLength - 1);
const end = last >= 0xd800 && last <= 0xdbff ? maxLength - 1 : maxLength;
return value.slice(0, end);
}
export function clipTemplateTopic(topic: string | null | undefined, channelType: number): string | null {
if (!topic) return null;
const maxLength = THREAD_ONLY_CHANNEL_TYPES.has(channelType) ? FORUM_TOPIC_MAX_LENGTH : CHANNEL_TOPIC_MAX_LENGTH;
return clipText(topic, maxLength);
}
function pickUnicodeEmoji(emojiName: string | null | undefined): string | null {
return emojiName != null && isValidSingleUnicodeEmoji(emojiName) ? emojiName : null;
}
async function buildTemplateTags(
tags: NonNullable<TemplateChannel['available_tags']>,
generateId: () => Promise<bigint>,
): Promise<Array<ForumTagUdt>> {
const names = new Set<string>();
const result: Array<ForumTagUdt> = [];
for (const tag of tags) {
if (result.length >= MAX_FORUM_TAGS_PER_CHANNEL) break;
const name = clipText(tag.name.trim(), FORUM_TAG_NAME_MAX_LENGTH);
if (name.length === 0 || names.has(name)) continue;
names.add(name);
result.push({
id: await generateId(),
name,
moderated: tag.moderated === true,
emoji_id: null,
emoji_name: pickUnicodeEmoji(tag.emoji_name),
});
}
return result;
}
export async function buildTemplateThreadParentConfig(
channel: TemplateChannel,
channelType: number,
generateId: () => Promise<bigint>,
): Promise<ThreadParentConfigPatch | null> {
if (!THREAD_PARENT_CHANNEL_TYPES.has(channelType)) return null;
const patch: ThreadParentConfigPatch = {};
const archiveDuration = channel.default_auto_archive_duration;
if (archiveDuration != null && AUTO_ARCHIVE_DURATIONS.has(archiveDuration)) {
patch.default_auto_archive_duration = archiveDuration;
}
const threadSlowmode = channel.default_thread_rate_limit_per_user;
if (threadSlowmode != null && threadSlowmode > 0) {
patch.default_thread_rate_limit_per_user = Math.min(threadSlowmode, THREAD_RATE_LIMIT_PER_USER_MAX);
}
if (THREAD_ONLY_CHANNEL_TYPES.has(channelType)) {
const tags = await buildTemplateTags(channel.available_tags ?? [], generateId);
if (tags.length > 0) patch.available_tags = tags;
const reactionEmoji = pickUnicodeEmoji(channel.default_reaction_emoji?.emoji_name);
if (reactionEmoji) patch.default_reaction_emoji_name = reactionEmoji;
const sortOrder = channel.default_sort_order;
if (sortOrder != null && FORUM_SORT_ORDERS.has(sortOrder)) patch.default_sort_order = sortOrder;
const layout = channel.default_forum_layout;
if (channelType === ChannelTypes.GUILD_FORUM && layout != null && FORUM_LAYOUTS.has(layout)) {
patch.default_forum_layout = layout;
}
const tagSetting = channel.default_tag_setting;
if (tagSetting != null && FORUM_TAG_SETTINGS.has(tagSetting)) patch.default_tag_setting = tagSetting;
let flags = (channel.flags ?? 0) & settableChannelFlags(channelType);
if (!tags.some((tag) => !tag.moderated)) flags &= ~ChannelFlags.REQUIRE_TAG;
if (flags !== 0) patch.flags = flags;
}
return Object.keys(patch).length > 0 ? patch : null;
}
@@ -1,12 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createGuildID} from '@app/api/BrandedTypes';
import {fetchMany} from '@app/api/database/CassandraQueryExecution';
import {getGuildChannels, getRoles} from '@app/api/guild/tests/GuildTestUtils';
import {ChannelsByGuild, ThreadOnlyChannelsByGuild} from '@app/api/Tables';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
import {CHANNEL_TOPIC_MAX_LENGTH, VOICE_CHANNEL_USER_LIMIT_MAX} from '@fluxer/constants/src/LimitConstants';
import {ChannelFlags, ForumLayoutTypes, ForumSortOrderTypes} from '@fluxer/constants/src/ThreadConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
@@ -110,7 +115,7 @@ describe('Guild Template Import', () => {
expect(guild.system_channel_flags).toBe(SystemChannelFlags.SUPPRESS_JOIN_NOTIFICATIONS);
expect(guild.afk_timeout).toBe(60);
});
test('maps supported competitor-only channel types to Fluxer equivalents and ignores unsupported ones', async () => {
test('imports announcement, stage, forum and media channels and ignores unsupported types', async () => {
const account = await createTestAccount(harness);
const guild = await createBuilder<GuildResponse>(harness, account.token)
.post('/guilds')
@@ -155,16 +160,31 @@ describe('Guild Template Import', () => {
},
{
id: '4104',
type: 16,
type: ChannelTypes.GUILD_MEDIA,
name: 'media-feed',
parent_id: '4101',
position: 3,
},
{
id: '4105',
type: ChannelTypes.GUILD_FORUM,
name: 'help',
parent_id: '4101',
position: 4,
rate_limit_per_user: 120,
},
{
id: '4106',
type: 14,
name: 'directory',
parent_id: '4101',
position: 5,
},
],
},
})
.execute();
const channels = await getGuildChannels(harness, account.token, guild.id);
const channels = await listThreadAwareChannels(account.token, guild.id);
const categoryChannel = channels.find((channel) => channel.name === 'Main');
const announcementsChannel = channels.find((channel) => channel.name === 'announcements');
const stageChannel = channels.find((channel) => channel.name === 'town-hall');
@@ -176,7 +196,14 @@ describe('Guild Template Import', () => {
expect(stageChannel).toBeDefined();
expect(stageChannel?.type).toBe(ChannelTypes.GUILD_VOICE);
expect(stageChannel?.parent_id).toBe(categoryChannel?.id ?? null);
expect(mediaChannel).toBeUndefined();
const forumChannel = channels.find((channel) => channel.name === 'help');
expect(mediaChannel?.type).toBe(ChannelTypes.GUILD_MEDIA);
expect(mediaChannel?.parent_id).toBe(categoryChannel?.id ?? null);
expect(mediaChannel?.position).toBe(3);
expect(forumChannel?.type).toBe(ChannelTypes.GUILD_FORUM);
expect(forumChannel?.parent_id).toBe(categoryChannel?.id ?? null);
expect(forumChannel?.rate_limit_per_user).toBe(120);
expect(channels.find((channel) => channel.name === 'directory')).toBeUndefined();
expect(guild.system_channel_id).toBe(announcementsChannel?.id ?? null);
});
test('accepts template roles and channels with empty names', async () => {
@@ -273,7 +300,7 @@ describe('Guild Template Import', () => {
expect(channels.find((channel) => channel.name === 'town-hall')?.user_limit).toBe(VOICE_CHANNEL_USER_LIMIT_MAX);
expect(channels.find((channel) => channel.name === 'lounge')?.voice_connection_limit).toBe(100);
});
test('accepts forum-length topics and shortens them to the channel topic limit', async () => {
test('accepts forum-length topics and shortens them only for non-forum channels', async () => {
const account = await createTestAccount(harness);
const longTopic = `${'a'.repeat(CHANNEL_TOPIC_MAX_LENGTH - 1)}\u{1F600}${'b'.repeat(300)}`;
const guild = await createBuilder<GuildResponse>(harness, account.token)
@@ -288,13 +315,224 @@ describe('Guild Template Import', () => {
}),
})
.execute();
const channels = await getGuildChannels(harness, account.token, guild.id);
const channels = await listThreadAwareChannels(account.token, guild.id);
const general = channels.find((channel) => channel.name === 'general');
expect(general?.topic).toBe('a'.repeat(CHANNEL_TOPIC_MAX_LENGTH - 1));
expect(channels.find((channel) => channel.name === 'projects')).toBeUndefined();
expect(channels.find((channel) => channel.name === 'projects')?.topic).toBe('c'.repeat(1356));
});
test('imports forum settings and drops values that cannot carry over', async () => {
const account = await createTestAccount(harness);
const guild = await createBuilder<GuildResponse>(harness, account.token)
.post('/guilds')
.body({
name: 'Forum Settings Guild',
template: buildMinimalTemplate({
channels: [
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0},
{
id: 6002,
type: ChannelTypes.GUILD_FORUM,
name: 'support',
position: 1,
topic: 'Be kind',
available_tags: [
{id: 111, name: 'bug', moderated: false, emoji_id: null, emoji_name: '\u{1F41B}'},
{id: 112, name: 'custom', moderated: false, emoji_id: '123456789', emoji_name: null},
{id: 113, name: 'bug', moderated: true, emoji_id: null, emoji_name: null},
{id: 114, name: 'staff', moderated: true, emoji_id: null, emoji_name: 'not-an-emoji'},
],
default_reaction_emoji: {emoji_id: '987654321', emoji_name: null},
default_sort_order: ForumSortOrderTypes.CREATION_TIME,
default_forum_layout: ForumLayoutTypes.GRID,
default_tag_setting: 'match_all',
default_thread_rate_limit_per_user: 60,
default_auto_archive_duration: 1440,
flags: ChannelFlags.REQUIRE_TAG | ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS,
},
{
id: 6003,
type: ChannelTypes.GUILD_MEDIA,
name: 'gallery',
position: 2,
available_tags: Array.from({length: 25}, (_, index) => ({name: `tag-${index}`})),
default_reaction_emoji: {emoji_id: null, emoji_name: '\u{1F525}'},
default_forum_layout: ForumLayoutTypes.LIST,
default_sort_order: 7,
flags: ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS,
},
{
id: 6004,
type: ChannelTypes.GUILD_FORUM,
name: 'announcements-forum',
position: 3,
available_tags: [{name: 'staff', moderated: true}],
flags: ChannelFlags.REQUIRE_TAG,
},
],
}),
})
.execute();
const channels = await listThreadAwareChannels(account.token, guild.id);
const forum = await getThreadAwareChannel(account.token, findChannelId(channels, 'support'));
expect(forum.type).toBe(ChannelTypes.GUILD_FORUM);
expect(forum.topic).toBe('Be kind');
expect(
forum.available_tags?.map(({name, moderated, emoji_id, emoji_name}) => ({name, moderated, emoji_id, emoji_name})),
).toEqual([
{name: 'bug', moderated: false, emoji_id: null, emoji_name: '\u{1F41B}'},
{name: 'custom', moderated: false, emoji_id: null, emoji_name: null},
{name: 'staff', moderated: true, emoji_id: null, emoji_name: null},
]);
expect(new Set(forum.available_tags?.map((tag) => tag.id)).size).toBe(3);
expect(forum.available_tags?.some((tag) => ['111', '112', '113', '114'].includes(tag.id))).toBe(false);
expect(forum.default_reaction_emoji).toBeNull();
expect(forum.default_sort_order).toBe(ForumSortOrderTypes.CREATION_TIME);
expect(forum.default_forum_layout).toBe(ForumLayoutTypes.GRID);
expect(forum.default_tag_setting).toBe('match_all');
expect(forum.default_thread_rate_limit_per_user).toBe(60);
expect(forum.default_auto_archive_duration).toBe(1440);
expect(forum.flags).toBe(ChannelFlags.REQUIRE_TAG);
const media = await getThreadAwareChannel(account.token, findChannelId(channels, 'gallery'));
expect(media.type).toBe(ChannelTypes.GUILD_MEDIA);
expect(media.available_tags).toHaveLength(20);
expect(media.default_reaction_emoji).toEqual({emoji_id: null, emoji_name: '\u{1F525}'});
expect(media.default_forum_layout).toBeUndefined();
expect(media.default_sort_order).toBeNull();
expect(media.flags).toBe(ChannelFlags.HIDE_MEDIA_DOWNLOAD_OPTIONS);
const moderatedOnly = await getThreadAwareChannel(account.token, findChannelId(channels, 'announcements-forum'));
expect(moderatedOnly.flags).toBe(0);
});
test('indexes imported forum and media channels as thread-only channels', async () => {
const account = await createTestAccount(harness);
const guild = await createBuilder<GuildResponse>(harness, account.token)
.post('/guilds')
.body({
name: 'Index Guild',
template: buildMinimalTemplate({
channels: [
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0},
{id: 6002, type: ChannelTypes.GUILD_FORUM, name: 'forum', position: 1},
{id: 6003, type: ChannelTypes.GUILD_MEDIA, name: 'media', position: 2},
],
}),
})
.execute();
const channels = await listThreadAwareChannels(account.token, guild.id);
const threadOnlyIds = [findChannelId(channels, 'forum'), findChannelId(channels, 'media')];
const guildId = createGuildID(BigInt(guild.id));
const threadOnlyRows = await fetchMany<{channel_id: bigint}>(
ThreadOnlyChannelsByGuild.selectCql({where: ThreadOnlyChannelsByGuild.where.eq('guild_id')}),
{guild_id: guildId},
);
const regularRows = await fetchMany<{channel_id: bigint}>(
ChannelsByGuild.selectCql({where: ChannelsByGuild.where.eq('guild_id')}),
{guild_id: guildId},
);
expect(threadOnlyRows.map((row) => row.channel_id.toString()).sort()).toEqual([...threadOnlyIds].sort());
expect(regularRows.some((row) => threadOnlyIds.includes(row.channel_id.toString()))).toBe(false);
expect(regularRows.some((row) => row.channel_id.toString() === findChannelId(channels, 'general'))).toBe(true);
});
test('accepts channels shaped like a full template export', async () => {
const account = await createTestAccount(harness);
const exported = {
bitrate: 64000,
user_limit: 0,
nsfw: false,
rate_limit_per_user: 0,
topic: null,
default_auto_archive_duration: null,
permission_overwrites: [{id: 0, type: 0, allow: '0', deny: Permissions.SEND_MESSAGES.toString()}],
available_tags: [],
template: '',
default_reaction_emoji: null,
default_thread_rate_limit_per_user: 0,
default_sort_order: null,
default_forum_layout: 0,
default_tag_setting: null,
icon_emoji: null,
theme_color: null,
};
const guild = await createBuilder<GuildResponse>(harness, account.token)
.post('/guilds')
.body({
name: 'Exported Guild',
template: {
...buildMinimalTemplate({
channels: [
{...exported, id: 1, type: ChannelTypes.GUILD_CATEGORY, name: 'Info', position: 0, parent_id: null},
{...exported, id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'rules', position: 0, parent_id: 1},
{...exported, id: 3, type: ChannelTypes.GUILD_CATEGORY, name: 'Nested', position: 1, parent_id: 1},
{
...exported,
id: 4,
type: ChannelTypes.GUILD_FORUM,
name: 'ideas',
position: 1,
parent_id: 1,
nsfw: null,
},
],
}),
afk_channel_id: null,
preferred_locale: 'en-US',
region: 'us-west',
},
})
.execute();
const channels = await listThreadAwareChannels(account.token, guild.id);
const category = channels.find((channel) => channel.name === 'Info');
const rules = channels.find((channel) => channel.name === 'rules');
expect(rules?.parent_id).toBe(category?.id ?? null);
expect(rules?.permission_overwrites?.find((overwrite) => overwrite.id === guild.id)?.deny).toBe(
Permissions.SEND_MESSAGES.toString(),
);
expect(channels.find((channel) => channel.name === 'Nested')?.parent_id ?? null).toBeNull();
expect(channels.find((channel) => channel.name === 'ideas')?.parent_id).toBe(category?.id ?? null);
expect(guild.system_channel_id).toBe(rules?.id ?? null);
});
test('hides imported forum channels from viewers outside the threads experiment', async () => {
const account = await createTestAccount(harness);
const guild = await createBuilder<GuildResponse>(harness, account.token)
.post('/guilds')
.body({
name: 'Hidden Forum Guild',
template: buildMinimalTemplate({
channels: [
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0},
{id: 6002, type: ChannelTypes.GUILD_FORUM, name: 'forum', position: 1},
],
}),
})
.execute();
const legacy = await getGuildChannels(harness, account.token, guild.id);
expect(legacy.some((channel) => channel.name === 'forum')).toBe(false);
expect(legacy.some((channel) => channel.name === 'general')).toBe(true);
});
function listThreadAwareChannels(token: string, guildId: string): Promise<Array<ChannelResponse>> {
return createBuilder<Array<ChannelResponse>>(harness, token)
.get(`/guilds/${guildId}/channels`)
.header(THREAD_FEATURES_HEADER, THREAD_FEATURES_CAPABLE)
.execute();
}
function getThreadAwareChannel(token: string, channelId: string): Promise<ChannelResponse> {
return createBuilder<ChannelResponse>(harness, token)
.get(`/channels/${channelId}`)
.header(THREAD_FEATURES_HEADER, THREAD_FEATURES_CAPABLE)
.execute();
}
});
const THREAD_FEATURES_HEADER = 'X-Fluxer-Features';
const THREAD_FEATURES_CAPABLE = 'channel_threads';
function findChannelId(channels: Array<ChannelResponse>, name: string): string {
const channel = channels.find((candidate) => candidate.name === name);
if (!channel) throw new Error(`Missing channel ${name}`);
return channel.id;
}
const DEFAULT_EVERYONE_PERMISSIONS = Permissions.VIEW_CHANNEL.toString();
function buildMinimalTemplate(overrides: {channels?: Array<object>; roles?: Array<object>}) {
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APICachePurgeConfig} from '@app/api/config/APIConfig';
import {createControllerCachePurgeAdapter} from '@app/api/infrastructure/ControllerCachePurgeAdapter';
import {createHttpCachePurgeAdapter} from '@app/api/infrastructure/HttpCachePurgeAdapter';
import {createNoneCachePurgeAdapter} from '@app/api/infrastructure/NoneCachePurgeAdapter';
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
@@ -17,6 +18,7 @@ export interface CachePurgeAdapter {
const CACHE_PURGE_ADAPTERS = {
none: createNoneCachePurgeAdapter,
http: createHttpCachePurgeAdapter,
controller: createControllerCachePurgeAdapter,
} satisfies Record<CachePurgeAdapterName, (config: APICachePurgeConfig) => CachePurgeAdapter>;
export function createCachePurgeAdapter(config: APICachePurgeConfig): CachePurgeAdapter {
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APICachePurgeConfig} from '@app/api/config/APIConfig';
import {createControllerCachePurgeAdapter} from '@app/api/infrastructure/ControllerCachePurgeAdapter';
import {afterEach, beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
const ENDPOINT = 'https://controller.test/v1/orgs/org-a/sites/site-b/purge';
const CONFIG: APICachePurgeConfig = {
adapter: 'controller',
http: {endpoint: '', token: '', timeoutMs: 10_000},
controller: {endpoint: ENDPOINT, token: 'pak_test', timeoutMs: 10_000},
};
function prefixes(count: number): Array<string> {
return Array.from({length: count}, (_, index) => `media.test/attachments/1/${index}/photo`);
}
function sentPatterns(spy: MockInstance<typeof fetch>, call: number): Array<string> {
const init = spy.mock.calls[call]![1]!;
return JSON.parse(String(init.body)).patterns;
}
describe('createControllerCachePurgeAdapter', () => {
let fetchSpy: MockInstance<typeof fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('sends each prefix as a wildcard pattern with the bearer token', async () => {
fetchSpy.mockResolvedValue(new Response(null, {status: 202}));
const outcome = await createControllerCachePurgeAdapter(CONFIG).purge(['media.test/emojis/1']);
expect(outcome).toEqual({kind: 'purged'});
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe(ENDPOINT);
expect(init!.method).toBe('POST');
expect(new Headers(init!.headers).get('Authorization')).toBe('Bearer pak_test');
expect(JSON.parse(String(init!.body))).toEqual({patterns: ['media.test/emojis/1*'], soft: false});
});
it('splits a batch into requests of at most 30 patterns', async () => {
fetchSpy.mockImplementation(async () => new Response(null, {status: 202}));
const outcome = await createControllerCachePurgeAdapter(CONFIG).purge(prefixes(65));
expect(outcome).toEqual({kind: 'purged'});
expect(fetchSpy).toHaveBeenCalledTimes(3);
expect(sentPatterns(fetchSpy, 0)).toHaveLength(30);
expect(sentPatterns(fetchSpy, 1)).toHaveLength(30);
expect(sentPatterns(fetchSpy, 2)).toHaveLength(5);
});
it('stops at the first chunk that fails so the whole batch is retried', async () => {
fetchSpy
.mockResolvedValueOnce(new Response(null, {status: 202}))
.mockResolvedValueOnce(new Response(null, {status: 503}));
const outcome = await createControllerCachePurgeAdapter(CONFIG).purge(prefixes(90));
expect(outcome).toEqual({kind: 'failed', status: 503, error: null});
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('reports a 400 as rejected', async () => {
fetchSpy.mockResolvedValue(new Response(null, {status: 400}));
expect(await createControllerCachePurgeAdapter(CONFIG).purge(prefixes(1))).toEqual({
kind: 'rejected',
status: 400,
});
});
it('reports a network error as failed', async () => {
const error = new TypeError('fetch failed');
fetchSpy.mockRejectedValue(error);
expect(await createControllerCachePurgeAdapter(CONFIG).purge(prefixes(1))).toEqual({
kind: 'failed',
status: null,
error,
});
});
});
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APICachePurgeConfig} from '@app/api/config/APIConfig';
import type {CachePurgeAdapter, CachePurgeOutcome} from '@app/api/infrastructure/CachePurgeAdapter';
import * as FetchUtils from '@app/api/utils/FetchUtils';
const MAX_PATTERNS_PER_REQUEST = 30;
export function createControllerCachePurgeAdapter(config: APICachePurgeConfig): CachePurgeAdapter {
async function purgeChunk(patterns: ReadonlyArray<string>): Promise<CachePurgeOutcome> {
let response: Response;
try {
response = await fetch(config.controller.endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${config.controller.token}`},
body: JSON.stringify({patterns, soft: false}),
redirect: 'manual',
signal: AbortSignal.timeout(config.controller.timeoutMs),
});
} catch (error) {
return {kind: 'failed', status: null, error};
}
FetchUtils.discardResponseBody(response.body, response.status);
if (response.ok) {
return {kind: 'purged'};
}
if (response.status === 400 || response.status === 422) {
return {kind: 'rejected', status: response.status};
}
return {kind: 'failed', status: response.status, error: null};
}
return {
async purge(prefixes: ReadonlyArray<string>): Promise<CachePurgeOutcome> {
const patterns = prefixes.map((prefix) => `${prefix}*`);
for (let start = 0; start < patterns.length; start += MAX_PATTERNS_PER_REQUEST) {
const outcome = await purgeChunk(patterns.slice(start, start + MAX_PATTERNS_PER_REQUEST));
if (outcome.kind !== 'purged') {
return outcome;
}
}
return {kind: 'purged'};
},
};
}
@@ -71,9 +71,6 @@ export interface IStorageService {
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null>;
@@ -107,6 +107,7 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
}
const REJECTED_SERVER_SIDE_COPY_ERRORS = new Set([
'NoSuchBucket',
'NoSuchKey',
'NotFound',
'NotImplemented',
@@ -239,6 +240,8 @@ export class StorageService implements IStorageService {
contentType,
contentLength,
expiresAt,
cacheControl,
contentDisposition,
}: {
bucket: string;
key: string;
@@ -246,6 +249,8 @@ export class StorageService implements IStorageService {
contentType?: string;
contentLength?: number;
expiresAt?: Date;
cacheControl?: string;
contentDisposition?: string;
}): Promise<void> {
const size = contentLength ?? (await fs.promises.stat(filePath)).size;
if (size <= STREAM_UPLOAD_PART_BYTES) {
@@ -258,6 +263,8 @@ export class StorageService implements IStorageService {
ContentType: contentType,
ContentLength: size,
Expires: expiresAt,
CacheControl: cacheControl,
ContentDisposition: contentDisposition,
}),
);
return;
@@ -278,6 +285,8 @@ export class StorageService implements IStorageService {
Body: stream,
ContentType: contentType,
Expires: expiresAt,
CacheControl: cacheControl,
ContentDisposition: contentDisposition,
},
partSize: STREAM_UPLOAD_PART_BYTES,
queueSize: STREAM_UPLOAD_CONCURRENCY,
@@ -426,9 +435,6 @@ export class StorageService implements IStorageService {
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
@@ -441,9 +447,6 @@ export class StorageService implements IStorageService {
contentLength: out.ContentLength ?? 0,
contentRange: out.ContentRange ?? null,
contentType: out.ContentType ?? null,
cacheControl: out.CacheControl ?? null,
contentDisposition: out.ContentDisposition ?? null,
expires: out.Expires ?? null,
etag: out.ETag ?? null,
lastModified: out.LastModified ?? null,
};
@@ -468,6 +471,10 @@ export class StorageService implements IStorageService {
async writeObjectToDisk(bucket: string, key: string, filePath: string): Promise<void> {
await this.ensureDirectoryExists(path.dirname(filePath));
await this.downloadObject(bucket, key, filePath);
}
private async downloadObject(bucket: string, key: string, filePath: string): Promise<GetObjectCommandOutput> {
const out = await this.client.send(new GetObjectCommand({Bucket: bucket, Key: key}));
const stream = extractStreamFromGet(out);
const writeStream = fs.createWriteStream(filePath);
@@ -477,6 +484,7 @@ export class StorageService implements IStorageService {
writeStream.destroy();
throw error;
}
return out;
}
async copyObject({
@@ -507,7 +515,7 @@ export class StorageService implements IStorageService {
}),
);
} catch (copyError) {
if (sourceBucket === destinationBucket || !isRejectedServerSideCopy(copyError)) {
if (!isRejectedServerSideCopy(copyError)) {
throw copyError;
}
await this.copyObjectThroughApi(
@@ -541,38 +549,28 @@ export class StorageService implements IStorageService {
},
copyError: unknown,
): Promise<void> {
const source = await this.streamObject({bucket: sourceBucket, key: sourceKey});
if (!source) {
throw copyError;
}
Logger.warn(
{sourceBucket, destinationBucket, error: copyError},
'Object storage rejected a cross-bucket copy, copying through the API instead',
);
const upload = new Upload({
client: this.client,
params: {
Bucket: destinationBucket,
Key: destinationKey,
Body: source.body,
ContentType: newContentType ?? source.contentType ?? undefined,
const downloadedSource = temporaryFile();
try {
const source = await this.downloadObject(sourceBucket, sourceKey, downloadedSource);
Logger.warn(
{sourceBucket, destinationBucket, error: copyError},
'Object storage rejected a server-side copy, copying through the API instead',
);
await this.uploadObjectFromFile({
bucket: destinationBucket,
key: destinationKey,
filePath: downloadedSource,
contentType: newContentType ?? source.ContentType,
...(newContentType
? {}
: {
CacheControl: source.cacheControl ?? undefined,
ContentDisposition: source.contentDisposition ?? undefined,
Expires: source.expires ?? undefined,
cacheControl: source.CacheControl,
contentDisposition: source.ContentDisposition,
expiresAt: source.Expires,
}),
},
partSize: STREAM_UPLOAD_PART_BYTES,
queueSize: STREAM_UPLOAD_CONCURRENCY,
leavePartsOnError: false,
});
try {
await upload.done();
} catch (error) {
source.body.destroy();
throw error;
});
} finally {
await fs.promises.unlink(downloadedSource).catch(() => undefined);
}
}
@@ -187,7 +187,7 @@ describe('StorageService copies on providers that reject cross-bucket CopyObject
expect(ceph.objects.has(`${CDN}/attachments/1/4/missing.pdf`)).toBe(false);
});
it('does not retry a failed same-bucket copy through the API', async () => {
it('reports a missing same-bucket source as a missing object', async () => {
await expect(
storage().copyObject({
sourceBucket: CDN,
@@ -20,7 +20,7 @@ function splitBucket(path: string): {bucket: string; key: string} {
return {bucket, key: key.join('/')};
}
function singleBucketStore() {
function singleBucketStore(unresolvedSourceCode = 'NoSuchBucket') {
const objects = new Map<string, {body: Uint8Array; contentType: string}>();
const resolve = (url: URL): {bucket: string; key: string} => {
const path = decodeURIComponent(url.pathname.slice(1));
@@ -58,7 +58,7 @@ function singleBucketStore() {
}
const source = splitBucket(decodeURIComponent(copySource.replace(/^\//u, '')));
if (source.bucket !== REAL_BUCKET) {
return errorResponse('NoSuchBucket', 404);
return errorResponse(unresolvedSourceCode, unresolvedSourceCode === 'AccessDenied' ? 403 : 404);
}
const object = objects.get(source.key);
if (!object) {
@@ -127,3 +127,59 @@ describe('StorageService with every bucket inside one real bucket', () => {
await expect(service.readObject(REAL_BUCKET, 'attachments/1/3/file.pdf')).resolves.toEqual(PDF);
});
});
describe.each(['NoSuchBucket', 'AccessDenied'])(
'StorageService with the bucket in the endpoint hostname, store answers %s',
(code) => {
let objects: ReturnType<typeof singleBucketStore>;
beforeEach(() => {
objects = singleBucketStore(code);
});
it('copies a non-media attachment out of the uploads prefix', async () => {
const service = storage(`https://${REAL_BUCKET}.${HOST}`, true);
await service.uploadObject({bucket: 'fluxer-uploads', key: 'upload 1', body: PDF});
await expect(
service.copyObjectWithMetadataStripping({
sourceBucket: 'fluxer-uploads',
sourceKey: 'upload 1',
destinationBucket: 'fluxer',
destinationKey: 'attachments/1/2/file.pdf',
contentType: 'application/pdf',
}),
).resolves.toBeNull();
expect(objects.get('fluxer/attachments/1/2/file.pdf')).toEqual({body: PDF, contentType: 'application/pdf'});
});
it('copies within the cdn prefix', async () => {
const service = storage(`https://${REAL_BUCKET}.${HOST}`, true);
await service.uploadObject({bucket: 'fluxer', key: 'emojis/1', body: PDF, contentType: 'image/png'});
await service.copyObject({
sourceBucket: 'fluxer',
sourceKey: 'emojis/1',
destinationBucket: 'fluxer',
destinationKey: 'emojis/2',
});
expect(objects.get('fluxer/emojis/2')).toEqual({body: PDF, contentType: 'image/png'});
});
it('reports a missing source as a missing object', async () => {
const service = storage(`https://${REAL_BUCKET}.${HOST}`, true);
await expect(
service.copyObject({
sourceBucket: 'fluxer',
sourceKey: 'emojis/missing',
destinationBucket: 'fluxer',
destinationKey: 'emojis/3',
}),
).rejects.toMatchObject({name: 'NoSuchKey'});
expect(objects.has('fluxer/emojis/3')).toBe(false);
});
},
);
@@ -936,6 +936,7 @@ function pendingRegistration(userId: string) {
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
registration_url_id: null,
client_ip: '127.0.0.1',
invite_code: null,
};
}
@@ -61,7 +61,7 @@ import {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
type InstanceEmailSmtpTestRequest,
type PendingRegistrationResponse,
PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
@@ -1053,7 +1053,6 @@ function parseStoredRegistrationConfig(raw: string | null): InstanceRegistration
}
const RegistrationUrlSchema = InstanceConfigResponse.shape.registration.shape.urls.element;
const PendingRegistrationSchema = InstanceConfigResponse.shape.registration.shape.pending_registrations.element;
const StoredRegistrationTimestampSchema = z.iso
.datetime({offset: true})
.refine((value) => value === value.trim() && Number.isFinite(Date.parse(value)))
@@ -1086,14 +1085,15 @@ const StoredRegistrationUrlSchema = RegistrationUrlSchema.extend({
last_used_at: StoredRegistrationTimestampSchema.nullable().default(null),
last_used_by_user_id: StoredNullableStringSchema.pipe(StoredSnowflakeStringSchema.nullable()),
}) satisfies z.ZodType<InstanceRegistrationUrl>;
const StoredPendingRegistrationSchema = PendingRegistrationSchema.extend({
const StoredPendingRegistrationSchema = PendingRegistrationResponse.extend({
user_id: StoredSnowflakeStringSchema,
discriminator: PendingRegistrationSchema.shape.discriminator.default(0),
discriminator: PendingRegistrationResponse.shape.discriminator.default(0),
global_name: StoredNullableStringSchema,
email: StoredNullableStringSchema,
requested_at: StoredRegistrationTimestampSchema,
registration_url_id: StoredNullableStringSchema.pipe(StoredRegistrationUrlIdSchema.nullable()),
client_ip: StoredNullableStringSchema,
invite_code: StoredNullableStringSchema,
}) satisfies z.ZodType<InstancePendingRegistration>;
function isRegistrationUrlUsable(registrationUrl: InstanceRegistrationUrl, now: Date): boolean {
@@ -521,6 +521,7 @@ class RequestServices implements RequestScopedServices {
getDiscriminatorService(),
getKVActivityTracker(),
this.singleCommunityService,
this.inviteService,
);
return this.cachedSsoService;
}
+286 -27
View File
@@ -22857,6 +22857,82 @@
}
}
},
"/webhooks/{webhook_id}/{token}/libredesk": {
"post": {
"operationId": "execute_libredesk_webhook",
"summary": "Execute Libredesk webhook",
"tags": ["Webhooks"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Receives and processes Libredesk help desk webhook events, formatting them as messages in the configured channel.",
"parameters": [
{
"name": "webhook_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the webhook", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the webhook"
},
{
"name": "token",
"in": "path",
"required": true,
"schema": {"description": "The webhook token", "type": "string"},
"description": "The webhook token"
},
{
"name": "thread_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"description": "ID of a thread in the webhook channel to target instead of the channel itself",
"x-fluxer-experiment": "channel_threads"
},
{"type": "null"}
]
}
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LibredeskWebhook"}}}
}
}
},
"/webhooks/{webhook_id}/{token}/messages/{message_id}": {
"get": {
"operationId": "get_webhook_message",
@@ -23675,6 +23751,93 @@
],
"additionalProperties": false
},
"LibredeskWebhook": {
"type": "object",
"properties": {
"event": {"type": "string"},
"payload": {
"type": "object",
"properties": {
"uuid": {"type": ["string", "null"]},
"reference_number": {"type": ["string", "null"]},
"subject": {"type": ["string", "null"]},
"inbox_name": {"type": ["string", "null"]},
"contact": {
"anyOf": [
{
"type": "object",
"properties": {
"first_name": {"type": ["string", "null"]},
"last_name": {"type": ["string", "null"]}
}
},
{"type": "null"}
]
},
"conversation_uuid": {"type": ["string", "null"]},
"conversation": {
"anyOf": [
{
"type": "object",
"properties": {
"uuid": {"type": ["string", "null"]},
"reference_number": {"type": ["string", "null"]},
"subject": {"type": ["string", "null"]},
"inbox_name": {"type": ["string", "null"]},
"status_category": {"type": ["string", "null"]}
}
},
{"type": "null"}
]
},
"previous_status": {"type": ["string", "null"]},
"new_status": {"type": ["string", "null"]},
"snooze_until": {"type": ["string", "null"]},
"previous_tags": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]},
"new_tags": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]},
"assigned_to": {"anyOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, {"type": "null"}]},
"type": {"type": ["string", "null"]},
"status": {"type": ["string", "null"]},
"text_content": {"type": ["string", "null"]},
"private": {"type": ["boolean", "null"]},
"sender_type": {"type": ["string", "null"]},
"meta": {
"anyOf": [
{
"type": "object",
"properties": {
"is_automated": {"type": ["boolean", "null"]},
"is_csat": {"type": ["boolean", "null"]},
"continuity_email": {"type": ["boolean", "null"]}
}
},
{"type": "null"}
]
},
"attachments": {
"anyOf": [
{"type": "array", "items": {"type": "object", "properties": {"name": {"type": ["string", "null"]}}}},
{"type": "null"}
]
},
"author": {
"anyOf": [
{
"type": "object",
"properties": {
"first_name": {"type": ["string", "null"]},
"last_name": {"type": ["string", "null"]}
}
},
{"type": "null"}
]
},
"name": {"type": ["string", "null"]}
}
}
},
"required": ["event", "payload"]
},
"InstatusWebhook": {
"type": "object",
"properties": {
@@ -36430,13 +36593,16 @@
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
"description": "The template-local channel ID"
},
"type": {"type": "number", "description": "The channel type (0 = text, 2 = voice, 4 = category)"},
"type": {
"type": "number",
"description": "The channel type (0 = text, 2 = voice, 4 = category, 5 = announcement, 13 = stage, 15 = forum, 16 = media)"
},
"name": {
"description": "The name of the channel",
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
},
"topic": {
"description": "The channel topic, shortened to the Fluxer channel topic limit",
"description": "The channel topic or forum guidelines, shortened to the limit for the channel type",
"anyOf": [{"type": "string", "maxLength": 4096}, {"type": "null"}]
},
"position": {"description": "The position of the channel", "$ref": "#/components/schemas/Int32Type"},
@@ -36462,38 +36628,129 @@
"description": "The per-user voice connection limit for voice channels",
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}]
},
"nsfw": {"description": "Whether the channel is NSFW", "type": "boolean"},
"nsfw": {"description": "Whether the channel is NSFW", "type": ["boolean", "null"]},
"rate_limit_per_user": {
"description": "Slowmode rate limit in seconds",
"type": "integer",
"minimum": 0,
"maximum": 21600
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 21600}, {"type": "null"}]
},
"permission_overwrites": {
"description": "Permission overwrites for this channel",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
"description": "The ID of the role or user for this overwrite"
},
"type": {
"description": "The type of overwrite (0/role = role, 1/member = member)",
"type": ["number", "string"]
},
"allow": {
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
"description": "The allowed permissions bitfield as a string"
},
"deny": {
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
"description": "The denied permissions bitfield as a string"
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
"description": "The ID of the role or user for this overwrite"
},
"type": {
"description": "The type of overwrite (0/role = role, 1/member = member)",
"type": ["number", "string"]
},
"allow": {
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
"description": "The allowed permissions bitfield as a string"
},
"deny": {
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}],
"description": "The denied permissions bitfield as a string"
}
},
"required": ["id", "type", "allow", "deny"]
}
},
"required": ["id", "type", "allow", "deny"]
}
{"type": "null"}
]
},
"available_tags": {
"description": "Tags for forum and media channels",
"anyOf": [
{
"maxItems": 100,
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 100, "description": "The name of the tag"},
"moderated": {
"description": "Whether only moderators can apply the tag",
"type": ["boolean", "null"]
},
"emoji_id": {
"description": "A custom emoji reference, ignored on import",
"anyOf": [
{
"anyOf": [
{"type": "string", "maxLength": 64},
{"type": "integer", "minimum": 0, "maximum": 9007199254740991}
]
},
{"type": "null"}
]
},
"emoji_name": {
"description": "The unicode emoji of the tag",
"anyOf": [{"type": "string", "maxLength": 64}, {"type": "null"}]
}
},
"required": ["name"]
}
},
{"type": "null"}
]
},
"default_reaction_emoji": {
"description": "Default reaction for forum and media posts",
"anyOf": [
{
"type": "object",
"properties": {
"emoji_id": {
"description": "A custom emoji reference, ignored on import",
"anyOf": [
{
"anyOf": [
{"type": "string", "maxLength": 64},
{"type": "integer", "minimum": 0, "maximum": 9007199254740991}
]
},
{"type": "null"}
]
},
"emoji_name": {
"description": "The unicode emoji",
"anyOf": [{"type": "string", "maxLength": 64}, {"type": "null"}]
}
}
},
{"type": "null"}
]
},
"default_sort_order": {
"description": "Default post sort order for forum and media channels",
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
},
"default_forum_layout": {
"description": "Default layout for forum channels",
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
},
"default_tag_setting": {
"description": "How posts are filtered by several tags",
"anyOf": [{"type": "string", "maxLength": 32}, {"type": "null"}]
},
"default_thread_rate_limit_per_user": {
"description": "Slowmode in seconds copied onto new threads",
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
},
"default_auto_archive_duration": {
"description": "Default auto archive duration in minutes for new threads",
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
},
"flags": {
"description": "Channel flags",
"anyOf": [{"type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991}, {"type": "null"}]
}
},
"required": ["id", "type", "position"]
@@ -39859,6 +40116,7 @@
"REACTION_EMAIL_VERIFICATION_REQUIRED",
"REPORT_EMAIL_VERIFICATION_REQUIRED",
"REGISTRATION_CLOSED",
"REGISTRATION_CREDENTIALS_REQUIRED",
"REGISTRATION_PENDING_APPROVAL",
"REGISTRATION_REJECTED",
"REGISTRATION_URL_INVALID",
@@ -40154,6 +40412,7 @@
"Email verification is required for this action.",
"Email verification is required for this action.",
"Registration is closed on this instance.",
"This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
"This registration is waiting for admin approval.",
"This registration request was rejected.",
"This registration link is invalid or has expired.",
@@ -52,4 +52,8 @@ export const WebhookRateLimitConfigs = {
bucket: 'webhook:instatus::webhook_id',
config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true},
} as RouteRateLimitConfig,
WEBHOOK_LIBREDESK: {
bucket: 'webhook:libredesk::webhook_id',
config: {limit: 200, windowMs: ms('1 minute'), exemptFromGlobal: true},
} as RouteRateLimitConfig,
} as const;
@@ -174,5 +174,39 @@ describe('Message Search Endpoint', () => {
}
expect(found).toBe(true);
});
test.each(['all_dms', 'open_dms', 'all', 'open_dms_and_all_guilds'] as const)(
'finds personal note messages in the %s scope',
async (scope) => {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
const personalNotesChannelId = account.userId;
await sendMessage(harness, account.token, personalNotesChannelId, `personal-notes-bootstrap-${Date.now()}`);
await markChannelAsIndexed(harness, personalNotesChannelId);
const uniqueContent = `personal-notes-${scope}-${Date.now()}`;
await sendMessage(harness, account.token, personalNotesChannelId, uniqueContent);
let foundInScope = false;
let foundInFilter = false;
for (let attempt = 0; attempt < 20 && !(foundInScope && foundInFilter); attempt++) {
const scoped = await createBuilder<MessageSearchResponse>(harness, account.token)
.post('/search/messages')
.body({content: uniqueContent, scope, context_channel_id: personalNotesChannelId})
.expect(HTTP_STATUS.OK)
.execute();
foundInScope ||= isSearchResult(scoped) && scoped.messages.some((m) => m.content === uniqueContent);
const filtered = await createBuilder<MessageSearchResponse>(harness, account.token)
.post('/search/messages')
.body({content: uniqueContent, scope, channel_ids: [personalNotesChannelId]})
.expect(HTTP_STATUS.OK)
.execute();
foundInFilter ||= isSearchResult(filtered) && filtered.messages.some((m) => m.content === uniqueContent);
if (!(foundInScope && foundInFilter)) {
await wait(TEST_TIMEOUTS.QUICK);
}
}
expect(foundInScope).toBe(true);
expect(foundInFilter).toBe(true);
},
);
});
});
@@ -216,9 +216,6 @@ export class MockStorageService implements IStorageService {
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
@@ -247,9 +244,6 @@ export class MockStorageService implements IStorageService {
contentLength: slice.length,
contentRange,
contentType: obj?.contentType ?? 'application/octet-stream',
cacheControl: null,
contentDisposition: null,
expires: null,
etag: computeEtag(data),
lastModified: obj?.lastModified ?? null,
};
@@ -37,6 +37,7 @@ import {MessageRequestSchema} from '@fluxer/schema/src/domains/message/MessageRe
import {MessageResponseSchema} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
import {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
import {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
import {
SlackWebhookRequest,
WebhookCreateRequest,
@@ -579,6 +580,34 @@ export function WebhookController(app: HonoApp) {
return ctx.body(null, 204);
},
);
app.post(
'/webhooks/:webhook_id/:token/libredesk',
RateLimitMiddleware(RateLimitConfigs.WEBHOOK_LIBREDESK),
BlockAppOriginMiddleware,
OpenAPI({
operationId: 'execute_libredesk_webhook',
summary: 'Execute Libredesk webhook',
description:
'Receives and processes Libredesk help desk webhook events, formatting them as messages in the configured channel.',
responseSchema: null,
statusCode: 204,
tags: ['Webhooks'],
}),
Validator('param', WebhookIdTokenParam),
Validator('query', WebhookMessageQueryRequest),
Validator('json', LibredeskWebhook),
async (ctx) => {
const {webhook_id: webhookId, token} = ctx.req.valid('param');
await ctx.get('webhookRequestService').executeLibredeskWebhook({
webhookId: createWebhookID(webhookId),
token: createWebhookToken(token),
threadId: ctx.req.query('thread_id'),
data: ctx.req.valid('json'),
requestCache: ctx.get('requestCache'),
});
return ctx.body(null, 204);
},
);
app.post('/webhooks/livekit', async (ctx) => {
const response = await ctx.get('webhookRequestService').handleLiveKitWebhook({
body: await ctx.req.text(),
@@ -26,6 +26,7 @@ import type {WebhookExecuteMessageData, WebhookService} from '@app/api/webhook/W
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
import type {
SlackWebhookRequest,
WebhookCreateRequest,
@@ -165,6 +166,14 @@ interface WebhookExecuteInstatusParams {
requestCache: RequestCache;
}
interface WebhookExecuteLibredeskParams {
webhookId: WebhookID;
token: WebhookToken;
threadId?: string;
data: LibredeskWebhook;
requestCache: RequestCache;
}
interface LiveKitWebhookParams {
body: string;
authHeader?: string;
@@ -351,6 +360,10 @@ export class WebhookRequestService {
await this.webhookService.executeInstatusWebhook(params);
}
async executeLibredeskWebhook(params: WebhookExecuteLibredeskParams): Promise<void> {
await this.webhookService.executeLibredeskWebhook(params);
}
async executeSlackWebhook(params: WebhookExecuteSlackParams): Promise<void> {
await this.webhookService.executeWebhook({
webhookId: params.webhookId,
@@ -45,6 +45,7 @@ import * as RandomUtils from '@app/api/utils/RandomUtils';
import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
import {transform as GitHubTransform} from '@app/api/webhook/transformers/GitHubTransformer';
import {instatusDeliveryKey, transformInstatusWebhook} from '@app/api/webhook/transformers/InstatusTransformer';
import {transformLibredeskWebhook} from '@app/api/webhook/transformers/LibredeskTransformer';
import type {WebhookRepository} from '@app/api/webhook/WebhookRepository';
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {
@@ -74,6 +75,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import type {AllowedMentionsRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
import type {GitHubWebhook} from '@fluxer/schema/src/domains/webhook/GitHubWebhookSchemas';
import type {InstatusWebhook} from '@fluxer/schema/src/domains/webhook/InstatusWebhookSchemas';
import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
import {
type WebhookCreateRequest,
WebhookForumPostRequestFields,
@@ -147,6 +149,11 @@ interface WebhookExecuteInstatusParams extends WebhookTokenParams {
requestCache: RequestCache;
}
interface WebhookExecuteLibredeskParams extends WebhookTokenParams, WebhookThreadParams {
data: LibredeskWebhook;
requestCache: RequestCache;
}
const WEBHOOK_AVATAR_MISSING_CACHE_VALUE = '__fluxer_webhook_avatar_missing__';
const HOSTED_WEBHOOK_AVATAR_URLS = {
github: 'https://fluxer.app/static/img/app-webhook-github.2d0319169a3aee33.webp',
@@ -603,6 +610,22 @@ export class WebhookService {
if (delivery) await this.cacheService.set(`instatus:${webhookId}:${delivery}`, 1, seconds('1 day'));
}
async executeLibredeskWebhook(params: WebhookExecuteLibredeskParams): Promise<void> {
const {webhookId, token, threadId, data, requestCache} = params;
const {webhook, channel} = await this.getTokenAuthenticatedWebhookChannel({webhookId, token});
if (channel.isThreadOnly() && threadId === undefined) throw new WebhookServiceForumUnsupportedError();
const thread = await this.resolveWebhookThread(webhook, threadId);
const embed = transformLibredeskWebhook(data);
if (!embed) return;
await this.channelService.messages.send.sendWebhookMessage({
webhook,
thread,
data: {embeds: [embed], allowed_mentions: WebhookService.NO_ALLOWED_MENTIONS},
username: 'Libredesk',
requestCache,
});
}
async dispatchWebhooksUpdate({
guildId,
channelId,
@@ -92,6 +92,7 @@ describe('Follower webhook management', () => {
{method: 'post', path: `${base}?wait=true`, body: {content: 'hello'}},
{method: 'post', path: `${base}/slack`, body: {text: 'hello'}},
{method: 'post', path: `${base}/instatus`, body: {meta: {unsubscribe: '', documentation: ''}, page: {id: 'x'}}},
{method: 'post', path: `${base}/libredesk`, body: {event: 'webhook.test', payload: {id: 1, name: 'x'}}},
{method: 'get', path: `${base}/messages/${messageId}`},
{method: 'patch', path: `${base}/messages/${messageId}`, body: {content: 'edited'}},
{method: 'delete', path: `${base}/messages/${messageId}`},
@@ -0,0 +1,731 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {transformLibredeskWebhook} from '@app/api/webhook/transformers/LibredeskTransformer';
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
import {describe, expect, it} from 'vitest';
const CONVERSATION_UUID = '3f1c2a7e-8b4d-4e6a-9c1f-5d2e7b8a9c01';
const EMBED_TOTAL_MAX_LENGTH = 6000;
const COMBINING_ACUTE = String.fromCodePoint(0x301);
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
function createContact(overrides: Record<string, unknown> = {}) {
return {
id: 17,
created_at: '2026-10-15T09:12:30.118204Z',
updated_at: '2026-10-15T09:12:30.118204Z',
first_name: 'Example contact',
last_name: '',
email: '[email protected]',
type: 'contact',
availability_status: 'offline',
avatar_url: null,
phone_number: null,
phone_number_country_code: null,
country: null,
custom_attributes: {},
enabled: true,
last_active_at: null,
last_login_at: null,
external_user_id: null,
...overrides,
};
}
function createConversation(overrides: Record<string, unknown> = {}) {
return {
id: 42,
created_at: '2026-10-15T09:12:30.154871Z',
updated_at: '2026-10-15T09:12:30.154871Z',
uuid: CONVERSATION_UUID,
contact_id: 17,
inbox_id: 1,
closed_at: null,
resolved_at: null,
reference_number: '1042',
priority: null,
priority_id: null,
status: 'Open',
status_category: 'open',
status_id: 1,
first_reply_at: null,
last_reply_at: null,
assigned_user_id: null,
assigned_team_id: null,
waiting_since: '2026-10-15T09:12:30.201377Z',
snoozed_until: null,
subject: 'Cannot sign in',
inbox_mail: '[email protected]',
inbox_reply_to: '',
inbox_name: 'Support',
inbox_channel: 'email',
tags: [],
meta: {},
custom_attributes: {},
last_message_at: '2026-10-15T09:12:30.101552Z',
last_message: 'Hello, I cannot sign in to my account since this morning.',
last_message_sender: 'contact',
last_interaction: 'Hello, I cannot sign in to my account since this morning.',
last_interaction_at: '2026-10-15T09:12:30.101552Z',
last_interaction_sender: 'contact',
contact: createContact(),
sla_policy_id: null,
sla_policy_name: null,
applied_sla_id: null,
next_sla_deadline_at: null,
first_response_deadline_at: null,
resolution_deadline_at: null,
next_response_deadline_at: null,
next_response_met_at: null,
csat_rating: null,
csat_feedback: null,
csat_responded_at: null,
previous_conversations: null,
...overrides,
};
}
function createAgentAuthor(overrides: Record<string, unknown> = {}) {
return {
id: 5,
first_name: 'Support',
last_name: 'agent',
email: '[email protected]',
avatar_url: null,
availability_status: 'online',
type: 'agent',
last_active_at: '2026-10-15T09:20:02.77631Z',
...overrides,
};
}
function createMessage(overrides: Record<string, unknown> = {}) {
return {
id: 311,
created_at: '2026-10-15T09:12:30.101552Z',
updated_at: '2026-10-15T09:12:30.101552Z',
uuid: 'c8a1f2e3-4b5d-4c6e-8f7a-9b0c1d2e3f40',
type: 'incoming',
status: 'received',
conversation_id: 42,
conversation_uuid: CONVERSATION_UUID,
content:
'<div dir="ltr"><p>Hello, I cannot sign in to my account since this morning.</p><p>The page says my password is wrong.</p></div>',
text_content: 'Hello, I cannot sign in to my account since this morning.\n\nThe page says my password is wrong.',
content_type: 'html',
private: false,
sender_id: 17,
sender_type: 'contact',
meta: {bcc: [], cc: [], from: ['[email protected]'], subject: 'Cannot sign in', to: ['[email protected]']},
attachments: [
{
name: 'screenshot.png',
size: 48213,
content: null,
content_id: '',
content_type: 'image/png',
disposition: 'attachment',
uuid: '7b2e9d14-3c5a-4f8e-a1b6-0d9c8e7f6a52',
url: 'https://desk.example.com/uploads/7b2e9d14-3c5a-4f8e-a1b6-0d9c8e7f6a52?sig=example&exp=1792143150',
thumbnail_url: 'https://desk.example.com/uploads/thumb_7b2e9d14-3c5a-4f8e-a1b6-0d9c8e7f6a52?sig=example',
},
],
author: {
id: 17,
first_name: 'Example contact',
last_name: '',
email: '[email protected]',
avatar_url: null,
availability_status: 'offline',
type: 'contact',
last_active_at: null,
},
...overrides,
};
}
function createOutgoingMessage(overrides: Record<string, unknown> = {}) {
return createMessage({
id: 318,
uuid: '0e4d7c2b-9a1f-4b3e-8d6c-5f2a1b0e9d87',
type: 'outgoing',
status: 'pending',
content: '<p>Thanks for reaching out. Please try again.</p>',
text_content: 'Thanks for reaching out. Please try again.',
sender_id: 5,
sender_type: 'agent',
meta: {to: ['[email protected]']},
attachments: [],
author: createAgentAuthor(),
...overrides,
});
}
function render(event: string, payload: unknown, timestamp = '2026-10-15T09:12:30Z'): RichEmbedRequest | null {
return transformLibredeskWebhook(LibredeskWebhook.parse({event, payload, timestamp}));
}
function renderedText(embed: RichEmbedRequest): Array<string> {
return [
embed.title,
embed.description,
embed.author?.name,
embed.footer?.text,
...(embed.fields ?? []).flatMap((field) => [field.name, field.value]),
].filter((value): value is string => typeof value === 'string');
}
function embedLength(embed: RichEmbedRequest): number {
return renderedText(embed).reduce((total, value) => total + value.length, 0);
}
function markdownSlots(embed: RichEmbedRequest): Array<string> {
return [embed.title, embed.description, ...(embed.fields ?? []).map((field) => field.value)].filter(
(value): value is string => typeof value === 'string',
);
}
function unescapedTrigger(value: string): RegExpMatchArray | null {
return value.match(/(?<!\\)(?:\\\\)*[<@[\]]/);
}
describe('Libredesk transformer', () => {
describe('conversation events', () => {
it('renders conversation.created with the inbox, reference, subject and contact name', () => {
expect(render('conversation.created', createConversation())).toEqual({
title: '\\[Support\\] New conversation: #1042 Cannot sign in',
color: 0x3b82f6,
author: {name: 'Example contact'},
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
});
it('never renders the contact email or phone number', () => {
const embed = render(
'conversation.created',
createConversation({contact: createContact({phone_number: '5550100', country: 'Example country'})}),
);
const text = renderedText(embed!).join('\n');
expect(text).not.toContain('[email protected]');
expect(text).not.toContain('5550100');
expect(text).not.toContain('[email protected]');
});
it('renders conversation.status_changed with both statuses', () => {
const embed = render('conversation.status_changed', {
actor_id: 5,
conversation: createConversation({status: 'Resolved', status_category: 'resolved'}),
conversation_uuid: CONVERSATION_UUID,
new_status: 'Resolved',
previous_status: 'Open',
snooze_until: '',
});
expect(embed).toEqual({
title: '\\[Support\\] Status changed: #1042 Cannot sign in',
color: 0x3ecf8e,
description: 'Open to Resolved',
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
});
it('renders the snooze end as a timestamp', () => {
const embed = render('conversation.status_changed', {
actor_id: 5,
conversation: createConversation({status: 'Snoozed', status_category: 'waiting'}),
conversation_uuid: CONVERSATION_UUID,
new_status: 'Snoozed',
previous_status: 'Open',
snooze_until: '2026-10-16T09:40:05Z',
});
expect(embed?.description).toBe(
`Open to Snoozed until <t:${Math.floor(Date.parse('2026-10-16T09:40:05Z') / 1000)}:f>`,
);
expect(embed?.color).toBe(0xf5a623);
});
it('drops a snooze end outside the timestamp range', () => {
for (const snooze_until of ['1970-01-01T00:00:00Z', '1969-12-31T00:00:00Z', '-271821-04-20T00:00:00Z', 'soon']) {
const embed = render('conversation.status_changed', {
conversation: createConversation({status: 'Snoozed', status_category: 'waiting'}),
conversation_uuid: CONVERSATION_UUID,
new_status: 'Snoozed',
previous_status: 'Open',
snooze_until,
});
expect(embed?.description).toBe('Open to Snoozed');
}
});
it('skips a status change without a new status', () => {
expect(render('conversation.status_changed', {conversation_uuid: CONVERSATION_UUID, new_status: ''})).toBeNull();
});
it('renders conversation.tags_changed as the added and removed tags', () => {
const embed = render('conversation.tags_changed', {
actor_id: 5,
conversation: createConversation({tags: ['account', 'login']}),
conversation_uuid: CONVERSATION_UUID,
new_tags: ['account', 'login'],
previous_tags: ['account', 'billing'],
});
expect(embed?.title).toBe('\\[Support\\] Tags changed: #1042 Cannot sign in');
expect(embed?.fields).toEqual([
{name: 'Added', value: 'login', inline: true},
{name: 'Removed', value: 'billing', inline: true},
]);
});
it('skips a tags change that changed nothing', () => {
expect(
render('conversation.tags_changed', {
conversation: createConversation(),
conversation_uuid: CONVERSATION_UUID,
new_tags: ['account'],
previous_tags: ['account'],
}),
).toBeNull();
});
it('renders conversation.assigned with the agent ID', () => {
const embed = render('conversation.assigned', {
actor_id: 6,
assigned_to: 5,
conversation: createConversation({assigned_user_id: 5}),
conversation_uuid: CONVERSATION_UUID,
});
expect(embed?.title).toBe('\\[Support\\] Conversation assigned: #1042 Cannot sign in');
expect(embed?.fields).toEqual([{name: 'Agent ID', value: '5', inline: true}]);
});
it('renders conversation.unassigned', () => {
const embed = render('conversation.unassigned', {
actor_id: 5,
conversation: createConversation({assigned_team_id: 2}),
conversation_uuid: CONVERSATION_UUID,
});
expect(embed?.title).toBe('\\[Support\\] Conversation unassigned: #1042 Cannot sign in');
expect(embed?.footer).toEqual({text: `Conversation ${CONVERSATION_UUID}`});
});
it('falls back to the top-level uuid when Libredesk could not load the conversation', () => {
const embed = render('conversation.unassigned', {
actor_id: 5,
conversation: createConversation({
id: 0,
uuid: '',
reference_number: '',
subject: null,
inbox_name: '',
inbox_channel: '',
status: null,
status_category: null,
tags: null,
meta: null,
custom_attributes: null,
contact: createContact({id: 0, first_name: '', email: null, custom_attributes: null}),
}),
conversation_uuid: CONVERSATION_UUID,
});
expect(embed).toEqual({
title: 'Conversation unassigned',
color: 0x5c6bc0,
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
});
it('renders a live chat conversation without a subject', () => {
const embed = render(
'conversation.created',
createConversation({subject: null, inbox_name: 'Live chat', inbox_channel: 'livechat'}),
);
expect(embed?.title).toBe('\\[Live chat\\] New conversation: #1042');
});
});
describe('message events', () => {
it('renders an incoming message with the sender name, excerpt and attachment names', () => {
expect(render('message.created', createMessage())).toEqual({
title: 'New message',
color: 0xf97316,
description: 'Hello, I cannot sign in to my account since this morning. The page says my password is wrong.',
author: {name: 'Example contact'},
fields: [{name: 'Attachments', value: 'screenshot.png', inline: false}],
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
});
it('renders an agent reply', () => {
const embed = render('message.created', createOutgoingMessage());
expect(embed?.title).toBe('New reply');
expect(embed?.author).toEqual({name: 'Support agent'});
expect(embed?.description).toBe('Thanks for reaching out. Please try again.');
expect(embed?.fields).toBeUndefined();
});
it('labels a private note and keeps the mention text inert', () => {
const embed = render(
'message.created',
createOutgoingMessage({
status: 'sent',
private: true,
meta: {},
content:
'<p>The account was locked. <span data-type="agent" data-id="6" data-label="Second agent">@Second agent</span> can you confirm?</p>',
text_content: 'The account was locked. @Second agent can you confirm?',
}),
);
expect(embed?.title).toBe('New private note');
expect(embed?.color).toBe(0xfcbd1f);
expect(embed?.description).toBe('The account was locked. \\@Second agent can you confirm?');
});
it('labels an automated reply from the system user', () => {
const embed = render(
'message.created',
createOutgoingMessage({
sender_id: 1,
meta: {is_automated: true},
text_content: 'We have received your request.',
author: createAgentAuthor({id: 1, first_name: 'System', last_name: '', email: 'System', type: 'agent'}),
}),
);
expect(embed?.title).toBe('Automated reply');
expect(embed?.author).toEqual({name: 'System'});
});
it('skips activity lines, satisfaction surveys and continuity digests', () => {
expect(
render(
'message.created',
createOutgoingMessage({
type: 'activity',
status: 'sent',
private: true,
content_type: 'text',
text_content: 'Support agent self-assigned this conversation',
meta: {activity_type: 'self_assign'},
}),
),
).toBeNull();
expect(
render(
'message.created',
createOutgoingMessage({
meta: {is_csat: true, is_automated: true, csat_uuid: 'b0f5c2d4-0000-4000-8000-000000000000'},
text_content: 'Rate us at https://desk.example.com/csat/b0f5c2d4-0000-4000-8000-000000000000',
}),
),
).toBeNull();
expect(render('message.created', createOutgoingMessage({meta: {continuity_email: true}}))).toBeNull();
});
it('names the sender generically when Libredesk sends an empty author', () => {
const embed = render(
'message.created',
createMessage({author: {id: 0, first_name: '', last_name: '', email: null, type: ''}, attachments: null}),
);
expect(embed?.author).toEqual({name: 'Contact'});
expect(embed?.fields).toBeUndefined();
});
it('renders an image-only message without a description', () => {
const embed = render(
'message.created',
createMessage({content: '<p><img src="cid:ldsk-1"></p>', text_content: ''}),
);
expect(embed?.description).toBeUndefined();
expect(embed?.fields).toEqual([{name: 'Attachments', value: 'screenshot.png', inline: false}]);
});
it('renders message.updated only for a failed send', () => {
const failed = render('message.updated', createOutgoingMessage({status: 'failed'}));
expect(failed).toEqual({
title: 'Reply failed to send',
color: 0xe23c39,
description: 'Thanks for reaching out. Please try again.',
author: {name: 'Support agent'},
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
expect(render('message.updated', createOutgoingMessage({status: 'sent'}))).toBeNull();
expect(render('message.updated', createOutgoingMessage({status: 'pending'}))).toBeNull();
});
});
describe('test and custom events', () => {
it('renders webhook.test', () => {
expect(render('webhook.test', {id: 3, name: 'Support feed'})).toEqual({
title: 'Test delivery received',
color: 0x5c6bc0,
description: 'Support feed',
});
});
it('renders an automation event that carries a conversation', () => {
const embed = render('priority_escalated', {
actor_id: 1,
conversation: createConversation({priority: 'High'}),
});
expect(embed?.title).toBe('\\[Support\\] priority_escalated: #1042 Cannot sign in');
expect(embed?.footer).toEqual({text: `Conversation ${CONVERSATION_UUID}`});
});
it('ignores an unknown event without a conversation', () => {
expect(render('inbox.renamed', {id: 1, name: 'Support'})).toBeNull();
});
it('renders an automation event named like a built-in event as an automation event', () => {
const payload = {actor_id: 1, conversation: createConversation()};
for (const event of [
'conversation.created',
'conversation.status_changed',
'conversation.tags_changed',
'conversation.assigned',
'conversation.unassigned',
'message.created',
'message.updated',
'webhook.test',
]) {
expect(render(event, payload)).toEqual({
title: `\\[Support\\] ${event}: #1042 Cannot sign in`,
color: 0x5c6bc0,
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
}
});
it('accepts an automation event name of any length and truncates it', () => {
const embed = render('x'.repeat(300), {conversation: createConversation()});
expect(embed?.title).toBe(`\\[Support\\] ${'x'.repeat(29)}...: #1042 Cannot sign in`);
});
});
describe('validation', () => {
it('accepts null and missing optional fields', () => {
expect(render('conversation.created', {uuid: CONVERSATION_UUID})).toEqual({
title: 'New conversation',
color: 0x3b82f6,
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
expect(render('message.created', {conversation_uuid: CONVERSATION_UUID})).toEqual({
title: 'New reply',
color: 0x3b82f6,
author: {name: 'Agent'},
footer: {text: `Conversation ${CONVERSATION_UUID}`},
});
expect(render('conversation.created', {})).toBeNull();
expect(render('message.created', {})).toBeNull();
expect(render('conversation.tags_changed', {conversation: null, new_tags: null, previous_tags: null})).toBeNull();
});
it('rejects a body without an event or a payload object', () => {
expect(LibredeskWebhook.safeParse({payload: {}}).success).toBe(false);
expect(LibredeskWebhook.safeParse({event: 'webhook.test'}).success).toBe(false);
expect(LibredeskWebhook.safeParse({event: 'webhook.test', payload: 'text'}).success).toBe(false);
expect(LibredeskWebhook.safeParse({event: 'message.created', payload: {text_content: 42}}).success).toBe(false);
});
it('never reads the raw HTML content', () => {
const embed = render(
'message.created',
createMessage({content: '<script>alert(1)</script><p>Visible</p>', text_content: 'Visible'}),
);
expect(embed?.description).toBe('Visible');
});
});
describe('sanitisation', () => {
it('neutralises mass mentions and mention syntax in every markdown slot', () => {
const hostile = '@everyone @here <@123456789012345678> <@&123456789012345678> <#123456789012345678> <t:0:R>';
const embed = render('message.created', createMessage({text_content: hostile, attachments: [{name: hostile}]}));
const conversation = render(
'conversation.created',
createConversation({subject: hostile, inbox_name: '@here', reference_number: '<@1>'}),
);
for (const value of [...markdownSlots(embed!), ...markdownSlots(conversation!)]) {
expect(unescapedTrigger(value)).toBeNull();
}
expect(embed?.description).toBe(
'\\@everyone \\@here \\<\\@123456789012345678> \\<\\@&123456789012345678> \\<#123456789012345678> \\<t:0:R>',
);
});
it('keeps an escaped backslash from re-enabling a mention', () => {
const embed = render('message.created', createMessage({text_content: '\\@everyone \\<@1>'}));
expect(embed?.description).toBe('\\\\\\@everyone \\\\\\<\\@1>');
expect(unescapedTrigger(embed!.description!)).toBeNull();
});
it('disarms masked links but keeps plain urls readable', () => {
const embed = render(
'message.created',
createMessage({
text_content: '[Reset password](https://phishing.example.com) or visit https://example.com/help',
}),
);
expect(embed?.description).toBe(
'\\[Reset password\\](https://phishing.example.com) or visit https://example.com/help',
);
});
it('percent-encodes markdown triggers inside urls so links keep their target', () => {
const embed = render(
'message.created',
createMessage({
text_content:
'See https://social.example/@user/1 and https://docs.example/a[1]?q=a\\b or https://x.example/<@123> then @here',
}),
);
expect(embed?.description).toBe(
'See https://social.example/%40user/1 and https://docs.example/a%5B1%5D?q=a%5Cb or https://x.example/%3C%40123> then \\@here',
);
expect(unescapedTrigger(embed!.description!)).toBeNull();
});
it('keeps a url that the parser would reject inert', () => {
const embed = render(
'message.created',
createMessage({text_content: 'https://**x.example/@everyone[click](https://phishing.example.com)'}),
);
expect(embed?.description).toBe('https://**x.example/%40everyone%5Bclick%5D(https://phishing.example.com)');
});
it('renders HTML in the plain text as literal text', () => {
const embed = render(
'message.created',
createMessage({text_content: '<script>alert(1)</script> <img src=x onerror=alert(1)>'}),
);
expect(embed?.description).toBe('\\<script>alert(1)\\</script> \\<img src=x onerror=alert(1)>');
});
it('collapses whitespace and strips control and bidi characters', () => {
const embed = render(
'message.created',
createMessage({
text_content: `Line one\n\n\tLine${String.fromCodePoint(0)} two${RIGHT_TO_LEFT_OVERRIDE} reversed${LINE_SEPARATOR}end `,
}),
);
expect(embed?.description).toBe('Line one Line two reversed end');
});
it('leaves plain-text slots unescaped', () => {
const embed = render('message.created', createMessage({author: {first_name: '@everyone', last_name: '<@1>'}}));
expect(embed?.author).toEqual({name: '@everyone <@1>'});
});
it('clamps a very long subject and body within the embed limits', () => {
const long = 'a'.repeat(100_000);
const created = render(
'conversation.created',
createConversation({subject: long, inbox_name: long, reference_number: long}),
);
expect(created!.title!.length).toBeLessThanOrEqual(256);
expect(created!.title!.endsWith('...')).toBe(true);
const message = render('message.created', createMessage({text_content: long, attachments: [{name: long}]}));
expect(message!.description!.length).toBeLessThanOrEqual(1000);
expect(message!.description!.endsWith('...')).toBe(true);
expect(message!.fields![0]!.value.length).toBeLessThanOrEqual(256);
});
it('keeps the worst case title within 256 units', () => {
const triggers = '@'.repeat(10_000);
const embed = render('priority_escalated', {
conversation: createConversation({subject: triggers, inbox_name: triggers, reference_number: triggers}),
});
expect(embed!.title!.length).toBeLessThanOrEqual(256);
expect(unescapedTrigger(embed!.title!)).toBeNull();
});
it('never splits an escape when truncating', () => {
for (let offset = 0; offset < 4; offset++) {
const embed = render(
'message.created',
createMessage({text_content: `${'x'.repeat(offset)}${'@'.repeat(2000)}`}),
);
const description = embed!.description!;
expect(description.length).toBeLessThanOrEqual(1000);
expect(description.endsWith('...')).toBe(true);
expect(unescapedTrigger(description)).toBeNull();
expect(description.slice(0, -3).endsWith('\\')).toBe(false);
}
});
it('bounds a single grapheme built from thousands of combining marks', () => {
const bomb = `a${COMBINING_ACUTE.repeat(20_000)}`;
const embed = render(
'message.created',
createMessage({
text_content: bomb,
author: {first_name: bomb, last_name: bomb},
attachments: [{name: bomb}],
conversation_uuid: bomb,
}),
);
expect(embed!.description).toBe('...');
expect(embed!.author!.name).toBe('...');
expect(embedLength(embed!)).toBeLessThanOrEqual(EMBED_TOTAL_MAX_LENGTH);
});
it('never emits a lone surrogate', () => {
const lone = render('message.created', createMessage({text_content: 'Hello \uD800 there'}));
expect(lone!.description).toBe('Hello \uFFFD there');
const split = render('message.created', createMessage({text_content: `x${'\u{1F600}'.repeat(3000)}`}));
expect(split!.description!.isWellFormed()).toBe(true);
expect(split!.description!.length).toBeLessThanOrEqual(1000);
});
it('stays within the total embed budget for every event when every string is hostile', () => {
const hostile = `${'<@&1> @everyone [x](https://example.com) '.repeat(500)}${COMBINING_ACUTE.repeat(500)}`;
const tags = Array.from({length: 200}, (_, index) => `${hostile}${index}`);
const conversation = createConversation({
subject: hostile,
inbox_name: hostile,
reference_number: hostile,
contact: createContact({first_name: hostile, last_name: hostile}),
});
const payloads: Array<[string, unknown]> = [
['conversation.created', conversation],
[
'conversation.status_changed',
{
conversation,
conversation_uuid: hostile,
previous_status: hostile,
new_status: hostile,
snooze_until: '2026-10-16T09:40:05Z',
},
],
['conversation.tags_changed', {conversation, conversation_uuid: hostile, previous_tags: [], new_tags: tags}],
['conversation.assigned', {conversation, conversation_uuid: hostile, assigned_to: Number.MAX_SAFE_INTEGER}],
['conversation.unassigned', {conversation, conversation_uuid: hostile}],
[
'message.created',
createMessage({
text_content: hostile,
author: {first_name: hostile, last_name: hostile},
attachments: tags.map((name) => ({name})),
conversation_uuid: hostile,
}),
],
[
'message.updated',
createOutgoingMessage({status: 'failed', text_content: hostile, conversation_uuid: hostile}),
],
['webhook.test', {id: 3, name: hostile}],
[hostile.slice(0, 256).trim(), {conversation}],
];
for (const [event, payload] of payloads) {
const embed = render(event, payload);
expect(embed).not.toBeNull();
expect(embedLength(embed!)).toBeLessThanOrEqual(EMBED_TOTAL_MAX_LENGTH);
expect(embed!.title!.length).toBeLessThanOrEqual(256);
expect((embed!.description ?? '').length).toBeLessThanOrEqual(4096);
for (const field of embed!.fields ?? []) expect(field.value.length).toBeLessThanOrEqual(1024);
for (const value of markdownSlots(embed!)) {
expect(unescapedTrigger(value.replace(/ until <t:\d+:f>$/, ''))).toBeNull();
}
}
});
});
});
@@ -10,7 +10,7 @@ import {createWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterEach, beforeEach, describe, it} from 'vitest';
const TOKEN_ROUTE_SUFFIXES = ['', '/github', '/slack', '/instatus'];
const TOKEN_ROUTE_SUFFIXES = ['', '/github', '/slack', '/instatus', '/libredesk'];
describe('Webhook token routes and the web app origin', () => {
let harness: ApiTestHarness;
@@ -137,6 +137,11 @@ describe('webhooks on forum and media channels', () => {
.body({text: 'hi'})
.expect(400, APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED)
.execute();
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${s.webhook.id}/${s.webhook.token}/libredesk`)
.body({event: 'webhook.test', payload: {id: 1, name: 'x'}})
.expect(400, APIErrorCodes.WEBHOOK_SERVICE_FORUM_UNSUPPORTED)
.execute();
const text = await createChannel(harness, s.owner.token, s.guildId, 'text');
const textHook = await threadsRequest<WebhookResponse>(harness, s.owner.token)
.post(`/channels/${text.id}/webhooks`)
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
import {getMessages} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {createWebhook, deleteWebhook} from '@app/api/webhook/tests/WebhookTestUtils';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const CONVERSATION_UUID = '3f1c2a7e-8b4d-4e6a-9c1f-5d2e7b8a9c01';
function createContactMessage(textContent: string) {
return {
id: 311,
created_at: '2026-10-15T09:12:30.101552Z',
updated_at: '2026-10-15T09:12:30.101552Z',
uuid: 'c8a1f2e3-4b5d-4c6e-8f7a-9b0c1d2e3f40',
type: 'incoming',
status: 'received',
conversation_id: 42,
conversation_uuid: CONVERSATION_UUID,
content: `<p>${textContent}</p>`,
text_content: textContent,
content_type: 'html',
private: false,
sender_id: 17,
sender_type: 'contact',
meta: {from: ['[email protected]'], to: ['[email protected]'], subject: 'Cannot sign in'},
attachments: [],
author: {
id: 17,
first_name: 'Example contact',
last_name: '',
email: '[email protected]',
avatar_url: null,
availability_status: 'offline',
type: 'contact',
last_active_at: null,
},
};
}
async function setup(harness: ApiTestHarness, name: string) {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, `${name} Guild`);
const channelId = guild.system_channel_id!;
const webhook = await createWebhook(harness, channelId, owner.token, `${name} Webhook`);
return {owner, channelId, webhook};
}
async function webhookMessages(
harness: ApiTestHarness,
token: string,
channelId: string,
webhookId: string,
): Promise<Array<MessageResponse>> {
const messages = await getMessages(harness, token, channelId);
return messages.filter((message) => message.webhook_id === webhookId);
}
describe('Webhook Libredesk integration', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
describe('POST /webhooks/:webhook_id/:token/libredesk', () => {
it('posts a test delivery under the Libredesk name and ignores the signature header', async () => {
const {owner, channelId, webhook} = await setup(harness, 'Libredesk Test Delivery');
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`)
.header('User-Agent', 'Libredesk-Webhook/unknown-version')
.header('X-Libredesk-Signature', 'sha256=0000000000000000000000000000000000000000000000000000000000000000')
.body({event: 'webhook.test', payload: {id: 3, name: 'Support feed'}, timestamp: '2026-10-15T09:00:00Z'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const messages = await webhookMessages(harness, owner.token, channelId, webhook.id);
expect(messages).toHaveLength(1);
expect(messages[0]!.author.username).toBe('Libredesk');
expect(messages[0]!.embeds?.[0]?.title).toBe('Test delivery received');
expect(messages[0]!.embeds?.[0]?.description).toBe('Support feed');
await deleteWebhook(harness, webhook.id, owner.token);
});
it('posts a contact message without resolving any mention', async () => {
const {owner, channelId, webhook} = await setup(harness, 'Libredesk Mentions');
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`)
.body({
event: 'message.created',
payload: createContactMessage(`@everyone @here <@${owner.userId}> please help`),
timestamp: '2026-10-15T09:12:30Z',
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const messages = await webhookMessages(harness, owner.token, channelId, webhook.id);
expect(messages).toHaveLength(1);
const message = messages[0]!;
expect(message.content).toBe('');
expect(message.mention_everyone).toBe(false);
expect(message.mentions ?? []).toHaveLength(0);
expect(message.mention_roles ?? []).toHaveLength(0);
expect(message.embeds?.[0]?.title).toBe('New message');
expect(message.embeds?.[0]?.description).toBe(`\\@everyone \\@here \\<\\@${owner.userId}> please help`);
await deleteWebhook(harness, webhook.id, owner.token);
});
it('posts every delivery because Libredesk sends no delivery identifier', async () => {
const {owner, channelId, webhook} = await setup(harness, 'Libredesk Repeat');
const body = {
event: 'message.created',
payload: createContactMessage('Still cannot sign in.'),
timestamp: '2026-10-15T09:12:30Z',
};
for (let attempt = 0; attempt < 2; attempt++) {
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`)
.body(body)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
expect(await webhookMessages(harness, owner.token, channelId, webhook.id)).toHaveLength(2);
await deleteWebhook(harness, webhook.id, owner.token);
});
it('acknowledges unknown events and activity lines without posting', async () => {
const {owner, channelId, webhook} = await setup(harness, 'Libredesk Ignored');
const bodies = [
{event: 'inbox.renamed', payload: {id: 1, name: 'Support'}},
{
event: 'message.created',
payload: {
...createContactMessage('Support agent self-assigned this conversation'),
type: 'activity',
private: true,
sender_type: 'agent',
meta: {activity_type: 'self_assign'},
},
},
{event: 'message.updated', payload: {...createContactMessage('Sent'), status: 'sent'}},
];
for (const body of bodies) {
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`)
.body(body)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
expect(await webhookMessages(harness, owner.token, channelId, webhook.id)).toHaveLength(0);
await deleteWebhook(harness, webhook.id, owner.token);
});
it('posts an automation event with a name over 256 characters', async () => {
const {owner, channelId, webhook} = await setup(harness, 'Libredesk Long Event');
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`)
.body({
event: 'x'.repeat(300),
payload: {
actor_id: 1,
conversation: {
uuid: CONVERSATION_UUID,
reference_number: '1042',
subject: 'Cannot sign in',
inbox_name: 'Support',
},
},
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const messages = await webhookMessages(harness, owner.token, channelId, webhook.id);
expect(messages).toHaveLength(1);
expect(messages[0]!.embeds?.[0]?.title).toBe(`\\[Support\\] ${'x'.repeat(29)}...: #1042 Cannot sign in`);
await deleteWebhook(harness, webhook.id, owner.token);
});
it('rejects a body without an event', async () => {
const {owner, webhook} = await setup(harness, 'Libredesk Invalid Body');
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/${webhook.token}/libredesk`)
.body({payload: {id: 3, name: 'Support feed'}})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
.execute();
await deleteWebhook(harness, webhook.id, owner.token);
});
it('rejects a libredesk webhook with an invalid token', async () => {
const {owner, webhook} = await setup(harness, 'Libredesk Invalid Token');
await createBuilderWithoutAuth(harness)
.post(`/webhooks/${webhook.id}/invalid_token/libredesk`)
.body({event: 'webhook.test', payload: {id: 3, name: 'Support feed'}})
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.UNKNOWN_WEBHOOK)
.execute();
await deleteWebhook(harness, webhook.id, owner.token);
});
});
});
@@ -129,7 +129,7 @@ export async function transformPush(body: GitHubWebhook): Promise<RichEmbedReque
/This reverts commit (\w{40})\./g,
(_, hash) => `This reverts commit [\`${hash.substring(0, 7)}\`](${body.repository?.html_url}/commit/${hash}).`,
);
return `[\`${shortCommitId}\`](${commit.url}) ${commitMessage} - ${commit.author.name}`;
return `[\`${shortCommitId}\`](${body.repository?.html_url}/commit/${commit.id}) ${commitMessage} - ${commit.author.name}`;
})
.join('\n');
if (!body.compare) {
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RichEmbedRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import type {LibredeskWebhook} from '@fluxer/schema/src/domains/webhook/LibredeskWebhookSchemas';
type LibredeskPayload = LibredeskWebhook['payload'];
type LibredeskHeadline = Pick<LibredeskPayload, 'inbox_name' | 'reference_number' | 'subject'>;
type LibredeskPerson = NonNullable<LibredeskPayload['author']>;
type EmbedField = NonNullable<RichEmbedRequest['fields']>[number];
const COLOUR_NEW = 0x3b82f6 as const;
const COLOUR_RESOLVED = 0x3ecf8e as const;
const COLOUR_WAITING = 0xf5a623 as const;
const COLOUR_CONTACT = 0xf97316 as const;
const COLOUR_NOTE = 0xfcbd1f as const;
const COLOUR_FAILED = 0xe23c39 as const;
const COLOUR_NEUTRAL = 0x5c6bc0 as const;
const INBOX_MAX_LENGTH = 48;
const REFERENCE_MAX_LENGTH = 32;
const EVENT_MAX_LENGTH = 32;
const SUBJECT_MAX_LENGTH = 128;
const STATUS_MAX_LENGTH = 64;
const NAME_MAX_LENGTH = 128;
const EXCERPT_MAX_LENGTH = 1000;
const FIELD_VALUE_MAX_LENGTH = 256;
const FOOTER_MAX_LENGTH = 128;
const ELLIPSIS = '...';
const graphemeSegmenter = new Intl.Segmenter('en', {granularity: 'grapheme'});
const WHITESPACE_REGEX = /[\s\p{Cc}]+/gu;
const BIDI_CONTROL_REGEX = /\p{Bidi_Control}/gu;
const MARKDOWN_TRIGGER_REGEX = /[\\<@[\]]/g;
const URL_REGEX = /https?:\/\/\S+/giu;
const MAX_TIMESTAMP_SECONDS = 8_640_000_000_000;
function escapeMarkdown(segment: string, inUrl: boolean): string {
return inUrl
? segment.replace(MARKDOWN_TRIGGER_REGEX, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)
: segment.replace(MARKDOWN_TRIGGER_REGEX, '\\$&');
}
function urlRanges(text: string): Array<[number, number]> {
return Array.from(text.matchAll(URL_REGEX), (match) => [match.index, match.index + match[0].length]);
}
function clampText(value: string | null | undefined, maxLength: number, markdown: boolean): string | undefined {
if (typeof value !== 'string') return undefined;
const window = maxLength * 4;
let truncated = value.length > window;
const source = (truncated ? value.slice(0, window) : value).toWellFormed();
const text = source.replace(BIDI_CONTROL_REGEX, '').replace(WHITESPACE_REGEX, ' ').trim();
if (text.length === 0) return undefined;
const budget = maxLength - ELLIPSIS.length;
const urls = markdown ? urlRanges(text) : [];
let range = 0;
let output = '';
let cut = 0;
for (const {segment, index} of graphemeSegmenter.segment(text)) {
while (range < urls.length && urls[range]![1] <= index) range++;
const inUrl = range < urls.length && urls[range]![0] < index + segment.length;
const piece = markdown ? escapeMarkdown(segment, inUrl) : segment;
if (output.length + piece.length > maxLength) {
truncated = true;
break;
}
output += piece;
if (output.length <= budget) cut = output.length;
}
return truncated ? `${output.slice(0, cut).trimEnd()}${ELLIPSIS}` : output;
}
function markdownText(value: string | null | undefined, maxLength: number): string | undefined {
return clampText(value, maxLength, true);
}
function plainText(value: string | null | undefined, maxLength: number): string | undefined {
return clampText(value, maxLength, false);
}
function personName(person: LibredeskPerson | null | undefined): string | undefined {
const parts = [person?.first_name, person?.last_name].filter((part) => part && part.trim().length > 0);
return plainText(parts.join(' '), NAME_MAX_LENGTH);
}
function conversationTitle(action: string, conversation: LibredeskHeadline | null | undefined): string {
const inbox = markdownText(conversation?.inbox_name, INBOX_MAX_LENGTH);
const reference = markdownText(conversation?.reference_number, REFERENCE_MAX_LENGTH);
const subject = markdownText(conversation?.subject, SUBJECT_MAX_LENGTH);
const headline = [reference ? `#${reference}` : undefined, subject].filter(Boolean).join(' ');
const label = headline ? `${action}: ${headline}` : action;
return inbox ? `\\[${inbox}\\] ${label}` : label;
}
function conversationFooter(uuid: string | null | undefined): RichEmbedRequest['footer'] {
const text = plainText(uuid, FOOTER_MAX_LENGTH);
return text ? {text: `Conversation ${text}`} : undefined;
}
function statusColour(category: string | null | undefined): number {
switch (category) {
case 'open':
return COLOUR_NEW;
case 'waiting':
return COLOUR_WAITING;
case 'resolved':
return COLOUR_RESOLVED;
default:
return COLOUR_NEUTRAL;
}
}
function snoozeMarkup(value: string | null | undefined): string | undefined {
if (!value) return undefined;
const seconds = Math.floor(new Date(value).getTime() / 1000);
return seconds >= 1 && seconds <= MAX_TIMESTAMP_SECONDS ? `<t:${seconds}:f>` : undefined;
}
function tagList(tags: ReadonlyArray<string>): string | undefined {
return markdownText(tags.join(', '), FIELD_VALUE_MAX_LENGTH);
}
function transformConversationCreated(payload: LibredeskPayload): RichEmbedRequest {
const name = personName(payload.contact);
return {
title: conversationTitle('New conversation', payload),
color: COLOUR_NEW,
author: name ? {name} : undefined,
footer: conversationFooter(payload.uuid),
};
}
function transformStatusChanged(payload: LibredeskPayload): RichEmbedRequest | null {
const next = markdownText(payload.new_status, STATUS_MAX_LENGTH);
if (!next) return null;
const previous = markdownText(payload.previous_status, STATUS_MAX_LENGTH);
const until = snoozeMarkup(payload.snooze_until);
const change = previous ? `${previous} to ${next}` : next;
return {
title: conversationTitle('Status changed', payload.conversation),
color: statusColour(payload.conversation?.status_category),
description: until ? `${change} until ${until}` : change,
footer: conversationFooter(payload.conversation_uuid),
};
}
function transformTagsChanged(payload: LibredeskPayload): RichEmbedRequest | null {
const previous = new Set(payload.previous_tags ?? []);
const next = new Set(payload.new_tags ?? []);
const added = tagList([...next].filter((tag) => !previous.has(tag)));
const removed = tagList([...previous].filter((tag) => !next.has(tag)));
if (!added && !removed) return null;
const fields: Array<EmbedField> = [];
if (added) fields.push({name: 'Added', value: added, inline: true});
if (removed) fields.push({name: 'Removed', value: removed, inline: true});
return {
title: conversationTitle('Tags changed', payload.conversation),
color: COLOUR_NEUTRAL,
fields,
footer: conversationFooter(payload.conversation_uuid),
};
}
function transformAssigned(payload: LibredeskPayload): RichEmbedRequest {
const assignee = payload.assigned_to;
return {
title: conversationTitle('Conversation assigned', payload.conversation),
color: COLOUR_NEUTRAL,
fields: assignee != null ? [{name: 'Agent ID', value: String(assignee), inline: true}] : undefined,
footer: conversationFooter(payload.conversation_uuid),
};
}
function transformUnassigned(payload: LibredeskPayload): RichEmbedRequest {
return {
title: conversationTitle('Conversation unassigned', payload.conversation),
color: COLOUR_NEUTRAL,
footer: conversationFooter(payload.conversation_uuid),
};
}
function isHiddenMessage(payload: LibredeskPayload): boolean {
return payload.type === 'activity' || payload.meta?.is_csat === true || payload.meta?.continuity_email === true;
}
function attachmentsField(payload: LibredeskPayload): EmbedField | undefined {
const names = (payload.attachments ?? []).map((attachment) => attachment.name?.trim()).filter(Boolean);
const value = markdownText(names.join(', '), FIELD_VALUE_MAX_LENGTH);
return value ? {name: 'Attachments', value, inline: false} : undefined;
}
function messageHeading(payload: LibredeskPayload): {title: string; color: number; fallbackName: string} {
if (payload.sender_type === 'contact') return {title: 'New message', color: COLOUR_CONTACT, fallbackName: 'Contact'};
if (payload.private === true) return {title: 'New private note', color: COLOUR_NOTE, fallbackName: 'Agent'};
if (payload.meta?.is_automated === true)
return {title: 'Automated reply', color: COLOUR_NEUTRAL, fallbackName: 'Agent'};
return {title: 'New reply', color: COLOUR_NEW, fallbackName: 'Agent'};
}
function transformMessageCreated(payload: LibredeskPayload): RichEmbedRequest | null {
if (isHiddenMessage(payload)) return null;
const {title, color, fallbackName} = messageHeading(payload);
const attachments = attachmentsField(payload);
return {
title,
color,
description: markdownText(payload.text_content, EXCERPT_MAX_LENGTH),
author: {name: personName(payload.author) ?? fallbackName},
fields: attachments ? [attachments] : undefined,
footer: conversationFooter(payload.conversation_uuid),
};
}
function transformMessageUpdated(payload: LibredeskPayload): RichEmbedRequest | null {
if (payload.status !== 'failed' || isHiddenMessage(payload)) return null;
return {
title: 'Reply failed to send',
color: COLOUR_FAILED,
description: markdownText(payload.text_content, EXCERPT_MAX_LENGTH),
author: {name: personName(payload.author) ?? 'Agent'},
footer: conversationFooter(payload.conversation_uuid),
};
}
function transformTest(payload: LibredeskPayload): RichEmbedRequest {
return {
title: 'Test delivery received',
color: COLOUR_NEUTRAL,
description: markdownText(payload.name, NAME_MAX_LENGTH),
};
}
function transformCustomEvent(event: string, payload: LibredeskPayload): RichEmbedRequest | null {
const conversation = payload.conversation;
if (!conversation) return null;
const name = markdownText(event, EVENT_MAX_LENGTH) ?? 'Event';
return {
title: conversationTitle(name, conversation),
color: COLOUR_NEUTRAL,
footer: conversationFooter(conversation.uuid),
};
}
export function transformLibredeskWebhook(body: LibredeskWebhook): RichEmbedRequest | null {
const {event, payload} = body;
const hasConversationUuid = payload.conversation_uuid != null;
switch (event) {
case 'conversation.created':
if (payload.uuid != null) return transformConversationCreated(payload);
break;
case 'conversation.status_changed':
if (hasConversationUuid) return transformStatusChanged(payload);
break;
case 'conversation.tags_changed':
if (hasConversationUuid) return transformTagsChanged(payload);
break;
case 'conversation.assigned':
if (hasConversationUuid) return transformAssigned(payload);
break;
case 'conversation.unassigned':
if (hasConversationUuid) return transformUnassigned(payload);
break;
case 'message.created':
if (hasConversationUuid) return transformMessageCreated(payload);
break;
case 'message.updated':
if (hasConversationUuid) return transformMessageUpdated(payload);
break;
case 'webhook.test':
if (payload.name != null) return transformTest(payload);
break;
}
return transformCustomEvent(event, payload);
}
@@ -64,7 +64,11 @@ describe('processCachePurgeQueue', () => {
let previousMedia: string;
beforeEach(() => {
previousCachePurge = {adapter: Config.cachePurge.adapter, http: Config.cachePurge.http};
previousCachePurge = {
adapter: Config.cachePurge.adapter,
http: Config.cachePurge.http,
controller: Config.cachePurge.controller,
};
previousMedia = Config.endpoints.media;
Config.endpoints.media = MEDIA;
Config.cachePurge.adapter = 'http';
+2 -1
View File
@@ -7,7 +7,8 @@
"author": "Fluxer Platform AB <[email protected]>",
"sideEffects": [
"*.css",
"**/*.css"
"**/*.css",
"./src/app/AbortSignalPolyfill.ts"
],
"type": "module",
"exports": {
@@ -7,7 +7,7 @@ import GuildReadState from '@app/features/guild/state/GuildReadState';
import {Logger} from '@app/features/platform/utils/AppLogger';
import Relationships from '@app/features/relationship/state/Relationships';
import Notification from '@app/features/ui/state/Notification';
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
import {updateDocumentTitleBadge} from '@app/features/window/hooks/useFluxerDocumentTitle';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import Favico from 'favico.js';
@@ -58,10 +58,12 @@ const initFavico = (): Favico | null => {
};
const setElectronBadge = (badge: number): void => {
const electronApi = getElectronAPI();
if (!electronApi?.setBadgeCount) return;
const electronBadge = badge > 0 ? badge : 0;
try {
electronApi.setBadgeCount(electronBadge);
if (electronApi?.setUnreadBadge) {
electronApi.setUnreadBadge(badge);
} else {
electronApi?.setBadgeCount?.(badge > 0 ? badge : 0);
}
} catch (e) {
logger.warn('Failed to set Electron badge', e);
}
@@ -85,7 +87,7 @@ const setFaviconBadge = (badge: number, source: string | null): void => {
}
};
const setPwaBadge = (badge: number): void => {
if (!navigator.setAppBadge || !navigator.clearAppBadge) {
if (isDesktop() || !navigator.setAppBadge || !navigator.clearAppBadge) {
return;
}
try {
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {clsx} from 'clsx';
import type React from 'react';
@@ -26,8 +26,6 @@ export interface GuildComboboxRenderersConfig<T extends GuildComboboxOption> {
getNotice?: (option: T, disabled: boolean) => React.ReactNode;
}
const ROW_INITIALS_MAX_LENGTH = 1;
const renderRow = <T extends GuildComboboxOption>(
option: T,
disabled: boolean,
@@ -37,7 +35,6 @@ const renderRow = <T extends GuildComboboxOption>(
) => {
const isGlobal = !option.value;
const iconUrl = option.iconUrl ?? null;
const initial = truncateInitials(getInitialsFromName(option.label), ROW_INITIALS_MAX_LENGTH).toUpperCase();
const notice = getNotice?.(option, disabled);
return (
<div
@@ -58,7 +55,10 @@ const renderRow = <T extends GuildComboboxOption>(
aria-hidden
data-flx="app.guild-combobox-renderers.render-row.avatar-placeholder"
>
{initial}
<GuildAcronym
name={option.label}
data-flx="app.guild-combobox-renderers.render-row.avatar-placeholder-acronym"
/>
</div>
))}
<span className={styles.label} data-flx="app.guild-combobox-renderers.render-row.label">
@@ -185,19 +185,12 @@
background-color: transparent;
background-size: cover;
background-position: center;
font-weight: 600;
font-size: 1.25rem;
color: var(--text-primary);
overflow: hidden;
transition:
border-radius 70ms ease-out,
background-color 70ms ease-out,
color 70ms ease-out;
container-type: size;
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
--initials-fit-inline-size: 92cqi;
--initials-letter-spacing: 0px;
--initials-scale: 1;
}
.guildIconSelected:not(.guildIconNoImage) {
@@ -242,28 +235,6 @@
}
}
.guildIconInitials {
white-space: nowrap;
display: inline-block;
text-align: center;
line-height: 1;
color: inherit;
font-weight: 600;
font-size: min(
var(--initials-font-size),
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
);
letter-spacing: var(--initials-letter-spacing);
transform: scale(var(--initials-scale));
transform-origin: center;
}
.guildIcon[data-initials-length='medium'] .guildIconInitials {
--initials-font-size: clamp(0.85rem, 38cqi, 1.11rem);
--initials-letter-spacing: 0px;
--initials-scale: 0.92;
}
.guildIndicator {
position: absolute;
left: var(--guild-list-indicator-inset);
@@ -765,10 +736,7 @@
align-items: center;
justify-content: center;
overflow: hidden;
font-size: 0.5rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.combinePreviewIconInitials {
@@ -206,6 +206,7 @@ const UserAreaInner = observer(
}, [hasVoiceConnection]);
const wrapperClassName = styles.userAreaInnerWrapper;
const isPushToTalkEffective = Keybind.isPushToTalkEffective();
const isSelfMuted = isMuted || muteReason === 'self';
const microphoneState = selectUserAreaMicrophoneState({
effectiveAudioMuted: isMuted,
effectiveAudioDeafened: isDeafened,
@@ -214,7 +215,6 @@ const UserAreaInner = observer(
isPermissionMuted,
muteReason,
isPushToTalkEffective,
isPushToTalkHeld: Keybind.pushToTalkHeld,
isPushToMuteEffective: Keybind.isPushToMuteEffective(),
isPushToMuteHeld: Keybind.pushToMuteHeld,
});
@@ -224,7 +224,8 @@ const UserAreaInner = observer(
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (isPushToTalkEffective && !isSelfMuted)
return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
})();
@@ -232,7 +233,8 @@ const UserAreaInner = observer(
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (isPushToTalkEffective && !isSelfMuted)
return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
})();
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
selectUserAreaMicrophoneState,
type UserAreaMicrophoneSignals,
} from '@app/features/app/components/layout/UserAreaState';
import {describe, expect, it} from 'vitest';
function signals(overrides: Partial<UserAreaMicrophoneSignals>): UserAreaMicrophoneSignals {
return {
effectiveAudioMuted: false,
effectiveAudioDeafened: false,
isGuildMuted: false,
isGuildDeafened: false,
isPermissionMuted: false,
muteReason: null,
isPushToTalkEffective: false,
isPushToMuteEffective: false,
isPushToMuteHeld: false,
...overrides,
};
}
describe('selectUserAreaMicrophoneState', () => {
it('shows the idle push to talk gate as unmuted without locking the toggle', () => {
expect(
selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'voice_push_to_talk'})),
).toEqual({effectiveMuted: false, muteToggleLocked: false});
});
it('shows a self-muted push to talk user as muted', () => {
expect(
selectUserAreaMicrophoneState(
signals({isPushToTalkEffective: true, effectiveAudioMuted: true, muteReason: 'self'}),
),
).toEqual({effectiveMuted: true, muteToggleLocked: false});
});
it('shows a push to talk user muted by the failure latch as muted', () => {
expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, muteReason: 'self'}))).toEqual({
effectiveMuted: true,
muteToggleLocked: false,
});
});
it('lets a self-deafened push to talk user click the toggle', () => {
expect(
selectUserAreaMicrophoneState(
signals({isPushToTalkEffective: true, effectiveAudioMuted: true, effectiveAudioDeafened: true}),
),
).toEqual({effectiveMuted: true, muteToggleLocked: false});
});
it('locks the toggle for server and permission mutes in push to talk', () => {
for (const overrides of [
{isGuildMuted: true, muteReason: 'guild' as const},
{isGuildDeafened: true},
{isPermissionMuted: true, muteReason: 'permission' as const},
]) {
expect(selectUserAreaMicrophoneState(signals({isPushToTalkEffective: true, ...overrides}))).toEqual({
effectiveMuted: true,
muteToggleLocked: true,
});
}
});
it('keeps voice activity and push to mute behaviour unchanged', () => {
expect(selectUserAreaMicrophoneState(signals({}))).toEqual({effectiveMuted: false, muteToggleLocked: false});
expect(selectUserAreaMicrophoneState(signals({effectiveAudioMuted: true, muteReason: 'self'}))).toEqual({
effectiveMuted: true,
muteToggleLocked: false,
});
expect(selectUserAreaMicrophoneState(signals({isPushToMuteEffective: true, isPushToMuteHeld: true}))).toEqual({
effectiveMuted: true,
muteToggleLocked: false,
});
expect(selectUserAreaMicrophoneState(signals({isGuildMuted: true}))).toEqual({
effectiveMuted: true,
muteToggleLocked: true,
});
});
});
@@ -10,7 +10,6 @@ export interface UserAreaMicrophoneSignals {
isPermissionMuted: boolean;
muteReason: UserAreaMuteReason;
isPushToTalkEffective: boolean;
isPushToTalkHeld: boolean;
isPushToMuteEffective: boolean;
isPushToMuteHeld: boolean;
}
@@ -32,13 +31,13 @@ export function selectUserAreaMicrophoneState(signals: UserAreaMicrophoneSignals
if (hardMuted) {
return {
effectiveMuted: true,
muteToggleLocked: muteToggleLocked || signals.isPushToTalkEffective,
muteToggleLocked,
};
}
if (signals.isPushToTalkEffective) {
return {
effectiveMuted: !signals.isPushToTalkHeld,
muteToggleLocked: true,
effectiveMuted: signals.effectiveAudioMuted || signals.muteReason === 'self',
muteToggleLocked,
};
}
return {
@@ -1,9 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {openMessagingPausedHelpModal} from '@app/features/user/components/modals/MessagingPausedHelpModal';
import {ACCOUNT_LIMITED_NOTICE_DESCRIPTOR} from '@app/features/user/utils/AccountLimitUtils';
import {TEXT_DID_NOT_GO_THROUGH_DESCRIPTOR} from '@app/features/user/utils/MessagingPausedHelpEmail';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
@@ -23,6 +27,17 @@ export const AccountLimitedNagbar = observer(({isMobile}: {isMobile: boolean}) =
{i18n._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR)}
</span>
}
actions={
RuntimeConfig.isSelfHosted() ? undefined : (
<NagbarButton
isMobile={isMobile}
onClick={openMessagingPausedHelpModal}
data-flx="app.app-layout.nagbars.account-limited-nagbar.nagbar-button.open-help"
>
{i18n._(TEXT_DID_NOT_GO_THROUGH_DESCRIPTOR)}
</NagbarButton>
)
}
data-flx="app.app-layout.nagbars.account-limited-nagbar.nagbar-content"
/>
</Nagbar>
@@ -124,15 +124,7 @@
.miniGuildIconWithInitials {
background-color: var(--folder-mini-surface);
}
.miniGuildInitials {
font-size: 0.5rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
text-align: center;
white-space: nowrap;
}
.folderBadge {
@@ -23,10 +23,10 @@ import {
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {useHover} from '@app/features/app/hooks/useHover';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import type {Guild} from '@app/features/guild/models/Guild';
import GuildFolderExpanded from '@app/features/guild/state/GuildFolderExpanded';
import GuildReadState from '@app/features/guild/state/GuildReadState';
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
@@ -760,8 +760,6 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
);
});
const MINI_GUILD_INITIALS_MAX_LENGTH = 2;
interface MiniGuildIconProps {
readonly guild: Guild;
}
@@ -782,12 +780,7 @@ const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
className={flxElementClassName(styles.miniGuildIcon, styles.miniGuildIconWithInitials)}
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-icon--2"
>
<span
className={styles.miniGuildInitials}
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-initials"
>
{truncateInitials(getInitialsFromName(guild.name), MINI_GUILD_INITIALS_MAX_LENGTH)}
</span>
<GuildAcronym name={guild.name} data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.acronym" />
</flx-app-mini-guild-icon>
);
});
@@ -9,16 +9,9 @@ import type {GuildListItemDragAndDrop} from '@app/features/app/components/layout
import {VoiceBadge, type VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge';
import {DropPlacement} from '@app/features/app/components/layout/types/DndTypes';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import {
type GuildInitialsLength,
getGuildIconDisplayInitials,
getGuildInitialsFitStyle,
getInitialsFromName,
getInitialsLength,
truncateInitials,
} from '@app/features/guild/utils/GuildInitialsUtils';
import {ariaCurrentPage} from '@app/features/platform/ARIAProps';
import {Edge} from '@app/features/ui/AxisOrientation';
import {MentionBadgeAnimated} from '@app/features/ui/components/MentionBadge';
@@ -58,13 +51,6 @@ function resolveGuildListItemMotion(useReducedMotion: boolean): GuildListItemMot
return STANDARD_GUILD_LIST_ITEM_MOTION;
}
function resolveInitialsLength(displayInitials: string): GuildInitialsLength | null {
if (displayInitials.length === 0) {
return null;
}
return getInitialsLength(displayInitials);
}
function resolveDraggingCursor(dragAndDrop: GuildListItemDragAndDrop | null): 'grabbing' | null {
if (dragAndDrop == null) {
return null;
@@ -164,7 +150,6 @@ function GuildListItemBadges({
function CombinePreviewIcon({guild}: {readonly guild: Guild}) {
const url = AvatarSourceUtils.getGuildIconURL(guild, false);
const initials = truncateInitials(getInitialsFromName(guild.name), 2);
const backgroundStyle: React.CSSProperties = {};
if (url.length > 0) {
backgroundStyle.backgroundImage = `url(${url})`;
@@ -176,7 +161,10 @@ function CombinePreviewIcon({guild}: {readonly guild: Guild}) {
data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.combine-preview-icon"
>
{url.length === 0 && (
<span data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.span">{initials}</span>
<GuildAcronym
name={guild.name}
data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.acronym"
/>
)}
</flx-app-combine-preview-icon>
);
@@ -304,8 +292,6 @@ export const GuildListItemPresentation = forwardRef<
forwardedRef,
) {
const rootRef = useMergeRefs([surfaceRef, forwardedRef]);
const displayInitials = getGuildIconDisplayInitials(getInitialsFromName(guild.name));
const initialsLength = resolveInitialsLength(displayInitials);
const dropIndicator = resolveDropIndicator(dragAndDrop);
const motion = resolveGuildListItemMotion(Accessibility.useReducedMotion);
const draggingCursor = resolveDraggingCursor(dragAndDrop);
@@ -372,18 +358,14 @@ export const GuildListItemPresentation = forwardRef<
to={guildIconTarget}
from={false}
tween={{duration: motion.iconTweenDuration, ease: 'easeOut'}}
data-initials-length={initialsLength}
style={guildIconStyle}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon"
>
{!hasPaintedIcon && (
<span
className={styles.guildIconInitials}
style={getGuildInitialsFitStyle(displayInitials)}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-initials"
>
{displayInitials}
</span>
<GuildAcronym
name={guild.name}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-acronym"
/>
)}
</GuildListItemIcon>
<GuildListItemBadges
@@ -776,29 +776,11 @@ export const SelfHostedSetupWizardGate = observer(() => {
setSetupTokenError(null);
}, []);
const submittedSetupToken = setupTokenRequired ? setupToken.trim() : undefined;
const submittedSetupToken = setupToken.trim() || undefined;
const handleSetupTokenRejected = useCallback(async (message: string) => {
const handleSetupTokenRejected = useCallback((message: string) => {
setSetupTokenError(message);
try {
await RuntimeConfig.refreshDiscovery();
} catch (error) {
logger.warn('Failed to refresh discovery after the setup token was rejected', error);
}
const required = RuntimeConfig.appPublic.setup.setup_token_required === true;
setWizardSnapshot((current) =>
transitionSetupWizardSnapshot(
transitionSetupWizardSnapshot(current, {
type: 'wizard.sync',
isAuthenticated: current.context.isAuthenticated,
hasConfig: current.context.hasConfig,
accountIdentity: current.context.accountIdentity,
identityLocked: current.context.identityLocked,
setupTokenRequired: required,
}),
{type: 'wizard.goto', step: 'setup_token'},
),
);
setWizardSnapshot((current) => transitionSetupWizardSnapshot(current, {type: 'wizard.setupTokenRejected'}));
}, []);
const submitSignInMethod = useCallback(async () => {
@@ -816,7 +798,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
(violation) => violation.path === 'setup_token',
);
if (setupTokenViolation) {
await handleSetupTokenRejected(setupTokenViolation.message);
handleSetupTokenRejected(setupTokenViolation.message);
setSavingSignInMethod(false);
return;
}
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
createSetupWizardSnapshot,
type SetupWizardSnapshot,
selectSetupWizardModel,
transitionSetupWizardSnapshot,
} from '@app/features/app/components/setup/SetupWizardStateMachine';
import {runtimeSnapshotFromDiscovery} from '@app/features/app/state/InstanceSnapshotStore';
import {instanceDiscoveryFixture} from '@app/features/platform/state/__fixtures__/RuntimeBootstrapFixture';
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
import {parseInstanceDiscoveryDocument} from '@fluxer/instance_bootstrap/src/Discovery';
import {describe, expect, test} from 'vitest';
function registerSnapshot(setupTokenRequired: boolean): SetupWizardSnapshot {
return transitionSetupWizardSnapshot(createSetupWizardSnapshot(), {
type: 'wizard.sync',
isAuthenticated: false,
hasConfig: false,
accountIdentity: AccountIdentityModes.USERNAME,
identityLocked: false,
setupTokenRequired,
});
}
describe('setup wizard setup token step', () => {
test('follows the welcome step when the discovery document requires the token', () => {
const wire = instanceDiscoveryFixture('https://chat.example.test/api');
wire.app_public = {
...wire.app_public,
setup: {configured: false, admin_url: null, account_identity_locked: false, setup_token_required: true},
};
const setup = runtimeSnapshotFromDiscovery(parseInstanceDiscoveryDocument(wire)).appPublic.setup;
expect(setup.setup_token_required).toBe(true);
expect(selectSetupWizardModel(registerSnapshot(setup.setup_token_required === true)).steps).toEqual([
'welcome',
'setup_token',
'theme',
'sign_in_method',
'admin_intro',
'admin_account',
]);
});
test('opens after a rejection while the client believes no token is required', () => {
let snapshot = registerSnapshot(false);
snapshot = transitionSetupWizardSnapshot(snapshot, {type: 'wizard.next'});
snapshot = transitionSetupWizardSnapshot(snapshot, {type: 'wizard.next'});
expect(selectSetupWizardModel(snapshot).step).toBe('sign_in_method');
snapshot = transitionSetupWizardSnapshot(snapshot, {type: 'wizard.setupTokenRejected'});
snapshot = transitionSetupWizardSnapshot(snapshot, {
type: 'wizard.sync',
isAuthenticated: false,
hasConfig: false,
accountIdentity: AccountIdentityModes.USERNAME,
identityLocked: false,
setupTokenRequired: false,
});
const model = selectSetupWizardModel(snapshot);
expect(model.step).toBe('setup_token');
expect(model.steps[model.stepIndex + 1]).toBe('theme');
});
});
@@ -66,6 +66,7 @@ const USERNAME_CONFIGURE_STEPS: ReadonlyArray<WizardStep> = [
function phaseSteps(
phase: WizardPhase,
currentStep: WizardStep,
accountIdentity: AccountIdentityMode,
identityLocked: boolean,
setupTokenRequired: boolean,
@@ -73,7 +74,9 @@ function phaseSteps(
switch (phase) {
case 'register':
return REGISTER_STEPS.filter(
(step) => (step !== 'sign_in_method' || !identityLocked) && (step !== 'setup_token' || setupTokenRequired),
(step) =>
step === currentStep ||
((step !== 'sign_in_method' || !identityLocked) && (step !== 'setup_token' || setupTokenRequired)),
);
case 'loading':
return LOADING_STEPS;
@@ -90,12 +93,13 @@ interface SetupWizardMachineContext {
accountIdentity: AccountIdentityMode;
identityLocked: boolean;
setupTokenRequired: boolean;
setupTokenRejected: boolean;
}
export type SetupWizardMachineEvent =
| {type: 'wizard.next'}
| {type: 'wizard.back'}
| {type: 'wizard.goto'; step: WizardStep}
| {type: 'wizard.setupTokenRejected'}
| {
type: 'wizard.sync';
isAuthenticated: boolean;
@@ -116,9 +120,10 @@ export interface SetupWizardModel {
function contextSteps(context: SetupWizardMachineContext): ReadonlyArray<WizardStep> {
return phaseSteps(
derivePhase(context.isAuthenticated, context.hasConfig),
context.step,
context.accountIdentity,
context.identityLocked,
context.setupTokenRequired,
context.setupTokenRequired || context.setupTokenRejected,
);
}
@@ -130,13 +135,6 @@ function advance(context: SetupWizardMachineContext, delta: number): Partial<Set
return {step: steps[nextIndex], direction: Math.sign(delta)};
}
function jump(context: SetupWizardMachineContext, step: WizardStep): Partial<SetupWizardMachineContext> {
const steps = contextSteps(context);
const targetIndex = steps.indexOf(step);
if (targetIndex < 0) return {};
return {step, direction: Math.sign(targetIndex - steps.indexOf(context.step))};
}
function targetsPhase(event: SetupWizardMachineEvent, context: SetupWizardMachineContext, phase: WizardPhase): boolean {
if (event.type !== 'wizard.sync') return false;
return (
@@ -169,7 +167,7 @@ const setupWizardStateMachine = setup({
}),
goNext: assign(({context}) => advance(context, 1)),
goBack: assign(({context}) => advance(context, -1)),
goTo: assign(({context, event}) => (event.type === 'wizard.goto' ? jump(context, event.step) : {})),
requireSetupToken: assign({setupTokenRejected: () => true, step: () => 'setup_token', direction: () => -1}),
enterRegister: assign({step: () => REGISTER_STEPS[0], direction: () => 0}),
enterLoading: assign({step: () => LOADING_STEPS[0], direction: () => 0}),
enterConfigure: assign({step: () => CONFIGURE_STEPS[0], direction: () => 0}),
@@ -184,6 +182,7 @@ const setupWizardStateMachine = setup({
accountIdentity: AccountIdentityModes.EMAIL,
identityLocked: false,
setupTokenRequired: false,
setupTokenRejected: false,
},
initial: 'register',
on: {
@@ -200,7 +199,7 @@ const setupWizardStateMachine = setup({
on: {
'wizard.next': {actions: 'goNext'},
'wizard.back': {actions: 'goBack'},
'wizard.goto': {actions: 'goTo'},
'wizard.setupTokenRejected': {actions: 'requireSetupToken'},
},
},
loading: {
@@ -5,6 +5,7 @@ import {SetupWelcomeGate} from '@app/features/app/components/setup/SetupWelcomeG
import type {SetupBrandingAssetKind} from '@app/features/app/components/setup/SetupWizardClient';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import {Button} from '@app/features/ui/button/Button';
import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerField';
import {Input} from '@app/features/ui/components/form/FormInput';
@@ -751,13 +752,7 @@ export const AdminAccountStep = observer(
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
redirectPath=""
runtimeSnapshot={RuntimeConfig.getSnapshot()}
+11 -1
View File
@@ -24,6 +24,16 @@ if (!parsedBuildInfo.success) {
}
const buildInfo = parsedBuildInfo.output;
const SERVED_RELEASE_CHANNEL_META_SELECTOR = 'meta[name="fluxer-release-channel"]';
function servedReleaseChannel(): 'stable' | 'canary' | null {
if (typeof document === 'undefined') {
return null;
}
const value = document.querySelector<HTMLMetaElement>(SERVED_RELEASE_CHANNEL_META_SELECTOR)?.content;
return value === 'stable' || value === 'canary' ? value : null;
}
function clientReleaseChannel(value: (typeof buildInfo)['PUBLIC_RELEASE_CHANNEL']): 'stable' | 'canary' {
switch (value) {
case 'stable':
@@ -36,5 +46,5 @@ function clientReleaseChannel(value: (typeof buildInfo)['PUBLIC_RELEASE_CHANNEL'
export default {
PUBLIC_BUILD_VERSION: buildInfo.PUBLIC_BUILD_VERSION,
PUBLIC_RELEASE_CHANNEL: clientReleaseChannel(buildInfo.PUBLIC_RELEASE_CHANNEL),
PUBLIC_RELEASE_CHANNEL: servedReleaseChannel() ?? clientReleaseChannel(buildInfo.PUBLIC_RELEASE_CHANNEL),
} as const;
@@ -521,7 +521,7 @@ class KeybindManager {
}),
);
}
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options)));
this.disposers.push(reactToPushToTalkModeChanges(() => MediaEngine.handlePushToTalkModeChange()));
this.disposers.push(
reaction(
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
@@ -229,7 +229,7 @@ describe('push-to-talk mode reconcile', () => {
dispose();
});
it('flips push-to-talk only on settled states and preserves self-mute when it does', () => {
it('flips push-to-talk only on settled states and reconciles once per flip', () => {
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
const reconcile = vi.fn();
const dispose = reactToPushToTalkModeChanges(reconcile);
@@ -238,7 +238,7 @@ describe('push-to-talk mode reconcile', () => {
emit(status);
expect(Keybind.isPushToTalkEffective()).toBe(effective);
expect(reconcile).toHaveBeenCalledTimes(1);
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true});
expect(reconcile).toHaveBeenLastCalledWith();
};
expectFlip(portalStatus('declined'), false);
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
@@ -251,8 +251,12 @@ describe('push-to-talk mode reconcile', () => {
expectFlip(portalStatus('bound'), true);
reconcile.mockClear();
Keybind.setTransmitMode('voice_activity');
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: false});
expect(Keybind.isPushToTalkEffective()).toBe(false);
expect(reconcile).toHaveBeenCalledTimes(1);
expect(reconcile).toHaveBeenLastCalledWith();
Keybind.setTransmitMode('voice_push_to_talk');
expect(Keybind.isPushToTalkEffective()).toBe(true);
expect(reconcile).toHaveBeenCalledTimes(2);
dispose();
});
});
@@ -3,14 +3,13 @@
import Keybind from '@app/features/input/state/InputKeybind';
import {compareStructural, reaction} from 'mobx';
export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void {
export function reactToPushToTalkModeChanges(onChange: () => void): () => void {
return reaction(
() => ({
transmitMode: Keybind.transmitMode,
pushToTalk: Keybind.isPushToTalkEffective(),
pushToMute: Keybind.isPushToMuteEffective(),
}),
(current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}),
() => onChange(),
{equals: compareStructural},
);
}
@@ -23,6 +23,7 @@ import {
import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types';
import {computeWireFormat} from '@fluxer/limits/src/LimitDiffer';
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
import {InstanceAppPublicSchema} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {runInAction} from 'mobx';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
@@ -217,6 +218,45 @@ describe('runtimeSnapshotFromDiscovery', () => {
expect(snapshot.appPublic.legal.terms_url).toBe('https://d.test/legal?doc=terms');
});
test('keeps every public app field the discovery schema defines', () => {
const document = discoveryDocument('q.test');
document.app_public = {
branding: {
product_name: 'Fluxer q.test',
icon_url: 'https://cdn.q.test/icon.png',
symbol_url: 'https://cdn.q.test/symbol.png',
logo_url: 'https://cdn.q.test/logo.png',
wordmark_url: 'https://cdn.q.test/wordmark.png',
favicon_url: 'https://cdn.q.test/favicon.png',
theme_color: '#4641d9',
status_page_url: 'https://status.q.test',
status_page_incident_history_url: 'https://status.q.test/history',
premium_product_name: 'Supporter',
premium_info_url: 'https://q.test/premium',
},
setup: {
configured: false,
admin_url: 'https://q.test/admin',
account_identity_locked: true,
setup_token_required: true,
},
legal: {
terms_url: 'https://q.test/terms',
privacy_url: 'https://q.test/privacy',
guidelines_url: 'https://q.test/guidelines',
},
registration: {collect_date_of_birth: true},
};
const snapshot = runtimeSnapshotFromDiscovery(parseInstanceDiscoveryDocument(document));
expect(snapshot.appPublic).toEqual(document.app_public);
expect(requireRuntimeConfigSnapshot(JSON.parse(JSON.stringify(snapshot))).appPublic).toEqual(document.app_public);
for (const section of ['branding', 'setup', 'legal', 'registration'] as const) {
expect(Object.keys(snapshot.appPublic[section]).sort()).toEqual(
Object.keys(InstanceAppPublicSchema.shape[section].shape).sort(),
);
}
});
test('rejects a branding URL that is not an absolute HTTP URL', () => {
const snapshot = runtimeSnapshotFromDiscovery(parsedDocument('e.test'));
expect(() =>
@@ -399,6 +399,11 @@ function readAppPublic(value: unknown): InstanceAppPublic {
const setup = readRecord(source.setup, 'appPublic.setup');
const legal = readRecord(source.legal, 'appPublic.legal');
const registration = readRecord(source.registration, 'appPublic.registration');
const accountIdentityLocked = readOptionalBoolean(
setup,
'account_identity_locked',
'appPublic.setup.account_identity_locked',
);
const setupTokenRequired = readOptionalBoolean(setup, 'setup_token_required', 'appPublic.setup.setup_token_required');
return {
branding: {
@@ -419,6 +424,7 @@ function readAppPublic(value: unknown): InstanceAppPublic {
setup: {
configured: readBoolean(setup, 'configured', 'appPublic.setup.configured'),
admin_url: readNullableAbsoluteHttpUrl(setup, 'admin_url', 'appPublic.setup.admin_url'),
...(accountIdentityLocked === undefined ? {} : {account_identity_locked: accountIdentityLocked}),
...(setupTokenRequired === undefined ? {} : {setup_token_required: setupTokenRequired}),
},
legal: {
+26 -109
View File
@@ -25,8 +25,7 @@ import {
type UpdaterMachineEvent,
type UpdaterMachineSnapshot,
} from '@app/features/updater/state/UpdaterStateMachine';
import {buildLinuxManualUpdateOptions} from '@app/features/updater/utils/LinuxManualUpdateOptions';
import type {UpdaterEvent as NativeUpdaterEvent, UpdaterDownloadOption} from '@app/types/electron.d';
import type {UpdaterEvent as NativeUpdaterEvent} from '@app/types/electron.d';
import {OFFICIAL_WEB_APP_HOSTS} from '@fluxer/instance_bootstrap/src/OfficialWebAppHosts';
import {makeAutoObservable, runInAction} from 'mobx';
@@ -41,7 +40,6 @@ type UpdaterEvent =
context: UpdaterContext;
version: string | null;
downloadUrl?: string;
downloadOptions?: Array<UpdaterDownloadOption>;
}
| {
type: 'not-available';
@@ -66,7 +64,6 @@ type UpdaterEvent =
const logger = new Logger('Updater');
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
const WEB_CHECK_TIMEOUT_MS = 15 * 1000;
const NATIVE_CHECK_TIMEOUT_MS = 30 * 1000;
const VERSION_ENDPOINT = '/version.json';
@@ -120,7 +117,6 @@ function normalizeUpdaterEvent(event: NativeUpdaterEvent): UpdaterEvent | null {
context,
version: event.version ?? null,
downloadUrl: event.downloadUrl,
downloadOptions: event.downloadOptions,
};
case 'not-available':
return {type: 'not-available', context};
@@ -151,7 +147,7 @@ class Updater {
private snapshot: UpdaterMachineSnapshot = createUpdaterMachineSnapshot();
currentVersion: string | null = null;
channel: string | null = null;
private desktopArch: string | null = null;
private flatpakAppId: string | null = null;
private isNative: boolean;
private backgroundCheckStarted = false;
private backgroundCheckInterval: number | null = null;
@@ -159,12 +155,12 @@ class Updater {
private unsubscribeNativeEvents: (() => void) | null = null;
private unsubscribeDesktopUpdate: (() => void) | null = null;
private desktopUpdateReported = false;
private desktopUpdateReportedReplacesShell = false;
private desktopUpdateRunning = false;
private olderShellModuleUpdateReady = false;
private olderShellUpdateDownloaded = false;
private olderShellInstallWhenDownloaded = false;
private desktopUpdateStarting = false;
private pendingManualDownloadRefreshes = 0;
private checkInProgress = false;
constructor() {
@@ -192,10 +188,6 @@ class Updater {
return this.snapshot.context.nativeManualDownloadUrl;
}
get nativeManualDownloadOptions(): ReadonlyArray<UpdaterDownloadOption> {
return this.snapshot.context.nativeManualDownloadOptions;
}
private get nativeCheckFailed(): boolean {
return this.snapshot.context.nativeCheckFailed;
}
@@ -212,6 +204,10 @@ class Updater {
return this.desktopUpdateReported || this.olderShellModuleUpdateReady || this.olderShellUpdateAvailable;
}
get desktopUpdateReplacesShell(): boolean {
return (this.desktopUpdateReported && this.desktopUpdateReportedReplacesShell) || this.olderShellUpdateAvailable;
}
private get olderShellUpdateAvailable(): boolean {
const electronApi = getElectronAPI();
if (electronApi == null || electronApi.desktopUpdate != null || electronApi.updaterInstall == null) {
@@ -260,11 +256,16 @@ class Updater {
private async bootstrapNative(): Promise<void> {
try {
const info = await getClientInfo();
const [info, desktopInfo] = await Promise.all([
getClientInfo(),
getElectronAPI()
?.getDesktopInfo()
.catch(() => null),
]);
runInAction(() => {
this.currentVersion = info.desktopVersion ?? null;
this.channel = info.desktopChannel ?? null;
this.desktopArch = info.desktopArch ?? info.arch ?? null;
this.flatpakAppId = desktopInfo?.flatpak === true ? (desktopInfo.flatpakAppId ?? null) : null;
});
} catch (error) {
logger.warn('Failed to read desktop info', error);
@@ -285,6 +286,7 @@ class Updater {
this.unsubscribeDesktopUpdate = desktopUpdate.onStateChanged((state) => {
runInAction(() => {
this.desktopUpdateReported = state.available;
this.desktopUpdateReportedReplacesShell = state.replacesShell === true;
this.desktopUpdateRunning = state.updating === true;
});
});
@@ -318,6 +320,7 @@ class Updater {
const state = await desktopUpdate.state();
runInAction(() => {
this.desktopUpdateReported = state.available;
this.desktopUpdateReportedReplacesShell = state.replacesShell === true;
this.desktopUpdateRunning = state.updating === true;
});
} catch (error) {
@@ -376,35 +379,22 @@ class Updater {
}
private handleNativeEvent(event: UpdaterEvent): void {
const isManualDownloadRefreshResult =
event.context === 'user' &&
this.pendingManualDownloadRefreshes > 0 &&
(event.type === 'available' ||
event.type === 'not-available' ||
event.type === 'error' ||
event.type === 'unsupported');
if (isManualDownloadRefreshResult) {
this.pendingManualDownloadRefreshes -= 1;
}
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
const isUserCheck = event.context === 'user';
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
switch (event.type) {
case 'checking':
this.transition({type: 'check.started'});
break;
case 'available': {
const manualDownloadOptions = this.resolveManualNativeDownloadOptions(event, event.downloadOptions ?? []);
case 'available':
this.transition({
type: 'native.available',
version: event.version ?? null,
downloadUrl: manualDownloadOptions[0]?.url ?? event.downloadUrl ?? null,
downloadOptions: manualDownloadOptions,
downloadUrl: getElectronAPI()?.platform === 'linux' ? DESKTOP_DOWNLOAD_URL : (event.downloadUrl ?? null),
});
if (shouldShowImmediateUserResult) {
this.showCurrentUpdateState();
}
break;
}
case 'not-available':
this.transition({type: 'native.notAvailable', now: Date.now()});
if (shouldShowImmediateUserResult) {
@@ -442,22 +432,6 @@ class Updater {
}
}
private resolveManualNativeDownloadOptions(
event: Extract<UpdaterEvent, {type: 'available'}>,
options: ReadonlyArray<UpdaterDownloadOption>,
): ReadonlyArray<UpdaterDownloadOption> {
if (getElectronAPI()?.platform !== 'linux') {
return options;
}
return buildLinuxManualUpdateOptions({
downloadUrl: event.downloadUrl ?? options[0]?.url ?? null,
channel: this.channel ?? Config.PUBLIC_RELEASE_CHANNEL,
arch: this.desktopArch,
version: event.version ?? null,
knownOptions: options,
});
}
private startBackgroundChecks(): void {
if (this.backgroundCheckStarted) return;
this.backgroundCheckStarted = true;
@@ -601,11 +575,7 @@ class Updater {
await this.startDesktopUpdate();
return;
}
if (this.isNative && this.nativeUnsupported?.reason === 'managed-package' && !this.updateInfo.web.available) {
showUpdaterModal((modals) => modals.pushUnsupportedUpdateModal('managed-package'));
return;
}
if (this.isNative && this.nativeManualUpdateAvailable && !this.nativeUnsupported) {
if (this.isNative && this.nativeManualUpdateAvailable) {
this.showManualNativeUpdateModal();
return;
}
@@ -617,15 +587,7 @@ class Updater {
}
if (this.isNative && this.updateInfo.native.available) {
logger.info('Native update is available but not installable in-app; opening desktop downloads...');
const url = this.nativeManualDownloadUrl ?? this.nativeUnsupported?.downloadUrl ?? null;
if (url) {
await openExternalUrl(url);
} else if (this.nativeUnsupported) {
const {reason, downloadUrl} = this.nativeUnsupported;
showUpdaterModal((modals) => modals.pushUnsupportedUpdateModal(reason, downloadUrl));
} else {
await openExternalUrl(DESKTOP_DOWNLOAD_URL);
}
await openExternalUrl(DESKTOP_DOWNLOAD_URL);
}
}
@@ -663,63 +625,17 @@ class Updater {
private showManualNativeUpdateModal(): void {
const version = this.updateInfo.native.version;
const options = this.nativeManualDownloadOptions;
if (options.length > 0) {
const currentVersion = this.currentVersion;
showUpdaterModal((modals) =>
modals.pushManualUpdateAvailableModal({
currentVersion,
version,
options,
onDownload: (option) => this.downloadManualNativeUpdateOption(option),
}),
);
if (getElectronAPI()?.platform === 'linux') {
const flatpakAppId = this.flatpakAppId;
showUpdaterModal((modals) => modals.pushManagedShellUpdateModal({version, flatpakAppId}));
return;
}
const url = this.nativeManualDownloadUrl ?? this.nativeUnsupported?.downloadUrl ?? DESKTOP_DOWNLOAD_URL;
const url = this.nativeManualDownloadUrl ?? DESKTOP_DOWNLOAD_URL;
showUpdaterModal((modals) =>
modals.pushUpdateAvailableModal(version, () => this.downloadManualNativeUpdateOrOpen(url)),
);
}
private async refreshManualNativeDownloadOption(option: UpdaterDownloadOption): Promise<UpdaterDownloadOption> {
if (this.checkInProgress) {
return option;
}
this.checkInProgress = true;
this.transition({type: 'check.started'});
let timeoutId: number | undefined;
const timedOut = new Promise<boolean>((resolve) => {
timeoutId = window.setTimeout(() => resolve(false), MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS);
});
try {
this.pendingManualDownloadRefreshes += 1;
const checked = await Promise.race([this.checkNativeUpdate('user'), timedOut]);
if (!checked) {
return option;
}
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
} finally {
window.clearTimeout(timeoutId);
this.checkInProgress = false;
this.transition({type: 'check.finished', now: Date.now()});
}
}
private async downloadManualNativeUpdateOption(option: UpdaterDownloadOption): Promise<void> {
if (this.manualNativeDownloadInFlight) {
return;
}
this.transition({type: 'manualDownload.started'});
let currentOption = option;
try {
currentOption = await this.refreshManualNativeDownloadOption(option);
} finally {
this.transition({type: 'manualDownload.finished'});
}
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName, currentOption.sha256);
}
private async downloadManualNativeUpdateOrOpen(
url: string,
suggestedName?: string,
@@ -760,6 +676,7 @@ class Updater {
this.unsubscribeDesktopUpdate = null;
}
this.desktopUpdateReported = false;
this.desktopUpdateReportedReplacesShell = false;
this.desktopUpdateRunning = false;
this.olderShellModuleUpdateReady = false;
this.olderShellUpdateDownloaded = false;
@@ -5,9 +5,14 @@ import {usesUniqueUsernames} from '@app/features/app/utils/AccountIdentityFeatur
import {normalizeInstanceDomain} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
import {parseAccountStorageKey} from '@app/features/auth/state/AccountStorageKey';
import type {Account} from '@app/features/platform/state/AuthSession';
import {formatTagForStreamerMode, getDisplayName} from '@app/features/user/utils/NicknameUtils';
import {shouldShowDiscriminator} from '@app/features/user/utils/UserTagUtils';
import {
formatNameForStreamerMode,
formatTagForStreamerMode,
getDisplayName,
} from '@app/features/user/utils/NicknameUtils';
import {formatUserTag, shouldShowDiscriminator} from '@app/features/user/utils/UserTagUtils';
import {msg} from '@lingui/core/macro';
import {noteText} from '../theme/fonts/ScriptFontLoader';
export const ACCOUNT_DETAILS_UNAVAILABLE_DESCRIPTOR = msg({
message: 'Account details unavailable',
@@ -18,6 +23,7 @@ export type AccountDisplayLabels =
| {
readonly available: true;
readonly displayLabel: string;
readonly usernameLabel: string;
readonly tagLabel: string;
readonly discriminatorLabel: string | null;
}
@@ -32,14 +38,17 @@ export function getAccountDisplayLabels(account: Account): AccountDisplayLabels
}
const displayLabel = getDisplayName(userData);
const discriminator = userData.discriminator;
const usernameLabel = formatNameForStreamerMode(userData.username);
noteText(usernameLabel);
const uniqueUsernames = account.instance != null && usesUniqueUsernames(account.instance.features);
if (!shouldShowDiscriminator(userData, uniqueUsernames)) {
return {available: true, displayLabel, tagLabel: displayLabel, discriminatorLabel: null};
return {available: true, displayLabel, usernameLabel, tagLabel: displayLabel, discriminatorLabel: null};
}
return {
available: true,
displayLabel,
tagLabel: formatTagForStreamerMode(`${displayLabel}#${discriminator}`),
usernameLabel,
tagLabel: formatTagForStreamerMode(formatUserTag(userData, uniqueUsernames)),
discriminatorLabel: formatTagForStreamerMode(`#${discriminator}`),
};
}
@@ -769,7 +769,7 @@ export async function fetchDesktopHandoffInfo({
return response.body;
}
export interface CompleteLoginOptions {
interface CompleteLoginOptions {
redirectPath?: string | null;
}
@@ -13,6 +13,7 @@ import {useCallback} from 'react';
interface AccountDisplayIdentityView {
readonly isAvailable: boolean;
readonly displayLabel: string;
readonly usernameLabel: string;
readonly tagLabel: string;
readonly discriminatorLabel: string | null;
}
@@ -22,11 +23,18 @@ function createAccountDisplayIdentityView(
unavailableLabel: string,
): AccountDisplayIdentityView {
if (!labels.available) {
return {isAvailable: false, displayLabel: unavailableLabel, tagLabel: unavailableLabel, discriminatorLabel: null};
return {
isAvailable: false,
displayLabel: unavailableLabel,
usernameLabel: unavailableLabel,
tagLabel: unavailableLabel,
discriminatorLabel: null,
};
}
return {
isAvailable: true,
displayLabel: labels.displayLabel,
usernameLabel: labels.usernameLabel,
tagLabel: labels.tagLabel,
discriminatorLabel: labels.discriminatorLabel,
};
@@ -64,7 +72,7 @@ export function AccountIdentityText({
const discriminatorLabel = identityView.discriminatorLabel;
return (
<>
{identityView.displayLabel}
{identityView.usernameLabel}
{discriminatorLabel == null ? null : (
<span className={discriminatorClassName} data-flx="auth.accounts.account-identity.discriminator">
{discriminatorLabel}
@@ -7,6 +7,7 @@ import authStyles from '@app/features/auth/components/accounts/account_switcher_
import pageStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
import {AuthSsoPanel, isSsoLoginEnforced, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
import Theme from '@app/features/theme/state/Theme';
import {flxElementClassName} from '@app/lib/react';
@@ -50,13 +51,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
data-flx="auth.accounts.account-switcher-modal.account-switcher-register-step.container"
>
<AuthRegisterFormCore
fields={{
showEmail: !usernameSignIn,
showPassword: true,
showPasswordConfirmation: true,
showUsernameValidation: true,
requireUsername: usernameSignIn,
}}
fields={credentialRegisterFields(usernameSignIn)}
submitLabel={<Trans>Create account</Trans>}
redirectPath=""
offerRecoveryKit={usernameSignIn}
@@ -64,7 +64,7 @@ const LoginPage = observer(function LoginPage({onBackActionChange}: LoginPagePro
title={null}
onLoginComplete={runtimeTarget.reset}
onBackActionChange={onBackActionChange}
completeLoginRedirectPath={null}
completeLoginRedirectPath={Routes.ME}
forceCredentials={false}
startWithAddAccount={false}
runtimeTarget={runtimeTarget}
@@ -320,9 +320,7 @@
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 0.9rem;
line-height: 1.75rem;
overflow: hidden;
border: 0.0625rem solid var(--background-modifier-accent);
}
Loaded 100 of 409 files, more files were not shown because too many files have changed in this diff. Show more