mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c777b244 | ||
|
|
1544e58e76 | ||
|
|
5d0c9c7cbe | ||
|
|
8f58fcc4c4 | ||
|
|
5799ef705d | ||
|
|
0de7dde1ce | ||
|
|
7b39e5a79d | ||
|
|
583c791016 | ||
|
|
bc5dcdfe21 | ||
|
|
973aaced96 | ||
|
|
3e9ee908f8 | ||
|
|
e7347b582c | ||
|
|
71b7cffabc | ||
|
|
da9e9ff0be | ||
|
|
c6941d5905 | ||
|
|
a3cf960660 | ||
|
|
7eebfca20b | ||
|
|
e9167d96ec | ||
|
|
1664050ef7 | ||
|
|
7fa00c0e89 | ||
|
|
81d69c41f5 | ||
|
|
4e6b837ccc | ||
|
|
a9f7a23c0d | ||
|
|
07301adc6d | ||
|
|
c6630008b5 | ||
|
|
cdcaba34ce | ||
|
|
09b9a57e38 | ||
|
|
79d7c85832 | ||
|
|
eb0e8366bc | ||
|
|
cf9752db4f | ||
|
|
d6fb3b2c50 | ||
|
|
b04fdc68df | ||
|
|
706c41aad9 | ||
|
|
db9ec0605e | ||
|
|
a95172bf88 | ||
|
|
597116a0b4 |
@@ -16,4 +16,10 @@ Every commit made by a contributor must include the [Developer Certificate of Or
|
||||
|
||||
## Name and marks
|
||||
|
||||
The AGPL does not grant permission to use the Fluxer name, logo or other branding. Forks must use a distinct name and branding unless Fluxer Platform AB grants permission otherwise.
|
||||
Fluxer and the Fluxer logo are trademarks of Fluxer Platform AB. Neither the AGPL nor the CC BY-SA 4.0 licence on Fluxer artwork grants trademark rights. Fluxer Platform AB grants everyone the following permissions.
|
||||
|
||||
- You may distribute unmodified builds of Fluxer, or builds with light patches, under the Fluxer name and logo. Light patches are changes for packaging, portability, security and bug fixes, configuration defaults and translations. Linux distributions, nixpkgs, Flathub and container images are all covered.
|
||||
- A self-hosted instance running such a build may show the Fluxer name and logo under the instance's own name and domain, as long as it does not imply affiliation with or endorsement by Fluxer Platform AB.
|
||||
- You may refer to Fluxer by name to describe compatibility, for example "works with Fluxer".
|
||||
|
||||
Forks with substantive functional changes must use their own name and logo. Any other use needs permission from Fluxer Platform AB. Contact support@fluxer.com.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
<img src="https://fluxer.app/static/img/screenshots-desktop-readme-1920w.70cb6ce340007e0a.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
|
||||
</p>
|
||||
|
||||
## Download
|
||||
@@ -143,14 +143,13 @@ Full setup notes, including canary, are in the [Linux repositories documentation
|
||||
|
||||
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
|
||||
|
||||
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
|
||||
imagery are copyright Fluxer, all rights reserved, as set out in
|
||||
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
|
||||
terms, listed in
|
||||
Fluxer artwork, such as the logo, icons, badges and default avatars, is
|
||||
licensed under [CC BY-SA 4.0](./fluxer_static/LICENSE). Third-party material
|
||||
keeps its own terms, listed in
|
||||
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Public availability of this repository does not grant trademark, brand, or
|
||||
endorsement rights.
|
||||
Use of the Fluxer name and logo is covered by the
|
||||
[name and marks policy](./.github/GOVERNANCE.md#name-and-marks).
|
||||
|
||||
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
|
||||
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
|
||||
|
||||
@@ -357,9 +357,8 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_GIFT_ENDPOINT=
|
||||
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
|
||||
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
|
||||
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
|
||||
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
|
||||
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
|
||||
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
|
||||
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
|
||||
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
|
||||
@@ -412,7 +411,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_NATS_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=1536mb
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
|
||||
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
|
||||
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
|
||||
@@ -438,8 +437,11 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
# Meilisearch indexing memory and threads. Each indexing thread needs its own
|
||||
# buffers on top of the indexing memory, so raise the threads only together with
|
||||
# the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=256mb
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_THREADS=2
|
||||
#FLUXER_MEILISEARCH_ENV=production
|
||||
#FLUXER_MEILISEARCH_NO_ANALYTICS=true
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/*
|
||||
handle @staticAssets {
|
||||
reverse_proxy static-proxy:8080
|
||||
}
|
||||
|
||||
@@ -330,12 +330,13 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
|
||||
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-1536mb}
|
||||
environment:
|
||||
MEILI_ENV: ${FLUXER_MEILISEARCH_ENV:-production}
|
||||
MEILI_NO_ANALYTICS: "${FLUXER_MEILISEARCH_NO_ANALYTICS:-true}"
|
||||
MEILI_UPGRADE_DB: "true"
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-384mb}
|
||||
MEILI_MAX_INDEXING_MEMORY: ${FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY:-256mb}
|
||||
MEILI_MAX_INDEXING_THREADS: ${FLUXER_MEILISEARCH_MAX_INDEXING_THREADS:-2}
|
||||
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
volumes:
|
||||
- meilisearch-data:/meili_data
|
||||
@@ -844,8 +845,6 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
|
||||
FLUXER_UNFURL_STATIC_CDN_ENDPOINT: ${FLUXER_UNFURL_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
@@ -600,7 +600,7 @@ fn select_faces(package_dir: &Path) -> Vec<Face> {
|
||||
}
|
||||
assert!(
|
||||
face["unicodeRange"].is_null(),
|
||||
"{wanted} face {} carries a unicode-range; Latin-core faces must not",
|
||||
"{wanted} face {} has a unicode-range; Latin-core faces must not",
|
||||
face["file"]
|
||||
);
|
||||
faces.push(Face {
|
||||
|
||||
+40
-167
@@ -280,7 +280,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
|
||||
"description": "Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1108,7 +1108,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
|
||||
"description": "List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.",
|
||||
"security": [{"adminApiKey": []}]
|
||||
}
|
||||
},
|
||||
@@ -1451,7 +1451,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.",
|
||||
"description": "Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -1529,7 +1529,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.",
|
||||
"description": "Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4680,7 +4680,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
|
||||
"description": "Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -4715,10 +4715,10 @@
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename",
|
||||
"description": "Return the single message with this attachment together with its surrounding context; requires filename",
|
||||
"allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]
|
||||
},
|
||||
"description": "Return the single message carrying this attachment together with its surrounding context; requires filename"
|
||||
"description": "Return the single message with this attachment together with its surrounding context; requires filename"
|
||||
},
|
||||
{
|
||||
"name": "filename",
|
||||
@@ -6137,72 +6137,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/bot-status": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_bot_status",
|
||||
"summary": "Set user bot status",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
|
||||
},
|
||||
"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": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBotStatusRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/change-log": {
|
||||
"get": {
|
||||
"operationId": "list_admin_user_change_log",
|
||||
@@ -7783,72 +7717,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/system-status": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_system_status",
|
||||
"summary": "Set user system status",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
|
||||
},
|
||||
"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": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
|
||||
"description": "The ID of the user"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSystemStatusRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/users/{user_id}/traits": {
|
||||
"put": {
|
||||
"operationId": "set_admin_user_traits",
|
||||
@@ -9295,13 +9163,6 @@
|
||||
},
|
||||
"required": ["traits"]
|
||||
},
|
||||
"AdminUserSystemStatusRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"system": {"type": "boolean", "description": "Whether the user should be marked as a system user"}
|
||||
},
|
||||
"required": ["system"]
|
||||
},
|
||||
"AdminStorePurchaseListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9752,11 +9613,6 @@
|
||||
"required": ["entries", "next_page_token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminUserBotStatusRequest": {
|
||||
"type": "object",
|
||||
"properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}},
|
||||
"required": ["bot"]
|
||||
},
|
||||
"AdminUserBanNoteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9871,7 +9727,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 103,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions to assign"
|
||||
@@ -12325,8 +12181,15 @@
|
||||
},
|
||||
"BanCheckResponseSchema": {
|
||||
"type": "object",
|
||||
"properties": {"banned": {"type": "boolean"}},
|
||||
"required": ["banned"],
|
||||
"properties": {
|
||||
"banned": {"type": "boolean"},
|
||||
"expires_at": {
|
||||
"nullable": true,
|
||||
"description": "ISO 8601 timestamp when the matching ban expires. Null when the ban is permanent, when nothing matches, and on every blocklist other than ip.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["banned", "expires_at"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdminBlocklistBulkDeleteRequest": {
|
||||
@@ -12482,7 +12345,7 @@
|
||||
},
|
||||
"value_field": {
|
||||
"type": "string",
|
||||
"description": "The request body field that carries the entry value when adding to this blocklist"
|
||||
"description": "The request body field that holds the entry value when adding to this blocklist"
|
||||
},
|
||||
"fields": {
|
||||
"maxItems": 8,
|
||||
@@ -12729,7 +12592,7 @@
|
||||
},
|
||||
"acls": {
|
||||
"description": "Replacement list of access control permissions for the key",
|
||||
"maxItems": 104,
|
||||
"maxItems": 103,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
||||
}
|
||||
@@ -12747,7 +12610,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 103,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12777,7 +12640,7 @@
|
||||
"maximum": 365
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 103,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12798,7 +12661,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 103,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -12811,7 +12674,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 104,
|
||||
"maxItems": 103,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"description": "Every admin access control permission the admin API recognises"
|
||||
@@ -12911,7 +12774,6 @@
|
||||
"user:view:email",
|
||||
"user:view:ip",
|
||||
"user:temp_ban",
|
||||
"user:update:bot_status",
|
||||
"user:update:dob",
|
||||
"user:update:email",
|
||||
"user:update:flags",
|
||||
@@ -13106,10 +12968,13 @@
|
||||
"BanUrlDomainRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"domain": {"description": "Domain to ban (e.g. example.com)", "type": "string"},
|
||||
"domain": {
|
||||
"description": "Domain to ban (e.g. example.com), or a pattern whose leftmost label contains * under a registrable domain (e.g. *shop*.example.com). Internationalized names are stored in ASCII form.",
|
||||
"type": "string"
|
||||
},
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
@@ -13162,7 +13027,15 @@
|
||||
},
|
||||
"BanIpRequest": {
|
||||
"type": "object",
|
||||
"properties": {"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"}},
|
||||
"properties": {
|
||||
"ip": {"description": "IPv4/IPv6 address or CIDR range to ban", "type": "string"},
|
||||
"duration_hours": {
|
||||
"description": "Hours until the ban expires and its entry is removed. Omit it or use 0 for a permanent ban.",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 8760
|
||||
}
|
||||
},
|
||||
"required": ["ip"]
|
||||
},
|
||||
"EmailBlocklistEntryType": {"type": "string"},
|
||||
@@ -13205,7 +13078,7 @@
|
||||
"properties": {
|
||||
"match_subdomains": {
|
||||
"default": true,
|
||||
"description": "If true, any subdomain rooted at this domain is also banned",
|
||||
"description": "If true, any subdomain rooted at this domain, or at a host the pattern matches, is also banned",
|
||||
"type": "boolean"
|
||||
},
|
||||
"category": {"description": "Category / source slug (defaults to \"manual\")", "type": "string"},
|
||||
@@ -13542,7 +13415,7 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"referenced_message": {
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15629,7 +15502,7 @@
|
||||
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {"maxItems": 104, "type": "array", "items": {"type": "string"}},
|
||||
"acls": {"maxItems": 103, "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"}},
|
||||
|
||||
@@ -88,7 +88,6 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
|
||||
pub const USER_VIEW_EMAIL: &str = "user:view:email";
|
||||
pub const USER_VIEW_IP: &str = "user:view:ip";
|
||||
pub const USER_TEMP_BAN: &str = "user:temp_ban";
|
||||
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
|
||||
pub const USER_UPDATE_DOB: &str = "user:update:dob";
|
||||
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
|
||||
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
|
||||
@@ -193,7 +192,6 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
USER_VIEW_EMAIL,
|
||||
USER_VIEW_IP,
|
||||
USER_TEMP_BAN,
|
||||
USER_UPDATE_BOT_STATUS,
|
||||
USER_UPDATE_DOB,
|
||||
USER_UPDATE_EMAIL,
|
||||
USER_UPDATE_FLAGS,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
@@ -28,11 +28,23 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("email", email, None).await
|
||||
}
|
||||
|
||||
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
pub async fn ban_ip(
|
||||
&self,
|
||||
ip: &str,
|
||||
duration_hours: u32,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.create_blocklist_entry(
|
||||
"ip",
|
||||
generated_types::AdminBlocklistEntryCreateRequest::from(
|
||||
generated_types::BanIpRequest { ip: ip.to_owned() },
|
||||
generated_types::BanIpRequest {
|
||||
duration_hours: Some(
|
||||
i32::try_from(duration_hours)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?
|
||||
.into(),
|
||||
),
|
||||
ip: ip.to_owned(),
|
||||
},
|
||||
),
|
||||
audit_log_reason,
|
||||
)
|
||||
@@ -136,6 +148,19 @@ impl AdminApiClient {
|
||||
self.check_blocklist_entry("url-domain", domain, None).await
|
||||
}
|
||||
|
||||
pub async fn list_url_domain_entries(
|
||||
&self,
|
||||
after: Option<&str>,
|
||||
) -> ApiResult<BlocklistEntryPage> {
|
||||
let list_type = blocklist_list_type("url-domain")?;
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_blocklist_entries(list_type, after, Some(BLOCKLIST_PAGE_SIZE), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_file_sha(
|
||||
&self,
|
||||
sha256_hex: &str,
|
||||
@@ -323,6 +348,8 @@ impl AdminApiClient {
|
||||
|
||||
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
|
||||
|
||||
const BLOCKLIST_PAGE_SIZE: &str = "200";
|
||||
|
||||
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
|
||||
generated_types::AdminBlocklistListType::try_from(list_type)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
|
||||
@@ -432,7 +432,7 @@ mod tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn audit_log_reason_header_carries_utf8_bytes() {
|
||||
fn audit_log_reason_header_keeps_utf8_bytes() {
|
||||
let reason = "§ 3 Regel – wiederholt 日本";
|
||||
let value = audit_log_reason_header(reason).expect("valid reason header");
|
||||
assert_eq!(value.as_bytes(), reason.as_bytes());
|
||||
|
||||
@@ -255,9 +255,30 @@ pub enum FlashLevel {
|
||||
pub struct BanCheckResult {
|
||||
pub banned: bool,
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntry {
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub match_subdomains: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub category: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BlocklistEntryPage {
|
||||
pub items: Vec<BlocklistEntry>,
|
||||
pub has_more: bool,
|
||||
#[serde(default)]
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BulkBanResult {
|
||||
pub job_id: String,
|
||||
|
||||
@@ -305,28 +305,6 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_bot_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_system_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn change_username(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::AdminApiClient,
|
||||
api::{
|
||||
client::{AdminApiClient, ApiError},
|
||||
types::FlashMessage,
|
||||
},
|
||||
middleware::{auth::AuthContext, csrf, htmx},
|
||||
state::AppState,
|
||||
templates,
|
||||
@@ -13,10 +16,12 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::ActionQuery;
|
||||
use super::bans_actions::{
|
||||
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
|
||||
to_flash,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
@@ -90,16 +95,7 @@ async fn generic_ban_post(
|
||||
};
|
||||
let value = extract_value(form, ban_cfg.input_name);
|
||||
let is_htmx = htmx::is_htmx_request(headers);
|
||||
let (level, msg) = execute_ban(
|
||||
&client,
|
||||
ban_key,
|
||||
action,
|
||||
&value,
|
||||
form.hashes.as_deref(),
|
||||
form.sha256_list.as_deref(),
|
||||
form.audit_log_reason.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
|
||||
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
|
||||
}
|
||||
|
||||
@@ -141,16 +137,56 @@ ban_post!(url_bans_post, "url-bans");
|
||||
ban_post!(file_sha_bans_post, "file-sha-bans");
|
||||
ban_post!(avatar_hash_bans_post, "avatar-hash-bans");
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UrlDomainListQuery {
|
||||
after: Option<String>,
|
||||
}
|
||||
|
||||
async fn render_url_domain_page(
|
||||
state: &AppState,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&FlashMessage>,
|
||||
csrf_token: &str,
|
||||
after: Option<&str>,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
|
||||
let entries = match client.list_url_domain_entries(after).await {
|
||||
Ok(page) => Some(page),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: list URL domain blocklist");
|
||||
None
|
||||
}
|
||||
};
|
||||
let markup = templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
flash,
|
||||
csrf_token,
|
||||
entries.as_ref(),
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
fn ban_url_domain_error(domain: &str, error: &ApiError) -> String {
|
||||
match error {
|
||||
ApiError::Http { status: 400, .. } => {
|
||||
format!("Failed to ban {domain}: not a valid domain, or the pattern is too broad")
|
||||
}
|
||||
_ => format!("Failed to ban {domain}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn url_domain_bans(
|
||||
State(state): State<AppState>,
|
||||
auth: axum::Extension<AuthContext>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let csrf_token = csrf::get_csrf_token(&request);
|
||||
let markup =
|
||||
templates::pages::url_domain_bans::url_domain_bans_page(config, &auth.0, None, &csrf_token);
|
||||
Html(markup.into_string()).into_response()
|
||||
let Query(query): Query<UrlDomainListQuery> =
|
||||
Query::try_from_uri(request.uri()).unwrap_or(Query(UrlDomainListQuery { after: None }));
|
||||
let after = query.after.as_deref().filter(|value| !value.is_empty());
|
||||
render_url_domain_page(&state, &auth.0, None, &csrf_token, after).await
|
||||
}
|
||||
|
||||
async fn url_domain_bans_post(
|
||||
@@ -181,10 +217,10 @@ async fn url_domain_bans_post(
|
||||
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
|
||||
Ok(()) => ("success", format!("{domain} banned successfully")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
|
||||
("error", format!("Failed to ban domain {domain}"))
|
||||
("error", ban_url_domain_error(&domain, &error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,15 +228,15 @@ async fn url_domain_bans_post(
|
||||
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => ("success", format!("Domain {domain} unbanned")),
|
||||
Ok(()) => ("success", format!("{domain} unbanned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
|
||||
("error", format!("Failed to unban domain {domain}"))
|
||||
("error", format!("Failed to unban {domain}"))
|
||||
}
|
||||
},
|
||||
"check" => match client.check_url_domain_ban(&domain).await {
|
||||
Ok(r) if r.banned => ("info", format!("Domain {domain} is banned")),
|
||||
Ok(_) => ("info", format!("Domain {domain} is NOT banned")),
|
||||
Ok(r) if r.banned => ("info", format!("{domain} is blocked")),
|
||||
Ok(_) => ("info", format!("{domain} is NOT blocked")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, domain, "admin API request failed: check URL domain ban");
|
||||
("error", "Error checking ban status".into())
|
||||
@@ -208,15 +244,11 @@ async fn url_domain_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"url-domain",
|
||||
)
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, &msg);
|
||||
}
|
||||
let flash = to_flash(level, &msg);
|
||||
render_url_domain_page(&state, &auth.0, Some(&flash), &csrf_token, None).await
|
||||
}
|
||||
|
||||
async fn profile_substring_bans(
|
||||
@@ -288,13 +320,5 @@ async fn profile_substring_bans_post(
|
||||
},
|
||||
_ => ("error", "Unknown action".into()),
|
||||
};
|
||||
custom_flash(
|
||||
config,
|
||||
&auth.0,
|
||||
is_htmx,
|
||||
level,
|
||||
&msg,
|
||||
&csrf_token,
|
||||
"profile-substring",
|
||||
)
|
||||
custom_flash(config, &auth.0, is_htmx, level, &msg, &csrf_token)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct BanFormData {
|
||||
#[serde(default)]
|
||||
pub substring: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration_hours: Option<String>,
|
||||
#[serde(default)]
|
||||
pub audit_log_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub _csrf: Option<String>,
|
||||
@@ -58,10 +60,12 @@ pub async fn execute_ban(
|
||||
ban_type: &str,
|
||||
action: &str,
|
||||
value: &str,
|
||||
bulk_hashes: Option<&str>,
|
||||
bulk_sha256_list: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
form: &BanFormData,
|
||||
) -> (&'static str, String) {
|
||||
let bulk_hashes = form.hashes.as_deref();
|
||||
let bulk_sha256_list = form.sha256_list.as_deref();
|
||||
let duration_hours = form.duration_hours.as_deref();
|
||||
let audit_log_reason = form.audit_log_reason.as_deref();
|
||||
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
|
||||
let raw_hashes = if action == "bulk-ban-files" {
|
||||
bulk_sha256_list
|
||||
@@ -74,6 +78,9 @@ pub async fn execute_ban(
|
||||
return ("error", "Value is required".into());
|
||||
}
|
||||
match action {
|
||||
"ban" if ban_type == "ip-bans" => {
|
||||
execute_ip_ban(client, value, duration_hours, audit_log_reason).await
|
||||
}
|
||||
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
|
||||
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
|
||||
"check" => execute_check(client, ban_type, value).await,
|
||||
@@ -107,6 +114,34 @@ async fn execute_bulk_ban(
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_ip_ban(
|
||||
client: &AdminApiClient,
|
||||
value: &str,
|
||||
duration_hours: Option<&str>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (&'static str, String) {
|
||||
let duration_hours = match duration_hours.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
None => 0,
|
||||
Some(raw) => match raw.parse::<u32>() {
|
||||
Ok(hours) => hours,
|
||||
Err(_) => return ("error", "Invalid ban duration".into()),
|
||||
},
|
||||
};
|
||||
let success_message = if duration_hours == 0 {
|
||||
format!("{value} banned permanently")
|
||||
} else {
|
||||
format!(
|
||||
"{value} banned for {}",
|
||||
crate::templates::pages::bans::ip_ban_duration_label(duration_hours)
|
||||
)
|
||||
};
|
||||
ban_action_result(
|
||||
client.ban_ip(value, duration_hours, audit_log_reason).await,
|
||||
success_message,
|
||||
format!("Failed to ban {value}"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn execute_single_ban(
|
||||
client: &AdminApiClient,
|
||||
ban_type: &str,
|
||||
@@ -114,7 +149,6 @@ async fn execute_single_ban(
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> (&'static str, String) {
|
||||
let result = match ban_type {
|
||||
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
|
||||
"email-bans" => client.ban_email(value, audit_log_reason).await,
|
||||
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
|
||||
"url-bans" => client.ban_url(value, audit_log_reason).await,
|
||||
@@ -166,7 +200,10 @@ async fn execute_check(
|
||||
_ => return ("error", "Unknown ban type".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned => ("info", format!("{value} is banned")),
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => ("info", format!("{value} is banned until {expires_at}")),
|
||||
None => ("info", format!("{value} is banned")),
|
||||
},
|
||||
Ok(_) => ("info", format!("{value} is NOT banned")),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
|
||||
@@ -243,25 +280,16 @@ pub fn custom_flash(
|
||||
level: &str,
|
||||
message: &str,
|
||||
csrf_token: &str,
|
||||
page_type: &str,
|
||||
) -> Response {
|
||||
if is_htmx {
|
||||
return render_inline_flash(level, message);
|
||||
}
|
||||
let flash = to_flash(level, message);
|
||||
let markup = match page_type {
|
||||
"url-domain" => templates::pages::url_domain_bans::url_domain_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
_ => templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
),
|
||||
};
|
||||
let markup = templates::pages::profile_substring_bans::profile_substring_bans_page(
|
||||
config,
|
||||
auth,
|
||||
Some(&flash),
|
||||
csrf_token,
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -178,22 +178,6 @@ pub async fn dispatch(
|
||||
"Failed to clear user fields",
|
||||
)
|
||||
}
|
||||
"set_bot_status" => {
|
||||
let val = form.bool_value("bot");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_bot_status(user_id, val).await,
|
||||
"Bot status updated successfully",
|
||||
"Failed to update bot status",
|
||||
)
|
||||
}
|
||||
"set_system_status" => {
|
||||
let val = form.bool_value("system");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_system_status(user_id, val).await,
|
||||
"System status updated successfully",
|
||||
"Failed to update system status",
|
||||
)
|
||||
}
|
||||
"change_username" => {
|
||||
let Some(username) = get("username") else {
|
||||
return DispatchOutcome::error("Username is required");
|
||||
@@ -259,8 +243,11 @@ pub async fn dispatch(
|
||||
let Some(ip) = get("ip") else {
|
||||
return DispatchOutcome::error("IP address is required");
|
||||
};
|
||||
let Ok(duration) = form.parse_value::<u32>("duration_hours") else {
|
||||
return DispatchOutcome::error("Invalid ban duration");
|
||||
};
|
||||
DispatchOutcome::from_result(
|
||||
client.ban_ip(&ip, None).await,
|
||||
client.ban_ip(&ip, duration.unwrap_or(0), None).await,
|
||||
"IP banned successfully",
|
||||
"Failed to ban IP",
|
||||
)
|
||||
|
||||
@@ -20,6 +20,24 @@ pub struct BanConfig {
|
||||
pub entity_name: &'static str,
|
||||
pub active_page: &'static str,
|
||||
pub show_bulk_tools: bool,
|
||||
pub show_duration: bool,
|
||||
}
|
||||
|
||||
const IP_BAN_DURATIONS: &[(u32, &str)] = &[
|
||||
(24, "1 day"),
|
||||
(168, "7 days"),
|
||||
(720, "30 days"),
|
||||
(0, "Permanent"),
|
||||
];
|
||||
|
||||
pub fn ip_ban_duration_label(hours: u32) -> String {
|
||||
IP_BAN_DURATIONS
|
||||
.iter()
|
||||
.find(|(value, _)| *value == hours)
|
||||
.map_or_else(
|
||||
|| format!("{hours} hours"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
@@ -33,6 +51,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "IP/CIDR",
|
||||
active_page: "ip-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: true,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Email Bans",
|
||||
@@ -44,6 +63,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Email",
|
||||
active_page: "email-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Phrase Bans",
|
||||
@@ -55,6 +75,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Phrase",
|
||||
active_page: "phrase-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Blocklist",
|
||||
@@ -66,6 +87,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "URL",
|
||||
active_page: "url-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "File SHA Blocklist",
|
||||
@@ -77,6 +99,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "SHA-256",
|
||||
active_page: "file-sha-bans",
|
||||
show_bulk_tools: true,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Avatar Hash Blocklist",
|
||||
@@ -88,6 +111,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Avatar Hash",
|
||||
active_page: "avatar-hash-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "URL Domain Blocklist",
|
||||
@@ -99,6 +123,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Domain",
|
||||
active_page: "url-domain-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
BanConfig {
|
||||
title: "Profile Substring Blocklist",
|
||||
@@ -110,6 +135,7 @@ pub const BAN_CONFIGS: &[BanConfig] = &[
|
||||
entity_name: "Substring",
|
||||
active_page: "profile-substring-bans",
|
||||
show_bulk_tools: false,
|
||||
show_duration: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -163,6 +189,9 @@ fn ban_card(base: &str, cfg: &BanConfig, csrf_token: &str) -> Markup {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(form_field(cfg.input_name, cfg.input_label, cfg.input_type, cfg.placeholder, true))
|
||||
@if cfg.show_duration {
|
||||
(duration_field())
|
||||
}
|
||||
(form_field("audit_log_reason", "Private reason (audit log, optional)", "text", "Why is this ban being applied?", false))
|
||||
(submit_btn("Ban", cfg.entity_name, false))
|
||||
}
|
||||
@@ -394,6 +423,24 @@ fn form_field(
|
||||
}
|
||||
}
|
||||
|
||||
fn duration_field() -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
label for="duration_hours" class="block text-sm font-medium text-neutral-700" {
|
||||
"Duration"
|
||||
}
|
||||
select id="duration_hours" name="duration_hours"
|
||||
class="block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
|
||||
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
|
||||
focus:ring-brand-primary" {
|
||||
@for &(value, label) in IP_BAN_DURATIONS {
|
||||
option value=(value) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn textarea_field(name: &str, label: &str, required: bool) -> Markup {
|
||||
html! {
|
||||
div class="space-y-1" {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{BlocklistEntry, BlocklistEntryPage},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{form::checkbox, page_container::page_header},
|
||||
components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{checkbox, csrf_input},
|
||||
page_container::page_header,
|
||||
table::{data_table, empty_state, table_cell, table_row},
|
||||
},
|
||||
layout::admin_layout,
|
||||
pages::blocklist_helpers::{
|
||||
BlocklistActionVariant, blocklist_action_card, blocklist_text_field,
|
||||
@@ -13,15 +19,21 @@ use crate::{
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
const PAGE_DESCRIPTION: &str = "A domain entry blocks that host and, when it matches subdomains, every host under it. \
|
||||
A pattern such as *shop*.example.com matches the one label left of a registrable domain, so it blocks \
|
||||
shop.example.com and my-shop-2.example.com but never example.com itself. Patterns are matched against the \
|
||||
ASCII form of a host.";
|
||||
|
||||
pub fn url_domain_bans_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
flash: Option<&crate::api::types::FlashMessage>,
|
||||
csrf_token: &str,
|
||||
entries: Option<&BlocklistEntryPage>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let content = html! {
|
||||
(page_header("URL Domain Blocklist", None))
|
||||
(page_header("URL Domain Blocklist", Some(PAGE_DESCRIPTION)))
|
||||
div class="grid gap-6 lg:grid-cols-2" {
|
||||
(ban_card(base, csrf_token))
|
||||
(check_card(base, csrf_token))
|
||||
@@ -29,6 +41,9 @@ pub fn url_domain_bans_page(
|
||||
div class="mt-6" {
|
||||
(unban_card(base, csrf_token))
|
||||
}
|
||||
div class="mt-6" {
|
||||
(entries_card(base, csrf_token, entries))
|
||||
}
|
||||
};
|
||||
admin_layout(
|
||||
config,
|
||||
@@ -43,15 +58,15 @@ pub fn url_domain_bans_page(
|
||||
fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=ban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Ban URL Domain",
|
||||
"Ban URL Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(checkbox("match_subdomains", "true", "Match subdomains (e.g. sub.example.com)", true, true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being applied?", false))
|
||||
},
|
||||
"Ban Domain",
|
||||
"Ban",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -59,13 +74,13 @@ fn ban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=check&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Check Domain Ban Status",
|
||||
"Test a Host or URL",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Host or URL", "shop-2.example.com or https://shop.example.com/x", true))
|
||||
},
|
||||
"Check Status",
|
||||
"Test",
|
||||
BlocklistActionVariant::Primary,
|
||||
)
|
||||
}
|
||||
@@ -73,14 +88,131 @@ fn check_card(base: &str, csrf_token: &str) -> Markup {
|
||||
fn unban_card(base: &str, csrf_token: &str) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
blocklist_action_card(
|
||||
"Remove Domain Ban",
|
||||
"Remove Domain or Pattern",
|
||||
&action_url,
|
||||
csrf_token,
|
||||
html! {
|
||||
(blocklist_text_field("domain", "Domain", "example.com", true))
|
||||
(blocklist_text_field("domain", "Domain or pattern", "example.com or *shop*.example.com", true))
|
||||
(blocklist_text_field("audit_log_reason", "Private reason (audit log, optional)", "Why is this ban being removed?", false))
|
||||
},
|
||||
"Unban Domain",
|
||||
"Unban",
|
||||
BlocklistActionVariant::Danger,
|
||||
)
|
||||
}
|
||||
|
||||
fn entries_card(base: &str, csrf_token: &str, entries: Option<&BlocklistEntryPage>) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
div class="mb-4 flex items-center justify-between gap-4" {
|
||||
h3 class="text-base font-medium text-neutral-900" { "Blocked Domains and Patterns" }
|
||||
a href={(base) "/url-domain-bans"} class="text-sm text-brand-primary hover:underline" { "Refresh" }
|
||||
}
|
||||
@match entries {
|
||||
None => {
|
||||
p class="text-sm text-red-700" { "Failed to load the blocklist entries" }
|
||||
}
|
||||
Some(page) => {
|
||||
(entries_table(base, csrf_token, page))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entries_table(base: &str, csrf_token: &str, page: &BlocklistEntryPage) -> Markup {
|
||||
if page.items.is_empty() {
|
||||
return empty_state("No domains or patterns are blocked");
|
||||
}
|
||||
let next_after = page.next_after.as_deref().filter(|_| page.has_more);
|
||||
html! {
|
||||
(data_table(
|
||||
&["Value", "Kind", "Subdomains", "Category", "Added", ""],
|
||||
html! {
|
||||
@for entry in &page.items {
|
||||
(entry_row(base, csrf_token, entry))
|
||||
}
|
||||
},
|
||||
))
|
||||
@if let Some(next) = next_after {
|
||||
div class="mt-4" {
|
||||
a href={(base) "/url-domain-bans?after=" (urlencoding::encode(next))}
|
||||
class="text-sm text-brand-primary hover:underline" {
|
||||
"Next page"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_row(base: &str, csrf_token: &str, entry: &BlocklistEntry) -> Markup {
|
||||
let action_url = format!("{base}/url-domain-bans?action=unban&_csrf={csrf_token}");
|
||||
let is_pattern = entry.value.contains('*');
|
||||
table_row(html! {
|
||||
(table_cell(false, html! { code class="break-all" { (entry.value) } }))
|
||||
(table_cell(false, html! {
|
||||
@if is_pattern {
|
||||
(badge("Pattern", BadgeVariant::Info))
|
||||
} @else {
|
||||
(badge("Domain", BadgeVariant::Default))
|
||||
}
|
||||
}))
|
||||
(table_cell(true, html! {
|
||||
@if entry.match_subdomains.unwrap_or(true) { "Yes" } @else { "No" }
|
||||
}))
|
||||
(table_cell(true, html! { (entry.category.as_deref().unwrap_or("")) }))
|
||||
(table_cell(true, html! { (entry.created_at.as_deref().unwrap_or("")) }))
|
||||
(table_cell(false, html! {
|
||||
form method="post" action=(action_url) {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="domain" value=(entry.value);
|
||||
button type="submit" class="text-sm font-medium text-red-600 hover:text-red-700" {
|
||||
"Remove"
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(value: &str, match_subdomains: bool) -> BlocklistEntry {
|
||||
BlocklistEntry {
|
||||
value: value.to_owned(),
|
||||
match_subdomains: Some(match_subdomains),
|
||||
category: Some("manual".to_owned()),
|
||||
created_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_lists_patterns_with_remove_forms() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: vec![
|
||||
entry("*shop*.example.com", false),
|
||||
entry("store.example.com", true),
|
||||
],
|
||||
has_more: true,
|
||||
next_after: Some("store.example.com".to_owned()),
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("*shop*.example.com"));
|
||||
assert!(markup.contains(">Pattern</span>"));
|
||||
assert!(markup.contains(">Domain</span>"));
|
||||
assert!(markup.contains(r#"name="domain" value="*shop*.example.com""#));
|
||||
assert!(markup.contains("/admin/url-domain-bans?action=unban&_csrf=token"));
|
||||
assert!(markup.contains("/admin/url-domain-bans?after=store.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_table_reports_an_empty_list() {
|
||||
let page = BlocklistEntryPage {
|
||||
items: Vec::new(),
|
||||
has_more: false,
|
||||
next_after: None,
|
||||
};
|
||||
let markup = entries_table("/admin", "token", &page).into_string();
|
||||
assert!(markup.contains("No domains or patterns are blocked"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ pub fn account_tab(
|
||||
(sessions_card(config, sessions))
|
||||
(quick_actions_card(base, user, csrf_token))
|
||||
(clear_fields_card(base, user, csrf_token))
|
||||
(user_status_card(base, user, csrf_token))
|
||||
(security_actions_card(base, user, csrf_token))
|
||||
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
|
||||
}
|
||||
@@ -204,19 +203,6 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
let is_bot = user.bot;
|
||||
let is_sys = user.system;
|
||||
html! {
|
||||
(card_with_header("User Status", html! {
|
||||
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
|
||||
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
|
||||
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
|
||||
html! {
|
||||
(card_with_header("Security Actions", html! {
|
||||
@@ -357,40 +343,3 @@ fn action_form(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn status_toggle(
|
||||
base: &str,
|
||||
uid: &str,
|
||||
action: &str,
|
||||
active: bool,
|
||||
kind: &str,
|
||||
csrf: &str,
|
||||
) -> Markup {
|
||||
let status_val = if active { "false" } else { "true" };
|
||||
let label = format!(
|
||||
"{} {} Status",
|
||||
if active { "Remove" } else { "Set" },
|
||||
capitalize(kind)
|
||||
);
|
||||
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
|
||||
html! {
|
||||
form method="post"
|
||||
action=(&action_url)
|
||||
hx-post=(&action_url)
|
||||
hx-target="#flash-container"
|
||||
hx-swap="none"
|
||||
hx-push-url="false" {
|
||||
(csrf_input(csrf))
|
||||
input type="hidden" name=(kind) value=(status_val);
|
||||
button type="submit" class=(BTN_CLS) { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().chain(c).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,11 +431,6 @@ fn acls_card(
|
||||
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
|
||||
}
|
||||
}
|
||||
@for item in &user.acls {
|
||||
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
|
||||
input type="hidden" name="acls[]" value=(item);
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save ACLs"))
|
||||
}))
|
||||
|
||||
@@ -859,6 +859,14 @@ fn deserialize_ban_check_response() {
|
||||
assert!(resp.banned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ban_check_response_with_expiry() {
|
||||
let json = r#"{"banned": true, "expires_at": "2026-10-04T12:00:00.000Z"}"#;
|
||||
let resp: types::BanCheckResult = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.banned);
|
||||
assert_eq!(resp.expires_at.as_deref(), Some("2026-10-04T12:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_codes_response() {
|
||||
let json = r#"{"codes": ["ABC-DEF", "GHI-JKL"]}"#;
|
||||
|
||||
@@ -195,7 +195,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
.expect("form has a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
|
||||
@@ -163,7 +163,7 @@ async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
.expect("form has a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"sharp": "catalog:",
|
||||
"stripe": "catalog:",
|
||||
"tempy": "catalog:",
|
||||
"tldts": "catalog:",
|
||||
"transliteration": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"uint8array-extras": "catalog:",
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('reconstructOriginalUrl', () => {
|
||||
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
|
||||
});
|
||||
|
||||
it('does not double the question mark when the query segment carries one', () => {
|
||||
it('does not double the question mark when the query segment has one', () => {
|
||||
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
|
||||
expect(decoded).toBe('https://example.com/x.png?a=1');
|
||||
expect(decoded).not.toContain('??');
|
||||
|
||||
@@ -43,13 +43,13 @@ describe('buildAPIServerOptions', () => {
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator header timeout from the environment into the server', async () => {
|
||||
test('passes the operator header timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
|
||||
expect(server.headersTimeout).toBe(45_000);
|
||||
expect(server.requestTimeout).toBe(120_000);
|
||||
});
|
||||
|
||||
test('carries the operator request timeout from the environment into the server', async () => {
|
||||
test('passes the operator request timeout from the environment into the server', async () => {
|
||||
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
|
||||
expect(server.headersTimeout).toBe(30_000);
|
||||
expect(server.requestTimeout).toBe(600_000);
|
||||
@@ -134,7 +134,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('carries the retired stripe price map from master config onto the api config', () => {
|
||||
it('copies the retired stripe price map from master config onto the api config', () => {
|
||||
const legacyPrices = {
|
||||
monthly_brl: ['price_retired_monthly_brl'],
|
||||
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
|
||||
@@ -145,7 +145,7 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the retired price map even when no live prices are configured', () => {
|
||||
it('copies the retired price map even when no live prices are configured', () => {
|
||||
const withoutPrices: MasterConfig = {
|
||||
...master,
|
||||
integrations: {
|
||||
|
||||
@@ -161,38 +161,43 @@ export class AdminRepository implements IAdminRepository {
|
||||
return false;
|
||||
}
|
||||
|
||||
async banIp(ip: string): Promise<void> {
|
||||
if (isIpBanExempt(ip)) {
|
||||
return;
|
||||
}
|
||||
const canonicalIp = canonicalizeBannedIpEntry(ip);
|
||||
await upsertOne(
|
||||
BannedIps.insert({
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'permanent',
|
||||
reason: 'platform_admin_enforcement',
|
||||
expires_at: null,
|
||||
created_at: new Date(),
|
||||
}),
|
||||
);
|
||||
async banIp(ip: string, ttlSeconds: number | null = null): Promise<void> {
|
||||
await this.writeIpBan(ip, 'platform_admin_enforcement', ttlSeconds);
|
||||
}
|
||||
|
||||
async banIpTemp(ip: string, ttlSeconds: number): Promise<void> {
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
||||
await this.writeIpBan(ip, 'abusive_api_access_patterns', ttlSeconds);
|
||||
}
|
||||
|
||||
private async writeIpBan(ip: string, reason: string, ttlSeconds: number | null): Promise<void> {
|
||||
if (ttlSeconds !== null && (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
|
||||
throw new RangeError('Temporary IP ban TTL must be a positive integer');
|
||||
}
|
||||
if (isIpBanExempt(ip)) {
|
||||
return;
|
||||
}
|
||||
const canonicalIp = canonicalizeBannedIpEntry(ip);
|
||||
const createdAt = new Date();
|
||||
if (ttlSeconds === null) {
|
||||
await upsertOne(
|
||||
BannedIps.insert({
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'permanent',
|
||||
reason,
|
||||
expires_at: null,
|
||||
created_at: createdAt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await upsertOne(
|
||||
BannedIps.insertWithTtl(
|
||||
{
|
||||
ip: canonicalIp,
|
||||
ban_kind: 'temporary_24h',
|
||||
reason: 'abusive_api_access_patterns',
|
||||
expires_at: new Date(Date.now() + ttlSeconds * 1000),
|
||||
created_at: new Date(),
|
||||
reason,
|
||||
expires_at: new Date(createdAt.getTime() + ttlSeconds * 1000),
|
||||
created_at: createdAt,
|
||||
},
|
||||
ttlSeconds,
|
||||
),
|
||||
@@ -228,13 +233,16 @@ export class AdminRepository implements IAdminRepository {
|
||||
expires_at?: Date | null;
|
||||
created_at?: Date | null;
|
||||
}>(LOAD_ALL_BANNED_IPS_QUERY.bind({}));
|
||||
return rows.map((row) => ({
|
||||
ip: row.ip,
|
||||
kind: parseBannedIpKind(row.ban_kind),
|
||||
reason: row.reason ?? null,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
}));
|
||||
const now = Date.now();
|
||||
return rows
|
||||
.filter((row) => !row.expires_at || row.expires_at.getTime() > now)
|
||||
.map((row) => ({
|
||||
ip: row.ip,
|
||||
kind: parseBannedIpKind(row.ban_kind),
|
||||
reason: row.reason ?? null,
|
||||
expiresAt: row.expires_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async isEmailBanned(email: string): Promise<boolean> {
|
||||
|
||||
@@ -43,7 +43,7 @@ export abstract class IAdminRepository {
|
||||
|
||||
abstract isIpBanned(ip: string): Promise<boolean>;
|
||||
|
||||
abstract banIp(ip: string): Promise<void>;
|
||||
abstract banIp(ip: string, ttlSeconds?: number | null): Promise<void>;
|
||||
|
||||
abstract banIpTemp(ip: string, ttlSeconds: number): Promise<void>;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
AdminApiKeyListResponse,
|
||||
CreateAdminApiKeyRequest,
|
||||
@@ -30,7 +30,7 @@ function toApiKeyResponse(key: AdminApiKeyView): ListAdminApiKeyResponseType {
|
||||
last_used_at: key.lastUsedAt?.toISOString() ?? null,
|
||||
expires_at: key.expiresAt?.toISOString() ?? null,
|
||||
created_by_user_id: String(key.createdById),
|
||||
acls: Array.from(key.acls),
|
||||
acls: filterKnownAdminACLs(key.acls),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
name: result.apiKey.name,
|
||||
created_at: result.apiKey.createdAt.toISOString(),
|
||||
expires_at: result.apiKey.expiresAt?.toISOString() ?? null,
|
||||
acls: Array.from(result.apiKey.acls),
|
||||
acls: filterKnownAdminACLs(result.apiKey.acls),
|
||||
};
|
||||
await recordAdminWrite(ctx, {
|
||||
targetType: 'admin_api_key',
|
||||
@@ -146,7 +146,7 @@ export function AdminApiKeyAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Renames an API key or replaces the access control lists (ACLs) it carries. The key may only carry permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
|
||||
'Renames an API key or replaces the access control lists (ACLs) it has. The key may only hold permissions the acting admin already holds. Omitted fields are left unchanged and the key material is never rotated or returned.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminApiKeyService = ctx.get('adminApiKeyService');
|
||||
|
||||
@@ -57,9 +57,9 @@ const BLOCKLIST_CATALOG = [
|
||||
{
|
||||
list_type: 'ip' as const,
|
||||
description:
|
||||
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively.',
|
||||
'IPv4/IPv6 addresses and CIDR ranges denied service. Applies to live connections and can be applied retroactively. An entry can carry an expiry, after which it stops applying and is removed.',
|
||||
value_field: 'ip',
|
||||
fields: [],
|
||||
fields: ['duration_hours'],
|
||||
scoped: false,
|
||||
supports_bulk_create: false,
|
||||
supports_bulk_delete: false,
|
||||
@@ -99,7 +99,8 @@ const BLOCKLIST_CATALOG = [
|
||||
},
|
||||
{
|
||||
list_type: 'url-domain' as const,
|
||||
description: 'Domains blocked from being linked, optionally covering every subdomain rooted at the domain.',
|
||||
description:
|
||||
'Domains blocked from being linked, optionally covering every subdomain rooted at the domain. A value whose leftmost label contains * is a pattern that matches that one label under a registrable domain.',
|
||||
value_field: 'domain',
|
||||
fields: ['match_subdomains', 'category', 'severity', 'source_url', 'notes'],
|
||||
scoped: false,
|
||||
@@ -232,7 +233,7 @@ async function checkBlocklistEntry(
|
||||
listType: AdminBlocklistListType,
|
||||
entryValue: string,
|
||||
scope: ProfileSubstringScope | undefined,
|
||||
): Promise<{banned: boolean}> {
|
||||
): Promise<{banned: boolean; expires_at?: string | null}> {
|
||||
switch (listType) {
|
||||
case 'ip':
|
||||
return bans.checkIpBan({ip: entryValue});
|
||||
@@ -269,7 +270,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'List every blocklist this instance maintains, the request field that carries an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
|
||||
'List every blocklist this instance maintains, the request field that holds an entry value, the extra fields its entries accept, and which of the bulk and update operations it supports.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
await recordAdminRead(ctx, {
|
||||
@@ -488,7 +489,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
security: ['adminApiKey'],
|
||||
tags: ['Admin'],
|
||||
description:
|
||||
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a URL can match a banned domain. The profile-substring blocklist requires a scope.',
|
||||
'Report whether a value is currently blocked by a blocklist. The value is percent-encoded in the path. An IP address can still match a broader stored CIDR entry, and a url-domain value can be a hostname or an http(s) URL that a stored domain or pattern covers. The profile-substring blocklist requires a scope.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
@@ -507,7 +508,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
banned: result.banned,
|
||||
},
|
||||
});
|
||||
return ctx.json(result);
|
||||
return ctx.json({banned: result.banned, expires_at: result.expires_at ?? null});
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
@@ -524,7 +525,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
tags: ['Admin'],
|
||||
requestSchema: AdminBlocklistEntryUpdateRequest,
|
||||
description:
|
||||
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries carry fields accept this operation, reported as supports_update by GET /admin/blocklists.',
|
||||
'Rewrite the stored fields of a blocklist entry without removing and re-adding it. The stored metadata is replaced by the supplied fields, so fields left out fall back to their defaults. Only blocklists whose entries have fields accept this operation, reported as supports_update by GET /admin/blocklists.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -49,7 +49,7 @@ export function MessageAdminController(app: HonoApp) {
|
||||
operationId: 'search_admin_messages',
|
||||
summary: 'Search messages',
|
||||
description:
|
||||
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message carrying that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
|
||||
'Searches the messages of a channel by content, or resolves a single message by its ID or by one of its attachments. Passing message_id returns that message with the messages surrounding it; passing attachment_id together with filename returns the message with that attachment with its surrounding context. Requires MESSAGE_LOOKUP permission.',
|
||||
responseSchema: AdminMessageSearchResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
AdminUserAclsRequest,
|
||||
AdminUserBanNoteRequest,
|
||||
AdminUserBanRequest,
|
||||
AdminUserBotStatusRequest,
|
||||
AdminUserChangeLogQuery,
|
||||
AdminUserClearFieldsRequest,
|
||||
AdminUserDeletionCancelRequest,
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
AdminUserPremiumFlagsUpdateRequest,
|
||||
AdminUserRelationshipCategoryQuery,
|
||||
AdminUserRelationshipParam,
|
||||
AdminUserSystemStatusRequest,
|
||||
AdminUsersMeResponse,
|
||||
AdminUserTraitsRequest,
|
||||
AdminUserUnbanRequest,
|
||||
@@ -598,70 +596,6 @@ export function UserAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/bot-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserBotStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_bot_status',
|
||||
summary: 'Set user bot status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserBotStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.put(
|
||||
'/admin/users/:user_id/system-status',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
|
||||
Validator('param', UserIdParam),
|
||||
Validator('json', AdminUserSystemStatusRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_user_system_status',
|
||||
summary: 'Set user system status',
|
||||
responseSchema: UserMutationResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
description:
|
||||
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const adminUserAcls = ctx.get('adminUserAcls');
|
||||
const {user_id: userId} = ctx.req.valid('param');
|
||||
return ctx.json(
|
||||
await adminService.userService.profileService.setUserSystemStatus(
|
||||
{user_id: userId, ...ctx.req.valid('json')},
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
adminUserAcls,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
app.patch(
|
||||
'/admin/users/:user_id/username',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {getIpAddressReverse, lookupGeoip} from '@app/api/utils/IpUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {AdminACLs, filterKnownAdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -65,7 +65,7 @@ export async function mapUserToAdminResponse(
|
||||
deletion_audit_log_reason: canViewAuditLog ? user.deletionAuditLogReason : null,
|
||||
deletion_scheduled_by: user.deletionScheduledBy?.toString() ?? null,
|
||||
deletion_scheduled_at: user.deletionScheduledAt?.toISOString() ?? null,
|
||||
acls: user.acls ? Array.from(user.acls) : [],
|
||||
acls: user.acls ? filterKnownAdminACLs(user.acls) : [],
|
||||
traits: Array.from(user.traits).sort(),
|
||||
has_totp: user.totpSecret !== null,
|
||||
authenticator_types: user.authenticatorTypes ? Array.from(user.authenticatorTypes) : [],
|
||||
|
||||
@@ -24,6 +24,7 @@ import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {canonicalizeStoredPhrase} from '@app/api/utils/PhraseBlocklistNormalization';
|
||||
import {parseUrlDomainEntry} from '@app/api/utils/UrlHostRules';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -108,6 +109,16 @@ function normalizeAvatarHashes(hashes: Array<string>): Array<string> {
|
||||
return Array.from(new Set(hashes.map((hash) => stripAvatarAnimationPrefix(hash.toLowerCase()))));
|
||||
}
|
||||
|
||||
function hostFromUrlOrHostname(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
try {
|
||||
return new URL(trimmed).hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function withReasonMetadata(entries: Array<[string, string]>, reason: string | undefined): Map<string, string> {
|
||||
if (!reason) {
|
||||
return new Map(entries);
|
||||
@@ -122,6 +133,7 @@ export class AdminBanManagementService {
|
||||
async banIp(
|
||||
data: {
|
||||
ip: string;
|
||||
duration_hours?: number;
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
@@ -142,15 +154,24 @@ export class AdminBanManagementService {
|
||||
message: 'This IP address is on the instance exemption list',
|
||||
});
|
||||
}
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
const durationHours = data.duration_hours ?? 0;
|
||||
const metadata = new Map([['ip', data.ip]]);
|
||||
if (durationHours > 0) {
|
||||
const ttlSeconds = durationHours * 3600;
|
||||
await adminRepository.banIp(data.ip, ttlSeconds);
|
||||
metadata.set('duration_hours', durationHours.toString());
|
||||
metadata.set('expires_at', new Date(Date.now() + ttlSeconds * 1000).toISOString());
|
||||
} else {
|
||||
await adminRepository.banIp(data.ip);
|
||||
}
|
||||
await ipBanCache.refresh();
|
||||
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
await this.createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
action: 'ban_ip',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,9 +198,10 @@ export class AdminBanManagementService {
|
||||
|
||||
async checkIpBan(data: {ip: string}): Promise<{
|
||||
banned: boolean;
|
||||
expires_at: string | null;
|
||||
}> {
|
||||
const banned = ipBanCache.isBanned(data.ip);
|
||||
return {banned};
|
||||
const match = ipBanCache.getMatch(data.ip);
|
||||
return {banned: match !== null, expires_at: toIsoString(match?.expiresAt)};
|
||||
}
|
||||
|
||||
async banEmail(
|
||||
@@ -355,7 +377,9 @@ export class AdminBanManagementService {
|
||||
) {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const d = data.domain.toLowerCase();
|
||||
const entry = parseUrlDomainEntry(data.domain);
|
||||
if (!entry.ok) throw InputValidationError.create('domain', entry.message);
|
||||
const d = entry.value;
|
||||
const matchSubs = data.match_subdomains ?? true;
|
||||
await adminRepository.banUrlDomain({
|
||||
domain: d,
|
||||
@@ -367,7 +391,7 @@ export class AdminBanManagementService {
|
||||
added_by: adminUserId,
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
urlBlocklistCache.addDomain(d);
|
||||
urlBlocklistCache.addDomain(d, matchSubs);
|
||||
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
|
||||
await this.createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
@@ -377,6 +401,7 @@ export class AdminBanManagementService {
|
||||
metadata: new Map([
|
||||
['domain', d],
|
||||
['match_subdomains', String(matchSubs)],
|
||||
['pattern', String(entry.pattern)],
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -390,7 +415,8 @@ export class AdminBanManagementService {
|
||||
) {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const d = data.domain.toLowerCase();
|
||||
const entry = parseUrlDomainEntry(data.domain);
|
||||
const d = entry.ok ? entry.value : data.domain.trim().toLowerCase();
|
||||
await adminRepository.unbanUrlDomain(d);
|
||||
urlBlocklistCache.removeDomain(d);
|
||||
await cacheService.publish(BANNED_URL_DOMAINS_REFRESH_CHANNEL, 'refresh');
|
||||
@@ -406,7 +432,8 @@ export class AdminBanManagementService {
|
||||
async checkUrlDomainBan(data: {domain: string}): Promise<{
|
||||
banned: boolean;
|
||||
}> {
|
||||
return {banned: urlBlocklistCache.isHostnameBanned(data.domain)};
|
||||
const host = hostFromUrlOrHostname(data.domain);
|
||||
return {banned: host != null && urlBlocklistCache.isHostnameBanned(host)};
|
||||
}
|
||||
|
||||
async banFileSha(
|
||||
|
||||
@@ -11,9 +11,6 @@ import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorS
|
||||
import type {EntityAssetService, PreparedAssetUpload} from '@app/api/infrastructure/EntityAssetService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
@@ -21,8 +18,6 @@ import type {
|
||||
ChangeEmailRequest,
|
||||
ChangeUsernameRequest,
|
||||
ClearUserFieldsRequest,
|
||||
SetUserBotStatusRequest,
|
||||
SetUserSystemStatusRequest,
|
||||
VerifyUserEmailRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import {types} from 'cassandra-driver';
|
||||
@@ -105,75 +100,6 @@ export class AdminUserProfileService {
|
||||
};
|
||||
}
|
||||
|
||||
async setUserBotStatus(
|
||||
data: SetUserBotStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.bot && user.acls.size > 0) {
|
||||
throw new AccessDeniedError();
|
||||
}
|
||||
const updates: Record<string, boolean> = {bot: data.bot};
|
||||
if (!data.bot) {
|
||||
updates['system'] = false;
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_bot_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['bot', data.bot.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async setUserSystemStatus(
|
||||
data: SetUserSystemStatusRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
if (data.system && !user.isBot) {
|
||||
throw InputValidationError.fromCode(
|
||||
'system',
|
||||
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
|
||||
);
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'set_system_status',
|
||||
auditLogReason,
|
||||
metadata: new Map([['system', data.system.toString()]]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyUserEmail(
|
||||
data: VerifyUserEmailRequest,
|
||||
adminUserId: UserID,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
code: string;
|
||||
errors?: Array<{path: string; message: string}>;
|
||||
}
|
||||
|
||||
interface EntryPage {
|
||||
items: Array<{value: string; match_subdomains: boolean | null}>;
|
||||
}
|
||||
|
||||
describe('Admin url-domain blocklist patterns', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
'ban:url_domain:add',
|
||||
'ban:url_domain:check',
|
||||
'ban:url_domain:remove',
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function add(domain: string, matchSubdomains?: boolean): Promise<void> {
|
||||
await createBuilder(harness, admin.token)
|
||||
.post('/admin/blocklists/url-domain/entries')
|
||||
.body(matchSubdomains === undefined ? {domain} : {domain, match_subdomains: matchSubdomains})
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function check(value: string): Promise<boolean> {
|
||||
const json = await createBuilder<{banned: boolean}>(harness, admin.token)
|
||||
.get(`/admin/blocklists/url-domain/entries/${encodeURIComponent(value)}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
return json.banned;
|
||||
}
|
||||
|
||||
async function list(): Promise<EntryPage['items']> {
|
||||
const json = await createBuilder<EntryPage>(harness, admin.token)
|
||||
.get('/admin/blocklists/url-domain/entries?limit=200')
|
||||
.expect(200)
|
||||
.execute();
|
||||
return json.items;
|
||||
}
|
||||
|
||||
it('stores a canonical pattern and reports the hosts it covers', async () => {
|
||||
await add('**Shop**.OnRender.com.');
|
||||
expect(await list()).toMatchObject([{value: '*shop*.onrender.com', match_subdomains: true}]);
|
||||
expect(await check('shop-2.onrender.com')).toBe(true);
|
||||
expect(await check('https://www.myshop.onrender.com/checkout')).toBe(true);
|
||||
expect(await check('onrender.com')).toBe(false);
|
||||
expect(await check('docs.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('records whether the entry is a pattern in the audit log', async () => {
|
||||
await add('*shop*.onrender.com', false);
|
||||
await add('shop.example.com');
|
||||
const logs = (await getAdminRepository().listAllAuditLogsPaginated(1000)).filter(
|
||||
(log) => log.action === 'ban_url_domain',
|
||||
);
|
||||
const metadata = logs.map((log) => Object.fromEntries(log.metadata));
|
||||
expect(metadata).toEqual(
|
||||
expect.arrayContaining([
|
||||
{domain: '*shop*.onrender.com', match_subdomains: 'false', pattern: 'true'},
|
||||
{domain: 'shop.example.com', match_subdomains: 'true', pattern: 'false'},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects patterns that are too broad or malformed', async () => {
|
||||
for (const domain of ['*', '*.com', '*shop*.co.uk', '*.onrender.com', '*ab*.onrender.com', 'shop.*.example.com']) {
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
|
||||
.post('/admin/blocklists/url-domain/entries')
|
||||
.body({domain})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(json.errors?.[0]?.path, domain).toBe('domain');
|
||||
}
|
||||
expect(await list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('validates the value on update', async () => {
|
||||
const json = await createBuilder<ValidationErrorResponse>(harness, admin.token)
|
||||
.patch(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*.com')}`)
|
||||
.body({})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(json.errors?.[0]?.path).toBe('domain');
|
||||
});
|
||||
|
||||
it('stores internationalized domains in ASCII form', async () => {
|
||||
await add('Bücher.Example.');
|
||||
expect((await list()).map((entry) => entry.value)).toEqual(['xn--bcher-kva.example']);
|
||||
expect(await check('www.bücher.example')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an add for a domain that is already blocked', async () => {
|
||||
await add('shop.example.com');
|
||||
await add('shop.example.com', false);
|
||||
expect(await list()).toMatchObject([{value: 'shop.example.com', match_subdomains: false}]);
|
||||
});
|
||||
|
||||
it('removes a pattern through any spelling that canonicalizes to it', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
await createBuilder(harness, admin.token)
|
||||
.delete(`/admin/blocklists/url-domain/entries/${encodeURIComponent('*SHOP**.onrender.com')}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
expect(await list()).toEqual([]);
|
||||
expect(await check('shop.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks messages whose masked links or autolinks point at a covered host', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, member.token, 'Links');
|
||||
const channel = await createChannel(harness, member.token, guild.id, 'general');
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
for (const content of [
|
||||
'[open the store](https://shop-2.onrender.com)',
|
||||
'<https://[email protected]:8443/x>',
|
||||
'https://SHOP.onrender.com./',
|
||||
]) {
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content})
|
||||
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
}
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: '[docs](https://docs.onrender.com) and https://onrender.com'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('blocks rich embeds that link to a covered host', async () => {
|
||||
await add('*shop*.onrender.com');
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, member.token, 'Embeds');
|
||||
const channel = await createChannel(harness, member.token, guild.id, 'general');
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({embeds: [{title: 'Store', url: 'https://shop.onrender.com/'}]})
|
||||
.expect(403, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,6 @@ const adminEndpoints: Array<AdminEndpointCase> = [
|
||||
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
|
||||
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
|
||||
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
|
||||
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
|
||||
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
|
||||
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BlocklistEntryPage {
|
||||
items: Array<{value: string; reason: string | null; expires_at: string | null; created_at: string | null}>;
|
||||
}
|
||||
|
||||
interface BlocklistCheck {
|
||||
banned: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
describe('Admin IP bans with an expiry', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let admin: TestAccount;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.BAN_IP_ADD,
|
||||
AdminACLs.BAN_IP_CHECK,
|
||||
AdminACLs.BAN_IP_REMOVE,
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
async function addIpBan(body: Record<string, unknown>, status: number = HTTP_STATUS.NO_CONTENT): Promise<void> {
|
||||
await createBuilder(harness, admin.token).post('/admin/blocklists/ip/entries').body(body).expect(status).execute();
|
||||
}
|
||||
|
||||
async function listIpBans(): Promise<BlocklistEntryPage['items']> {
|
||||
const page = await createBuilder<BlocklistEntryPage>(harness, admin.token)
|
||||
.get('/admin/blocklists/ip/entries')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return page.items;
|
||||
}
|
||||
|
||||
async function checkIpBan(ip: string): Promise<BlocklistCheck> {
|
||||
return createBuilder<BlocklistCheck>(harness, admin.token)
|
||||
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(ip)}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function banIpAuditLogs(): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
|
||||
return logs.filter((log) => log.action === 'ban_ip');
|
||||
}
|
||||
|
||||
it('stores an expiring ban that the listing and the check both report', async () => {
|
||||
const before = Date.now();
|
||||
await addIpBan({ip: '198.51.100.7', duration_hours: 24});
|
||||
|
||||
const [entry] = await listIpBans();
|
||||
expect(entry?.value).toBe('198.51.100.7');
|
||||
expect(entry?.reason).toBe('platform_admin_enforcement');
|
||||
const expiresAt = Date.parse(entry?.expires_at ?? '');
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(before + 24 * HOUR_MS);
|
||||
expect(expiresAt).toBeLessThanOrEqual(Date.now() + 24 * HOUR_MS);
|
||||
|
||||
const check = await checkIpBan('198.51.100.7');
|
||||
expect(check.banned).toBe(true);
|
||||
expect(Date.parse(check.expires_at ?? '')).toBe(expiresAt);
|
||||
});
|
||||
|
||||
it('records the duration and expiry in the audit log', async () => {
|
||||
await addIpBan({ip: '198.51.100.8', duration_hours: 168});
|
||||
|
||||
const [log] = await banIpAuditLogs();
|
||||
const metadata = Object.fromEntries(log!.metadata);
|
||||
expect(metadata['ip']).toBe('198.51.100.8');
|
||||
expect(metadata['duration_hours']).toBe('168');
|
||||
expect(Date.parse(metadata['expires_at'] ?? '')).toBeGreaterThan(Date.now() + 167 * HOUR_MS);
|
||||
});
|
||||
|
||||
it('keeps a ban permanent when no duration is given', async () => {
|
||||
await addIpBan({ip: '198.51.100.9'});
|
||||
await addIpBan({ip: '198.51.100.10', duration_hours: 0});
|
||||
|
||||
const entries = await listIpBans();
|
||||
expect(entries.map((entry) => entry.expires_at)).toEqual([null, null]);
|
||||
expect(await checkIpBan('198.51.100.9')).toEqual({banned: true, expires_at: null});
|
||||
const [log] = await banIpAuditLogs();
|
||||
expect(log!.metadata.has('duration_hours')).toBe(false);
|
||||
});
|
||||
|
||||
it('replaces a permanent ban with an expiring one when the address is banned again', async () => {
|
||||
await addIpBan({ip: '198.51.100.11'});
|
||||
await addIpBan({ip: '198.51.100.11', duration_hours: 24});
|
||||
|
||||
const [entry] = await listIpBans();
|
||||
expect(entry?.expires_at).not.toBeNull();
|
||||
const check = await checkIpBan('198.51.100.11');
|
||||
expect(check.banned).toBe(true);
|
||||
expect(check.expires_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a duration beyond one year', async () => {
|
||||
await addIpBan({ip: '198.51.100.12', duration_hours: 8761}, HTTP_STATUS.BAD_REQUEST);
|
||||
await addIpBan({ip: '198.51.100.12', duration_hours: 1.5}, HTTP_STATUS.BAD_REQUEST);
|
||||
|
||||
expect(await listIpBans()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a check with no match as not banned with no expiry', async () => {
|
||||
expect(await checkIpBan('198.51.100.13')).toEqual({banned: false, expires_at: null});
|
||||
});
|
||||
|
||||
it('stops applying an expiring ban once its expiry has passed', async () => {
|
||||
await new AdminRepository().banIp('198.51.100.14', 1);
|
||||
await ipBanCache.refresh();
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
|
||||
await ipBanCache.refresh();
|
||||
expect(ipBanCache.isBanned('198.51.100.14')).toBe(false);
|
||||
expect(await listIpBans()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ interface AdminUserLookupResponse {
|
||||
}>;
|
||||
}
|
||||
|
||||
const RETIRED_ACL = 'retired:acl';
|
||||
|
||||
describe('Admin set user ACLs validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
@@ -69,4 +71,43 @@ describe('Admin set user ACLs validation', () => {
|
||||
.execute();
|
||||
expect(lookup.users[0]!.acls).toEqual([AdminACLs.USER_LOOKUP]);
|
||||
});
|
||||
test('lists only registry values when a retired ACL is stored', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.USER_LOOKUP,
|
||||
]);
|
||||
const target = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.USER_LOOKUP, RETIRED_ACL]);
|
||||
const lookup = await createBuilder<AdminUserLookupResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${target.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(lookup.users[0]!.acls).toEqual([AdminACLs.USER_LOOKUP]);
|
||||
});
|
||||
test('saves ACLs for an account that holds a retired ACL', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.ACL_SET_USER,
|
||||
AdminACLs.USER_LOOKUP,
|
||||
AdminACLs.USER_VIEW_EMAIL,
|
||||
]);
|
||||
const target = await setUserACLs(harness, await createTestAccount(harness), [AdminACLs.USER_LOOKUP, RETIRED_ACL]);
|
||||
const result = await createBuilder<AdminUserMutationResponse>(harness, `${admin.token}`)
|
||||
.put(`/admin/users/${target.userId}/acls`)
|
||||
.body({acls: [AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.user.acls.sort()).toEqual([AdminACLs.USER_LOOKUP, AdminACLs.USER_VIEW_EMAIL].sort());
|
||||
});
|
||||
test('returns the current admin when every registry ACL and a retired ACL are stored', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
...Object.values(AdminACLs),
|
||||
RETIRED_ACL,
|
||||
]);
|
||||
const me = await createBuilder<AdminUserMutationResponse>(harness, `${admin.token}`)
|
||||
.get('/admin/users/@me')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(me.user.acls).toHaveLength(Object.values(AdminACLs).length);
|
||||
expect(me.user.acls).not.toContain(RETIRED_ACL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,8 +20,6 @@ const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: str
|
||||
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
|
||||
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
|
||||
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
|
||||
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
|
||||
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
|
||||
];
|
||||
|
||||
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('VoiceAdminController', () => {
|
||||
expect(deletedRegion.success).toBe(true);
|
||||
expect(await voiceRepository.getRegion(fixture.regionId)).toBeNull();
|
||||
});
|
||||
test('rejects voice server creation when no region carries the identifier', async () => {
|
||||
test('rejects voice server creation when no region has the identifier', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_SERVER_CREATE]);
|
||||
const regionId = 'voice-region-missing-for-server-create';
|
||||
const serverId = 'voice-server-missing-region';
|
||||
|
||||
@@ -185,39 +185,6 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/bot-status',
|
||||
async prepare({harness}) {
|
||||
const target = await createTestAccount(harness);
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
|
||||
expected: {
|
||||
action: 'set_bot_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {bot: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PUT',
|
||||
route: '/admin/users/:user_id/system-status',
|
||||
async prepare(context) {
|
||||
const target = await createTestAccount(context.harness);
|
||||
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
|
||||
return {
|
||||
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
|
||||
expected: {
|
||||
action: 'set_system_status',
|
||||
targetType: 'user',
|
||||
targetId: target.userId,
|
||||
metadata: {system: 'true'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/users/:user_id/username',
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -280,7 +280,7 @@ export async function resetPassword(
|
||||
await ctx.services.botMfaMirror.syncAuthenticatorTypesForOwner(updatedUser);
|
||||
}
|
||||
await AuthSession.terminateAllUserSessions(ctx, user.id);
|
||||
await users.deletePasswordResetToken(data.token);
|
||||
await users.deleteAllPasswordResetTokens(user.id);
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, updatedUser, webauthnIsSecondFactor);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ interface DispatchAuthSessionChangeParams {
|
||||
userId: UserID;
|
||||
oldAuthSessionIdHash: string;
|
||||
newAuthSessionIdHash: string;
|
||||
newToken: string;
|
||||
}
|
||||
|
||||
interface ReplaceCurrentAuthSessionParams {
|
||||
@@ -192,13 +191,12 @@ export async function replaceCurrentAuthSession(
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
|
||||
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, [currentAuthSession]);
|
||||
await dispatchAuthSessionChange(ctx, {
|
||||
userId: user.id,
|
||||
oldAuthSessionIdHash,
|
||||
newAuthSessionIdHash,
|
||||
newToken,
|
||||
});
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, [currentAuthSession]);
|
||||
return {
|
||||
token: newToken,
|
||||
authSession: newAuthSession,
|
||||
@@ -231,14 +229,13 @@ function encodeSessionIdHash(sessionIdHash: Uint8Array): string {
|
||||
|
||||
async function dispatchAuthSessionChange(ctx: ApiContext, params: DispatchAuthSessionChangeParams): Promise<void> {
|
||||
const {gateway} = ctx.services;
|
||||
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash, newToken} = params;
|
||||
const {userId, oldAuthSessionIdHash, newAuthSessionIdHash} = params;
|
||||
await gateway.dispatchPresence({
|
||||
userId,
|
||||
event: 'AUTH_SESSION_CHANGE',
|
||||
data: {
|
||||
old_auth_session_id_hash: oldAuthSessionIdHash,
|
||||
new_auth_session_id_hash: newAuthSessionIdHash,
|
||||
new_token: newToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ async function verifySudoMode(
|
||||
const apiContext = ctx.get('apiContext');
|
||||
const credentials = await apiContext.services.users.listWebAuthnCredentials(user.id);
|
||||
const hasPasskeyCredentials = credentials.length > 0;
|
||||
const hasMfa = userHasMfa(user);
|
||||
const hasSudoCapability = userHasSudoCapability(user, hasPasskeyCredentials);
|
||||
const hasUsableMfa = userHasMfa(user) && hasSudoCapability;
|
||||
const issueSudoToken = options.issueSudoToken ?? hasSudoCapability;
|
||||
if (hasSudoCapability && ctx.get('sudoModeValid')) {
|
||||
const sudoToken = ctx.get('sudoModeToken') ?? ctx.req.header(SUDO_MODE_HEADER) ?? undefined;
|
||||
@@ -82,10 +82,10 @@ async function verifySudoMode(
|
||||
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
||||
return {verified: true, sudoToken, method: 'mfa'};
|
||||
}
|
||||
if (hasNoVerifiableCredential(user, hasMfa, hasPasskeyCredentials)) {
|
||||
if (hasNoVerifiableCredential(user, hasUsableMfa, hasPasskeyCredentials)) {
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
if (body.password && !hasMfa) {
|
||||
if (body.password && !hasUsableMfa) {
|
||||
if (!user.passwordHash) {
|
||||
throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_NOT_SET);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearTestEmails,
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
loginAccount,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {generateUniquePassword, HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
interface AuthSessionRow {
|
||||
id_hash: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
interface ReplacementResponse {
|
||||
token?: string;
|
||||
auth_session_id_hash?: string;
|
||||
}
|
||||
|
||||
type GatewayCall = {kind: 'terminate'; hashes: Array<string>} | {kind: 'session_change'; data: Record<string, unknown>};
|
||||
|
||||
async function getCurrentAuthSessionHash(harness: ApiTestHarness, token: string): Promise<string> {
|
||||
const sessions = await createBuilder<Array<AuthSessionRow>>(harness, token).get('/auth/sessions').execute();
|
||||
const current = sessions.find((session) => session.current);
|
||||
if (!current) {
|
||||
throw new Error('Current auth session not found');
|
||||
}
|
||||
return current.id_hash;
|
||||
}
|
||||
|
||||
async function completePasswordChange(
|
||||
harness: ApiTestHarness,
|
||||
account: TestAccount,
|
||||
newPassword: string,
|
||||
): Promise<ReplacementResponse> {
|
||||
const start = await createBuilder<{ticket: string}>(harness, account.token)
|
||||
.post('/users/@me/password-change/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const record = findLastTestEmail(emails, 'password_change_verification');
|
||||
if (!record) {
|
||||
throw new Error('Password change verification email not found');
|
||||
}
|
||||
const verify = await createBuilder<{verification_proof: string}>(harness, account.token)
|
||||
.post('/users/@me/password-change/verify')
|
||||
.body({ticket: start.ticket, code: record.metadata.code})
|
||||
.execute();
|
||||
return createBuilder<ReplacementResponse>(harness, account.token)
|
||||
.post('/users/@me/password-change/complete')
|
||||
.body({ticket: start.ticket, verification_proof: verify.verification_proof, new_password: newPassword})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Auth session replacement on password change', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let calls: Array<GatewayCall>;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
await clearTestEmails(harness);
|
||||
calls = [];
|
||||
vi.spyOn(NoopGatewayService.prototype, 'terminateSession').mockImplementation(async (params) => {
|
||||
calls.push({kind: 'terminate', hashes: [...params.sessionIdHashes]});
|
||||
});
|
||||
vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence').mockImplementation(async (params) => {
|
||||
if (params.event === 'AUTH_SESSION_CHANGE') {
|
||||
calls.push({kind: 'session_change', data: params.data as Record<string, unknown>});
|
||||
}
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
function expectReplacedSessionClosedBeforeEvent(oldHash: string, newHash: string | undefined): void {
|
||||
const eventIndex = calls.findIndex((call) => call.kind === 'session_change');
|
||||
const terminateIndex = calls.findIndex((call) => call.kind === 'terminate' && call.hashes.includes(oldHash));
|
||||
expect(terminateIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(eventIndex).toBeGreaterThan(terminateIndex);
|
||||
const event = calls[eventIndex] as Extract<GatewayCall, {kind: 'session_change'}>;
|
||||
expect(event.data).toEqual({old_auth_session_id_hash: oldHash, new_auth_session_id_hash: newHash});
|
||||
}
|
||||
|
||||
it('returns the replacement token from PATCH /users/@me', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const otherSession = await loginAccount(harness, account);
|
||||
const oldHash = await getCurrentAuthSessionHash(harness, account.token);
|
||||
calls = [];
|
||||
const response = await createBuilder<ReplacementResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({password: account.password, new_password: generateUniquePassword()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(typeof response.token).toBe('string');
|
||||
expect(typeof response.auth_session_id_hash).toBe('string');
|
||||
expectReplacedSessionClosedBeforeEvent(oldHash, response.auth_session_id_hash);
|
||||
await createBuilder(harness, account.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
await createBuilder(harness, otherSession.token).get('/users/@me').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
await createBuilder(harness, response.token!).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
expect(await getCurrentAuthSessionHash(harness, response.token!)).toBe(response.auth_session_id_hash);
|
||||
});
|
||||
|
||||
it('leaves the PATCH response without a token when the password is unchanged', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const response = await createBuilder<ReplacementResponse>(harness, account.token)
|
||||
.patch('/users/@me')
|
||||
.body({global_name: 'Renamed'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.token).toBeUndefined();
|
||||
expect(response.auth_session_id_hash).toBeUndefined();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('closes the replaced session before announcing the change on password-change/complete', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const oldHash = await getCurrentAuthSessionHash(harness, account.token);
|
||||
calls = [];
|
||||
const response = await completePasswordChange(harness, account, generateUniquePassword());
|
||||
expectReplacedSessionClosedBeforeEvent(oldHash, response.auth_session_id_hash);
|
||||
await createBuilder(harness, response.token!).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createAuthHarness, createTestAccount, createTotpSecret, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface ValidationErrorBody {
|
||||
code: string;
|
||||
errors: Array<{path: string; code: string}>;
|
||||
}
|
||||
|
||||
function wrongCodeFor(code: string): string {
|
||||
return ((Number(code) + 500_000) % 1_000_000).toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
describe('Enabling TOTP checks the setup code before sudo', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('rejects a wrong setup code on the code field without asking for a password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: wrongCodeFor(totpCodeNow(secret))})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
expect(error.errors[0]?.path).toBe('code');
|
||||
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_CODE);
|
||||
});
|
||||
it('asks for sudo for a valid setup code, then enables with the password', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
const code = totpCodeNow(secret);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
const enabled = await createBuilder<{backup_codes: Array<{code: string}>}>(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code, password: account.password})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(enabled.backup_codes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,45 @@ describe('Password reset flow', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
it('invalidates every outstanding reset token once a reset completes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/forgot')
|
||||
.body({email: account.email})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
const emails = await listTestEmails(harness, {recipient: account.email});
|
||||
const tokens = [
|
||||
...new Set(
|
||||
emails
|
||||
.filter((email) => email.type === 'password_reset')
|
||||
.map((email) => email.metadata?.token)
|
||||
.filter((token): token is string => typeof token === 'string'),
|
||||
),
|
||||
];
|
||||
expect(tokens).toHaveLength(2);
|
||||
const [earlierToken, laterToken] = tokens;
|
||||
const newPassword = generateUniquePassword();
|
||||
const resetResp = await createBuilderWithoutAuth<LoginSuccessResponse>(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: laterToken, password: newPassword})
|
||||
.execute();
|
||||
expect(resetResp.token.length).toBeGreaterThan(0);
|
||||
const check = await createBuilderWithoutAuth<{valid: boolean}>(harness)
|
||||
.get(`/auth/reset/${earlierToken}`)
|
||||
.execute();
|
||||
expect(check.valid).toBe(false);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/reset')
|
||||
.body({token: earlierToken, password: generateUniquePassword()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
await createBuilder(harness, resetResp.token).get('/users/@me').expect(HTTP_STATUS.OK).execute();
|
||||
const login = await loginUser(harness, {email: account.email, password: newPassword});
|
||||
expect('token' in login && login.token.length > 0).toBe(true);
|
||||
});
|
||||
it('rejects invalid reset token', async () => {
|
||||
await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createTotpSecret,
|
||||
generateTotpCode,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setWebAuthnTwoFactor} from '@app/api/auth/tests/WebAuthnTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface PrivateUserResponse {
|
||||
mfa_enabled: boolean;
|
||||
authenticator_types: Array<number>;
|
||||
}
|
||||
|
||||
interface SudoModeRequiredResponse {
|
||||
code: string;
|
||||
has_mfa?: boolean;
|
||||
}
|
||||
|
||||
async function setAuthenticatorTypes(account: TestAccount, types: Array<number>): Promise<void> {
|
||||
const users = getUserRepository();
|
||||
const user = (await users.findUnique(createUserID(BigInt(account.userId))))!;
|
||||
await users.patchUpsert(user.id, {authenticator_types: new Set<number>(types)}, user.toRow());
|
||||
}
|
||||
|
||||
describe('Sudo mode for accounts whose authenticator types list no usable factor', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('accepts the password and lets the account turn passkey two-factor off when no passkey remains', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setAuthenticatorTypes(account, [UserAuthenticatorTypes.WEBAUTHN]);
|
||||
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
expect(challenge.has_mfa).toBe(false);
|
||||
const disabled = await setWebAuthnTwoFactor(harness, account.token, false, {password: account.password});
|
||||
expect(disabled.user.authenticator_types).toEqual([]);
|
||||
const me = await createBuilder<PrivateUserResponse>(harness, account.token).get('/users/@me').execute();
|
||||
expect(me.mfa_enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the password when the TOTP type is listed without a stored secret', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setAuthenticatorTypes(account, [UserAuthenticatorTypes.TOTP]);
|
||||
await createBuilder(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: 'wrong-password'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
await setWebAuthnTwoFactor(harness, account.token, false, {password: account.password});
|
||||
});
|
||||
|
||||
it('still refuses the password from an account with TOTP enrolled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/mfa/totp/enable')
|
||||
.body({secret, code: generateTotpCode(secret), password: account.password})
|
||||
.execute();
|
||||
const challenge = await createBuilder<SudoModeRequiredResponse>(harness, account.token)
|
||||
.put('/users/@me/mfa/webauthn/two-factor')
|
||||
.body({enabled: false, password: account.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
expect(challenge.has_mfa).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ describe('WebAuthn registration user handle', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('ensures registration options carry the stable user identifier', async () => {
|
||||
it('ensures registration options include the stable user identifier', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const secret = createTotpSecret();
|
||||
await createBuilder(harness, account.token)
|
||||
|
||||
@@ -500,7 +500,7 @@ describe('mapStripeRefundToRow', () => {
|
||||
expect(result.byPaymentIntent).not.toBeNull();
|
||||
expect(result.byInvoice).toBeNull();
|
||||
});
|
||||
it('payment_intent is an expanded object; hints carry through', () => {
|
||||
it('payment_intent is an expanded object; hints pass through', () => {
|
||||
const r = stripeFixture<Stripe.Refund>({
|
||||
id: 're_2',
|
||||
charge: null,
|
||||
|
||||
@@ -172,6 +172,8 @@ export class ChannelService {
|
||||
snowflakeService,
|
||||
this.messages.persistence,
|
||||
limitConfigService,
|
||||
voiceRoomStore,
|
||||
liveKitService,
|
||||
);
|
||||
this.calls = new CallService(
|
||||
channelRepository,
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('StreamService.uploadPreview', () => {
|
||||
expect(uploaded).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a thumbnail carrying no base64 digits', async () => {
|
||||
it('rejects a thumbnail with no base64 digits', async () => {
|
||||
await expect(upload('====')).rejects.toBeInstanceOf(InvalidStreamThumbnailPayloadError);
|
||||
expect(uploaded).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
|
||||
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import {ChannelHelpers} from '@app/api/guild/services/channel/ChannelHelpers';
|
||||
import {createGuildMfaEnforcer} from '@app/api/guild/services/GuildMfaEnforcement';
|
||||
import {contentModerationService} from '@app/api/infrastructure/ContentModerationService';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
|
||||
@@ -629,6 +630,27 @@ export class ChannelOperationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkOverwritePermission(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
}): Promise<void> {
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
channelId: params.channelId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||
userRepository: this.userRepository,
|
||||
guildData,
|
||||
userId: params.userId,
|
||||
});
|
||||
enforceGuildMfa(Permissions.MANAGE_ROLES);
|
||||
}
|
||||
|
||||
async setChannelPermissionOverwrite(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -644,13 +666,7 @@ export class ChannelOperationsService {
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
@@ -716,13 +732,7 @@ export class ChannelOperationsService {
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
await this.checkOverwritePermission({guildId: channel.guildId, userId: params.userId, channelId: channel.id});
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
const removedRole = overwrites.get(createRoleID(params.overwriteId));
|
||||
|
||||
@@ -10,8 +10,11 @@ import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/
|
||||
import type {MessagePersistenceService} from '@app/api/channel/services/message/MessagePersistenceService';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import type {IVoiceRoomStore} from '@app/api/infrastructure/IVoiceRoomStore';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '@app/api/limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
|
||||
@@ -47,6 +50,8 @@ export class GroupDmOperationsService {
|
||||
private snowflakeService: ISnowflakeService,
|
||||
private messagePersistenceService: MessagePersistenceService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly voiceRoomStore: IVoiceRoomStore,
|
||||
private readonly liveKitService: ILiveKitService,
|
||||
) {
|
||||
this.userPermissionUtils = new UserPermissionUtils(userRepository, guildRepository);
|
||||
}
|
||||
@@ -258,6 +263,7 @@ export class GroupDmOperationsService {
|
||||
await deleteChannelMessageSearchDocuments(channelId, {context: {source: 'group_dm_delete'}});
|
||||
await this.channelRepository.channelData.delete(channelId);
|
||||
await this.userRepository.closeDmForUser(recipientId, channelId);
|
||||
await this.disconnectRemovedRecipientFromCall(channelId, recipientId);
|
||||
await dispatchChannelDelete({
|
||||
channel,
|
||||
requestCache,
|
||||
@@ -275,6 +281,7 @@ export class GroupDmOperationsService {
|
||||
nicks: updatedNicknames.size > 0 ? updatedNicknames : null,
|
||||
});
|
||||
await this.userRepository.closeDmForUser(recipientId, channelId);
|
||||
await this.disconnectRemovedRecipientFromCall(channelId, recipientId);
|
||||
const recipientUserResponse = await this.userCacheService.getUserPartialResponse(recipientId, requestCache);
|
||||
for (const recId of updatedRecipientIds) {
|
||||
await this.gatewayService.dispatchPresence({
|
||||
@@ -319,6 +326,31 @@ export class GroupDmOperationsService {
|
||||
);
|
||||
}
|
||||
|
||||
private async disconnectRemovedRecipientFromCall(channelId: ChannelID, recipientId: UserID): Promise<void> {
|
||||
try {
|
||||
const {voiceStates} = await this.gatewayService.getVoiceStatesForChannel({channelId});
|
||||
await this.gatewayService.disconnectVoiceUserIfInChannel({channelId, userId: recipientId});
|
||||
const recipientVoiceStates = voiceStates.filter((voiceState) => voiceState.userId === recipientId.toString());
|
||||
if (recipientVoiceStates.length === 0) return;
|
||||
const pinnedServer = await this.voiceRoomStore.getPinnedRoomServer(undefined, channelId);
|
||||
if (!pinnedServer) return;
|
||||
for (const voiceState of recipientVoiceStates) {
|
||||
await this.liveKitService.disconnectParticipant({
|
||||
userId: recipientId,
|
||||
channelId,
|
||||
connectionId: voiceState.connectionId,
|
||||
regionId: pinnedServer.regionId,
|
||||
serverId: pinnedServer.serverId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, channelId: channelId.toString(), userId: recipientId.toString()},
|
||||
'Failed to disconnect removed group DM recipient from call',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async syncGroupDmRecipientsForUser(userId: UserID): Promise<void> {
|
||||
const channels = await this.userRepository.listPrivateChannels(userId);
|
||||
const groupDmChannels = channels.filter((ch) => ch.type === ChannelTypes.GROUP_DM);
|
||||
|
||||
@@ -33,7 +33,7 @@ function attachment(id: bigint, hash: string | null): MessageAttachment {
|
||||
};
|
||||
}
|
||||
|
||||
function message(attachments: Array<MessageAttachment>): Message {
|
||||
function message(attachments: Array<MessageAttachment>, content = ''): Message {
|
||||
return new Message({
|
||||
channel_id: createChannelID(10n),
|
||||
bucket: 0,
|
||||
@@ -43,7 +43,7 @@ function message(attachments: Array<MessageAttachment>): Message {
|
||||
webhook_id: null,
|
||||
webhook_name: null,
|
||||
webhook_avatar_hash: null,
|
||||
content: '',
|
||||
content,
|
||||
edited_timestamp: null,
|
||||
pinned_timestamp: null,
|
||||
flags: 0,
|
||||
@@ -75,10 +75,10 @@ describe('message activity', () => {
|
||||
resetActivityEventsForTests();
|
||||
});
|
||||
|
||||
function params(attachments: Array<MessageAttachment>) {
|
||||
function params(attachments: Array<MessageAttachment>, content = '') {
|
||||
return {
|
||||
user: {id: createUserID(3n), isBot: false} as unknown as User,
|
||||
message: message(attachments),
|
||||
message: message(attachments, content),
|
||||
channel: {id: createChannelID(10n), type: ChannelTypes.DM} as unknown as Channel,
|
||||
guildId: null,
|
||||
guildOwnerId: null,
|
||||
@@ -117,4 +117,18 @@ describe('message activity', () => {
|
||||
expect(updated.data).toMatchObject({message_id: '100', attachments: [{hash: HASH.toLowerCase()}]});
|
||||
expect(updated.id).not.toBe(created.id);
|
||||
});
|
||||
|
||||
it('records the link domain of a masked markdown link without its brackets', async () => {
|
||||
const publisher = new CapturingPublisher();
|
||||
await startActivityEvents({publisher, kv: new MockKVProvider()});
|
||||
emitMessageCreated(
|
||||
params(
|
||||
[],
|
||||
'[OPEN](https://Shop.Example.com) [docs](<https://www.docs.example.org/a>) https://[email protected]:8443/x',
|
||||
),
|
||||
);
|
||||
await vi.waitFor(() => expect(publisher.payloads).toHaveLength(1));
|
||||
const event = JSON.parse(publisher.payloads[0]!);
|
||||
expect(event.data.link_domains).toEqual(['shop.example.com', 'docs.example.org', 'cdn.example.net']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,22 +10,20 @@ import type {Message} from '@app/api/models/Message';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {findInvites} from '@app/api/utils/InviteUtils';
|
||||
import {extractLinkHosts} from '@app/api/utils/UrlNormalizer';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
|
||||
const CONTENT_MAX_CHARS = 2000;
|
||||
const LIST_MAX = 10;
|
||||
const MENTIONS_MAX = 20;
|
||||
const LINK_PATTERN = /https?:\/\/([^\s/?#<>"']+)/giu;
|
||||
const WWW_PREFIX_RE = /^www\./u;
|
||||
|
||||
function linkDomains(content: string): Array<string> {
|
||||
const domains = new Set<string>();
|
||||
for (const match of content.matchAll(LINK_PATTERN)) {
|
||||
const host = match[1]
|
||||
?.toLowerCase()
|
||||
.replace(/:\d+$/u, '')
|
||||
.replace(/^www\./u, '');
|
||||
if (host) domains.add(host);
|
||||
for (const host of extractLinkHosts(content)) {
|
||||
const domain = host.replace(WWW_PREFIX_RE, '');
|
||||
if (domain) domains.add(domain);
|
||||
if (domains.size >= LIST_MAX) break;
|
||||
}
|
||||
return [...domains];
|
||||
|
||||
@@ -94,10 +94,14 @@ export class MessageValidationService {
|
||||
contentModerationService.scanText(data.content, modCtx);
|
||||
if (data.embeds) {
|
||||
for (const embed of data.embeds) {
|
||||
if (embed.url) contentModerationService.scanUrl(embed.url, modCtx);
|
||||
contentModerationService.scanText(embed.title ?? null, modCtx);
|
||||
contentModerationService.scanText(embed.description ?? null, modCtx);
|
||||
if (embed.footer) contentModerationService.scanText(embed.footer.text ?? null, modCtx);
|
||||
if (embed.author) contentModerationService.scanText(embed.author.name ?? null, modCtx);
|
||||
if (embed.author) {
|
||||
contentModerationService.scanText(embed.author.name ?? null, modCtx);
|
||||
if (embed.author.url) contentModerationService.scanUrl(embed.author.url, modCtx);
|
||||
}
|
||||
if (embed.fields) {
|
||||
for (const field of embed.fields) {
|
||||
contentModerationService.scanText(field.name ?? null, modCtx);
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('Attachment Decay', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const {response, json} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
@@ -258,7 +258,7 @@ describe('Attachment Decay', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const smallFile = loadFixture('yeah.png');
|
||||
const largeFile = loadFixture('thisisfine.gif');
|
||||
const largeFile = loadFixture('animated.gif');
|
||||
const smallResult = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('Attachment Upload Validation', () => {
|
||||
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
}),
|
||||
method: 'POST',
|
||||
path: `/channels/${channelId}/messages`,
|
||||
path: '/channels/:channel_id/messages',
|
||||
requestId: expect.any(String),
|
||||
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
},
|
||||
@@ -327,17 +327,17 @@ describe('Attachment Upload Validation', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Ordered files test',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png', description: 'First file', title: 'First'},
|
||||
{id: 1, filename: 'thisisfine.gif', description: 'Second file', title: 'Second'},
|
||||
{id: 1, filename: 'animated.gif', description: 'Second file', title: 'Second'},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
@@ -346,7 +346,7 @@ describe('Attachment Upload Validation', () => {
|
||||
expect(json.attachments![0].filename).toBe('yeah.png');
|
||||
expect(json.attachments![0].description).toBe('First file');
|
||||
expect(json.attachments![0].title).toBe('First');
|
||||
expect(json.attachments![1].filename).toBe('thisisfine.gif');
|
||||
expect(json.attachments![1].filename).toBe('animated.gif');
|
||||
expect(json.attachments![1].description).toBe('Second file');
|
||||
expect(json.attachments![1].title).toBe('Second');
|
||||
});
|
||||
@@ -356,17 +356,17 @@ describe('Attachment Upload Validation', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Sparse IDs test',
|
||||
attachments: [
|
||||
{id: 2, filename: 'yeah.png', description: 'ID is 2', title: 'Two'},
|
||||
{id: 5, filename: 'thisisfine.gif', description: 'ID is 5', title: 'Five'},
|
||||
{id: 5, filename: 'animated.gif', description: 'ID is 5', title: 'Five'},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 2, filename: 'yeah.png', data: file1Data},
|
||||
{index: 5, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 5, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
@@ -472,7 +472,7 @@ describe('Attachment Upload Validation', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Mixed metadata test',
|
||||
attachments: [
|
||||
@@ -485,13 +485,13 @@ describe('Attachment Upload Validation', () => {
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
filename: 'thisisfine.gif',
|
||||
filename: 'animated.gif',
|
||||
},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.attachments).toBeDefined();
|
||||
|
||||
@@ -471,7 +471,7 @@ describe('Crosspost moderation', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('forwarding a published source carries none of the server bits', async () => {
|
||||
test('forwarding a published source keeps none of the server bits', async () => {
|
||||
const source = await sendChannelMessage(harness, world.b.owner.token, world.a.ann.id, 'forward me');
|
||||
await publish(harness, world.b.owner.token, world.a.ann.id, source.id);
|
||||
const forwarded = await forward(world.b.owner.token, world.b.t2.id, world.a.ann.id, world.a.guild.id, source.id)
|
||||
@@ -480,7 +480,7 @@ describe('Crosspost moderation', () => {
|
||||
expect(forwarded.message_snapshots?.[0]?.flags ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
test('forwarding a copy carries none of the server bits', async () => {
|
||||
test('forwarding a copy keeps none of the server bits', async () => {
|
||||
const source = await sendChannelMessage(harness, world.a.member.token, world.a.ann.id, 'update');
|
||||
const {copyId} = await fabricateCopy({
|
||||
harness,
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function sendWithImage(
|
||||
filenames.map((filename, index) => ({
|
||||
index,
|
||||
filename,
|
||||
data: loadFixture(filename.endsWith('.gif') ? 'thisisfine.gif' : 'yeah.png'),
|
||||
data: loadFixture(filename.endsWith('.gif') ? 'animated.gif' : 'yeah.png'),
|
||||
})),
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
|
||||
@@ -86,25 +86,25 @@ describe('Embed Attachment URL Resolution', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Both image and thumbnail',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png'},
|
||||
{id: 1, filename: 'thisisfine.gif'},
|
||||
{id: 1, filename: 'animated.gif'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
title: 'Complete Embed',
|
||||
description: 'This embed uses both image and thumbnail',
|
||||
image: {url: 'attachment://thisisfine.gif'},
|
||||
image: {url: 'attachment://animated.gif'},
|
||||
thumbnail: {url: 'attachment://yeah.png'},
|
||||
},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
@@ -576,12 +576,12 @@ describe('Embed Attachment URL Resolution', () => {
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const file1Data = loadFixture('yeah.png');
|
||||
const file2Data = loadFixture('thisisfine.gif');
|
||||
const file2Data = loadFixture('animated.gif');
|
||||
const payload = {
|
||||
content: 'Multiple embeds with different attachments',
|
||||
attachments: [
|
||||
{id: 0, filename: 'yeah.png'},
|
||||
{id: 1, filename: 'thisisfine.gif'},
|
||||
{id: 1, filename: 'animated.gif'},
|
||||
],
|
||||
embeds: [
|
||||
{
|
||||
@@ -592,19 +592,19 @@ describe('Embed Attachment URL Resolution', () => {
|
||||
{
|
||||
title: 'Second Embed',
|
||||
description: 'Uses GIF',
|
||||
image: {url: 'attachment://thisisfine.gif'},
|
||||
image: {url: 'attachment://animated.gif'},
|
||||
},
|
||||
],
|
||||
};
|
||||
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
|
||||
{index: 0, filename: 'yeah.png', data: file1Data},
|
||||
{index: 1, filename: 'thisisfine.gif', data: file2Data},
|
||||
{index: 1, filename: 'animated.gif', data: file2Data},
|
||||
]);
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.embeds).toBeDefined();
|
||||
expect(json.embeds).toHaveLength(2);
|
||||
expect(json.embeds![0].image?.url).toContain('yeah.png');
|
||||
expect(json.embeds![1].image?.url).toContain('thisisfine.gif');
|
||||
expect(json.embeds![1].image?.url).toContain('animated.gif');
|
||||
});
|
||||
it('should resolve multiple files referenced by embeds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
getChannel,
|
||||
removeRecipientFromGroupDm,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {DisabledLiveKitService} from '@app/api/infrastructure/DisabledLiveKitService';
|
||||
import {InMemoryVoiceRoomStore} from '@app/api/infrastructure/InMemoryVoiceRoomStore';
|
||||
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
describe('Group DM recipient removal call teardown', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setupGroupDm() {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const other = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await ensureSessionStarted(harness, other.token);
|
||||
await createFriendship(harness, owner, member);
|
||||
await createFriendship(harness, owner, other);
|
||||
const groupDm = await createGroupDmChannel(harness, owner.token, [member.userId, other.userId]);
|
||||
return {owner, member, other, groupDm};
|
||||
}
|
||||
|
||||
it('disconnects the removed recipient from the call and the voice room', async () => {
|
||||
const {owner, member, other, groupDm} = await setupGroupDm();
|
||||
vi.spyOn(NoopGatewayService.prototype, 'getVoiceStatesForChannel').mockResolvedValue({
|
||||
voiceStates: [
|
||||
{connectionId: 'member-conn', userId: member.userId, channelId: groupDm.id},
|
||||
{connectionId: 'other-conn', userId: other.userId, channelId: groupDm.id},
|
||||
],
|
||||
});
|
||||
vi.spyOn(InMemoryVoiceRoomStore.prototype, 'getPinnedRoomServer').mockResolvedValue({
|
||||
regionId: 'region-a',
|
||||
serverId: 'server-a',
|
||||
endpoint: 'wss://voice.invalid',
|
||||
});
|
||||
const disconnectFromCall = vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel');
|
||||
const disconnectParticipant = vi.spyOn(DisabledLiveKitService.prototype, 'disconnectParticipant');
|
||||
|
||||
await removeRecipientFromGroupDm(harness, owner.token, groupDm.id, member.userId);
|
||||
|
||||
expect(disconnectFromCall).toHaveBeenCalledTimes(1);
|
||||
const callParams = disconnectFromCall.mock.calls[0]![0];
|
||||
expect(callParams.guildId).toBeUndefined();
|
||||
expect(callParams.channelId.toString()).toBe(groupDm.id);
|
||||
expect(callParams.userId.toString()).toBe(member.userId);
|
||||
expect(disconnectParticipant).toHaveBeenCalledTimes(1);
|
||||
const participantParams = disconnectParticipant.mock.calls[0]![0];
|
||||
expect(participantParams.userId.toString()).toBe(member.userId);
|
||||
expect(participantParams.channelId.toString()).toBe(groupDm.id);
|
||||
expect(participantParams.connectionId).toBe('member-conn');
|
||||
expect(participantParams.regionId).toBe('region-a');
|
||||
expect(participantParams.serverId).toBe('server-a');
|
||||
});
|
||||
|
||||
it('disconnects a recipient who leaves the group DM themselves', async () => {
|
||||
const {member, groupDm} = await setupGroupDm();
|
||||
const disconnectFromCall = vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel');
|
||||
|
||||
await removeRecipientFromGroupDm(harness, member.token, groupDm.id, member.userId);
|
||||
|
||||
expect(disconnectFromCall).toHaveBeenCalledTimes(1);
|
||||
expect(disconnectFromCall.mock.calls[0]![0].userId.toString()).toBe(member.userId);
|
||||
});
|
||||
|
||||
it('still removes the recipient when the call teardown fails', async () => {
|
||||
const {owner, member, groupDm} = await setupGroupDm();
|
||||
vi.spyOn(NoopGatewayService.prototype, 'disconnectVoiceUserIfInChannel').mockRejectedValue(
|
||||
new Error('gateway unavailable'),
|
||||
);
|
||||
|
||||
await removeRecipientFromGroupDm(harness, owner.token, groupDm.id, member.userId);
|
||||
|
||||
const channel = await getChannel(harness, owner.token, groupDm.id);
|
||||
expect(channel.recipients?.map((recipient) => recipient.id)).not.toContain(member.userId);
|
||||
});
|
||||
});
|
||||
@@ -249,7 +249,7 @@ describe('Crosspost fan-out', () => {
|
||||
expect(after?.mentionedRoleIds.size).toBe(0);
|
||||
});
|
||||
|
||||
test('sendable flags carry over to the copy', async () => {
|
||||
test('the copy keeps the sendable flags', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const message = await sendMessage(harness, world.a.owner.token, world.a.ann.id, {content: 'quiet'});
|
||||
const sendable = MessageFlags.SUPPRESS_EMBEDS | MessageFlags.SUPPRESS_NOTIFICATIONS | MessageFlags.VOICE_MESSAGE;
|
||||
@@ -259,7 +259,7 @@ describe('Crosspost fan-out', () => {
|
||||
expect(copy!.flags).toBe(MessageFlags.IS_CROSSPOST | sendable);
|
||||
});
|
||||
|
||||
test('copies carry the source attachments and resolve to the source channel', async () => {
|
||||
test('copies have the source attachments and resolve to the source channel', async () => {
|
||||
await followInto(harness, world, world.b.t1.id);
|
||||
const message = await sendWithImage(harness, world.a.owner.token, world.a.ann.id, {content: 'files'}, [
|
||||
'first.png',
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('Reaction users pagination', () => {
|
||||
return {token: owner.token, channelId: systemChannel.id, messageId: message.id};
|
||||
}
|
||||
|
||||
it('carries the pagination signal of the page in headers', async () => {
|
||||
it('sends the pagination signal of the page in headers', async () => {
|
||||
const {token, channelId, messageId} = await setupReactedMessage();
|
||||
|
||||
const legacy = await createBuilder<Array<{id: string}>>(harness, token)
|
||||
|
||||
@@ -67,7 +67,7 @@ function collectErrorChain(error: unknown): Array<ErrorNode> {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function carriesPostgresClient(node: ErrorNode): boolean {
|
||||
function hasPostgresClient(node: ErrorNode): boolean {
|
||||
const client = node['client'];
|
||||
return typeof client === 'object' && client !== null;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function isTransientDatabaseError(error: unknown): boolean {
|
||||
if (nodes.some(hasTransientSqlState)) {
|
||||
return true;
|
||||
}
|
||||
if (nodes.some(carriesPostgresClient) && nodes.some(hasTransientSocketCode)) {
|
||||
if (nodes.some(hasPostgresClient) && nodes.some(hasTransientSocketCode)) {
|
||||
return true;
|
||||
}
|
||||
return nodes.some(hasTransientDriverMessage);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||
import {DefaultUserOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
@@ -15,12 +15,13 @@ import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstan
|
||||
import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
|
||||
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
||||
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
|
||||
import {GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {GuildIdChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {
|
||||
DiscoveryApplicationPatchRequest,
|
||||
DiscoveryApplicationRequest,
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryListResponse,
|
||||
DiscoveryChannelPreviewResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
DiscoverySearchQuery,
|
||||
DiscoveryStatusResponse,
|
||||
@@ -100,6 +101,31 @@ export function GuildDiscoveryController(app: HonoApp) {
|
||||
return ctx.json(categories);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/discovery/guilds/:guild_id/channels/:channel_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_CHANNEL_PREVIEW),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', GuildIdChannelIdParam),
|
||||
OpenAPI({
|
||||
operationId: 'get_discovery_channel_preview',
|
||||
summary: 'Preview a channel in a discoverable guild',
|
||||
description:
|
||||
'Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.',
|
||||
responseSchema: DiscoveryChannelPreviewResponse,
|
||||
statusCode: 200,
|
||||
security: ['sessionToken', 'bearerToken'],
|
||||
tags: ['Discovery'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
ensureDiscoveryEnabled();
|
||||
const {guild_id, channel_id} = ctx.req.valid('param');
|
||||
const preview = await ctx
|
||||
.get('discoveryService')
|
||||
.getChannelPreview(createGuildID(guild_id), createChannelID(channel_id));
|
||||
return ctx.json(preview);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/discovery/guilds/:guild_id/join',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {type ChannelID, type GuildID, guildIdToRoleId, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
|
||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
|
||||
@@ -10,6 +11,7 @@ import {contentModerationService} from '@app/api/infrastructure/ContentModeratio
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {IGuildSearchService} from '@app/api/search/IGuildSearchService';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
DISCOVERY_DEFAULT_LANGUAGE,
|
||||
DISCOVERY_MAX_TAGS,
|
||||
@@ -83,6 +85,8 @@ export abstract class IGuildDiscoveryService {
|
||||
|
||||
abstract listByStatus(params: {status: string}): Promise<Array<GuildDiscoveryRow>>;
|
||||
|
||||
abstract getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview>;
|
||||
|
||||
abstract searchDiscoverable(params: {
|
||||
query?: string;
|
||||
categoryId?: number;
|
||||
@@ -118,7 +122,13 @@ interface DiscoveryGuildResult {
|
||||
verification_level: number;
|
||||
}
|
||||
|
||||
interface DiscoveryChannelPreview {
|
||||
guild: {id: string; name: string; icon: string | null};
|
||||
channel: {id: string; name: string | null; type: number};
|
||||
}
|
||||
|
||||
const DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
|
||||
const PUBLIC_CHANNEL_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY;
|
||||
|
||||
function toDiscoveryCategoryCounts(
|
||||
counts: Readonly<Record<string, number>> | undefined,
|
||||
@@ -143,6 +153,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
private readonly guildRepository: IGuildRepositoryAggregate,
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildSearchService: IGuildSearchService | null,
|
||||
private readonly channelDataRepository: IChannelDataRepository,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -382,6 +393,38 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
return this.discoveryRepository.listFullByStatus(params.status);
|
||||
}
|
||||
|
||||
async getChannelPreview(guildId: GuildID, channelId: ChannelID): Promise<DiscoveryChannelPreview> {
|
||||
const [status, guild, channel, everyoneRole] = await Promise.all([
|
||||
this.discoveryRepository.findByGuildId(guildId),
|
||||
this.guildRepository.findUnique(guildId),
|
||||
this.channelDataRepository.findUnique(channelId),
|
||||
this.guildRepository.getRole(guildIdToRoleId(guildId), guildId),
|
||||
]);
|
||||
if (
|
||||
status?.status !== DiscoveryApplicationStatus.APPROVED ||
|
||||
!guild ||
|
||||
guild.features.has(GuildFeatures.INVITES_DISABLED) ||
|
||||
!channel ||
|
||||
channel.guildId !== guildId ||
|
||||
!everyoneRole
|
||||
) {
|
||||
throw new DiscoveryNotDiscoverableError();
|
||||
}
|
||||
if ((everyoneRole.permissions & Permissions.ADMINISTRATOR) === 0n) {
|
||||
const overwrite = channel.permissionOverwrites.get(everyoneRole.id);
|
||||
const permissions = overwrite
|
||||
? (everyoneRole.permissions & ~overwrite.deny) | overwrite.allow
|
||||
: everyoneRole.permissions;
|
||||
if ((permissions & PUBLIC_CHANNEL_PERMISSIONS) !== PUBLIC_CHANNEL_PERMISSIONS) {
|
||||
throw new DiscoveryNotDiscoverableError();
|
||||
}
|
||||
}
|
||||
return {
|
||||
guild: {id: guild.id.toString(), name: guild.name, icon: guild.iconHash},
|
||||
channel: {id: channel.id.toString(), name: channel.name, type: channel.type},
|
||||
};
|
||||
}
|
||||
|
||||
async searchDiscoverable(params: {
|
||||
query?: string;
|
||||
categoryId?: number;
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
MAX_GUILD_ROLES,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
VOICE_CHANNEL_USER_LIMIT_MAX,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -1065,7 +1066,7 @@ export class GuildOperationsService {
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
|
||||
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
|
||||
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
|
||||
user_limit: isVoice ? Math.min(channel.user_limit ?? 0, VOICE_CHANNEL_USER_LIMIT_MAX) : null,
|
||||
voice_connection_limit: isVoice
|
||||
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
: null,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import type {GuildID} from '@app/api/BrandedTypes';
|
||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {createPermissionOverwrite} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {createChannel, createGuild, getUserGuilds} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {setInjectedGatewayService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {getGuildRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {banUser} from '@app/api/moderation/tests/ModerationTestUtils';
|
||||
@@ -15,11 +16,13 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
|
||||
import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildVerificationLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryChannelPreviewResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
@@ -507,4 +510,65 @@ describe('Discovery Search and Join', () => {
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
describe('channel preview', () => {
|
||||
async function createListedGuild(name: string): Promise<{ownerToken: string; guildId: string; channelId: string}> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
admin.token,
|
||||
guild.id,
|
||||
`${name} welcomes everyone`,
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
return {ownerToken: owner.token, guildId: guild.id, channelId: guild.system_channel_id!};
|
||||
}
|
||||
test('should preview a channel that new members can read', async () => {
|
||||
const {guildId, channelId} = await createListedGuild('Preview Guild');
|
||||
const viewer = await createTestAccount(harness);
|
||||
const preview = await createBuilder<DiscoveryChannelPreviewResponse>(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guildId}/channels/${channelId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(preview.guild.id).toBe(guildId);
|
||||
expect(preview.guild.name).toBe('Preview Guild');
|
||||
expect(preview.channel.id).toBe(channelId);
|
||||
});
|
||||
test('should not preview a channel hidden from everyone', async () => {
|
||||
const {ownerToken, guildId} = await createListedGuild('Hidden Channel Guild');
|
||||
const hidden = await createChannel(harness, ownerToken, guildId, 'staff');
|
||||
await createPermissionOverwrite(harness, ownerToken, hidden.id, guildId, {
|
||||
type: 0,
|
||||
allow: '0',
|
||||
deny: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
const viewer = await createTestAccount(harness);
|
||||
await createBuilder(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guildId}/channels/${hidden.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
test('should not preview a channel from another guild', async () => {
|
||||
const {guildId} = await createListedGuild('Listed Guild');
|
||||
const other = await createListedGuild('Other Listed Guild');
|
||||
const viewer = await createTestAccount(harness);
|
||||
await createBuilder(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guildId}/channels/${other.channelId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
test('should not preview a guild that is not listed', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Unlisted Guild');
|
||||
const viewer = await createTestAccount(harness);
|
||||
await createBuilder(harness, viewer.token)
|
||||
.get(`/discovery/guilds/${guild.id}/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('Guild expression clone opt-in', () => {
|
||||
expect(cloned.name).toBe(source.sticker.name);
|
||||
}
|
||||
|
||||
test('rejects both emoji and sticker cloning when the source guild carries no clone features', async () => {
|
||||
test('rejects both emoji and sticker cloning when the source guild has no clone features', async () => {
|
||||
const source = await createSource(harness, 'No Clone Features Source');
|
||||
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
|
||||
expect(source.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
|
||||
@@ -154,7 +154,7 @@ describe('Guild expression clone opt-in', () => {
|
||||
await expectStickerCloneAllowed(source, 'Deprecated Plus Enabled');
|
||||
});
|
||||
|
||||
test('rejects cloning when the source guild carries only the deprecated disabled features', async () => {
|
||||
test('rejects cloning when the source guild has only the deprecated disabled features', async () => {
|
||||
const source = await createSource(harness, 'Deprecated Only Source');
|
||||
await addDeprecatedFeatures(harness, source, [
|
||||
GuildFeatures.CLONE_EMOJI_DISABLED,
|
||||
@@ -186,7 +186,7 @@ describe('Guild expression clone opt-in', () => {
|
||||
expect(stickerAfter.allow_cloning).toBe(true);
|
||||
});
|
||||
|
||||
test('reports allow_cloning false for a guild carrying only the deprecated disabled features', async () => {
|
||||
test('reports allow_cloning false for a guild with only the deprecated disabled features', async () => {
|
||||
const source = await createSource(harness, 'Metadata Deprecated Source');
|
||||
await addDeprecatedFeatures(harness, source, [
|
||||
GuildFeatures.CLONE_EMOJI_DISABLED,
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
@@ -116,6 +123,43 @@ describe('Guild MFA level', () => {
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
it('requires 2FA for channel permission overwrite edits in an elevated guild', async () => {
|
||||
const {owner, members, guild, channels} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
const channel = channels[0]!;
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Managers',
|
||||
permissions: (Permissions.MANAGE_ROLES | Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
|
||||
});
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {name: 'Target'});
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedInOwner = await loginWithTotp(harness, owner);
|
||||
await createBuilder<GuildResponse>(harness, loggedInOwner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const overwrite = {type: 0, allow: Permissions.SEND_MESSAGES.toString(), deny: '0'};
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/channels/${channel.id}/permissions/${targetRole.id}`)
|
||||
.body(overwrite)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||
.execute();
|
||||
await createBuilder(harness, loggedInOwner.token)
|
||||
.put(`/channels/${channel.id}/permissions/${targetRole.id}`)
|
||||
.body(overwrite)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/channels/${channel.id}/permissions/${targetRole.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||
.execute();
|
||||
const stored = await getChannel(harness, loggedInOwner.token, channel.id);
|
||||
expect(stored.permission_overwrites?.find((entry) => entry.id === targetRole.id)?.allow).toBe(
|
||||
Permissions.SEND_MESSAGES.toString(),
|
||||
);
|
||||
});
|
||||
it('does not require sudo mode for non-mfa_level guild updates', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
|
||||
import {VOICE_CHANNEL_USER_LIMIT_MAX} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
@@ -209,6 +210,84 @@ describe('Guild Template Import', () => {
|
||||
expect(roles.some((role) => role.name === '')).toBe(true);
|
||||
expect(channels.some((channel) => channel.name === '')).toBe(true);
|
||||
});
|
||||
test.each([
|
||||
['a negative slowmode', {rate_limit_per_user: -1}],
|
||||
['a slowmode above the channel maximum', {rate_limit_per_user: 1_000_000_000}],
|
||||
['a fractional position', {position: 0.5}],
|
||||
['a negative position', {position: -3}],
|
||||
['a topic above the channel maximum', {topic: 'x'.repeat(1025)}],
|
||||
['a name above the channel maximum', {name: 'x'.repeat(101)}],
|
||||
['a negative user limit', {type: ChannelTypes.GUILD_VOICE, user_limit: -1}],
|
||||
['a voice connection limit above the maximum', {type: ChannelTypes.GUILD_VOICE, voice_connection_limit: 100_000}],
|
||||
['a negative voice connection limit', {type: ChannelTypes.GUILD_VOICE, voice_connection_limit: -5}],
|
||||
])('rejects a template channel with %s', async (_label, overrides) => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Bounded Guild',
|
||||
template: buildMinimalTemplate({
|
||||
channels: [{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0, ...overrides}],
|
||||
}),
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
test.each([
|
||||
['a negative colour', {color: -1}],
|
||||
['a colour above 0xffffff', {color: 0x1000000}],
|
||||
['a name above the role maximum', {name: 'x'.repeat(101)}],
|
||||
])('rejects a template role with %s', async (_label, overrides) => {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Bounded Guild',
|
||||
template: buildMinimalTemplate({
|
||||
roles: [
|
||||
{id: 0, name: '@everyone', permissions: DEFAULT_EVERYONE_PERMISSIONS},
|
||||
{id: 6100, name: 'Role', permissions: '0', ...overrides},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.expect(400, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
});
|
||||
test('clamps imported voice user limits to the channel maximum and keeps channels readable', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createBuilder<GuildResponse>(harness, account.token)
|
||||
.post('/guilds')
|
||||
.body({
|
||||
name: 'Stage Guild',
|
||||
template: buildMinimalTemplate({
|
||||
channels: [
|
||||
{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0, rate_limit_per_user: 30},
|
||||
{id: 6002, type: 13, name: 'town-hall', position: 1, user_limit: 10_000},
|
||||
{id: 6003, type: ChannelTypes.GUILD_VOICE, name: 'lounge', position: 2, voice_connection_limit: 100},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
expect(channels.find((channel) => channel.name === 'general')?.rate_limit_per_user).toBe(30);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
const DEFAULT_EVERYONE_PERMISSIONS = Permissions.VIEW_CHANNEL.toString();
|
||||
|
||||
function buildMinimalTemplate(overrides: {channels?: Array<object>; roles?: Array<object>}) {
|
||||
return {
|
||||
name: 'Template Source',
|
||||
description: null,
|
||||
verification_level: 0,
|
||||
default_message_notifications: 0,
|
||||
explicit_content_filter: 0,
|
||||
system_channel_id: 6001,
|
||||
afk_timeout: 300,
|
||||
system_channel_flags: 0,
|
||||
roles: overrides.roles ?? [{id: 0, name: '@everyone', permissions: DEFAULT_EVERYONE_PERMISSIONS}],
|
||||
channels: overrides.channels ?? [{id: 6001, type: ChannelTypes.GUILD_TEXT, name: 'general', position: 0}],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('AvatarService emoji and sticker size ceilings', () => {
|
||||
{path: 'image', code: ValidationErrorCodes.IMAGE_SIZE_EXCEEDS_LIMIT, variables: {maxSize: 1024}},
|
||||
]);
|
||||
});
|
||||
it('applies a guild-feature-filtered emoji_max_size rule only to a guild that carries the feature', async () => {
|
||||
it('applies a guild-feature-filtered emoji_max_size rule only to a guild that has the feature', async () => {
|
||||
const rules: Array<LimitRule> = [
|
||||
{id: 'big-emoji', filters: {guildFeatures: ['BIG_EMOJI']}, limits: {emoji_max_size: EMOJI_MAX_SIZE * 2}},
|
||||
];
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('canonicalizePurgeUrl', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a base path when the media endpoint carries one', () => {
|
||||
it('keeps a base path when the media endpoint has one', () => {
|
||||
Config.endpoints.media = `${MEDIA}/media`;
|
||||
expect(canonicalizePurgeUrl(`${MEDIA}/media/avatars/1/b35cc3d3`)).toEqual([
|
||||
'media.test/media/avatars/1/b35cc3d3',
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Logger} from '@app/api/Logger';
|
||||
import {fileShaCache} from '@app/api/middleware/FileShaCache';
|
||||
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {extractUrlCandidates} from '@app/api/utils/UrlNormalizer';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
|
||||
export interface ModerationContext {
|
||||
@@ -40,16 +39,12 @@ class ContentModerationService {
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
const urls = extractUrlCandidates(text);
|
||||
if (urls.length === 0) return;
|
||||
for (const url of urls) {
|
||||
if (urlBlocklistCache.isUrlOrDomainBanned(url)) {
|
||||
Logger.warn(
|
||||
{surface: ctx.surface, userId: ctx.userId?.toString(), guildId: ctx.guildId?.toString()},
|
||||
'content_moderation.block url match in text',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
if (urlBlocklistCache.containsBannedLink(text)) {
|
||||
Logger.warn(
|
||||
{surface: ctx.surface, userId: ctx.userId?.toString(), guildId: ctx.guildId?.toString()},
|
||||
'content_moderation.block url match in text',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,22 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
const REJECTED_SERVER_SIDE_COPY_ERRORS = new Set([
|
||||
'NoSuchKey',
|
||||
'NotFound',
|
||||
'NotImplemented',
|
||||
'AccessDenied',
|
||||
'InvalidRequest',
|
||||
'MethodNotAllowed',
|
||||
]);
|
||||
|
||||
function isRejectedServerSideCopy(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof S3ServiceException &&
|
||||
(REJECTED_SERVER_SIDE_COPY_ERRORS.has(error.name) || error.$metadata?.httpStatusCode === 501)
|
||||
);
|
||||
}
|
||||
|
||||
export class StorageService implements IStorageService {
|
||||
private readonly client: S3Client;
|
||||
private readonly presignClient: S3Client;
|
||||
@@ -473,15 +489,76 @@ export class StorageService implements IStorageService {
|
||||
if (isSameObject && !newContentType) {
|
||||
return;
|
||||
}
|
||||
await this.client.send(
|
||||
new CopyObjectCommand({
|
||||
try {
|
||||
await this.client.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: destinationBucket,
|
||||
Key: destinationKey,
|
||||
CopySource: `${encodeURIComponent(sourceBucket)}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`,
|
||||
ContentType: newContentType,
|
||||
MetadataDirective: newContentType ? 'REPLACE' : undefined,
|
||||
}),
|
||||
);
|
||||
} catch (copyError) {
|
||||
if (sourceBucket === destinationBucket || !isRejectedServerSideCopy(copyError)) {
|
||||
throw copyError;
|
||||
}
|
||||
await this.copyObjectThroughApi(
|
||||
{sourceBucket, sourceKey, destinationBucket, destinationKey, newContentType},
|
||||
copyError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async copyObjectThroughApi(
|
||||
{
|
||||
sourceBucket,
|
||||
sourceKey,
|
||||
destinationBucket,
|
||||
destinationKey,
|
||||
newContentType,
|
||||
}: {
|
||||
sourceBucket: string;
|
||||
sourceKey: string;
|
||||
destinationBucket: string;
|
||||
destinationKey: string;
|
||||
newContentType?: string;
|
||||
},
|
||||
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,
|
||||
CopySource: `${encodeURIComponent(sourceBucket)}/${sourceKey.split('/').map(encodeURIComponent).join('/')}`,
|
||||
ContentType: newContentType,
|
||||
MetadataDirective: newContentType ? 'REPLACE' : undefined,
|
||||
}),
|
||||
);
|
||||
Body: source.body,
|
||||
ContentType: newContentType ?? source.contentType ?? undefined,
|
||||
...(newContentType
|
||||
? {}
|
||||
: {
|
||||
CacheControl: source.cacheControl ?? undefined,
|
||||
ContentDisposition: source.contentDisposition ?? undefined,
|
||||
Expires: source.expires ?? undefined,
|
||||
}),
|
||||
},
|
||||
partSize: STREAM_UPLOAD_PART_BYTES,
|
||||
queueSize: STREAM_UPLOAD_CONCURRENCY,
|
||||
leavePartsOnError: false,
|
||||
});
|
||||
try {
|
||||
await upload.done();
|
||||
} catch (error) {
|
||||
source.body.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async copyObjectWithMetadataStripping({
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {StorageService} from '@app/api/infrastructure/StorageService';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const ENDPOINT = 'https://objects.ceph-rgw.test';
|
||||
const UPLOADS = 'fluxer-uploads';
|
||||
const CDN = 'fluxer-cdn';
|
||||
|
||||
interface StoredObject {
|
||||
body: Uint8Array;
|
||||
contentType: string;
|
||||
cacheControl?: string;
|
||||
contentDisposition?: string;
|
||||
}
|
||||
|
||||
const NO_SUCH_KEY = (bucket: string) =>
|
||||
new HttpResponse(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchKey</Code><Message></Message><BucketName>${bucket}</BucketName><RequestId>tx0</RequestId><HostId>ceph</HostId></Error>`,
|
||||
{status: 404, headers: {'Content-Type': 'application/xml'}},
|
||||
);
|
||||
|
||||
function cephWithoutCrossBucketCopy() {
|
||||
const objects = new Map<string, StoredObject>();
|
||||
const copies: Array<{source: string; destination: string}> = [];
|
||||
const locate = (params: {bucket?: string | ReadonlyArray<string>; key?: string | ReadonlyArray<string>}) => {
|
||||
const bucket = String(params.bucket);
|
||||
const key = Array.isArray(params.key) ? params.key.join('/') : String(params.key);
|
||||
return {bucket, key, id: `${bucket}/${key}`};
|
||||
};
|
||||
server.use(
|
||||
http.put(`${ENDPOINT}/:bucket/*`, async ({request, params}) => {
|
||||
const target = locate({bucket: params.bucket, key: params[0] as string});
|
||||
const copySource = request.headers.get('x-amz-copy-source');
|
||||
if (copySource) {
|
||||
const decoded = decodeURIComponent(copySource.replace(/^\//u, ''));
|
||||
const sourceBucket = decoded.split('/')[0];
|
||||
copies.push({source: decoded, destination: target.id});
|
||||
if (sourceBucket !== target.bucket) {
|
||||
return NO_SUCH_KEY(target.bucket);
|
||||
}
|
||||
const source = objects.get(decoded);
|
||||
if (!source) {
|
||||
return NO_SUCH_KEY(target.bucket);
|
||||
}
|
||||
objects.set(target.id, {
|
||||
...source,
|
||||
contentType: request.headers.get('content-type') ?? source.contentType,
|
||||
});
|
||||
return new HttpResponse(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><CopyObjectResult><ETag>"x"</ETag></CopyObjectResult>',
|
||||
{status: 200, headers: {'Content-Type': 'application/xml'}},
|
||||
);
|
||||
}
|
||||
const body = new Uint8Array(await request.arrayBuffer());
|
||||
objects.set(target.id, {
|
||||
body,
|
||||
contentType: request.headers.get('content-type') ?? 'application/octet-stream',
|
||||
...(request.headers.get('cache-control') ? {cacheControl: request.headers.get('cache-control')!} : {}),
|
||||
...(request.headers.get('content-disposition')
|
||||
? {contentDisposition: request.headers.get('content-disposition')!}
|
||||
: {}),
|
||||
});
|
||||
return new HttpResponse(null, {status: 200, headers: {ETag: '"x"'}});
|
||||
}),
|
||||
http.get(`${ENDPOINT}/:bucket/*`, ({params}) => {
|
||||
const target = locate({bucket: params.bucket, key: params[0] as string});
|
||||
const object = objects.get(target.id);
|
||||
if (!object) {
|
||||
return NO_SUCH_KEY(target.bucket);
|
||||
}
|
||||
return new HttpResponse(object.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': object.contentType,
|
||||
'Content-Length': String(object.body.length),
|
||||
...(object.cacheControl ? {'Cache-Control': object.cacheControl} : {}),
|
||||
...(object.contentDisposition ? {'Content-Disposition': object.contentDisposition} : {}),
|
||||
},
|
||||
});
|
||||
}),
|
||||
http.head(`${ENDPOINT}/:bucket/*`, ({params}) => {
|
||||
const target = locate({bucket: params.bucket, key: params[0] as string});
|
||||
const object = objects.get(target.id);
|
||||
if (!object) {
|
||||
return new HttpResponse(null, {status: 404});
|
||||
}
|
||||
return new HttpResponse(null, {
|
||||
status: 200,
|
||||
headers: {'Content-Type': object.contentType, 'Content-Length': String(object.body.length)},
|
||||
});
|
||||
}),
|
||||
);
|
||||
return {objects, copies};
|
||||
}
|
||||
|
||||
function storage(): StorageService {
|
||||
return new StorageService({
|
||||
endpoint: ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
region: 'nbg1',
|
||||
accessKeyId: 'TEST',
|
||||
secretAccessKey: 'TEST',
|
||||
});
|
||||
}
|
||||
|
||||
const PDF = new TextEncoder().encode('%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
|
||||
|
||||
describe('StorageService copies on providers that reject cross-bucket CopyObject', () => {
|
||||
let ceph: ReturnType<typeof cephWithoutCrossBucketCopy>;
|
||||
|
||||
beforeEach(() => {
|
||||
ceph = cephWithoutCrossBucketCopy();
|
||||
});
|
||||
|
||||
it('stores a non-media attachment in the CDN bucket', async () => {
|
||||
ceph.objects.set(`${UPLOADS}/upload-1`, {body: PDF, contentType: 'application/octet-stream'});
|
||||
|
||||
await expect(
|
||||
storage().copyObjectWithMetadataStripping({
|
||||
sourceBucket: UPLOADS,
|
||||
sourceKey: 'upload-1',
|
||||
destinationBucket: CDN,
|
||||
destinationKey: 'attachments/1/2/file.pdf',
|
||||
contentType: 'application/pdf',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
const stored = ceph.objects.get(`${CDN}/attachments/1/2/file.pdf`);
|
||||
expect(stored?.contentType).toBe('application/pdf');
|
||||
expect(Buffer.from(stored!.body).equals(Buffer.from(PDF))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the original file when media processing fails', async () => {
|
||||
const brokenHeic = new Uint8Array(4096).fill(7);
|
||||
ceph.objects.set(`${UPLOADS}/upload-2`, {body: brokenHeic, contentType: 'application/octet-stream'});
|
||||
|
||||
await expect(
|
||||
storage().copyObjectWithMetadataStripping({
|
||||
sourceBucket: UPLOADS,
|
||||
sourceKey: 'upload-2',
|
||||
destinationBucket: CDN,
|
||||
destinationKey: 'attachments/1/3/photo.heic',
|
||||
contentType: 'image/heic',
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
const stored = ceph.objects.get(`${CDN}/attachments/1/3/photo.heic`);
|
||||
expect(stored?.contentType).toBe('image/heic');
|
||||
expect(Buffer.from(stored!.body).equals(Buffer.from(brokenHeic))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the source headers when no new content type is given', async () => {
|
||||
ceph.objects.set(`${CDN}/avatars/1/a.png`, {
|
||||
body: PDF,
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000, immutable',
|
||||
contentDisposition: 'inline',
|
||||
});
|
||||
|
||||
await storage().copyObject({
|
||||
sourceBucket: CDN,
|
||||
sourceKey: 'avatars/1/a.png',
|
||||
destinationBucket: 'fluxer-reports',
|
||||
destinationKey: 'evidence/a.png',
|
||||
});
|
||||
|
||||
expect(ceph.objects.get('fluxer-reports/evidence/a.png')).toMatchObject({
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000, immutable',
|
||||
contentDisposition: 'inline',
|
||||
});
|
||||
});
|
||||
|
||||
it('still fails when the source object does not exist', async () => {
|
||||
await expect(
|
||||
storage().copyObject({
|
||||
sourceBucket: UPLOADS,
|
||||
sourceKey: 'missing',
|
||||
destinationBucket: CDN,
|
||||
destinationKey: 'attachments/1/4/missing.pdf',
|
||||
newContentType: 'application/pdf',
|
||||
}),
|
||||
).rejects.toMatchObject({name: 'NoSuchKey'});
|
||||
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 () => {
|
||||
await expect(
|
||||
storage().copyObject({
|
||||
sourceBucket: CDN,
|
||||
sourceKey: 'missing',
|
||||
destinationBucket: CDN,
|
||||
destinationKey: 'other',
|
||||
newContentType: 'image/png',
|
||||
}),
|
||||
).rejects.toMatchObject({name: 'NoSuchKey'});
|
||||
expect(ceph.copies).toEqual([{source: `${CDN}/missing`, destination: `${CDN}/other`}]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {isTestSsoProvider, normalizeAndValidateSsoConfig} from '@app/api/instance/SsoConfigValidation';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function ssoConfig(overrides: {authorizationUrl?: string | null; tokenUrl?: string | null} = {}) {
|
||||
return {
|
||||
enabled: true,
|
||||
enforced: false,
|
||||
issuer: null,
|
||||
authorizationUrl: 'test',
|
||||
tokenUrl: 'test',
|
||||
userInfoUrl: null,
|
||||
jwksUrl: null,
|
||||
clientId: 'client',
|
||||
allowedEmailDomains: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('isTestSsoProvider', () => {
|
||||
it('recognises the placeholder provider only when test mode is enabled', () => {
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: null}, true)).toBe(true);
|
||||
expect(isTestSsoProvider({authorizationUrl: null, tokenUrl: 'test'}, true)).toBe(true);
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test-provider', tokenUrl: null}, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('never recognises the placeholder provider outside test mode', () => {
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: null}, false)).toBe(false);
|
||||
expect(isTestSsoProvider({authorizationUrl: null, tokenUrl: 'test'}, false)).toBe(false);
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test', tokenUrl: 'test'}, false)).toBe(false);
|
||||
expect(isTestSsoProvider({authorizationUrl: 'test-provider', tokenUrl: null}, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAndValidateSsoConfig placeholder endpoints', () => {
|
||||
it('accepts placeholder endpoints in test mode', async () => {
|
||||
const result = await normalizeAndValidateSsoConfig(ssoConfig(), {testModeEnabled: true});
|
||||
expect(result.ready).toBe(true);
|
||||
expect(result.authorizationUrl).toBe('test');
|
||||
});
|
||||
|
||||
it('rejects a placeholder authorization endpoint outside test mode', async () => {
|
||||
await expect(
|
||||
normalizeAndValidateSsoConfig(ssoConfig({tokenUrl: null}), {testModeEnabled: false}),
|
||||
).rejects.toBeInstanceOf(InputValidationError);
|
||||
});
|
||||
|
||||
it('rejects a placeholder token endpoint outside test mode', async () => {
|
||||
await expect(
|
||||
normalizeAndValidateSsoConfig(ssoConfig({authorizationUrl: null}), {testModeEnabled: false}),
|
||||
).rejects.toBeInstanceOf(InputValidationError);
|
||||
});
|
||||
});
|
||||
@@ -64,10 +64,13 @@ export function isTestSsoProvider(
|
||||
},
|
||||
testModeEnabled: boolean,
|
||||
): boolean {
|
||||
if (!testModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
config.authorizationUrl === 'test' ||
|
||||
config.tokenUrl === 'test' ||
|
||||
(testModeEnabled && (config.authorizationUrl?.startsWith('test-') ?? false))
|
||||
(config.authorizationUrl?.startsWith('test-') ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,5 +24,7 @@ export abstract class IInviteRepository {
|
||||
|
||||
abstract updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise<void>;
|
||||
|
||||
abstract compareAndSetInviteUses(invite: Invite, uses: number): Promise<boolean>;
|
||||
|
||||
abstract delete(code: InviteCode): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import type {ChannelID, GuildID, InviteCode, UserID} from '@app/api/BrandedTypes';
|
||||
import {createInviteCode} from '@app/api/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
BatchBuilder,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {Db} from '@app/api/database/CassandraTypes';
|
||||
import type {InviteRow} from '@app/api/database/types/ChannelTypes';
|
||||
import {IInviteRepository} from '@app/api/invite/IInviteRepository';
|
||||
@@ -168,17 +174,13 @@ export class InviteRepository extends IInviteRepository {
|
||||
|
||||
async updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise<void> {
|
||||
if (invite.maxAge > 0) {
|
||||
const remainingTtl = Math.max(
|
||||
Math.floor((invite.createdAt.getTime() + invite.maxAge * 1000 - Date.now()) / 1000),
|
||||
1,
|
||||
);
|
||||
await upsertOne(
|
||||
Invites.patchByPkWithTtl(
|
||||
{code},
|
||||
{
|
||||
uses: Db.set(uses),
|
||||
},
|
||||
remainingTtl,
|
||||
this.remainingTtl(invite),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -193,6 +195,21 @@ export class InviteRepository extends IInviteRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async compareAndSetInviteUses(invite: Invite, uses: number): Promise<boolean> {
|
||||
const patch = {uses: Db.set(uses)};
|
||||
const expected = {uses: invite.uses};
|
||||
if (invite.maxAge > 0) {
|
||||
return executeConditional(
|
||||
Invites.conditionalPatchByPkWithTtl({code: invite.code}, patch, expected, this.remainingTtl(invite)),
|
||||
);
|
||||
}
|
||||
return executeConditional(Invites.conditionalPatchByPk({code: invite.code}, patch, expected));
|
||||
}
|
||||
|
||||
private remainingTtl(invite: Invite): number {
|
||||
return Math.max(Math.floor((invite.createdAt.getTime() + invite.maxAge * 1000 - Date.now()) / 1000), 1);
|
||||
}
|
||||
|
||||
async delete(code: InviteCode): Promise<void> {
|
||||
const invite = await this.findUnique(code);
|
||||
if (!invite) {
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
GuildInviteMetadataResponse,
|
||||
} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
const INVITE_USE_RESERVATION_EXTRA_ATTEMPTS = 8;
|
||||
|
||||
interface GetChannelInvitesParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -262,13 +264,17 @@ export class InviteService {
|
||||
return invite;
|
||||
}
|
||||
if (user) assertAccountNotLimited(user);
|
||||
await this.channelService.groupDms.addRecipientViaInvite({
|
||||
channelId: invite.channelId,
|
||||
recipientId: userId,
|
||||
inviterId: invite.inviterId,
|
||||
requestCache,
|
||||
});
|
||||
return this.incrementInviteUses(invite, {deleteWhenExhausted: true});
|
||||
const channelId = invite.channelId;
|
||||
const reservedInvite = await this.reserveInviteUse(invite);
|
||||
await this.withReservedInviteUse(reservedInvite, () =>
|
||||
this.channelService.groupDms.addRecipientViaInvite({
|
||||
channelId,
|
||||
recipientId: userId,
|
||||
inviterId: invite.inviterId,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
return this.completeInviteUse(reservedInvite, {deleteWhenExhausted: true});
|
||||
}
|
||||
if (!invite.guildId) throw new UnknownInviteError();
|
||||
const guild = await this.guildService.data.getGuildSystem(invite.guildId);
|
||||
@@ -294,20 +300,24 @@ export class InviteService {
|
||||
}
|
||||
const vanityCode = guild.vanityUrlCode ? vanityCodeToInviteCode(guild.vanityUrlCode) : null;
|
||||
const isVanityInvite = invite.code === vanityCode;
|
||||
await this.guildService.members.addUserToGuild({
|
||||
userId,
|
||||
guildId: invite.guildId,
|
||||
sendJoinMessage: true,
|
||||
requestCache,
|
||||
isTemporary: invite.temporary,
|
||||
joinSourceType: isVanityInvite ? JoinSourceTypes.VANITY_URL : JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode: isVanityInvite ? undefined : invite.code,
|
||||
inviterId: isVanityInvite ? undefined : (invite.inviterId ?? undefined),
|
||||
});
|
||||
const guildId = invite.guildId;
|
||||
const reservedInvite = await this.reserveInviteUse(invite);
|
||||
await this.withReservedInviteUse(reservedInvite, () =>
|
||||
this.guildService.members.addUserToGuild({
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
requestCache,
|
||||
isTemporary: invite.temporary,
|
||||
joinSourceType: isVanityInvite ? JoinSourceTypes.VANITY_URL : JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode: isVanityInvite ? undefined : invite.code,
|
||||
inviterId: isVanityInvite ? undefined : (invite.inviterId ?? undefined),
|
||||
}),
|
||||
);
|
||||
if (invite.temporary) {
|
||||
await this.apiContext.services.gateway.addTemporaryGuild({userId, guildId: invite.guildId});
|
||||
await this.apiContext.services.gateway.addTemporaryGuild({userId, guildId});
|
||||
}
|
||||
return this.incrementInviteUses(invite, {deleteWhenExhausted: !isVanityInvite});
|
||||
return this.completeInviteUse(reservedInvite, {deleteWhenExhausted: !isVanityInvite});
|
||||
}
|
||||
|
||||
private createRandomInviteCode(): InviteCode {
|
||||
@@ -326,13 +336,53 @@ export class InviteService {
|
||||
});
|
||||
}
|
||||
|
||||
private async incrementInviteUses(invite: Invite, params: {deleteWhenExhausted: boolean}): Promise<Invite> {
|
||||
const newUses = invite.uses + 1;
|
||||
await this.inviteRepository.updateInviteUses(invite.code, newUses, invite);
|
||||
if (params.deleteWhenExhausted && invite.maxUses > 0 && newUses >= invite.maxUses) {
|
||||
private async reserveInviteUse(invite: Invite): Promise<Invite> {
|
||||
if (invite.maxUses <= 0) return invite;
|
||||
let current: Invite | null = invite;
|
||||
for (let attempt = 0; attempt <= invite.maxUses + INVITE_USE_RESERVATION_EXTRA_ATTEMPTS; attempt++) {
|
||||
if (!current || current.uses >= current.maxUses) break;
|
||||
const reservedUses = current.uses + 1;
|
||||
if (await this.inviteRepository.compareAndSetInviteUses(current, reservedUses)) {
|
||||
return this.cloneInviteWithUses(current, reservedUses);
|
||||
}
|
||||
current = await this.inviteRepository.findUnique(invite.code);
|
||||
}
|
||||
throw new UnknownInviteError();
|
||||
}
|
||||
|
||||
private async withReservedInviteUse(reservedInvite: Invite, join: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await join();
|
||||
} catch (error) {
|
||||
await this.releaseInviteUse(reservedInvite);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async releaseInviteUse(reservedInvite: Invite): Promise<void> {
|
||||
if (reservedInvite.maxUses <= 0) return;
|
||||
try {
|
||||
let current = await this.inviteRepository.findUnique(reservedInvite.code);
|
||||
for (let attempt = 0; attempt <= reservedInvite.maxUses + INVITE_USE_RESERVATION_EXTRA_ATTEMPTS; attempt++) {
|
||||
if (!current || current.uses <= 0) return;
|
||||
if (await this.inviteRepository.compareAndSetInviteUses(current, current.uses - 1)) return;
|
||||
current = await this.inviteRepository.findUnique(reservedInvite.code);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error({error, inviteCode: reservedInvite.code}, 'Failed to release reserved invite use');
|
||||
}
|
||||
}
|
||||
|
||||
private async completeInviteUse(invite: Invite, params: {deleteWhenExhausted: boolean}): Promise<Invite> {
|
||||
if (invite.maxUses <= 0) {
|
||||
const newUses = invite.uses + 1;
|
||||
await this.inviteRepository.updateInviteUses(invite.code, newUses, invite);
|
||||
return this.cloneInviteWithUses(invite, newUses);
|
||||
}
|
||||
if (params.deleteWhenExhausted && invite.uses >= invite.maxUses) {
|
||||
await this.inviteRepository.delete(invite.code);
|
||||
}
|
||||
return this.cloneInviteWithUses(invite, newUses);
|
||||
return invite;
|
||||
}
|
||||
|
||||
private async findInviteWithLowercaseFallback(inviteCode: InviteCode): Promise<Invite | null> {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MAX_GUILD_MEMBERS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface InviteResponse {
|
||||
code: string;
|
||||
uses?: number;
|
||||
}
|
||||
|
||||
async function setupInvite(harness: ApiTestHarness, maxUses: number, joinerCount: number) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Max uses guild');
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
const invite = await createBuilder<InviteResponse>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}/invites`)
|
||||
.body({max_uses: maxUses, unique: true})
|
||||
.execute();
|
||||
const joiners: Array<TestAccount> = [];
|
||||
for (let i = 0; i < joinerCount; i++) {
|
||||
joiners.push(await createTestAccount(harness));
|
||||
}
|
||||
return {owner, guild, invite, joiners};
|
||||
}
|
||||
|
||||
async function countMembers(harness: ApiTestHarness, accounts: Array<TestAccount>, guildId: string): Promise<number> {
|
||||
let count = 0;
|
||||
for (const account of accounts) {
|
||||
const guilds = await createBuilder<Array<{id: string}>>(harness, account.token).get('/users/@me/guilds').execute();
|
||||
if (guilds.some((guild) => guild.id === guildId)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function findGuildInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
code: string,
|
||||
): Promise<InviteResponse | null> {
|
||||
const invites = await createBuilder<Array<InviteResponse>>(harness, token)
|
||||
.get(`/guilds/${guildId}/invites`)
|
||||
.execute();
|
||||
return invites.find((invite) => invite.code === code) ?? null;
|
||||
}
|
||||
|
||||
describe('Invite max uses', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
it.each([
|
||||
[1, 8],
|
||||
[3, 10],
|
||||
])('admits at most max_uses=%i of %i simultaneous joiners', async (maxUses, joinerCount) => {
|
||||
const {owner, guild, invite, joiners} = await setupInvite(harness, maxUses, joinerCount);
|
||||
const responses = await Promise.all(
|
||||
joiners.map((joiner) =>
|
||||
createBuilder(harness, joiner.token).post(`/invites/${invite.code}`).body(null).executeRaw(),
|
||||
),
|
||||
);
|
||||
const statuses = responses.map((result) => result.response.status);
|
||||
expect(statuses.filter((status) => status === HTTP_STATUS.OK)).toHaveLength(maxUses);
|
||||
expect(
|
||||
statuses.filter((status) => status !== HTTP_STATUS.OK).every((status) => status === HTTP_STATUS.NOT_FOUND),
|
||||
).toBe(true);
|
||||
expect(await countMembers(harness, joiners, guild.id)).toBe(maxUses);
|
||||
expect(await findGuildInvite(harness, owner.token, guild.id, invite.code)).toBeNull();
|
||||
});
|
||||
it('counts every use when joiners arrive together', async () => {
|
||||
const {owner, guild, invite, joiners} = await setupInvite(harness, 10, 4);
|
||||
await Promise.all(
|
||||
joiners.map((joiner) =>
|
||||
createBuilder(harness, joiner.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute(),
|
||||
),
|
||||
);
|
||||
const after = await findGuildInvite(harness, owner.token, guild.id, invite.code);
|
||||
expect(after?.uses).toBe(4);
|
||||
});
|
||||
it('returns the use when the join fails', async () => {
|
||||
const {owner, guild, invite, joiners} = await setupInvite(harness, 1, 2);
|
||||
const [first, second] = joiners;
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/guilds/${guild.id}/member-count`)
|
||||
.body({member_count: MAX_GUILD_MEMBERS})
|
||||
.execute();
|
||||
await createBuilder(harness, first!.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.MAX_GUILD_MEMBERS)
|
||||
.execute();
|
||||
const afterFailure = await findGuildInvite(harness, owner.token, guild.id, invite.code);
|
||||
expect(afterFailure?.uses).toBe(0);
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guild.id}/member-count`).body({member_count: 1}).execute();
|
||||
await createBuilder(harness, second!.token)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(await countMembers(harness, [second!], guild.id)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import {Logger} from '@app/api/Logger';
|
||||
import {phraseBlocklistCache} from '@app/api/middleware/PhraseBlocklistCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {readRequestJsonBody} from '@app/api/utils/RequestJsonBody';
|
||||
import {extractUrlCandidates} from '@app/api/utils/UrlNormalizer';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
@@ -73,6 +72,8 @@ const SKIP_FIELD_SUFFIXES = [
|
||||
] as const;
|
||||
const SKIP_CONTENT_FILTER_PATH_PARTS = [
|
||||
'/admin/blocklists/phrase/',
|
||||
'/admin/blocklists/url-domain/',
|
||||
'/admin/blocklists/url/',
|
||||
'/auth/',
|
||||
'/oauth2/',
|
||||
'/premium/store/',
|
||||
@@ -149,15 +150,12 @@ const ContentFilterMiddleware = createMiddleware(async (ctx, next) => {
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
const urls = extractUrlCandidates(text);
|
||||
for (const url of urls) {
|
||||
if (urlBlocklistCache.isUrlOrDomainBanned(url)) {
|
||||
Logger.warn(
|
||||
{surface: 'global_filter', userId: userId?.toString(), path},
|
||||
'content_moderation.block url match in request body',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
if (urlBlocklistCache.containsBannedLink(text)) {
|
||||
Logger.warn(
|
||||
{surface: 'global_filter', userId: userId?.toString(), path},
|
||||
'content_moderation.block url match in request body',
|
||||
);
|
||||
throw new ContentBlockedError();
|
||||
}
|
||||
}
|
||||
return next();
|
||||
|
||||
@@ -127,7 +127,7 @@ class IpBanCache {
|
||||
const sameIpDecisionKey = getSameIpDecisionKey(parsed.canonical);
|
||||
if (sameIpDecisionKey) {
|
||||
const decisionCount = this.sameIpDecisionBans.get(sameIpDecisionKey);
|
||||
if (decisionCount) {
|
||||
if (decisionCount && this.isActive(decisionCount)) {
|
||||
return {
|
||||
ipAddress: parsed.canonical,
|
||||
matchedEntry: sameIpDecisionKey,
|
||||
@@ -137,7 +137,7 @@ class IpBanCache {
|
||||
}
|
||||
const singleMap = this.singleIpBans[parsed.family];
|
||||
const single = singleMap.get(parsed.canonical);
|
||||
if (single) {
|
||||
if (single && this.isActive(single.count)) {
|
||||
return {
|
||||
ipAddress: parsed.canonical,
|
||||
matchedEntry: parsed.canonical,
|
||||
@@ -146,7 +146,7 @@ class IpBanCache {
|
||||
}
|
||||
const rangeMap = this.rangeIpBans[parsed.family];
|
||||
for (const [canonical, range] of rangeMap.entries()) {
|
||||
if (parsed.value >= range.start && parsed.value <= range.end) {
|
||||
if (parsed.value >= range.start && parsed.value <= range.end && this.isActive(range.count)) {
|
||||
return {
|
||||
ipAddress: parsed.canonical,
|
||||
matchedEntry: canonical,
|
||||
@@ -232,6 +232,13 @@ class IpBanCache {
|
||||
return count.permanent <= 0 && count.temporary <= 0;
|
||||
}
|
||||
|
||||
private isActive(count: IpBanCount): boolean {
|
||||
if (count.permanent > 0 || !count.temporaryExpiresAt) {
|
||||
return true;
|
||||
}
|
||||
return count.temporaryExpiresAt.getTime() > Date.now();
|
||||
}
|
||||
|
||||
private resolveCount(count: IpBanCount): {
|
||||
kind: BannedIpKind;
|
||||
expiresAt: Date | null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Config} from '@app/api/Config';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {InternalServerError} from '@fluxer/errors/src/domains/core/InternalServerError';
|
||||
import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern';
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {Context, MiddlewareHandler} from 'hono';
|
||||
import type {ZodType} from 'zod';
|
||||
@@ -53,7 +54,7 @@ async function validateAndRewriteResponse(ctx: Context<HonoEnv>, schema: ZodType
|
||||
}));
|
||||
const errorContext = {
|
||||
method: ctx.req.method,
|
||||
path: ctx.req.path,
|
||||
path: resolveRoutePattern(ctx),
|
||||
status: response.status,
|
||||
validationErrors,
|
||||
body,
|
||||
|
||||
@@ -463,6 +463,7 @@ export const getGuildDiscoveryService = singleton(
|
||||
getGuildRepository(),
|
||||
getGatewayService(),
|
||||
getGuildSearchService(),
|
||||
getChannelRepository().channelData,
|
||||
),
|
||||
);
|
||||
export const getReadStateRequestService = singleton(() => new ReadStateRequestService(getReadStateService()));
|
||||
|
||||
@@ -7,12 +7,13 @@ import {BANNED_URL_DOMAINS_REFRESH_CHANNEL, BANNED_URLS_REFRESH_CHANNEL} from '@
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {RefreshSubscription} from '@app/api/utils/RefreshSubscription';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
import {UrlHostRuleSet} from '@app/api/utils/UrlHostRules';
|
||||
import {canonicalizeUrl, extractLinkHosts, extractUrlCandidates} from '@app/api/utils/UrlNormalizer';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
|
||||
class UrlBlocklistCache {
|
||||
private exactUrls: Set<string> = new Set();
|
||||
private blockedDomains: Set<string> = new Set();
|
||||
private hostRules = new UrlHostRuleSet();
|
||||
private adminRepository = new AdminRepository();
|
||||
private kvClient: IKVProvider | null = null;
|
||||
private storageService: IStorageService | null = null;
|
||||
@@ -55,15 +56,15 @@ class UrlBlocklistCache {
|
||||
for (const row of manualUrls) {
|
||||
if (row.url_canonical) nextUrls.add(row.url_canonical.toLowerCase());
|
||||
}
|
||||
const nextDomains = new Set<string>();
|
||||
const nextHostRules = new UrlHostRuleSet();
|
||||
for (const row of domains) {
|
||||
nextDomains.add(row.domain.toLowerCase());
|
||||
nextHostRules.add(row.domain, row.match_subdomains ?? true);
|
||||
}
|
||||
this.exactUrls = nextUrls;
|
||||
this.blockedDomains = nextDomains;
|
||||
this.hostRules = nextHostRules;
|
||||
this.consecutiveFailures = 0;
|
||||
Logger.debug(
|
||||
{urls: nextUrls.size, domains: nextDomains.size, feedUrls: feedUrls.size},
|
||||
{urls: nextUrls.size, ...nextHostRules.size, feedUrls: feedUrls.size},
|
||||
'URL blocklist cache refreshed',
|
||||
);
|
||||
}
|
||||
@@ -94,7 +95,17 @@ class UrlBlocklistCache {
|
||||
}
|
||||
|
||||
isHostnameBanned(host: string): boolean {
|
||||
return this.blockedDomains.has(host.toLowerCase());
|
||||
return this.hostRules.matches(host);
|
||||
}
|
||||
|
||||
containsBannedLink(text: string): boolean {
|
||||
for (const url of extractUrlCandidates(text)) {
|
||||
if (this.isUrlOrDomainBanned(url)) return true;
|
||||
}
|
||||
for (const host of extractLinkHosts(text)) {
|
||||
if (this.isHostnameBanned(host)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
addExactUrl(canonical: string): void {
|
||||
@@ -105,21 +116,22 @@ class UrlBlocklistCache {
|
||||
this.exactUrls.delete(canonical.toLowerCase());
|
||||
}
|
||||
|
||||
addDomain(domain: string): void {
|
||||
this.blockedDomains.add(domain.toLowerCase());
|
||||
addDomain(domain: string, matchSubdomains = true): void {
|
||||
this.hostRules.add(domain, matchSubdomains);
|
||||
}
|
||||
|
||||
removeDomain(domain: string): void {
|
||||
this.blockedDomains.delete(domain.toLowerCase());
|
||||
this.hostRules.remove(domain);
|
||||
}
|
||||
|
||||
get size(): {
|
||||
urls: number;
|
||||
domains: number;
|
||||
patterns: number;
|
||||
} {
|
||||
return {
|
||||
urls: this.exactUrls.size,
|
||||
domains: this.blockedDomains.size,
|
||||
...this.hostRules.size,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,7 +140,7 @@ class UrlBlocklistCache {
|
||||
Logger.error({error}, 'Failed to shut down URL blocklist cache');
|
||||
});
|
||||
this.exactUrls = new Set();
|
||||
this.blockedDomains = new Set();
|
||||
this.hostRules = new UrlHostRuleSet();
|
||||
this.kvClient = null;
|
||||
this.storageService = null;
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
@@ -102,7 +102,7 @@ describe('client ip resolution across the request pipeline', () => {
|
||||
expect(pipeline.resolutions[0]?.ip).toBe('203.0.113.10');
|
||||
expect(pipeline.resolutions[1]?.ip).toBe('203.0.113.10');
|
||||
});
|
||||
it('rejects an invalid trusted header even when the configured header carries a valid address', async () => {
|
||||
it('rejects an invalid trusted header even when the configured header contains a valid address', async () => {
|
||||
const pipeline = createPipeline('x-real-ip');
|
||||
const response = await pipeline.request({'x-forwarded-for': '203.0.113.10', 'x-real-ip': 'not-an-ip'});
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
@@ -81,4 +81,14 @@ describe('shouldSkipContentFilterPath', () => {
|
||||
const result = paths.map((path) => shouldSkipContentFilterPath(path));
|
||||
expect(result).toEqual([false, false, false]);
|
||||
});
|
||||
test('skips blocklist writes whose values are the blocked content', () => {
|
||||
const paths = [
|
||||
'/admin/blocklists/phrase/entries',
|
||||
'/admin/blocklists/url/entries',
|
||||
'/admin/blocklists/url-domain/entries',
|
||||
'/admin/blocklists/profile-substring/entries',
|
||||
];
|
||||
const result = paths.map((path) => shouldSkipContentFilterPath(path));
|
||||
expect(result).toEqual([true, true, true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
beforeEach(() => {
|
||||
ipBanCache.resetCaches();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('IpBanCache', () => {
|
||||
it('blocks IPv4-mapped IPv6 when a single IPv4 address is banned', () => {
|
||||
ipBanCache.ban('127.0.0.1');
|
||||
@@ -44,4 +48,21 @@ describe('IpBanCache', () => {
|
||||
expect(match?.kind).toBe('permanent');
|
||||
expect(match?.expiresAt).toBe(null);
|
||||
});
|
||||
it('stops matching a temporary ban once it has expired', () => {
|
||||
vi.useFakeTimers({toFake: ['Date']});
|
||||
ipBanCache.banTemp('203.0.113.52', 3600);
|
||||
ipBanCache.banTemp('203.0.113.0/24', 3600);
|
||||
expect(ipBanCache.isBanned('203.0.113.52')).toBe(true);
|
||||
expect(ipBanCache.isBanned('203.0.113.53')).toBe(true);
|
||||
vi.advanceTimersByTime(3_600_001);
|
||||
expect(ipBanCache.getMatch('203.0.113.52')).toBe(null);
|
||||
expect(ipBanCache.getMatch('203.0.113.53')).toBe(null);
|
||||
});
|
||||
it('keeps matching a permanent ban that shares an address with an expired temporary one', () => {
|
||||
vi.useFakeTimers({toFake: ['Date']});
|
||||
ipBanCache.banTemp('203.0.113.54', 3600);
|
||||
ipBanCache.ban('203.0.113.54');
|
||||
vi.advanceTimersByTime(3_600_001);
|
||||
expect(ipBanCache.getMatch('203.0.113.54')?.kind).toBe('permanent');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('urlBlocklistCache link matching', () => {
|
||||
afterEach(() => {
|
||||
urlBlocklistCache.resetForTesting();
|
||||
});
|
||||
|
||||
it('blocks a masked markdown link whose target a pattern covers', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', true);
|
||||
expect(urlBlocklistCache.containsBannedLink('[open the store](https://shop-2.onrender.com)')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('[open the store](<https://www.shop.onrender.com/x>)')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('[https://docs.onrender.com](https://shop.onrender.com)')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks autolinks and bare links', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', false);
|
||||
expect(urlBlocklistCache.containsBannedLink('<https://myshop.onrender.com/path>')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('visit myshop.onrender.com today')).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes the link target before matching', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', false);
|
||||
const variants = [
|
||||
'https://user:[email protected]:8443/x',
|
||||
'https://[email protected]',
|
||||
'https://shop.onrender.com./',
|
||||
'https://shop%2Eonrender%2Ecom/',
|
||||
'https://shop。onrender。com/',
|
||||
'https://shop-ü.onrender.com/',
|
||||
];
|
||||
for (const text of variants) {
|
||||
expect(urlBlocklistCache.containsBannedLink(text), text).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the bare suffix and unrelated hosts alone', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', true);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://onrender.com/docs')).toBe(false);
|
||||
expect(urlBlocklistCache.containsBannedLink('[docs](https://docs.onrender.com)')).toBe(false);
|
||||
expect(urlBlocklistCache.containsBannedLink('the shop is closed')).toBe(false);
|
||||
});
|
||||
|
||||
it('covers subdomains of a domain entry only when it is flagged to', () => {
|
||||
urlBlocklistCache.addDomain('shop.example.com', true);
|
||||
urlBlocklistCache.addDomain('store.example.com', false);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://www.shop.example.com')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://store.example.com')).toBe(true);
|
||||
expect(urlBlocklistCache.containsBannedLink('https://www.store.example.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('stops matching after removal', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', true);
|
||||
urlBlocklistCache.removeDomain('*shop*.onrender.com');
|
||||
expect(urlBlocklistCache.containsBannedLink('https://shop.onrender.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies domain rules to a single URL', () => {
|
||||
urlBlocklistCache.addDomain('*shop*.onrender.com', false);
|
||||
expect(urlBlocklistCache.isUrlOrDomainBanned('https://shop.onrender.com/checkout')).toBe(true);
|
||||
expect(urlBlocklistCache.isUrlOrDomainBanned('https://docs.onrender.com/')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -304,7 +304,10 @@ export class OAuth2Service {
|
||||
if (authCode.userId && !(await this.findActiveUser(authCode.userId))) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
await this.tokens.deleteAuthorizationCode(code);
|
||||
if (!(await this.tokens.consumeAuthorizationCode(code, authCode.applicationId))) {
|
||||
Logger.debug({code_len: code.length}, 'OAuth2 tokenExchange: authorization code already redeemed');
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
const res = await this.issueTokens({
|
||||
application,
|
||||
userId: authCode.userId,
|
||||
@@ -328,7 +331,9 @@ export class OAuth2Service {
|
||||
if (!(await this.findActiveUser(refresh.userId))) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
await this.tokens.deleteRefreshToken(params.refreshToken!, refresh.applicationId, refresh.userId);
|
||||
if (!(await this.tokens.consumeRefreshToken(params.refreshToken!, refresh.applicationId, refresh.userId))) {
|
||||
throw new InvalidGrantError();
|
||||
}
|
||||
const res = await this.issueTokens({
|
||||
application,
|
||||
userId: refresh.userId,
|
||||
|
||||
@@ -14,13 +14,14 @@ export interface IOAuth2TokenRepository {
|
||||
createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode>;
|
||||
getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null>;
|
||||
deleteAuthorizationCode(code: string): Promise<void>;
|
||||
consumeAuthorizationCode(code: string, applicationId: ApplicationID): Promise<boolean>;
|
||||
createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken>;
|
||||
getAccessToken(token: string): Promise<OAuth2AccessToken | null>;
|
||||
deleteAccessToken(token: string, applicationId: ApplicationID, userId: UserID | null): Promise<void>;
|
||||
deleteAllAccessTokensForUser(userId: UserID): Promise<void>;
|
||||
createRefreshToken(data: OAuth2RefreshTokenRow): Promise<OAuth2RefreshToken>;
|
||||
getRefreshToken(token: string): Promise<OAuth2RefreshToken | null>;
|
||||
deleteRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<void>;
|
||||
consumeRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<boolean>;
|
||||
deleteAllRefreshTokensForUser(userId: UserID): Promise<void>;
|
||||
listRefreshTokensForUser(userId: UserID): Promise<Array<OAuth2RefreshToken>>;
|
||||
deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise<void>;
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApplicationID, UserID} from '@app/api/BrandedTypes';
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import {
|
||||
BatchBuilder,
|
||||
deleteOneOrMany,
|
||||
executeConditional,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {
|
||||
OAuth2AccessTokenByUserRow,
|
||||
OAuth2AccessTokenRow,
|
||||
@@ -71,6 +78,10 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
|
||||
await deleteOneOrMany(OAuth2AuthorizationCodes.deleteByPk({code}));
|
||||
}
|
||||
|
||||
async consumeAuthorizationCode(code: string, applicationId: ApplicationID): Promise<boolean> {
|
||||
return executeConditional(OAuth2AuthorizationCodes.conditionalDeleteByPk({code}, {application_id: applicationId}));
|
||||
}
|
||||
|
||||
async createAccessToken(data: OAuth2AccessTokenRow): Promise<OAuth2AccessToken> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2AccessTokens.insertWithTtl(data, ACCESS_TOKEN_TTL_SECONDS));
|
||||
@@ -142,11 +153,14 @@ export class OAuth2TokenRepository implements IOAuth2TokenRepository {
|
||||
return row ? new OAuth2RefreshToken(row) : null;
|
||||
}
|
||||
|
||||
async deleteRefreshToken(token: string, _applicationId: ApplicationID, userId: UserID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(OAuth2RefreshTokens.deleteByPk({token_: token}));
|
||||
batch.addPrepared(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
|
||||
await batch.execute();
|
||||
async consumeRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise<boolean> {
|
||||
const consumed = await executeConditional(
|
||||
OAuth2RefreshTokens.conditionalDeleteByPk({token_: token}, {application_id: applicationId, user_id: userId}),
|
||||
);
|
||||
if (consumed) {
|
||||
await deleteOneOrMany(OAuth2RefreshTokensByUser.deleteByPk({user_id: userId, token_: token}));
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
|
||||
async deleteAllRefreshTokensForUser(userId: UserID): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
authorizeOAuth2,
|
||||
createOAuth2TestSetup,
|
||||
exchangeOAuth2AuthorizationCode,
|
||||
} from '@app/api/oauth/tests/OAuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const CONCURRENT_REQUESTS = 8;
|
||||
|
||||
interface TokenResult {
|
||||
status: number;
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
function addQueryLatency(): void {
|
||||
const executeQuery = InMemoryCassandraQueryExecutor.prototype.executeQuery;
|
||||
vi.spyOn(InMemoryCassandraQueryExecutor.prototype, 'executeQuery').mockImplementation(async function (
|
||||
this: InMemoryCassandraQueryExecutor,
|
||||
...args: Parameters<typeof executeQuery>
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
return executeQuery.apply(this, args);
|
||||
} as typeof executeQuery);
|
||||
}
|
||||
|
||||
async function postToken(
|
||||
harness: ApiTestHarness,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
form: Record<string, string>,
|
||||
): Promise<TokenResult> {
|
||||
const response = await harness.app.request('/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
||||
'x-forwarded-for': '127.0.0.1',
|
||||
},
|
||||
body: new URLSearchParams(form).toString(),
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as {access_token?: string} | null;
|
||||
return {status: response.status, accessToken: body?.access_token ?? null};
|
||||
}
|
||||
|
||||
async function postConcurrently(
|
||||
harness: ApiTestHarness,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
form: Record<string, string>,
|
||||
): Promise<Array<TokenResult>> {
|
||||
addQueryLatency();
|
||||
try {
|
||||
return await Promise.all(
|
||||
Array.from({length: CONCURRENT_REQUESTS}, () => postToken(harness, clientId, clientSecret, form)),
|
||||
);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
}
|
||||
|
||||
function expectSingleSuccess(results: Array<TokenResult>): void {
|
||||
const succeeded = results.filter((result) => result.status === HTTP_STATUS.OK);
|
||||
expect(succeeded).toHaveLength(1);
|
||||
expect(succeeded[0]!.accessToken).toBeTruthy();
|
||||
expect(results.filter((result) => result.status === HTTP_STATUS.BAD_REQUEST)).toHaveLength(CONCURRENT_REQUESTS - 1);
|
||||
}
|
||||
|
||||
describe('OAuth2 concurrent grant redemption', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('redeems an authorization code once when requests overlap', async () => {
|
||||
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
|
||||
const {code} = await authorizeOAuth2(harness, endUser.token, {
|
||||
client_id: application.id,
|
||||
redirect_uri: redirectURI,
|
||||
scope: 'identify',
|
||||
});
|
||||
const results = await postConcurrently(harness, application.id, application.client_secret, {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
client_id: application.id,
|
||||
});
|
||||
expectSingleSuccess(results);
|
||||
});
|
||||
|
||||
test('rotates a refresh token once when requests overlap', async () => {
|
||||
const {endUser, redirectURI, application} = await createOAuth2TestSetup(harness);
|
||||
const {code} = await authorizeOAuth2(harness, endUser.token, {
|
||||
client_id: application.id,
|
||||
redirect_uri: redirectURI,
|
||||
scope: 'identify',
|
||||
});
|
||||
const initial = await exchangeOAuth2AuthorizationCode(harness, {
|
||||
client_id: application.id,
|
||||
client_secret: application.client_secret,
|
||||
code,
|
||||
redirect_uri: redirectURI,
|
||||
});
|
||||
const results = await postConcurrently(harness, application.id, application.client_secret, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: initial.refresh_token!,
|
||||
client_id: application.id,
|
||||
});
|
||||
expectSingleSuccess(results);
|
||||
});
|
||||
});
|
||||
@@ -5541,6 +5541,77 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/discovery/guilds/{guild_id}/channels/{channel_id}": {
|
||||
"get": {
|
||||
"operationId": "get_discovery_channel_preview",
|
||||
"summary": "Preview a channel in a discoverable guild",
|
||||
"tags": ["Discovery"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/DiscoveryChannelPreviewResponse"}}
|
||||
}
|
||||
},
|
||||
"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": "Returns the guild and channel behind a channel or message link when the guild is listed in discovery and new members can read the channel.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "guild_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the guild", "$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "The ID of the guild"
|
||||
},
|
||||
{
|
||||
"name": "channel_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"},
|
||||
"description": "The ID of the channel"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/discovery/guilds/{guild_id}/join": {
|
||||
"post": {
|
||||
"operationId": "join_discovery_guild",
|
||||
@@ -13912,7 +13983,7 @@
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserPrivateResponse"}}}
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserUpdateResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
@@ -13953,7 +14024,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile.",
|
||||
"description": "Updates the authenticated user's profile information such as username, avatar, and bio. Requires sudo mode verification for security-sensitive changes. Only default users can modify their own profile. A password change invalidates all existing sessions and returns the replacement session token.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
@@ -21543,7 +21614,7 @@
|
||||
]
|
||||
},
|
||||
"referenced_message": {
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message has no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
@@ -25300,6 +25371,250 @@
|
||||
"webauthn_challenge": {"description": "WebAuthn challenge string", "type": "string"}
|
||||
}
|
||||
},
|
||||
"UserUpdateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "The unique identifier (snowflake) for this user",
|
||||
"$ref": "#/components/schemas/SnowflakeStringType"
|
||||
},
|
||||
"username": {"type": "string", "description": "The username of the user, not unique across the platform"},
|
||||
"discriminator": {"type": "string", "description": "The four-digit discriminator tag of the user"},
|
||||
"global_name": {"description": "The display name of the user, if set", "type": ["string", "null"]},
|
||||
"avatar": {"description": "The hash of the user avatar image", "type": ["string", "null"]},
|
||||
"avatar_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The dominant avatar color of the user as an integer"
|
||||
},
|
||||
"bot": {"description": "Whether the user is a bot account", "type": "boolean"},
|
||||
"system": {"description": "Whether the user is an official system user", "type": "boolean"},
|
||||
"flags": {"$ref": "#/components/schemas/PublicUserFlags"},
|
||||
"mention_flags": {
|
||||
"description": "The user's account-wide reply mention preference. Omitted when the user has no preference set (treated as NO_PREFERENCE).",
|
||||
"$ref": "#/components/schemas/MentionReplyPreferences"
|
||||
},
|
||||
"is_staff": {"type": "boolean", "description": "Whether the user has staff permissions"},
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Access control list entries for the user"
|
||||
},
|
||||
"traits": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Special traits assigned to the user account"
|
||||
},
|
||||
"email": {"description": "The email address associated with the account", "type": ["string", "null"]},
|
||||
"email_bounced": {
|
||||
"description": "Whether the current email address is marked as bounced by the mail provider",
|
||||
"type": "boolean"
|
||||
},
|
||||
"has_verified_phone": {"type": "boolean", "description": "Deprecated. Always false."},
|
||||
"bio": {"description": "The user biography text", "type": ["string", "null"]},
|
||||
"pronouns": {"description": "The preferred pronouns of the user", "type": ["string", "null"]},
|
||||
"accent_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The user-selected accent color as an integer"
|
||||
},
|
||||
"timezone": {"description": "The IANA timezone identifier saved by the user", "type": ["string", "null"]},
|
||||
"timezone_privacy_flags": {"$ref": "#/components/schemas/ProfileFieldPrivacyFlags"},
|
||||
"banner": {"description": "The hash of the user profile banner image", "type": ["string", "null"]},
|
||||
"banner_color": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The default banner color if no custom banner is set"
|
||||
},
|
||||
"mfa_enabled": {"type": "boolean", "description": "Whether multi-factor authentication is enabled"},
|
||||
"authenticator_types": {
|
||||
"description": "The types of authenticators configured for MFA",
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/UserAuthenticatorTypes"}
|
||||
},
|
||||
"verified": {"type": "boolean", "description": "Whether the email address has been verified"},
|
||||
"account_limited": {"description": "Whether the account is limited", "type": "boolean"},
|
||||
"premium_type": {
|
||||
"anyOf": [
|
||||
{"$ref": "#/components/schemas/UserPremiumTypes", "description": "The type of premium subscription"},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"premium_since": {
|
||||
"description": "ISO8601 timestamp of when premium was first activated",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_until": {
|
||||
"description": "ISO8601 timestamp of when premium access ends, including stacked gift time",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_will_cancel": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium is set to cancel at the end of the billing period"
|
||||
},
|
||||
"premium_billing_cycle": {
|
||||
"description": "The billing cycle for the premium subscription",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_lifetime_sequence": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/Int32Type"}, {"type": "null"}],
|
||||
"description": "The sequence number for lifetime premium subscribers"
|
||||
},
|
||||
"premium_grace_ends_at": {
|
||||
"description": "ISO8601 timestamp at which grace access ends after premium_until passes: after a failed renewal payment (7 days from the renewal for monthly plans, 14 for yearly), after a subscription ends (3 days), or during an App Store or Google Play grace period. Perks stay active and the original premium_since is kept on resubscribe until this timestamp passes. Null when no grace is recorded, in which case access lasts 3 days after premium_until.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"premium_discriminator": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user selected a premium-only discriminator that will be rerolled when non-lifetime premium access ends"
|
||||
},
|
||||
"premium_badge_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the premium badge is hidden on the profile"
|
||||
},
|
||||
"premium_badge_masked": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the premium badge shows a masked appearance"
|
||||
},
|
||||
"premium_badge_timestamp_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the premium start timestamp is hidden"
|
||||
},
|
||||
"premium_badge_sequence_hidden": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the lifetime sequence number is hidden"
|
||||
},
|
||||
"premium_purchase_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium purchases are disabled for this account"
|
||||
},
|
||||
"premium_enabled_override": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium features are enabled via override"
|
||||
},
|
||||
"premium_perks_disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether premium perks are temporarily disabled for this account"
|
||||
},
|
||||
"password_last_changed_at": {
|
||||
"description": "ISO8601 timestamp of the last password change",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"last_voice_activity_sharing_change_at": {
|
||||
"description": "ISO8601 timestamp of the last bulk voice-activity-sharing change. Drives the 24-hour cooldown for re-toggling the Active Now sharing default.",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"required_actions": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Deprecated. Always empty."
|
||||
},
|
||||
"nsfw_allowed": {"type": "boolean", "description": "Whether the user is allowed to view NSFW content"},
|
||||
"has_dismissed_premium_onboarding": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user has dismissed the premium onboarding flow"
|
||||
},
|
||||
"has_ever_purchased": {"type": "boolean", "description": "Whether the user has ever made a purchase"},
|
||||
"has_unread_gift_inventory": {
|
||||
"type": "boolean",
|
||||
"description": "Whether there are unread items in the gift inventory"
|
||||
},
|
||||
"unread_gift_inventory_count": {
|
||||
"description": "The number of unread gift inventory items",
|
||||
"$ref": "#/components/schemas/Int32Type"
|
||||
},
|
||||
"pending_bulk_message_deletion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scheduled_at": {
|
||||
"type": "string",
|
||||
"description": "ISO8601 timestamp of when the deletion was scheduled"
|
||||
},
|
||||
"channel_count": {
|
||||
"description": "The number of channels with messages to delete",
|
||||
"$ref": "#/components/schemas/Int32Type"
|
||||
},
|
||||
"message_count": {
|
||||
"description": "The total number of messages to delete",
|
||||
"$ref": "#/components/schemas/Int32Type"
|
||||
}
|
||||
},
|
||||
"required": ["scheduled_at", "channel_count", "message_count"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Information about a pending bulk message deletion request. Only populated when the legacy delayed-deletion flow is in progress; the new immediate-deletion flow does not surface a pending state here."
|
||||
},
|
||||
"age_verified_adult": {
|
||||
"description": "Whether the user has verified their age as an adult via credit card verification",
|
||||
"type": "boolean"
|
||||
},
|
||||
"terms_agreed_at": {
|
||||
"description": "ISO8601 timestamp of when the user last agreed to the terms of service",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"privacy_agreed_at": {
|
||||
"description": "ISO8601 timestamp of when the user last agreed to the privacy policy",
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"token": {
|
||||
"description": "Authentication token for the replacement session, present when the password was changed",
|
||||
"type": "string"
|
||||
},
|
||||
"auth_session_id_hash": {
|
||||
"description": "Base64url-encoded hash of the replacement authentication session, present when the password was changed",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"username",
|
||||
"discriminator",
|
||||
"global_name",
|
||||
"avatar",
|
||||
"avatar_color",
|
||||
"flags",
|
||||
"is_staff",
|
||||
"acls",
|
||||
"traits",
|
||||
"email",
|
||||
"has_verified_phone",
|
||||
"bio",
|
||||
"pronouns",
|
||||
"accent_color",
|
||||
"banner",
|
||||
"banner_color",
|
||||
"mfa_enabled",
|
||||
"verified",
|
||||
"premium_type",
|
||||
"premium_since",
|
||||
"premium_until",
|
||||
"premium_will_cancel",
|
||||
"premium_billing_cycle",
|
||||
"premium_lifetime_sequence",
|
||||
"premium_grace_ends_at",
|
||||
"premium_discriminator",
|
||||
"premium_badge_hidden",
|
||||
"premium_badge_masked",
|
||||
"premium_badge_timestamp_hidden",
|
||||
"premium_badge_sequence_hidden",
|
||||
"premium_purchase_disabled",
|
||||
"premium_enabled_override",
|
||||
"premium_perks_disabled",
|
||||
"password_last_changed_at",
|
||||
"last_voice_activity_sharing_change_at",
|
||||
"required_actions",
|
||||
"nsfw_allowed",
|
||||
"has_dismissed_premium_onboarding",
|
||||
"has_ever_purchased",
|
||||
"has_unread_gift_inventory",
|
||||
"unread_gift_inventory_count",
|
||||
"pending_bulk_message_deletion",
|
||||
"terms_agreed_at",
|
||||
"privacy_agreed_at"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UnfurlRequest": {
|
||||
"type": "object",
|
||||
"properties": {"url": {"description": "The URL to unfurl", "type": "string"}},
|
||||
@@ -28335,6 +28650,35 @@
|
||||
"required": ["url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DiscoveryChannelPreviewResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"description": "Guild ID", "$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"name": {"type": "string", "description": "Guild name"},
|
||||
"icon": {"description": "Guild icon hash", "type": ["string", "null"]}
|
||||
},
|
||||
"required": ["id", "name", "icon"],
|
||||
"additionalProperties": false,
|
||||
"description": "The discoverable guild the channel belongs to"
|
||||
},
|
||||
"channel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"description": "Channel ID", "$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"name": {"description": "Channel name", "type": ["string", "null"]},
|
||||
"type": {"type": "number", "description": "Channel type"}
|
||||
},
|
||||
"required": ["id", "name", "type"],
|
||||
"additionalProperties": false,
|
||||
"description": "A channel that new members can view"
|
||||
}
|
||||
},
|
||||
"required": ["guild", "channel"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DiscoveryGuildListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -30548,7 +30892,7 @@
|
||||
"original": {"type": "string", "description": "The requested URL, echoed back unchanged"},
|
||||
"refreshed": {
|
||||
"type": "string",
|
||||
"description": "The same URL carrying a fresh signature, or the original when it is not an attachment URL of ours"
|
||||
"description": "The same URL with a fresh signature, or the original when it is not an attachment URL of ours"
|
||||
}
|
||||
},
|
||||
"required": ["original", "refreshed"],
|
||||
@@ -32300,9 +32644,15 @@
|
||||
"description": "The template-local channel ID"
|
||||
},
|
||||
"type": {"type": "number", "description": "The channel type (0 = text, 2 = voice, 4 = category)"},
|
||||
"name": {"description": "The name of the channel", "type": ["string", "null"]},
|
||||
"topic": {"description": "The channel topic", "type": ["string", "null"]},
|
||||
"position": {"type": "number", "description": "The position of the channel"},
|
||||
"name": {
|
||||
"description": "The name of the channel",
|
||||
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
|
||||
},
|
||||
"topic": {
|
||||
"description": "The channel topic",
|
||||
"anyOf": [{"type": "string", "maxLength": 1024}, {"type": "null"}]
|
||||
},
|
||||
"position": {"description": "The position of the channel", "$ref": "#/components/schemas/Int32Type"},
|
||||
"parent_id": {
|
||||
"description": "The template-local ID of the parent category",
|
||||
"anyOf": [
|
||||
@@ -32313,14 +32663,25 @@
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"bitrate": {"description": "The bitrate for voice channels", "type": ["number", "null"]},
|
||||
"user_limit": {"description": "The user limit for voice channels", "type": ["number", "null"]},
|
||||
"bitrate": {
|
||||
"description": "The bitrate for voice channels",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"user_limit": {
|
||||
"description": "The user limit for voice channels",
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "null"}]
|
||||
},
|
||||
"voice_connection_limit": {
|
||||
"description": "The per-user voice connection limit for voice channels",
|
||||
"type": ["number", "null"]
|
||||
"anyOf": [{"type": "integer", "minimum": 1, "maximum": 100}, {"type": "null"}]
|
||||
},
|
||||
"nsfw": {"description": "Whether the channel is NSFW", "type": "boolean"},
|
||||
"rate_limit_per_user": {"description": "Slowmode rate limit in seconds", "type": "number"},
|
||||
"rate_limit_per_user": {
|
||||
"description": "Slowmode rate limit in seconds",
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 21600
|
||||
},
|
||||
"permission_overwrites": {
|
||||
"description": "Permission overwrites for this channel",
|
||||
"type": "array",
|
||||
@@ -32357,7 +32718,10 @@
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 9007199254740991}, {"type": "string"}],
|
||||
"description": "The template-local role ID"
|
||||
},
|
||||
"name": {"description": "The name of the role", "type": ["string", "null"]},
|
||||
"name": {
|
||||
"description": "The name of the role",
|
||||
"anyOf": [{"type": "string", "maxLength": 100}, {"type": "null"}]
|
||||
},
|
||||
"permissions": {
|
||||
"description": "The permissions bitfield as a string (legacy)",
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}]
|
||||
@@ -32366,7 +32730,7 @@
|
||||
"description": "The permissions bitfield as a string (preferred)",
|
||||
"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 9007199254740991}]
|
||||
},
|
||||
"color": {"description": "The colour of the role as an integer", "type": "number"},
|
||||
"color": {"description": "The colour of the role as an integer", "$ref": "#/components/schemas/ColorType"},
|
||||
"hoist": {"description": "Whether the role is hoisted", "type": "boolean"},
|
||||
"mentionable": {"description": "Whether the role is mentionable", "type": "boolean"},
|
||||
"unicode_emoji": {"description": "The unicode emoji for the role icon", "type": ["string", "null"]}
|
||||
@@ -35032,6 +35396,14 @@
|
||||
"required": ["src", "proxy_src", "width", "height"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UserAuthenticatorTypes": {
|
||||
"description": "Authenticator type",
|
||||
"type": "integer",
|
||||
"enum": [0, 2],
|
||||
"format": "int32",
|
||||
"x-enumNames": ["TOTP", "WEBAUTHN"],
|
||||
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
|
||||
},
|
||||
"ProfileFieldPrivacyFlags": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
@@ -35283,14 +35655,6 @@
|
||||
"required": ["id", "rawId", "type", "clientExtensionResults", "response"],
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"UserAuthenticatorTypes": {
|
||||
"description": "Authenticator type",
|
||||
"type": "integer",
|
||||
"enum": [0, 2],
|
||||
"format": "int32",
|
||||
"x-enumNames": ["TOTP", "WEBAUTHN"],
|
||||
"x-enumDescriptions": ["Time-based one-time password authenticator", "WebAuthn authenticator"]
|
||||
},
|
||||
"HexString32Type": {"type": "string", "pattern": "^[a-f0-9]{32}$"},
|
||||
"CompletedPasskeyBridgeSudoRedeemResponse": {
|
||||
"type": "object",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user