From 4e6b837ccce52b43d769ba7dadfe82e291a84e87 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 3 Oct 2026 13:04:29 +0200 Subject: [PATCH] fix: tighten edge cases across services (#3158) --- fluxer_admin/build.rs | 2 +- fluxer_admin/openapi-admin.json | 16 +- fluxer_admin/src/api/client.rs | 2 +- fluxer_admin/tests/notification_writes.rs | 2 +- .../tests/voice_restriction_writes.rs | 2 +- .../src/ExternalMediaProxyPathCodec.test.ts | 2 +- fluxer_api/src/api/Config.test.ts | 8 +- .../controllers/AdminApiKeyAdminController.ts | 2 +- .../admin/controllers/BanAdminController.ts | 4 +- .../controllers/MessageAdminController.ts | 2 +- .../admin/tests/VoiceAdminController.test.ts | 2 +- fluxer_api/src/api/auth/AuthPassword.ts | 2 +- fluxer_api/src/api/auth/AuthSession.ts | 7 +- .../auth/tests/AuthSessionReplacement.test.ts | 138 ++++++++ .../src/api/auth/tests/PasswordReset.test.ts | 39 +++ .../WebAuthnRegistrationUserHandle.test.ts | 2 +- .../mappers/StripeToBillingMapper.test.ts | 2 +- .../api/channel/services/ChannelService.ts | 2 + .../channel/services/StreamService.test.ts | 2 +- .../channel_data/ChannelOperationsService.ts | 38 ++- .../group_dm/GroupDmOperationsService.ts | 32 ++ .../tests/AttachmentUploadValidation.test.ts | 2 +- .../channel/tests/CrosspostModeration.test.ts | 4 +- .../tests/GroupDmRecipientRemoveCall.test.ts | 98 ++++++ .../tests/MessageCrosspostFanout.test.ts | 4 +- .../tests/ReactionUsersPagination.test.ts | 2 +- .../api/database/TransientDatabaseError.ts | 4 +- .../services/data/GuildOperationsService.ts | 3 +- .../tests/GuildExpressionCloneOptIn.test.ts | 6 +- .../src/api/guild/tests/GuildMfaLevel.test.ts | 46 ++- .../guild/tests/GuildTemplateImport.test.ts | 79 +++++ .../AvatarServiceSizeLimits.test.ts | 2 +- .../infrastructure/CachePurgePaths.test.ts | 2 +- .../src/api/invite/IInviteRepository.ts | 2 + fluxer_api/src/api/invite/InviteRepository.ts | 29 +- fluxer_api/src/api/invite/InviteService.ts | 98 ++++-- .../api/invite/tests/InviteMaxUses.test.ts | 120 +++++++ .../api/middleware/ResponseTypeMiddleware.ts | 3 +- .../tests/ClientIpResolution.test.ts | 2 +- fluxer_api/src/api/oauth/OAuth2Service.ts | 9 +- .../repositories/IOAuth2TokenRepository.ts | 3 +- .../repositories/OAuth2TokenRepository.ts | 26 +- .../tests/OAuth2ConcurrentRedemption.test.ts | 119 +++++++ fluxer_api/src/api/openapi/openapi.json | 306 ++++++++++++++++-- .../tests/RpcSessionInitHarnessAccess.test.ts | 4 +- .../search/tests/MessageSearchFilters.test.ts | 2 +- .../tests/AppStoreJwsVerifier.test.ts | 2 +- .../tests/StoreBillingAppStore.test.ts | 2 +- fluxer_api/src/api/test/CaptchaTestUtils.ts | 2 +- .../user/controllers/UserAccountController.ts | 5 +- .../services/UserAccountRequestService.ts | 20 +- .../services/UserAccountSecurityService.ts | 10 +- .../api/user/services/UserAccountService.ts | 23 +- .../user/tests/FavoriteMemeOperations.test.ts | 2 +- .../tests/UserProfileTextValidation.test.ts | 2 +- .../utils/tests/SessionClientIdentity.test.ts | 2 +- .../api/webhook/tests/WebhookInstatus.test.ts | 2 +- .../BulkDeleteMessagesForUsersAudit.test.ts | 2 +- .../tests/BulkUpdateUserFlagsTask.test.ts | 2 +- .../worker/tests/WorkerRetiredTask.test.ts | 2 +- .../src/room/PCTransport.test.ts | 2 +- .../livekit-client/src/room/RTCEngine.test.ts | 2 +- .../app/components/setup/SetupWizardClient.ts | 2 +- .../developer_tools/GeneralOptionsMenu.tsx | 2 +- .../state/ExperimentAssignments.test.ts | 4 +- .../messaging/utils/MediaProxyUtils.ts | 4 +- .../platform/transport/RestTransport.ts | 2 +- .../read_state/state/ReadStates.test.ts | 2 +- .../quick_switcher/QuickSwitcherModal.tsx | 2 +- .../src/features/ui/utils/ModalUtils.ts | 4 +- .../features/user/commands/UserCommands.ts | 6 + .../voice/engine/VoiceMediaGraphInvariants.ts | 2 +- .../voice/utils/ScreenShareOptions.test.ts | 2 +- .../utils/VoiceMessageDescriptors.test.ts | 2 +- fluxer_app_proxy/src/bootstrap.rs | 6 +- fluxer_app_proxy/src/config.rs | 2 +- fluxer_app_proxy/src/routes/assets_proxy.rs | 20 +- fluxer_app_proxy/src/routes/spa_index.rs | 12 +- fluxer_common/src/attachment_url_signature.rs | 16 +- fluxer_common/src/config.rs | 6 +- fluxer_common/src/external_media_path.rs | 4 +- fluxer_desktop/native/encoder-ring/src/qsv.rs | 2 +- .../native/encoder-ring/src/ring.rs | 2 +- .../src/pipewire_bridge.rs | 2 +- .../native/linux-input-hook/src/hook.rs | 2 +- .../src/pipewire_stream.rs | 2 +- .../src/napi_surface_macos.rs | 6 +- .../src/main/WindowsShortcuts.test.mjs | 2 +- fluxer_docs/scripts/VerifyDocsCoverage.ts | 12 +- .../src/content/docs/gateway/events.md | 5 +- .../src/content/docs/http-api/unfurl.mdx | 2 +- .../docs/http-api/users/current-user.mdx | 3 +- .../http-api/users/email-and-password.mdx | 2 +- fluxer_docs/src/content/docs/voice/index.md | 2 +- fluxer_docs/src/installer/install.ps1 | 12 +- fluxer_docs/src/installer/install.sh | 18 +- .../gateway/gateway_rpc_guild_mentions.erl | 6 +- fluxer_gateway/src/guild/guild.erl | 5 +- .../src/guild/guild_dispatch_push.erl | 6 +- fluxer_gateway/src/guild/guild_handoff.erl | 4 +- fluxer_gateway/src/guild/guild_init.erl | 110 +++---- .../src/guild/guild_maintenance.erl | 2 +- .../guild/guild_manager_shard_lifecycle.erl | 4 +- fluxer_gateway/src/guild/guild_presence.erl | 2 +- fluxer_gateway/src/guild/guild_state.erl | 4 +- .../guild_subscription_mutual_channels.erl | 2 +- .../guild/guild_virtual_channel_access.erl | 53 ++- .../src/guild/guild_visibility_channels.erl | 8 +- .../src/guild/guild_voice_lifecycle.erl | 19 +- .../voice/guild_voice_permission_sync.erl | 2 +- .../guild/voice/guild_voice_permissions.erl | 2 +- .../src/guild/voice/guild_voice_server.erl | 5 + .../src/guild/voice/voice_utils.erl | 2 +- fluxer_gateway/src/push/push.erl | 2 +- .../src/push/push_job_publisher.erl | 2 +- .../src/utils/custom_status_expiry.erl | 18 +- .../test/guild_voice_access_revoke_tests.erl | 268 +++++++++++++++ fluxer_media_proxy/src/http_client/mod.rs | 2 +- fluxer_media_proxy/src/http_headers.rs | 2 +- fluxer_media_proxy/src/media_limits.rs | 2 +- fluxer_media_proxy/src/media_process/apng.rs | 4 +- .../src/media_process/encoding.rs | 4 +- .../src/media_process/tests/animated_apng.rs | 4 +- .../src/media_process/tests/animated_webp.rs | 6 +- .../src/media_process/tests/heif.rs | 4 +- .../src/media_process/tests/metadata.rs | 2 +- fluxer_media_proxy/src/metrics/tests.rs | 2 +- .../src/public_net_policy/mod.rs | 14 +- .../src/public_net_policy/tests.rs | 36 ++- .../src/server/attachment_signature.rs | 4 +- fluxer_media_proxy/src/server/cors.rs | 2 +- .../src/server/external/tests/mod.rs | 2 +- .../src/server/response/error.rs | 2 +- fluxer_media_proxy/src/server/response/mod.rs | 2 +- .../src/server/routes/dispatch.rs | 2 +- fluxer_media_proxy/src/server/self_origin.rs | 2 +- .../src/server/transform/cache_key.rs | 2 +- fluxer_media_proxy/src/spool.rs | 2 +- .../src/storage/response_body.rs | 2 +- fluxer_media_proxy/src/tests/provisioning.rs | 2 +- fluxer_messages/src/shard_impl.rs | 50 +-- fluxer_push/src/vendor.rs | 2 +- fluxer_static/avatars/NOTICE.md | 2 +- fluxer_svc/src/postgres.rs | 2 +- fluxer_unfurl/src/cache_policy.rs | 2 +- fluxer_unfurl/src/network_policy.rs | 23 ++ .../src/resolvers/default_resolver.rs | 6 +- fluxer_unfurl/src/router_impl.rs | 2 +- .../config/src/__tests__/ConfigLoader.test.ts | 4 +- .../__tests__/NonDefaultPortCompose.test.ts | 6 +- .../__tests__/AppErrorHandlerLogging.test.ts | 11 + .../errors/src/__tests__/DomainErrors.test.ts | 2 +- .../errors/src/domains/core/ErrorHandlers.ts | 3 +- .../errors/src/error_handling/RoutePattern.ts | 11 + packages/hono/src/middleware/ErrorHandler.ts | 3 +- packages/hono/src/middleware/RequestLogger.ts | 6 +- .../middleware/tests/RequestLogger.test.ts | 53 +++ .../domains/admin/AdminBlocklistSchemas.ts | 2 +- .../admin/AdminMessageBrowseSchemas.ts | 2 +- .../src/domains/guild/GuildTemplateSchemas.ts | 45 ++- .../src/domains/message/AttachmentSchemas.ts | 2 +- .../domains/message/MessageResponseSchemas.ts | 2 +- .../tests/AnnouncementChannelSchemas.test.ts | 2 +- .../src/domains/user/UserResponseSchemas.ts | 15 + .../src/runtime/frameCoalescing.ts | 4 +- tools/ci/src/app_proxy.rs | 8 +- tools/ci/src/ci_workflow.rs | 2 +- tools/ci/src/desktop.rs | 2 +- tools/ci/src/image_set.rs | 2 +- tools/ci/src/release.rs | 2 +- 170 files changed, 2028 insertions(+), 421 deletions(-) create mode 100644 fluxer_api/src/api/auth/tests/AuthSessionReplacement.test.ts create mode 100644 fluxer_api/src/api/channel/tests/GroupDmRecipientRemoveCall.test.ts create mode 100644 fluxer_api/src/api/invite/tests/InviteMaxUses.test.ts create mode 100644 fluxer_api/src/api/oauth/tests/OAuth2ConcurrentRedemption.test.ts create mode 100644 fluxer_gateway/test/guild_voice_access_revoke_tests.erl create mode 100644 packages/errors/src/error_handling/RoutePattern.ts create mode 100644 packages/hono/src/middleware/tests/RequestLogger.test.ts diff --git a/fluxer_admin/build.rs b/fluxer_admin/build.rs index ca3a83840..04b75b82f 100644 --- a/fluxer_admin/build.rs +++ b/fluxer_admin/build.rs @@ -600,7 +600,7 @@ fn select_faces(package_dir: &Path) -> Vec { } 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 { diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 75fa66a13..ddd82fd4b 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -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": []}] } }, @@ -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", @@ -12482,7 +12482,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, @@ -13542,7 +13542,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": { diff --git a/fluxer_admin/src/api/client.rs b/fluxer_admin/src/api/client.rs index 8fc478fa9..f2c9216f4 100644 --- a/fluxer_admin/src/api/client.rs +++ b/fluxer_admin/src/api/client.rs @@ -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()); diff --git a/fluxer_admin/tests/notification_writes.rs b/fluxer_admin/tests/notification_writes.rs index 07a6a759c..a732134ba 100644 --- a/fluxer_admin/tests/notification_writes.rs +++ b/fluxer_admin/tests/notification_writes.rs @@ -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() diff --git a/fluxer_admin/tests/voice_restriction_writes.rs b/fluxer_admin/tests/voice_restriction_writes.rs index 63a182b74..b22cbd83f 100644 --- a/fluxer_admin/tests/voice_restriction_writes.rs +++ b/fluxer_admin/tests/voice_restriction_writes.rs @@ -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() diff --git a/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.test.ts b/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.test.ts index a7b0283b9..424a47066 100644 --- a/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.test.ts +++ b/fluxer_api/pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec.test.ts @@ -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('??'); diff --git a/fluxer_api/src/api/Config.test.ts b/fluxer_api/src/api/Config.test.ts index a36b44104..736d66be4 100644 --- a/fluxer_api/src/api/Config.test.ts +++ b/fluxer_api/src/api/Config.test.ts @@ -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: { diff --git a/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts b/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts index f7d9310b7..f8a3378e7 100644 --- a/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/AdminApiKeyAdminController.ts @@ -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'); diff --git a/fluxer_api/src/api/admin/controllers/BanAdminController.ts b/fluxer_api/src/api/admin/controllers/BanAdminController.ts index 774079b25..95e4e9b8b 100644 --- a/fluxer_api/src/api/admin/controllers/BanAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/BanAdminController.ts @@ -269,7 +269,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, { @@ -524,7 +524,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'); diff --git a/fluxer_api/src/api/admin/controllers/MessageAdminController.ts b/fluxer_api/src/api/admin/controllers/MessageAdminController.ts index 7e9384617..cdf4e917c 100644 --- a/fluxer_api/src/api/admin/controllers/MessageAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/MessageAdminController.ts @@ -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', diff --git a/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts b/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts index 660d8f137..c0d0cd78d 100644 --- a/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts +++ b/fluxer_api/src/api/admin/tests/VoiceAdminController.test.ts @@ -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'; diff --git a/fluxer_api/src/api/auth/AuthPassword.ts b/fluxer_api/src/api/auth/AuthPassword.ts index acb4f0777..43c9c2932 100644 --- a/fluxer_api/src/api/auth/AuthPassword.ts +++ b/fluxer_api/src/api/auth/AuthPassword.ts @@ -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); } diff --git a/fluxer_api/src/api/auth/AuthSession.ts b/fluxer_api/src/api/auth/AuthSession.ts index f574050fa..8946aa11e 100644 --- a/fluxer_api/src/api/auth/AuthSession.ts +++ b/fluxer_api/src/api/auth/AuthSession.ts @@ -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 { 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, }, }); } diff --git a/fluxer_api/src/api/auth/tests/AuthSessionReplacement.test.ts b/fluxer_api/src/api/auth/tests/AuthSessionReplacement.test.ts new file mode 100644 index 000000000..87912e2ec --- /dev/null +++ b/fluxer_api/src/api/auth/tests/AuthSessionReplacement.test.ts @@ -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} | {kind: 'session_change'; data: Record}; + +async function getCurrentAuthSessionHash(harness: ApiTestHarness, token: string): Promise { + const sessions = await createBuilder>(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 { + 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(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; + 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}); + } + }); + }); + 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; + 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(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(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(); + }); +}); diff --git a/fluxer_api/src/api/auth/tests/PasswordReset.test.ts b/fluxer_api/src/api/auth/tests/PasswordReset.test.ts index 505dc3f1c..81f46a094 100644 --- a/fluxer_api/src/api/auth/tests/PasswordReset.test.ts +++ b/fluxer_api/src/api/auth/tests/PasswordReset.test.ts @@ -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(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) diff --git a/fluxer_api/src/api/auth/tests/WebAuthnRegistrationUserHandle.test.ts b/fluxer_api/src/api/auth/tests/WebAuthnRegistrationUserHandle.test.ts index f05171752..4954feda6 100644 --- a/fluxer_api/src/api/auth/tests/WebAuthnRegistrationUserHandle.test.ts +++ b/fluxer_api/src/api/auth/tests/WebAuthnRegistrationUserHandle.test.ts @@ -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) diff --git a/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts b/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts index 8ecace227..9f8ce5f01 100644 --- a/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts +++ b/fluxer_api/src/api/billing/mappers/StripeToBillingMapper.test.ts @@ -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({ id: 're_2', charge: null, diff --git a/fluxer_api/src/api/channel/services/ChannelService.ts b/fluxer_api/src/api/channel/services/ChannelService.ts index 7f12025a6..c7cd981c5 100644 --- a/fluxer_api/src/api/channel/services/ChannelService.ts +++ b/fluxer_api/src/api/channel/services/ChannelService.ts @@ -172,6 +172,8 @@ export class ChannelService { snowflakeService, this.messages.persistence, limitConfigService, + voiceRoomStore, + liveKitService, ); this.calls = new CallService( channelRepository, diff --git a/fluxer_api/src/api/channel/services/StreamService.test.ts b/fluxer_api/src/api/channel/services/StreamService.test.ts index b59a74537..cecaf328c 100644 --- a/fluxer_api/src/api/channel/services/StreamService.test.ts +++ b/fluxer_api/src/api/channel/services/StreamService.test.ts @@ -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); }); diff --git a/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts b/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts index 4f3a9ece2..9d1ae8263 100644 --- a/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts +++ b/fluxer_api/src/api/channel/services/channel_data/ChannelOperationsService.ts @@ -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 { + 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 { 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 { 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)); diff --git a/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts b/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts index ed799b315..af3d336a8 100644 --- a/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts +++ b/fluxer_api/src/api/channel/services/group_dm/GroupDmOperationsService.ts @@ -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 { + 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 { const channels = await this.userRepository.listPrivateChannels(userId); const groupDmChannels = channels.filter((ch) => ch.type === ChannelTypes.GROUP_DM); diff --git a/fluxer_api/src/api/channel/tests/AttachmentUploadValidation.test.ts b/fluxer_api/src/api/channel/tests/AttachmentUploadValidation.test.ts index 2901a1f17..0346638f2 100644 --- a/fluxer_api/src/api/channel/tests/AttachmentUploadValidation.test.ts +++ b/fluxer_api/src/api/channel/tests/AttachmentUploadValidation.test.ts @@ -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, }, diff --git a/fluxer_api/src/api/channel/tests/CrosspostModeration.test.ts b/fluxer_api/src/api/channel/tests/CrosspostModeration.test.ts index cd7e8e3b8..1cd053779 100644 --- a/fluxer_api/src/api/channel/tests/CrosspostModeration.test.ts +++ b/fluxer_api/src/api/channel/tests/CrosspostModeration.test.ts @@ -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, diff --git a/fluxer_api/src/api/channel/tests/GroupDmRecipientRemoveCall.test.ts b/fluxer_api/src/api/channel/tests/GroupDmRecipientRemoveCall.test.ts new file mode 100644 index 000000000..987b4e8d3 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/GroupDmRecipientRemoveCall.test.ts @@ -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); + }); +}); diff --git a/fluxer_api/src/api/channel/tests/MessageCrosspostFanout.test.ts b/fluxer_api/src/api/channel/tests/MessageCrosspostFanout.test.ts index 9604e9ac3..56e4b8fa4 100644 --- a/fluxer_api/src/api/channel/tests/MessageCrosspostFanout.test.ts +++ b/fluxer_api/src/api/channel/tests/MessageCrosspostFanout.test.ts @@ -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', diff --git a/fluxer_api/src/api/channel/tests/ReactionUsersPagination.test.ts b/fluxer_api/src/api/channel/tests/ReactionUsersPagination.test.ts index 9415cf5a0..d35eee6c1 100644 --- a/fluxer_api/src/api/channel/tests/ReactionUsersPagination.test.ts +++ b/fluxer_api/src/api/channel/tests/ReactionUsersPagination.test.ts @@ -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>(harness, token) diff --git a/fluxer_api/src/api/database/TransientDatabaseError.ts b/fluxer_api/src/api/database/TransientDatabaseError.ts index a55124cef..ab3ee3623 100644 --- a/fluxer_api/src/api/database/TransientDatabaseError.ts +++ b/fluxer_api/src/api/database/TransientDatabaseError.ts @@ -67,7 +67,7 @@ function collectErrorChain(error: unknown): Array { 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); diff --git a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts index 65c2272de..3a255fb33 100644 --- a/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts +++ b/fluxer_api/src/api/guild/services/data/GuildOperationsService.ts @@ -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, diff --git a/fluxer_api/src/api/guild/tests/GuildExpressionCloneOptIn.test.ts b/fluxer_api/src/api/guild/tests/GuildExpressionCloneOptIn.test.ts index 4d3e342e2..3a03c82c5 100644 --- a/fluxer_api/src/api/guild/tests/GuildExpressionCloneOptIn.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildExpressionCloneOptIn.test.ts @@ -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, diff --git a/fluxer_api/src/api/guild/tests/GuildMfaLevel.test.ts b/fluxer_api/src/api/guild/tests/GuildMfaLevel.test.ts index b8b261f78..61ae7894c 100644 --- a/fluxer_api/src/api/guild/tests/GuildMfaLevel.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildMfaLevel.test.ts @@ -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(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'); diff --git a/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts b/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts index 662b1581e..d7166a795 100644 --- a/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildTemplateImport.test.ts @@ -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(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; roles?: Array}) { + 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}], + }; +} diff --git a/fluxer_api/src/api/infrastructure/AvatarServiceSizeLimits.test.ts b/fluxer_api/src/api/infrastructure/AvatarServiceSizeLimits.test.ts index 6fab2e210..fddfb6ec5 100644 --- a/fluxer_api/src/api/infrastructure/AvatarServiceSizeLimits.test.ts +++ b/fluxer_api/src/api/infrastructure/AvatarServiceSizeLimits.test.ts @@ -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 = [ {id: 'big-emoji', filters: {guildFeatures: ['BIG_EMOJI']}, limits: {emoji_max_size: EMOJI_MAX_SIZE * 2}}, ]; diff --git a/fluxer_api/src/api/infrastructure/CachePurgePaths.test.ts b/fluxer_api/src/api/infrastructure/CachePurgePaths.test.ts index e84eaa8c6..ef47c035f 100644 --- a/fluxer_api/src/api/infrastructure/CachePurgePaths.test.ts +++ b/fluxer_api/src/api/infrastructure/CachePurgePaths.test.ts @@ -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', diff --git a/fluxer_api/src/api/invite/IInviteRepository.ts b/fluxer_api/src/api/invite/IInviteRepository.ts index 234a6f1f6..a29a5bc6b 100644 --- a/fluxer_api/src/api/invite/IInviteRepository.ts +++ b/fluxer_api/src/api/invite/IInviteRepository.ts @@ -24,5 +24,7 @@ export abstract class IInviteRepository { abstract updateInviteUses(code: InviteCode, uses: number, invite: Invite): Promise; + abstract compareAndSetInviteUses(invite: Invite, uses: number): Promise; + abstract delete(code: InviteCode): Promise; } diff --git a/fluxer_api/src/api/invite/InviteRepository.ts b/fluxer_api/src/api/invite/InviteRepository.ts index c2bbae2d8..9ac8cf2fd 100644 --- a/fluxer_api/src/api/invite/InviteRepository.ts +++ b/fluxer_api/src/api/invite/InviteRepository.ts @@ -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 { 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 { + 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 { const invite = await this.findUnique(code); if (!invite) { diff --git a/fluxer_api/src/api/invite/InviteService.ts b/fluxer_api/src/api/invite/InviteService.ts index d39c46e89..61ec6baed 100644 --- a/fluxer_api/src/api/invite/InviteService.ts +++ b/fluxer_api/src/api/invite/InviteService.ts @@ -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 { - 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 { + 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): Promise { + try { + await join(); + } catch (error) { + await this.releaseInviteUse(reservedInvite); + throw error; + } + } + + private async releaseInviteUse(reservedInvite: Invite): Promise { + 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 { + 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 { diff --git a/fluxer_api/src/api/invite/tests/InviteMaxUses.test.ts b/fluxer_api/src/api/invite/tests/InviteMaxUses.test.ts new file mode 100644 index 000000000..9f19a42c0 --- /dev/null +++ b/fluxer_api/src/api/invite/tests/InviteMaxUses.test.ts @@ -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(harness, owner.token) + .post(`/channels/${guild.system_channel_id}/invites`) + .body({max_uses: maxUses, unique: true}) + .execute(); + const joiners: Array = []; + for (let i = 0; i < joinerCount; i++) { + joiners.push(await createTestAccount(harness)); + } + return {owner, guild, invite, joiners}; +} + +async function countMembers(harness: ApiTestHarness, accounts: Array, guildId: string): Promise { + let count = 0; + for (const account of accounts) { + const guilds = await createBuilder>(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 { + const invites = await createBuilder>(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); + }); +}); diff --git a/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts b/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts index c009401b9..340d2b756 100644 --- a/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts +++ b/fluxer_api/src/api/middleware/ResponseTypeMiddleware.ts @@ -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, schema: ZodType })); const errorContext = { method: ctx.req.method, - path: ctx.req.path, + path: resolveRoutePattern(ctx), status: response.status, validationErrors, body, diff --git a/fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts b/fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts index cc985dca2..4945c788a 100644 --- a/fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts +++ b/fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts @@ -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); diff --git a/fluxer_api/src/api/oauth/OAuth2Service.ts b/fluxer_api/src/api/oauth/OAuth2Service.ts index 3fc563ee6..98a7a40a4 100644 --- a/fluxer_api/src/api/oauth/OAuth2Service.ts +++ b/fluxer_api/src/api/oauth/OAuth2Service.ts @@ -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, diff --git a/fluxer_api/src/api/oauth/repositories/IOAuth2TokenRepository.ts b/fluxer_api/src/api/oauth/repositories/IOAuth2TokenRepository.ts index a36c6b988..c64d6808b 100644 --- a/fluxer_api/src/api/oauth/repositories/IOAuth2TokenRepository.ts +++ b/fluxer_api/src/api/oauth/repositories/IOAuth2TokenRepository.ts @@ -14,13 +14,14 @@ export interface IOAuth2TokenRepository { createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise; getAuthorizationCode(code: string): Promise; deleteAuthorizationCode(code: string): Promise; + consumeAuthorizationCode(code: string, applicationId: ApplicationID): Promise; createAccessToken(data: OAuth2AccessTokenRow): Promise; getAccessToken(token: string): Promise; deleteAccessToken(token: string, applicationId: ApplicationID, userId: UserID | null): Promise; deleteAllAccessTokensForUser(userId: UserID): Promise; createRefreshToken(data: OAuth2RefreshTokenRow): Promise; getRefreshToken(token: string): Promise; - deleteRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise; + consumeRefreshToken(token: string, applicationId: ApplicationID, userId: UserID): Promise; deleteAllRefreshTokensForUser(userId: UserID): Promise; listRefreshTokensForUser(userId: UserID): Promise>; deleteAllTokensForUserAndApplication(userId: UserID, applicationId: ApplicationID): Promise; diff --git a/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts b/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts index 45829c7d0..5cab8a476 100644 --- a/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts +++ b/fluxer_api/src/api/oauth/repositories/OAuth2TokenRepository.ts @@ -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 { + return executeConditional(OAuth2AuthorizationCodes.conditionalDeleteByPk({code}, {application_id: applicationId})); + } + async createAccessToken(data: OAuth2AccessTokenRow): Promise { 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 { - 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 { + 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 { diff --git a/fluxer_api/src/api/oauth/tests/OAuth2ConcurrentRedemption.test.ts b/fluxer_api/src/api/oauth/tests/OAuth2ConcurrentRedemption.test.ts new file mode 100644 index 000000000..c57c5c841 --- /dev/null +++ b/fluxer_api/src/api/oauth/tests/OAuth2ConcurrentRedemption.test.ts @@ -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 + ) { + 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, +): Promise { + 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, +): Promise> { + addQueryLatency(); + try { + return await Promise.all( + Array.from({length: CONCURRENT_REQUESTS}, () => postToken(harness, clientId, clientSecret, form)), + ); + } finally { + vi.restoreAllMocks(); + } +} + +function expectSingleSuccess(results: Array): 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); + }); +}); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 6bcf7e2e8..578111623 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -13912,7 +13912,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 +13953,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 +21543,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 +25300,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"}}, @@ -30548,7 +30792,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 +32544,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 +32563,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 +32618,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 +32630,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 +35296,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 +35555,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", diff --git a/fluxer_api/src/api/rpc/tests/RpcSessionInitHarnessAccess.test.ts b/fluxer_api/src/api/rpc/tests/RpcSessionInitHarnessAccess.test.ts index 5e6979d92..f1d9af434 100644 --- a/fluxer_api/src/api/rpc/tests/RpcSessionInitHarnessAccess.test.ts +++ b/fluxer_api/src/api/rpc/tests/RpcSessionInitHarnessAccess.test.ts @@ -51,7 +51,7 @@ describe('POST /test/rpc-session-init harness access', () => { .execute(); }); - test('rejects a session init carrying the wrong harness token', async () => { + test('rejects a session init with the wrong harness token', async () => { const account = await createTestAccount(harness); Config.dev.testHarnessToken = HARNESS_TOKEN; await createBuilder(harness, '') @@ -62,7 +62,7 @@ describe('POST /test/rpc-session-init harness access', () => { .execute(); }); - test('accepts a session init carrying the harness token', async () => { + test('accepts a session init with the harness token', async () => { const account = await createTestAccount(harness); Config.dev.testHarnessToken = HARNESS_TOKEN; const response = await createBuilder(harness, '') diff --git a/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts b/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts index 0f14fb321..2b870b07a 100644 --- a/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts +++ b/fluxer_api/src/api/search/tests/MessageSearchFilters.test.ts @@ -569,7 +569,7 @@ describe('Message Search Filters', () => { } } }); - test('has: snapshot combined with has: image finds forwards whose snapshot carries an image', async () => { + test('has: snapshot combined with has: image finds forwards whose snapshot has an image', async () => { const account = await createTestAccount(harness); const guild = await createGuild(harness, account.token, 'Forward Image Guild'); const sourceChannelId = guild.system_channel_id!; diff --git a/fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts b/fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts index c93522329..7bbf5b4c8 100644 --- a/fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts +++ b/fluxer_api/src/api/store_billing/tests/AppStoreJwsVerifier.test.ts @@ -370,7 +370,7 @@ describe('App Store JWS verification with a test chain', () => { ); }); - it('rejects a leaf that carries the identifier only as a policy', async () => { + it('rejects a leaf that has the identifier only as a policy', async () => { const policyOnly = createAppleTestPki({ leaf: {appleExtension: false, extraExtensions: [certificatePoliciesExtension(APPLE_RECEIPT_SIGNING_OID)]}, }); diff --git a/fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts b/fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts index e84a59565..82d25de62 100644 --- a/fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts +++ b/fluxer_api/src/api/store_billing/tests/StoreBillingAppStore.test.ts @@ -262,7 +262,7 @@ describe('App Store purchases', () => { }; } - it('grants premium for a claim that carries the account token and answers repeats the same way', async () => { + it('grants premium for a claim that includes the account token and answers repeats the same way', async () => { const account = await createTestAccount(harness); const token = await accountToken(account); const expiresDate = Date.now() + ms('30 days'); diff --git a/fluxer_api/src/api/test/CaptchaTestUtils.ts b/fluxer_api/src/api/test/CaptchaTestUtils.ts index cee09ae91..e3a0a4d99 100644 --- a/fluxer_api/src/api/test/CaptchaTestUtils.ts +++ b/fluxer_api/src/api/test/CaptchaTestUtils.ts @@ -23,7 +23,7 @@ export async function useCheapCaptcha(): Promise { export async function solveCaptchaChallenge(body: CaptchaErrorBody): Promise { const challenge = body.altcha_challenge; - if (!challenge) throw new Error('The response carried no ALTCHA challenge'); + if (!challenge) throw new Error('The response had no ALTCHA challenge'); const solution = await solveChallenge({challenge, deriveKey, timeout: 0}); if (!solution) throw new Error('The ALTCHA challenge was not solved'); const payload = {challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution}; diff --git a/fluxer_api/src/api/user/controllers/UserAccountController.ts b/fluxer_api/src/api/user/controllers/UserAccountController.ts index 1a2fd93cf..ca0f891a6 100644 --- a/fluxer_api/src/api/user/controllers/UserAccountController.ts +++ b/fluxer_api/src/api/user/controllers/UserAccountController.ts @@ -78,6 +78,7 @@ import { UserProfileFullResponse, UserSettingsResponse, UserTagCheckResponse, + UserUpdateResponse, } from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {uint8ArrayToBase64} from 'uint8array-extras'; @@ -118,12 +119,12 @@ export function UserAccountController(app: HonoApp) { OpenAPI({ operationId: 'update_current_user', summary: 'Update current user profile', - responseSchema: UserPrivateResponse, + responseSchema: UserUpdateResponse, statusCode: 200, security: ['bearerToken', 'sessionToken'], tags: ['Users'], 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.", + "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.", }), async (ctx) => { const userAccountRequestService = ctx.get('userAccountRequestService'); diff --git a/fluxer_api/src/api/user/services/UserAccountRequestService.ts b/fluxer_api/src/api/user/services/UserAccountRequestService.ts index c158cb825..22cb8808d 100644 --- a/fluxer_api/src/api/user/services/UserAccountRequestService.ts +++ b/fluxer_api/src/api/user/services/UserAccountRequestService.ts @@ -35,7 +35,11 @@ import type { EmailChangeApplyRequest, UserUpdateWithVerificationRequest, } from '@fluxer/schema/src/domains/user/UserRequestSchemas'; -import type {UserPrivateResponse, UserProfileFullResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; +import type { + UserPrivateResponse, + UserProfileFullResponse, + UserUpdateResponse, +} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import type {Context} from 'hono'; type UserUpdatePayload = Omit< @@ -127,7 +131,7 @@ export class UserAccountRequestService { user: User; body: UserUpdateWithVerificationRequest; authSession: AuthSession; - }): Promise { + }): Promise { const {ctx, body, authSession} = params; const {user} = params; const oldEmail = user.email; @@ -180,7 +184,7 @@ export class UserAccountRequestService { throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS); } } - const updatedUser = await this.userAccountService.update({ + const {user: updatedUser, authSessionReplacement} = await this.userAccountService.update({ user, oldAuthSession: authSession, data: userUpdateData, @@ -224,7 +228,15 @@ export class UserAccountRequestService { Logger.warn({error, userId: updatedUser.id}, 'Failed to issue email revert token'); } } - return mapUserToPrivateResponse(updatedUser); + const response = mapUserToPrivateResponse(updatedUser); + if (!authSessionReplacement) { + return response; + } + return { + ...response, + token: authSessionReplacement.token, + auth_session_id_hash: authSessionReplacement.authSessionIdHash, + }; } async applyEmailChange(params: { diff --git a/fluxer_api/src/api/user/services/UserAccountSecurityService.ts b/fluxer_api/src/api/user/services/UserAccountSecurityService.ts index 769b90ef1..bbc24ead1 100644 --- a/fluxer_api/src/api/user/services/UserAccountSecurityService.ts +++ b/fluxer_api/src/api/user/services/UserAccountSecurityService.ts @@ -41,6 +41,11 @@ interface UserAccountSecurityServiceDeps { limitConfigService: LimitConfigService; } +export interface AuthSessionReplacement { + token: string; + authSessionIdHash: string; +} + export class UserAccountSecurityService { constructor(private readonly deps: UserAccountSecurityServiceDeps) {} @@ -158,12 +163,13 @@ export class UserAccountSecurityService { user: User; oldAuthSession: AuthSessionModel; request: Request; - }): Promise { - await AuthSession.replaceCurrentAuthSession(this.deps.apiContext, { + }): Promise { + const replacement = await AuthSession.replaceCurrentAuthSession(this.deps.apiContext, { user, currentAuthSession: oldAuthSession, request, }); + return {token: replacement.token, authSessionIdHash: replacement.newAuthSessionIdHash}; } private async createSudoModeRequiredError(user: User): Promise { diff --git a/fluxer_api/src/api/user/services/UserAccountService.ts b/fluxer_api/src/api/user/services/UserAccountService.ts index c803ee342..6c5c600e3 100644 --- a/fluxer_api/src/api/user/services/UserAccountService.ts +++ b/fluxer_api/src/api/user/services/UserAccountService.ts @@ -22,7 +22,10 @@ import {UserAccountLifecycleService} from '@app/api/user/services/UserAccountLif import {UserAccountLookupService} from '@app/api/user/services/UserAccountLookupService'; import {UserAccountNotesService} from '@app/api/user/services/UserAccountNotesService'; import {UserAccountProfileService} from '@app/api/user/services/UserAccountProfileService'; -import {UserAccountSecurityService} from '@app/api/user/services/UserAccountSecurityService'; +import { + type AuthSessionReplacement, + UserAccountSecurityService, +} from '@app/api/user/services/UserAccountSecurityService'; import {UserAccountSettingsService} from '@app/api/user/services/UserAccountSettingsService'; import {UserAccountUpdatePropagator} from '@app/api/user/services/UserAccountUpdatePropagator'; import type {UserContactChangeLogService} from '@app/api/user/services/UserContactChangeLogService'; @@ -41,6 +44,11 @@ interface UpdateUserParams { emailVerifiedViaToken?: boolean; } +interface UpdateUserResult { + user: User; + authSessionReplacement: AuthSessionReplacement | null; +} + interface UserAccountRepository extends IUserAccountRepository, IUserSettingsRepository, @@ -137,7 +145,7 @@ export class UserAccountService { }); } - async update(params: UpdateUserParams): Promise { + async update(params: UpdateUserParams): Promise { const {user, oldAuthSession, data, request, sudoContext, emailVerifiedViaToken = false} = params; const profileResult = await this.profileService.processProfileUpdates({user, data}); const securityResult = await this.securityService.processSecurityUpdates({user, data, sudoContext}); @@ -195,14 +203,21 @@ export class UserAccountService { } }, ]; + let authSessionReplacement: AuthSessionReplacement | null = null; if (securityResult.metadata.invalidateAuthSessions) { finalizationSteps.push( - () => this.securityService.invalidateAndRecreateSessions({user, oldAuthSession, request}), + async () => { + authSessionReplacement = await this.securityService.invalidateAndRecreateSessions({ + user, + oldAuthSession, + request, + }); + }, () => this.userAccountRepository.deleteAllPasswordResetTokens(user.id), ); } await runAllInOrder(finalizationSteps, 'Failed to finalize user update'); - return updatedUser; + return {user: updatedUser, authSessionReplacement}; } private async reindexGuildMembersForUser(updatedUser: User): Promise { diff --git a/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts b/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts index 9ac33f3ad..c40c6e516 100644 --- a/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts +++ b/fluxer_api/src/api/user/tests/FavoriteMemeOperations.test.ts @@ -132,7 +132,7 @@ describe('Favorite Meme Operations', () => { expect(sent.attachments[0].filename).toBe(filename); expect(sent.attachments[0].flags & MessageAttachmentFlags.IS_ANIMATED).toBe(MessageAttachmentFlags.IS_ANIMATED); }); - test('should carry the saved placeholder onto the sent attachment', async () => { + test('should copy the saved placeholder onto the sent attachment', async () => { const account = await createTestAccountForAttachmentTests(harness); const {channel} = await setupTestGuildAndChannel(harness, account); const message = await createMessageWithImageAttachment(harness, account.token, channel.id); diff --git a/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts b/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts index 62106cbb3..96d09b90f 100644 --- a/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts +++ b/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts @@ -87,7 +87,7 @@ describe('User profile text validation', () => { await createBuilder(harness, account.token) .put(`/users/@me/notes/${target.userId}`) .header('content-type', 'text/plain') - .body({note: 'note carrying a blockedphrase value'}) + .body({note: 'note with a blockedphrase value'}) .expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONTENT_BLOCKED) .execute(); await createBuilder(harness, account.token) diff --git a/fluxer_api/src/api/utils/tests/SessionClientIdentity.test.ts b/fluxer_api/src/api/utils/tests/SessionClientIdentity.test.ts index 25dc525c7..be33fb99a 100644 --- a/fluxer_api/src/api/utils/tests/SessionClientIdentity.test.ts +++ b/fluxer_api/src/api/utils/tests/SessionClientIdentity.test.ts @@ -66,7 +66,7 @@ describe('resolveSessionClientInfo', () => { }); }); - it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => { + it('treats a narrow Linux window as a desktop because the product token cannot include form factor', () => { expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({ platform: 'Fluxer Lite Linux', os: 'Linux', diff --git a/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts b/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts index a5d79a820..65d5eef65 100644 --- a/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts +++ b/fluxer_api/src/api/webhook/tests/WebhookInstatus.test.ts @@ -96,7 +96,7 @@ describe('Webhook Instatus integration', () => { expect(await countWebhookMessages(harness, owner.token, channelId, webhook.id)).toBe(1); await deleteWebhook(harness, webhook.id, owner.token); }); - it('processes a callback carrying no identifier every time', async () => { + it('processes a callback with no identifier every time', async () => { const owner = await createTestAccount(harness); const guild = await createGuild(harness, owner.token, 'Instatus Unidentified Guild'); const channelId = guild.system_channel_id!; diff --git a/fluxer_api/src/api/worker/tests/BulkDeleteMessagesForUsersAudit.test.ts b/fluxer_api/src/api/worker/tests/BulkDeleteMessagesForUsersAudit.test.ts index 0fefbf64d..666193e97 100644 --- a/fluxer_api/src/api/worker/tests/BulkDeleteMessagesForUsersAudit.test.ts +++ b/fluxer_api/src/api/worker/tests/BulkDeleteMessagesForUsersAudit.test.ts @@ -84,7 +84,7 @@ describe('Bulk delete messages for users', () => { return messages.filter((message) => message.author.id === userId).length; } - it('carries the admin reason and the message count on the per-user audit row', async () => { + it('records the admin reason and the message count on the per-user audit row', async () => { const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1); const member = members[0]!; await sendChannelMessage(harness, member.token, systemChannel.id, 'first spam'); diff --git a/fluxer_api/src/api/worker/tests/BulkUpdateUserFlagsTask.test.ts b/fluxer_api/src/api/worker/tests/BulkUpdateUserFlagsTask.test.ts index fd1bb6800..e13617c9e 100644 --- a/fluxer_api/src/api/worker/tests/BulkUpdateUserFlagsTask.test.ts +++ b/fluxer_api/src/api/worker/tests/BulkUpdateUserFlagsTask.test.ts @@ -76,7 +76,7 @@ describe('bulkUpdateUserFlags task', () => { clearWorkerDependencies(); }); - test('writes a per-user audit row carrying the admin reason and records failed items', async () => { + test('writes a per-user audit row with the admin reason and records failed items', async () => { const first = await createTestAccount(harness); const second = await createTestAccount(harness); const result = (await bulkUpdateUserFlags( diff --git a/fluxer_api/src/api/worker/tests/WorkerRetiredTask.test.ts b/fluxer_api/src/api/worker/tests/WorkerRetiredTask.test.ts index 878ae0495..76898abb0 100644 --- a/fluxer_api/src/api/worker/tests/WorkerRetiredTask.test.ts +++ b/fluxer_api/src/api/worker/tests/WorkerRetiredTask.test.ts @@ -96,7 +96,7 @@ describe('Retired worker task types', () => { expect(msg.ack).not.toHaveBeenCalled(); }); - it('dead-letters a legacy job that carries no ledger id', async () => { + it('dead-letters a legacy job that has no ledger id', async () => { const runner = createRunner(); const msg = createJobMessage(RETIRED_TASK_TYPE, {userId: '1', scheduledMessageId: '2'}); diff --git a/fluxer_app/pkgs/livekit-client/src/room/PCTransport.test.ts b/fluxer_app/pkgs/livekit-client/src/room/PCTransport.test.ts index e36c984d5..a827b691b 100644 --- a/fluxer_app/pkgs/livekit-client/src/room/PCTransport.test.ts +++ b/fluxer_app/pkgs/livekit-client/src/room/PCTransport.test.ts @@ -307,7 +307,7 @@ describe('ensureVideoDDExtension', () => { expect(ddOf(sdp, '1')).toBe(13); }); - it('leaves a section that already carries the extension alone', () => { + it('leaves a section that already has the extension alone', () => { const sdp = parse(`${singlePcOffer}\na=extmap:3 ${ddExtensionURI}`); expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 0)).toBe(3); expect(sectionOf(sdp, '2').ext).toHaveLength(2); diff --git a/fluxer_app/pkgs/livekit-client/src/room/RTCEngine.test.ts b/fluxer_app/pkgs/livekit-client/src/room/RTCEngine.test.ts index 7812b19c2..328cf6008 100644 --- a/fluxer_app/pkgs/livekit-client/src/room/RTCEngine.test.ts +++ b/fluxer_app/pkgs/livekit-client/src/room/RTCEngine.test.ts @@ -214,7 +214,7 @@ describe('publisher data channels before negotiation', () => { return {engine, created}; } - it('creates them on a renegotiation that already carries transceivers', async () => { + it('creates them on a renegotiation that already has transceivers', async () => { const {engine, created} = engineWithPublisherChannels(false); await engine.negotiate(); expect(created).toEqual(['publisher']); diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts index 3484b4bff..7a64588fa 100644 --- a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts +++ b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts @@ -44,7 +44,7 @@ export type SetupUnauthorizedCause = 'stale_session' | 'origin_mismatch' | 'unkn export async function classifySetupUnauthorized(): Promise { if (!SessionManager.token) return 'unknown'; - if (!http.carriesAuthorization()) return 'origin_mismatch'; + if (!http.hasAuthorization()) return 'origin_mismatch'; try { const response = await http.get(Endpoints.USER_ME, {mode: 'silent'}); return response.status === 401 ? 'stale_session' : 'unknown'; diff --git a/fluxer_app/src/features/channel/components/channel_header_components/developer_tools/GeneralOptionsMenu.tsx b/fluxer_app/src/features/channel/components/channel_header_components/developer_tools/GeneralOptionsMenu.tsx index b326b3f9f..bbbc73632 100644 --- a/fluxer_app/src/features/channel/components/channel_header_components/developer_tools/GeneralOptionsMenu.tsx +++ b/fluxer_app/src/features/channel/components/channel_header_components/developer_tools/GeneralOptionsMenu.tsx @@ -42,7 +42,7 @@ const ToggleGroupSubmenu: React.FC<{group: ToggleGroup}> = observer(({group}) => data-flx="channel.channel-header-components.developer-tools-context-menu.toggle-group-submenu.checkbox-item" > {description ? ( - // biome-ignore lint/a11y/useAriaPropsSupportedByRole: project policy forbids the native title attribute, so aria-label carries the description on the developer-options row + // biome-ignore lint/a11y/useAriaPropsSupportedByRole: project policy forbids the native title attribute, so aria-label holds the description on the developer-options row { expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE); }); - it('accepts an envelope that carries no domain migration assignment', async () => { + it('accepts an envelope that has no domain migration assignment', async () => { await adopt({poll_interval_seconds: 600, poll_jitter_percent: 0, assignments: {}}); expect(ExperimentAssignments.response.assignments.domain_migration).toBeUndefined(); expect(lastScheduledDelayMs()).toBe(600_000); @@ -387,7 +387,7 @@ describe('ExperimentAssignments lifecycle', () => { expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1); }); - it('does not carry the etag or the backoff of the previous session across a reset', async () => { + it('does not keep the etag or the backoff of the previous session across a reset', async () => { vi.spyOn(Math, 'random').mockReturnValue(0.5); await adopt(CANARY_ENVELOPE); vi.mocked(http.get).mockResolvedValue(reply(500, {message: '500: Internal Server Error'})); diff --git a/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts b/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts index 452fce6d1..0c9b35629 100644 --- a/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts +++ b/fluxer_app/src/features/messaging/utils/MediaProxyUtils.ts @@ -110,7 +110,7 @@ function readProxyDimensionParam(url: URL, key: 'width' | 'height'): number | un return resolveProxyDimension(Number(raw)); } -function carriedProxyDimensions(proxyURL: string): {width?: number; height?: number} | undefined { +function proxyUrlDimensions(proxyURL: string): {width?: number; height?: number} | undefined { let parsed: URL; try { parsed = new URL(proxyURL); @@ -124,7 +124,7 @@ function carriedProxyDimensions(proxyURL: string): {width?: number; height?: num } function variantDimensions(proxyURL: string, width?: number, height?: number): {width?: number; height?: number} { - return carriedProxyDimensions(proxyURL) ?? {width, height}; + return proxyUrlDimensions(proxyURL) ?? {width, height}; } export function stripMediaProxyParams(proxyURL: string): string { diff --git a/fluxer_app/src/features/platform/transport/RestTransport.ts b/fluxer_app/src/features/platform/transport/RestTransport.ts index ad0c51d8b..f5d672e8f 100644 --- a/fluxer_app/src/features/platform/transport/RestTransport.ts +++ b/fluxer_app/src/features/platform/transport/RestTransport.ts @@ -177,7 +177,7 @@ export class RestClient { this.state.globalIntercept = hooks.intercept; } - carriesAuthorization(): boolean { + hasAuthorization(): boolean { return !isOffOrigin(resolveUrl(this.state, '/', undefined)); } diff --git a/fluxer_app/src/features/read_state/state/ReadStates.test.ts b/fluxer_app/src/features/read_state/state/ReadStates.test.ts index af4a1bc17..b84ebaf47 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.test.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.test.ts @@ -582,7 +582,7 @@ describe('ReadStates private channel open, close and reopen', () => { }); }); - it("characterisation: acks the current user's message that CHANNEL_CREATE already carries", () => { + it("characterisation: acks the current user's message that CHANNEL_CREATE already includes", () => { ready([], []); channelCreate(dm(CHANNEL.newDm, MESSAGE.own)); messageCreate(wireMessage(MESSAGE.own, CHANNEL.newDm, ME)); diff --git a/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx b/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx index 4b5292a12..a07af885e 100644 --- a/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx +++ b/fluxer_app/src/features/search/components/quick_switcher/QuickSwitcherModal.tsx @@ -436,7 +436,7 @@ const QuickSwitcherModalComponent: React.FC = observer(() => { text={i18n._(QUICK_SWITCHER_DESCRIPTOR)} data-flx="search.quick-switcher.quick-switcher-modal.quick-switcher-modal-component.modal-screen-reader-label" /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer arbitration surface for the result rows; it carries no affordance of its own. */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer arbitration surface for the result rows; it has no affordance of its own. */}
void { }; } -function isBackdropActivationCarriedOver(ownerDocument: Document): boolean { +function isBackdropActivationLeftOver(ownerDocument: Document): boolean { const watcher = backdropActivationWatchers.get(ownerDocument); if (!watcher) { return false; @@ -222,7 +222,7 @@ export function useModalLogic({ }, [handleClose, modalKey]); const handleBackdropClick = useCallback( (customOnClose?: () => void) => { - if (isBackdropActivationCarriedOver(ownerDocument)) { + if (isBackdropActivationLeftOver(ownerDocument)) { return; } handleClose(customOnClose); diff --git a/fluxer_app/src/features/user/commands/UserCommands.ts b/fluxer_app/src/features/user/commands/UserCommands.ts index c5a9c967d..4aa3242bc 100644 --- a/fluxer_app/src/features/user/commands/UserCommands.ts +++ b/fluxer_app/src/features/user/commands/UserCommands.ts @@ -90,6 +90,7 @@ type UserUpdatePayload = Partial & { }; type UserUpdateResponse = UserPrivate & { token?: string; + auth_session_id_hash?: string; }; interface HarvestRequestResponse { @@ -203,6 +204,11 @@ export async function update(user: UserUpdatePayload): Promise { } }); - it('keeps temporal layers for the SVC codecs that carry one', () => { + it('keeps temporal layers for the SVC codecs that have one', () => { for (const codec of ['av1', 'vp9'] as const) { expect(resolveScreenShareLayering({codec, svcSetting: 'auto'}).scalabilityMode).toBe('L1T3'); } diff --git a/fluxer_app/src/features/voice/utils/VoiceMessageDescriptors.test.ts b/fluxer_app/src/features/voice/utils/VoiceMessageDescriptors.test.ts index 5a14565c4..b2e4a6af4 100644 --- a/fluxer_app/src/features/voice/utils/VoiceMessageDescriptors.test.ts +++ b/fluxer_app/src/features/voice/utils/VoiceMessageDescriptors.test.ts @@ -126,7 +126,7 @@ describe('formatScreenShareTargetLabel', () => { }); describe('SCREEN_SHARE_STATUS_SOURCE_RESOLUTION_DESCRIPTOR', () => { - it('reads Source, carries a translator comment and keeps the wording plain', () => { + it('reads Source, has a translator comment and keeps the wording plain', () => { expect(SCREEN_SHARE_STATUS_SOURCE_RESOLUTION_DESCRIPTOR.message).toBe('Source'); expect(SCREEN_SHARE_STATUS_SOURCE_RESOLUTION_DESCRIPTOR.comment).toEqual(expect.stringMatching(/\S/)); expect(SCREEN_SHARE_STATUS_SOURCE_RESOLUTION_DESCRIPTOR.message).not.toMatch(/[;:—–]/); diff --git a/fluxer_app_proxy/src/bootstrap.rs b/fluxer_app_proxy/src/bootstrap.rs index 413c1a5b5..1d847e466 100644 --- a/fluxer_app_proxy/src/bootstrap.rs +++ b/fluxer_app_proxy/src/bootstrap.rs @@ -251,7 +251,7 @@ mod tests { } #[test] - fn shipped_shell_carries_no_nonce_attribute_or_placeholder() { + fn shipped_shell_has_no_nonce_attribute_or_placeholder() { assert!(!SHIPPED_APP_SHELL.contains("nonce")); assert!(!SHIPPED_APP_SHELL.contains("{{CSP_NONCE_PLACEHOLDER}}")); } @@ -375,7 +375,7 @@ mod tests { } #[test] - fn media_preconnect_carries_no_crossorigin_attribute() { + fn media_preconnect_has_no_crossorigin_attribute() { assert!(!MEDIA_PRECONNECT_TAG.contains("crossorigin")); } @@ -464,7 +464,7 @@ mod tests { } #[test] - fn a_configured_endpoint_that_already_carries_a_port_is_left_alone() { + fn a_configured_endpoint_that_already_has_a_port_is_left_alone() { let discovery = discovery_offering("https://chat.example.test:8443/api"); assert_eq!( api_public_endpoint(Some("https://chat.example.test:9443/api"), &discovery), diff --git a/fluxer_app_proxy/src/config.rs b/fluxer_app_proxy/src/config.rs index ba33d45a6..20e562760 100644 --- a/fluxer_app_proxy/src/config.rs +++ b/fluxer_app_proxy/src/config.rs @@ -707,7 +707,7 @@ mod tests { } #[test] - fn the_boot_html_api_endpoint_keeps_a_port_it_already_carries() { + fn the_boot_html_api_endpoint_keeps_a_port_it_already_has() { assert_eq!( resolve_bootstrap_endpoint_from_pairs(&[ ( diff --git a/fluxer_app_proxy/src/routes/assets_proxy.rs b/fluxer_app_proxy/src/routes/assets_proxy.rs index f3c3efaec..50926c832 100644 --- a/fluxer_app_proxy/src/routes/assets_proxy.rs +++ b/fluxer_app_proxy/src/routes/assets_proxy.rs @@ -613,7 +613,7 @@ mod tests { } #[tokio::test] - async fn a_not_found_carrying_a_long_upstream_lifetime_is_rewritten_to_no_store() { + async fn a_not_found_with_a_long_upstream_lifetime_is_rewritten_to_no_store() { let response = proxied_asset( StatusCode::NOT_FOUND, "public, max-age=31536000, immutable", @@ -630,7 +630,7 @@ mod tests { } #[tokio::test] - async fn a_bad_gateway_carrying_a_long_upstream_lifetime_is_rewritten_to_no_store() { + async fn a_bad_gateway_with_a_long_upstream_lifetime_is_rewritten_to_no_store() { let response = proxied_asset( StatusCode::BAD_GATEWAY, "public, max-age=604800", @@ -646,7 +646,7 @@ mod tests { } #[tokio::test] - async fn a_server_error_carrying_a_long_upstream_lifetime_is_rewritten_to_no_store() { + async fn a_server_error_with_a_long_upstream_lifetime_is_rewritten_to_no_store() { let response = proxied_asset( StatusCode::INTERNAL_SERVER_ERROR, "public, max-age=86400, immutable", @@ -779,7 +779,7 @@ mod tests { ) .await; assert_eq!(cors_origin_of(&first), Some(CORS_ALLOW_ANY_VALUE)); - let entity_tag = entity_tag_of(&first).expect("first response carries a validator"); + let entity_tag = entity_tag_of(&first).expect("first response has a validator"); let mut conditional = HeaderMap::new(); conditional.insert( @@ -875,7 +875,7 @@ mod tests { test_asset_csp(), ) .await; - let entity_tag = entity_tag_of(&first).expect("first response carries a validator"); + let entity_tag = entity_tag_of(&first).expect("first response has a validator"); let mut conditional = HeaderMap::new(); conditional.insert( @@ -1083,7 +1083,7 @@ mod tests { } #[tokio::test] - async fn a_precompressed_variant_carries_its_own_validator() { + async fn a_precompressed_variant_has_its_own_validator() { let fixture = LocalAssetDir::with_asset("f00dcafe12345678.css", b"body{}") .and_sibling("f00dcafe12345678.css.br", b"brotli-bytes-are-longer"); @@ -1095,7 +1095,7 @@ mod tests { test_asset_csp(), ) .await; - let brotli_tag = entity_tag_of(&brotli).expect("the brotli variant carries a validator"); + let brotli_tag = entity_tag_of(&brotli).expect("the brotli variant has a validator"); let identity = serve_local_asset( &budgets(), @@ -1105,7 +1105,7 @@ mod tests { test_asset_csp(), ) .await; - let identity_tag = entity_tag_of(&identity).expect("the raw file carries a validator"); + let identity_tag = entity_tag_of(&identity).expect("the raw file has a validator"); assert_ne!( brotli_tag, identity_tag, @@ -1305,7 +1305,7 @@ mod tests { assert_eq!( body_bytes(response).await, b"already brotli, and long enough to clear the thirty-two byte floor", - "re-encoding upstream bytes that already carry an encoding breaks every browser" + "re-encoding upstream bytes that already have an encoding breaks every browser" ); } @@ -1536,7 +1536,7 @@ mod tests { assert_eq!( body.as_ref(), b"console.log(1)", - "a response served past the read slot count carried the wrong bytes" + "a response served past the read slot count had the wrong bytes" ); } } diff --git a/fluxer_app_proxy/src/routes/spa_index.rs b/fluxer_app_proxy/src/routes/spa_index.rs index 023a4f866..4b87f037c 100644 --- a/fluxer_app_proxy/src/routes/spa_index.rs +++ b/fluxer_app_proxy/src/routes/spa_index.rs @@ -687,7 +687,7 @@ mod tests { const SHELL_WITH_AN_INLINE_SCRIPT: &str = r#"Fluxer"#; #[test] - fn the_rendered_document_always_carries_the_bootstrap() { + fn the_rendered_document_always_includes_the_bootstrap() { let rendered = render_spa_document( SHELL_WITH_AN_INLINE_SCRIPT, "", @@ -732,7 +732,7 @@ mod tests { "#; #[test] - fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() { + fn the_static_cdn_argument_resolves_every_hole_the_shell_has() { let rendered = render_spa_document( SHELL_WITH_ENDPOINT_HOLES, "", @@ -1069,13 +1069,13 @@ mod tests { let tag = &tag[..tag.find('>').unwrap()]; assert!( tag.is_empty() || tag.contains(" src="), - "the served document carries a script tag the test cannot classify: " + "the served document has a script tag the test cannot classify: " ); } let inline = bare_inline_scripts_in(document); assert!( !inline.is_empty(), - "the served document carries no inline script at all" + "the served document has no inline script at all" ); let mut expected: Vec = inline.iter().map(|script| sha256_source(script)).collect(); expected.sort(); @@ -1084,7 +1084,7 @@ mod tests { granted.sort(); assert_eq!( granted, expected, - "the policy must grant exactly the inline scripts the document carries" + "the policy must grant exactly the inline scripts the document contains" ); assert!(!document.contains("nonce")); assert!(!policy.contains("nonce")); @@ -1437,7 +1437,7 @@ mod tests { } #[tokio::test] - async fn the_shipped_shell_runs_every_inline_script_it_carries_under_its_policy() { + async fn the_shipped_shell_runs_every_inline_script_it_contains_under_its_policy() { let state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await; let response = serve_spa_index(&state, &HeaderMap::new()).await; diff --git a/fluxer_common/src/attachment_url_signature.rs b/fluxer_common/src/attachment_url_signature.rs index 6d5b3e092..71e2d4bea 100644 --- a/fluxer_common/src/attachment_url_signature.rs +++ b/fluxer_common/src/attachment_url_signature.rs @@ -310,7 +310,7 @@ mod tests { fn fixture_secrets(fixture: &Value) -> Vec> { fixture["secrets_base64"] .as_array() - .expect("the fixture carries a secret list") + .expect("the fixture has a secret list") .iter() .map(|entry| { BASE64_STANDARD @@ -323,20 +323,20 @@ mod tests { fn cases<'a>(fixture: &'a Value, name: &str) -> &'a [Value] { fixture[name] .as_array() - .expect("the fixture carries the case list") + .expect("the fixture has the case list") .as_slice() } fn text<'a>(case: &'a Value, field: &str) -> &'a str { case[field] .as_str() - .unwrap_or_else(|| panic!("case carries {field}")) + .unwrap_or_else(|| panic!("case has {field}")) } fn number(case: &Value, field: &str) -> u64 { case[field] .as_u64() - .unwrap_or_else(|| panic!("case carries {field}")) + .unwrap_or_else(|| panic!("case has {field}")) } fn secret_bytes() -> Vec { @@ -443,7 +443,7 @@ mod tests { data_packages += 1; (UrlKind::DataPackage, 0, with_data_package_signature) } - other => panic!("{name} carries an unknown uc {other}"), + other => panic!("{name} has an unknown uc {other}"), }; assert_eq!( text(case, "signature_input"), @@ -548,7 +548,7 @@ mod tests { let signed = with_signature("https://media.test/x.gif", KEY, ANCHOR, now, &secret); let query = signed .split_once('?') - .expect("a signed url carries a query") + .expect("a signed url has a query") .1; let (issued, expires) = issue_window(ANCHOR, now); assert!(issued <= now && now < expires); @@ -576,7 +576,7 @@ mod tests { with_data_package_signature("https://media.test/x.gif", KEY, ANCHOR, now, &secret); let query = signed .split_once('?') - .expect("a signed url carries a query") + .expect("a signed url has a query") .1; let (issued, expires) = issue_window(ANCHOR, now); assert!(query.starts_with(&format!("ex=0&is={issued:08x}&hm="))); @@ -688,7 +688,7 @@ mod tests { } #[test] - fn a_signature_never_carries_across_keys_or_windows() { + fn a_signature_never_validates_across_keys_or_windows() { let secret = secret_bytes(); let now = ANCHOR; let (issued, expires) = issue_window(ANCHOR, now); diff --git a/fluxer_common/src/config.rs b/fluxer_common/src/config.rs index 5c847d386..fe945d8f7 100644 --- a/fluxer_common/src/config.rs +++ b/fluxer_common/src/config.rs @@ -609,14 +609,14 @@ mod tests { let vectors = vectors.as_array().expect("vectors are an array"); assert!(!vectors.is_empty()); for vector in vectors { - let url = vector["url"].as_str().expect("vector carries a url"); + let url = vector["url"].as_str().expect("vector has a url"); let base_domain = vector["base_domain"] .as_str() - .expect("vector carries a base domain"); + .expect("vector has a base domain"); let public_port = vector["public_port"].as_u64().map(|port| port as u16); let expected = vector["normalized"] .as_str() - .expect("vector carries a normalized url"); + .expect("vector has a normalized url"); assert_eq!( expected, normalize_public_endpoint(url, base_domain, public_port), diff --git a/fluxer_common/src/external_media_path.rs b/fluxer_common/src/external_media_path.rs index 27829d0f8..49229e2db 100644 --- a/fluxer_common/src/external_media_path.rs +++ b/fluxer_common/src/external_media_path.rs @@ -267,8 +267,8 @@ mod tests { let vectors = vectors.as_array().expect("vectors are an array"); assert!(!vectors.is_empty()); for vector in vectors { - let original = vector["url"].as_str().expect("vector carries a url"); - let expected = vector["path"].as_str().expect("vector carries a path"); + let original = vector["url"].as_str().expect("vector has a url"); + let expected = vector["path"].as_str().expect("vector has a path"); let normalized = url::Url::parse(original).expect("vector url parses"); assert_eq!( expected, diff --git a/fluxer_desktop/native/encoder-ring/src/qsv.rs b/fluxer_desktop/native/encoder-ring/src/qsv.rs index 7c331dc6f..2cb7b0158 100644 --- a/fluxer_desktop/native/encoder-ring/src/qsv.rs +++ b/fluxer_desktop/native/encoder-ring/src/qsv.rs @@ -912,7 +912,7 @@ mod tests { } #[test] - fn variant_payload_carries_u32_value() { + fn variant_payload_holds_u32_value() { let v = MfxVariant { version: MfxStructVersion { minor: MFX_VARIANT_VERSION_MINOR, diff --git a/fluxer_desktop/native/encoder-ring/src/ring.rs b/fluxer_desktop/native/encoder-ring/src/ring.rs index b39f83c1f..7d1f9fecc 100644 --- a/fluxer_desktop/native/encoder-ring/src/ring.rs +++ b/fluxer_desktop/native/encoder-ring/src/ring.rs @@ -863,7 +863,7 @@ mod tests { for (sequence, duplicate_count) in by_sequence.iter().take(7) { assert_eq!(*duplicate_count, 0, "sequence {sequence} not duplicated"); } - assert_eq!(by_sequence[7], (8, 1), "newest carries the lagged frame"); + assert_eq!(by_sequence[7], (8, 1), "newest holds the lagged frame"); } #[test] diff --git a/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs b/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs index 485a7f461..779820da0 100644 --- a/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs +++ b/fluxer_desktop/native/linux-audio-capture/src/pipewire_bridge.rs @@ -310,7 +310,7 @@ mod tests { } #[test] - fn link_props_carry_per_port_routing() { + fn link_props_include_per_port_routing() { let props = build_link_props(101, 7, 202, 13); let dict = props.dict(); assert_eq!(dict.get("link.output.node"), Some("101")); diff --git a/fluxer_desktop/native/linux-input-hook/src/hook.rs b/fluxer_desktop/native/linux-input-hook/src/hook.rs index 39ce58410..1e0f1c685 100644 --- a/fluxer_desktop/native/linux-input-hook/src/hook.rs +++ b/fluxer_desktop/native/linux-input-hook/src/hook.rs @@ -579,7 +579,7 @@ mod tests { use super::*; #[test] - fn decoded_keydown_carries_keysym_and_name() { + fn decoded_keydown_has_keysym_and_name() { let mut event = DecodedEvent::new(EventKind::KeyDown, modifiers::from_state(0)); event.keycode = 0x0061; event.key_name = keymap::keysym_to_name(0x0061).unwrap().to_string(); diff --git a/fluxer_desktop/native/linux-screen-capture/src/pipewire_stream.rs b/fluxer_desktop/native/linux-screen-capture/src/pipewire_stream.rs index e5d232484..c93d619b5 100644 --- a/fluxer_desktop/native/linux-screen-capture/src/pipewire_stream.rs +++ b/fluxer_desktop/native/linux-screen-capture/src/pipewire_stream.rs @@ -1319,7 +1319,7 @@ mod tests { } #[test] - fn format_pod_with_modifiers_carries_mandatory_dont_fixate_choice() { + fn format_pod_with_modifiers_has_mandatory_dont_fixate_choice() { let bytes = serialize_video_format_pod(VideoFormat::BGRA, Some(&DMABUF_MODIFIERS_BASELINE)) .expect("modifier pod serializes"); let obj = deserialize_format_object(&bytes); diff --git a/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs b/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs index 501e7bbbc..6af8ccf43 100644 --- a/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs +++ b/fluxer_desktop/native/mac-screen-capture/src/napi_surface_macos.rs @@ -2008,7 +2008,7 @@ mod dispatch_queue_tests { } #[test] - fn audio_frame_payload_into_input_carries_slot_and_metadata() { + fn audio_frame_payload_into_input_keeps_slot_and_metadata() { use crate::audio_pool::MacAudioFramePool; let pool = MacAudioFramePool::new(2, 64).expect("pool"); let mut slot = pool.try_acquire().expect("slot"); @@ -2114,7 +2114,7 @@ mod dispatch_queue_tests { } #[test] - fn build_capture_config_carries_audio_settings() { + fn build_capture_config_includes_audio_settings() { let cfg = super::build_capture_config( 30, true, @@ -2129,7 +2129,7 @@ mod dispatch_queue_tests { } #[test] - fn start_options_carry_cursor_color_and_rect_intent() { + fn start_options_include_cursor_color_and_rect_intent() { let options = super::normalize_start_options(Some(super::ScreenCaptureStartOptions { show_cursor_clicks: Some(true), capture_rect: Some(super::ScreenCaptureRect { diff --git a/fluxer_desktop/src/main/WindowsShortcuts.test.mjs b/fluxer_desktop/src/main/WindowsShortcuts.test.mjs index 997fc5823..9008b4efa 100644 --- a/fluxer_desktop/src/main/WindowsShortcuts.test.mjs +++ b/fluxer_desktop/src/main/WindowsShortcuts.test.mjs @@ -157,7 +157,7 @@ describe('Windows Start Menu shortcut repair', () => { assert.equal(harness.files.has(AUTHOR_SHORTCUT), false); }); - test('still rewrites the author shortcut carrying the legacy AppUserModelID', async () => { + test('still rewrites the author shortcut with the legacy AppUserModelID', async () => { const harness = loadWindowsShortcuts([[AUTHOR_SHORTCUT, lnkBuffer(CURRENT_EXE, LEGACY_APP_USER_MODEL_ID)]]); await runRepair(harness); diff --git a/fluxer_docs/scripts/VerifyDocsCoverage.ts b/fluxer_docs/scripts/VerifyDocsCoverage.ts index 926518a47..037e6f749 100644 --- a/fluxer_docs/scripts/VerifyDocsCoverage.ts +++ b/fluxer_docs/scripts/VerifyDocsCoverage.ts @@ -38,7 +38,7 @@ const MAIN_SPEC_EXEMPT = new MapAUTH_SESSION_CHANGE -The account's authentication session was rotated, for example by a password change on another device. +The account's authentication session was rotated by a password change. | Field | Type | Description | | --- | --- | --- | | old_auth_session_id_hash | string | Base64url hash of the authentication session that was replaced | | new_auth_session_id_hash | string | Base64url hash of the replacement authentication session | -| new_token | string | Replacement for the token the client holds | -Every session of the account receives the event, including the one that caused the rotation. A client MUST use `new_token` for every later HTTP request and for any later [Resume](/gateway/commands/#resume) or [Identify](/gateway/commands/#identify). A client whose own `auth_session_id_hash` from [Ready](#ready) equals `old_auth_session_id_hash` MUST replace it with `new_auth_session_id_hash`. +The event never includes the replacement token. The API closes every gateway session of the replaced authentication session before it sends the event, so those sessions do not receive it. The client that changed the password gets the replacement token and `auth_session_id_hash` in the HTTP response. It MUST use that token for every later HTTP request and for any later [Identify](/gateway/commands/#identify), and MUST replace its own `auth_session_id_hash` from [Ready](#ready) with the one in the response. ### RATE_LIMITED diff --git a/fluxer_docs/src/content/docs/http-api/unfurl.mdx b/fluxer_docs/src/content/docs/http-api/unfurl.mdx index 8880cda7d..8655766dd 100644 --- a/fluxer_docs/src/content/docs/http-api/unfurl.mdx +++ b/fluxer_docs/src/content/docs/http-api/unfurl.mdx @@ -86,7 +86,7 @@ No permission applies. Outside a development instance, Fluxer also rejects a URL that omits a top-level domain. -Explicit media can carry `CONTAINS_EXPLICIT_MEDIA` in its [flags](/http-api/messages/#attachment-flags). Klipy media is not classified. Message previews in channels that permit explicit media can have different flags. +Explicit media can have `CONTAINS_EXPLICIT_MEDIA` in its [flags](/http-api/messages/#attachment-flags). Klipy media is not classified. Message previews in channels that permit explicit media can have different flags. :::note[This route applies none of the message-path filtering] It resolves precisely the URL in the request body and returns the complete resolver output, with no URL extraction, no embed cap, and no banned-content scan. diff --git a/fluxer_docs/src/content/docs/http-api/users/current-user.mdx b/fluxer_docs/src/content/docs/http-api/users/current-user.mdx index 4ac5868b9..1eda3a5e1 100644 --- a/fluxer_docs/src/content/docs/http-api/users/current-user.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/current-user.mdx @@ -156,7 +156,7 @@ Each control is separate from the route bucket. A denial returns 400 `INVALID_FO Fluxer consumes the avatar control before it detects identical content, and clearing the avatar consumes nothing. A clearing request consumes the banner control, and so does a request whose value is unchanged. :::caution[A password change rotates the session] -Supplying `new_password` on a claimed account deletes every other authentication session and every outstanding password reset token, then replaces the session that issued the request. The replacement arrives as [Auth Session Change](/gateway/events/#auth-session-change). +Supplying `new_password` on a claimed account deletes every other authentication session and every outstanding password reset token, then replaces the session that issued the request. The user object in the response then also has the fields of the [password change completion object](/http-api/users/email-and-password/#password-change-completion-object), and the client sends every later request with that token. [Auth Session Change](/gateway/events/#auth-session-change) never includes it. ::: ### Response @@ -164,6 +164,7 @@ Supplying `new_password` on a claimed account deletes every other authentication | Status | Body | Condition | | --- | --- | --- | | 200 | [user](/http-api/users/#user-object) object | Account was returned after applying every permitted field | +| 200 | [user](/http-api/users/#user-object) object with the [password change completion](/http-api/users/email-and-password/#password-change-completion-object) fields | Account was returned after a password change rotated the session | | 400 | [error response](/http-api/#error-response) | Body, image, tag, password, entitlement, secondary control, or sudo proof is invalid | | 403 | [error response](/http-api/#error-response) | Email verification, sudo verification, or a staff-only field is required, or content is blocked | | 403 | [error response](/http-api/#error-response) | The account is limited and the body changes a profile field, and the request returns `ACCOUNT_LIMITED` | diff --git a/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx b/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx index ca742f8fb..94dc9fe59 100644 --- a/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx @@ -183,7 +183,7 @@ The proof that the emailed code was accepted. ## Password change completion object -The replacement session [Complete password change](#complete-password-change) returns after the password is written. +The replacement session [Complete password change](#complete-password-change) returns after the password is written. [Modify current user](/http-api/users/current-user/#modify-current-user) adds the same fields to its user object when it changes the password. ### Structure diff --git a/fluxer_docs/src/content/docs/voice/index.md b/fluxer_docs/src/content/docs/voice/index.md index bde866328..42b259e7f 100644 --- a/fluxer_docs/src/content/docs/voice/index.md +++ b/fluxer_docs/src/content/docs/voice/index.md @@ -73,7 +73,7 @@ Fluxer reports a refusal by sending no Dispatch. A client observes a refused pla A guild voice channel stores its `bitrate`, `user_limit`, `voice_connection_limit`, and `rtc_region` on the [channel object](/http-api/channels/#channel-object). It also has ordinary messages, pins, and slowmode, so its text history is read and written through the [Messages resource](/http-api/messages/). -A new voice channel stores a `bitrate` of 64000. The ceiling is 96000, and the `AUDIO_BITRATE_128_KBPS`, `AUDIO_BITRATE_256_KBPS`, and `AUDIO_BITRATE_384_KBPS` [guild features](/http-api/guilds/#guild-features) raise it to 128000, 256000, and 384000. A direct message and a group direct message call carry no `bitrate` and always run at 64000. +A new voice channel stores a `bitrate` of 64000. The ceiling is 96000, and the `AUDIO_BITRATE_128_KBPS`, `AUDIO_BITRATE_256_KBPS`, and `AUDIO_BITRATE_384_KBPS` [guild features](/http-api/guilds/#guild-features) raise it to 128000, 256000, and 384000. A direct message and a group direct message call have no `bitrate` and always run at 64000. ### Permissions diff --git a/fluxer_docs/src/installer/install.ps1 b/fluxer_docs/src/installer/install.ps1 index 30efdadde..a6d4ae0d2 100644 --- a/fluxer_docs/src/installer/install.ps1 +++ b/fluxer_docs/src/installer/install.ps1 @@ -11,11 +11,11 @@ # -Rollback Put the images and the stack files of the last recorded upgrade back. # # Why one script and not a separate upgrader: an upgrade needs the host checks, the stack -# download, the readiness poll and the health probe that the install already carries. A second +# download, the readiness poll and the health probe that the install already has. A second # script either copies them or drifts from them, and the operator has two downloads and two # checksums to verify instead of one. # -# This file is also the procedure. Every step of the upgrade carries the command an operator +# This file is also the procedure. Every step of the upgrade includes the command an operator # types to do that step by hand, and the reason the step exists. # # Read this file before running it. The default mode writes .env, which holds every secret the @@ -664,7 +664,7 @@ function Get-FluxerStackFiles([string]$StagingDir, [string]$RefValue) { # None of these files is part of an image, and all four are read from the working directory, so # docker compose pull never updates any of them. That is why an upgrade refreshes them itself. # -# A refreshed docker-compose.yml can declare a variable the running .env does not carry. Compose +# A refreshed docker-compose.yml can declare a variable the running .env does not define. Compose # writes ${NAME:?message} for a variable the stack requires and stops with that message until .env # sets it, and ${NAME:-default} for one that needs nothing from the operator. Every optional # override ships commented out in .env.example, so a new required key is the only kind that asks @@ -684,7 +684,7 @@ function Move-FluxerStackFiles([string]$StagingDir, [string]$TargetDir) { # values only the operator knows. The five other non-secret keys in the list above ship correct in # .env.example and need no edit. # -# Every secret in .env.example carries the literal CHANGE_ME. A key whose name ends in _BASE64 +# Every secret in .env.example contains the literal CHANGE_ME. A key whose name ends in _BASE64 # takes 32 random bytes as base64, every other key takes 32 random bytes as hex, and the VAPID pair # comes from the generator above. # @@ -1233,7 +1233,7 @@ function Write-FluxerTextFile([string]$Path, [string[]]$Lines) { # keep, and what makes a rollback possible on a moving tag. # # The reference list comes from Compose and the ID under each reference comes from the container -# running it, for the reason in Get-FluxerRunningImageIds. A reference no container carries is +# running it, for the reason in Get-FluxerRunningImageIds. A reference no container uses is # recorded as `-`, which a rollback skips, because a version that was not running is not a version # to go back to. # @@ -1748,7 +1748,7 @@ function Invoke-FluxerUpgrade([string]$TargetDir, [string]$EnvPath, [string]$Bac # # Two shapes, depending on what the upgrade moved: # -# A pinned tag moved, so the old images still carry their own tag. The tag goes back into .env +# A pinned tag moved, so the old images still have their own tag. The tag goes back into .env # and Compose finds them. # # By hand: set FLUXER_IMAGE_TAG back, then docker compose up -d diff --git a/fluxer_docs/src/installer/install.sh b/fluxer_docs/src/installer/install.sh index 82721a829..5cf7383bb 100644 --- a/fluxer_docs/src/installer/install.sh +++ b/fluxer_docs/src/installer/install.sh @@ -14,11 +14,11 @@ # # Why one script and not a separate upgrader: an upgrade needs the host checks, # the stack download, the readiness poll and the health probe that the install -# already carries. A second script either copies them or drifts from them, and +# already has. A second script either copies them or drifts from them, and # the operator has two downloads and two checksums to verify instead of one. # The modes share one contract, one digest and one set of exit codes. # -# This file is also the procedure. Every step of the upgrade carries the command +# This file is also the procedure. Every step of the upgrade includes the command # an operator types to do that step by hand, and the reason the step exists. # # Read this file before you run it. The default mode writes .env, which holds @@ -86,7 +86,7 @@ FLUXER_DUMP_FILE='fluxer.dump' # with it. FLUXER_VOLUME_HEADROOM=110 -# The keys .env carries, in the order they are written. The installer iterates +# The keys .env holds, in the order they are written. The installer iterates # these two lists, so a key that leaves a list is a key the installer stops # writing. The docs CI parses the same text and compares it against # deploy/self-hosting/.env.example. @@ -801,7 +801,7 @@ fluxer_fetch_stack() { # upgrade refreshes them itself. # # A refreshed docker-compose.yml can declare a variable the running .env does not -# carry. Compose writes ${NAME:?message} for a variable the stack requires and +# define. Compose writes ${NAME:?message} for a variable the stack requires and # stops with that message until .env sets it, and ${NAME:-default} for one that # needs nothing from the operator. Every optional override ships commented out in # .env.example, so a new required key is the only kind that asks for an edit. @@ -900,7 +900,7 @@ fluxer_generate_vapid() { # operator knows. The five other non-secret keys in the list above ship correct # in .env.example and need no edit. # -# Every secret in .env.example carries the literal CHANGE_ME. A key whose name +# Every secret in .env.example contains the literal CHANGE_ME. A key whose name # ends in _BASE64 takes openssl rand -base64 32, every other key takes # openssl rand -hex 32, and the VAPID pair comes from the generator above. # @@ -1412,7 +1412,7 @@ $(fluxer_indent_file "$fluxer_scratch/inspect-err" ' ')" sort -u "$fluxer_scratch/inspected" } -# The recorded ID for one reference, or nothing when no container carries it. +# The recorded ID for one reference, or nothing when no container uses it. fluxer_recorded_id_for() { awk -v fluxer_want="$1" '$1 == fluxer_want {print $2; exit}' "$fluxer_scratch/running" } @@ -1427,7 +1427,7 @@ fluxer_recorded_id_for() { # # The reference list comes from Compose and the ID under each reference comes # from the container running it, for the reason in fluxer_running_image_ids. A -# reference no container carries is recorded as `-`, which a rollback skips, +# reference no container uses is recorded as `-`, which a rollback skips, # because a version that was not running is not a version to go back to. # # By hand: @@ -1770,7 +1770,7 @@ fluxer_prepare_record() { mkdir -m 700 "$fluxer_record" } -# Record names carry a UTC stamp, so the shell expands the glob in byte order +# Record names include a UTC stamp, so the shell expands the glob in byte order # and the last match is the most recent upgrade. fluxer_newest_record() { fluxer_newest='' @@ -2008,7 +2008,7 @@ fluxer_set_image_tag() { # # Two shapes, depending on what the upgrade moved: # -# A pinned tag moved, so the old images still carry their own tag. The tag goes +# A pinned tag moved, so the old images still have their own tag. The tag goes # back into .env and Compose finds them. # # By hand: set FLUXER_IMAGE_TAG back, then docker compose up -d diff --git a/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl b/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl index aed705a62..453ae31cd 100644 --- a/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl +++ b/fluxer_gateway/src/gateway/gateway_rpc_guild_mentions.erl @@ -322,7 +322,7 @@ parse_page_params_defaults_optional_fields_test() -> maps:get(limit, Req) ). -mention_guild_calls_carry_the_caller_deadline_test() -> +mention_guild_calls_pass_the_caller_deadline_test() -> Cases = [ { fun(Pid) -> guild_call_sources_page(Pid, #{limit => 5}) end, @@ -350,9 +350,9 @@ mention_guild_calls_carry_the_caller_deadline_test() -> #{<<"user_ids">> => [<<"7">>]} } ], - lists:foreach(fun assert_call_carries_deadline/1, Cases). + lists:foreach(fun assert_call_passes_deadline/1, Cases). -assert_call_carries_deadline({Call, GuildReply, Expected}) -> +assert_call_passes_deadline({Call, GuildReply, Expected}) -> Self = self(), Guild = spawn(fun() -> receive diff --git a/fluxer_gateway/src/guild/guild.erl b/fluxer_gateway/src/guild/guild.erl index 17aa4b5c4..387b02d4c 100644 --- a/fluxer_gateway/src/guild/guild.erl +++ b/fluxer_gateway/src/guild/guild.erl @@ -741,6 +741,7 @@ voice_guild_state_keys() -> virtual_channel_access_pending, virtual_channel_access_preserve, virtual_channel_access_move_pending, + virtual_channel_access_view_only, test_perm_fun, test_force_disconnect_fun, test_livekit_fun, @@ -824,7 +825,8 @@ voice_guild_state_pins_projected_key_set_test() -> virtual_channel_access, virtual_channel_access_pending, virtual_channel_access_preserve, - virtual_channel_access_move_pending + virtual_channel_access_move_pending, + virtual_channel_access_view_only ]), lists:sort(maps:keys(Projected)) ), @@ -1093,6 +1095,7 @@ voice_projection_state(Tab) -> virtual_channel_access_pending => #{}, virtual_channel_access_preserve => #{}, virtual_channel_access_move_pending => #{}, + virtual_channel_access_view_only => #{}, presence_subscriptions => #{}, member_list_subscriptions => #{}, connected_user_ids => sets:new(), diff --git a/fluxer_gateway/src/guild/guild_dispatch_push.erl b/fluxer_gateway/src/guild/guild_dispatch_push.erl index 71d3add0f..3b99373a6 100644 --- a/fluxer_gateway/src/guild/guild_dispatch_push.erl +++ b/fluxer_gateway/src/guild/guild_dispatch_push.erl @@ -1475,7 +1475,7 @@ released_push_holds_treat_a_dead_guild_as_nothing_released_test() -> end, ?assertEqual([], released_push_holds(GuildPid, [{1, <<"s1">>}])). -push_states_carry_the_guild_pid_for_the_grace_recheck_test() -> +push_states_include_the_guild_pid_for_the_grace_recheck_test() -> ?assertEqual({self(), self()}, push_state_guild_pids()). push_states_skip_the_grace_recheck_while_presence_eligibility_is_off_test() -> @@ -1798,7 +1798,7 @@ compact_push_state_drops_members_and_keeps_member_count_test() -> ets:delete(Tab) end. -legacy_push_state_carries_member_count_test() -> +legacy_push_state_includes_member_count_test() -> UpdatedState = #{id => 7, data => #{}, sessions => #{}, member_count => 1234}, Legacy = legacy_push_state(7, UpdatedState), ?assertEqual(1234, maps:get(member_count, Legacy)), @@ -1869,7 +1869,7 @@ spawn_push_without_members_table_falls_back_to_legacy_push_test() -> reset_push_worker_state() end. -compact_push_carries_large_guild_metadata_test() -> +compact_push_includes_large_guild_metadata_test() -> Self = self(), Tab = ets:new(test_members, [set, public]), ok = meck:new(push, [passthrough, no_link]), diff --git a/fluxer_gateway/src/guild/guild_handoff.erl b/fluxer_gateway/src/guild/guild_handoff.erl index 5871ee6e5..ea726ee06 100644 --- a/fluxer_gateway/src/guild/guild_handoff.erl +++ b/fluxer_gateway/src/guild/guild_handoff.erl @@ -28,7 +28,9 @@ export_handoff_state(State) -> virtual_channel_access_preserve, State, #{} ), virtual_channel_access_move_pending => - maps:get(virtual_channel_access_move_pending, State, #{}) + maps:get(virtual_channel_access_move_pending, State, #{}), + virtual_channel_access_view_only => + maps:get(virtual_channel_access_view_only, State, #{}) }. -spec derived_data_keys() -> [atom() | binary()]. diff --git a/fluxer_gateway/src/guild/guild_init.erl b/fluxer_gateway/src/guild/guild_init.erl index 46b56db43..8aefd6982 100644 --- a/fluxer_gateway/src/guild/guild_init.erl +++ b/fluxer_gateway/src/guild/guild_init.erl @@ -13,7 +13,7 @@ handle_reload/2 ]). --define(MAX_RELOAD_CARRY_WARN_MEMBERS, 100000). +-define(MAX_RELOAD_REUSE_WARN_MEMBERS, 100000). -type guild_state() :: map(). -type user_id() :: integer(). @@ -130,7 +130,7 @@ handle_reload(NewData, State) -> NewData, ExistingVoiceStates ), NormalizedNewData0 = guild_data_index:normalize_map(ReloadData), - NormalizedNewData = carry_members_table(OldData, NormalizedNewData0), + NormalizedNewData = reuse_members_table(OldData, NormalizedNewData0), NewState0 = guild_member_list_engine_inputs:forget_all( State#{voice_states => ReloadVoiceStates, data => NormalizedNewData} ), @@ -149,16 +149,16 @@ handle_reload(NewData, State) -> ok = guild_maintenance:maybe_put_permission_cache(NewState), {reply, ok, NewState}. --spec carry_members_table(term(), term()) -> term(). -carry_members_table(OldData, NewData) when is_map(OldData), is_map(NewData) -> - carry_healthy_members_table(OldData, NewData); -carry_members_table(_OldData, NewData) -> +-spec reuse_members_table(term(), term()) -> term(). +reuse_members_table(OldData, NewData) when is_map(OldData), is_map(NewData) -> + reuse_healthy_members_table(OldData, NewData); +reuse_members_table(_OldData, NewData) -> NewData. --spec carry_healthy_members_table(map(), map()) -> map(). -carry_healthy_members_table(OldData, NewData) -> +-spec reuse_healthy_members_table(map(), map()) -> map(). +reuse_healthy_members_table(OldData, NewData) -> case members_ets_table(OldData) of - Tab when is_reference(Tab) -> carry_live_members_table(Tab, OldData, NewData); + Tab when is_reference(Tab) -> reuse_live_members_table(Tab, OldData, NewData); undefined -> maps:remove(members_ets, NewData) end. @@ -168,8 +168,8 @@ members_ets_table(#{members_ets := Tab}) -> members_ets_table(_Data) -> undefined. --spec carry_live_members_table(ets:tid(), map(), map()) -> map(). -carry_live_members_table(Tab, OldData, NewData) -> +-spec reuse_live_members_table(ets:tid(), map(), map()) -> map(). +reuse_live_members_table(Tab, OldData, NewData) -> case guild_members_table_repair:members_table_healthy(Tab) of true -> apply_members_table_delta(Tab, OldData, NewData); false -> maps:remove(members_ets, NewData) @@ -179,7 +179,7 @@ carry_live_members_table(Tab, OldData, NewData) -> apply_members_table_delta(Tab, OldData, NewData) -> OldMap = guild_data_index_members:member_map(OldData), NewMap = guild_data_index_members:member_map(NewData), - maybe_warn_large_carry(map_size(NewMap)), + maybe_warn_large_reuse(map_size(NewMap)), try ok = insert_changed_members(Tab, OldMap, NewMap), ok = delete_removed_members(Tab, OldMap, NewMap), @@ -223,13 +223,13 @@ delete_member_row(Tab, UserId) -> true = ets:delete(Tab, UserId), ok. --spec maybe_warn_large_carry(non_neg_integer()) -> ok. -maybe_warn_large_carry(Size) when Size > ?MAX_RELOAD_CARRY_WARN_MEMBERS -> +-spec maybe_warn_large_reuse(non_neg_integer()) -> ok. +maybe_warn_large_reuse(Size) when Size > ?MAX_RELOAD_REUSE_WARN_MEMBERS -> logger:warning( "guild_reload_members_table_carry_large: members=~p threshold=~p", - [Size, ?MAX_RELOAD_CARRY_WARN_MEMBERS] + [Size, ?MAX_RELOAD_REUSE_WARN_MEMBERS] ); -maybe_warn_large_carry(_Size) -> +maybe_warn_large_reuse(_Size) -> ok. -spec collect_active_pid(term(), map(), [pid()]) -> [pid()]. @@ -265,66 +265,66 @@ guild_id(State) -> -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -carry_members_table_carries_healthy_tid_test() -> - Tab = ets:new(carry_members, [set, public]), +reuse_members_table_keeps_healthy_tid_test() -> + Tab = ets:new(reuse_members, [set, public]), try - OldMap = #{1 => carry_member(1), 2 => carry_member(2)}, - NewMap = #{1 => carry_member(1), 3 => carry_member(3)}, - seed_carry_table(Tab, OldMap), - OldData = carry_data(OldMap, Tab), - Carried = carry_members_table(OldData, carry_data(NewMap, undefined)), - ?assertEqual(Tab, maps:get(members_ets, Carried)), - ?assertEqual([1, 3], carry_table_ids(Tab)) + OldMap = #{1 => reuse_member(1), 2 => reuse_member(2)}, + NewMap = #{1 => reuse_member(1), 3 => reuse_member(3)}, + seed_reuse_table(Tab, OldMap), + OldData = reuse_data(OldMap, Tab), + Reused = reuse_members_table(OldData, reuse_data(NewMap, undefined)), + ?assertEqual(Tab, maps:get(members_ets, Reused)), + ?assertEqual([1, 3], reuse_table_ids(Tab)) after ets:delete(Tab) end. -carry_members_table_skips_dead_tid_test() -> - Tab = ets:new(dead_carry_members, [set, public]), +reuse_members_table_skips_dead_tid_test() -> + Tab = ets:new(dead_reuse_members, [set, public]), true = ets:delete(Tab), - OldData = carry_data(#{1 => carry_member(1)}, Tab), - NewData = carry_data(#{1 => carry_member(1)}, undefined), - ?assertEqual(NewData, carry_members_table(OldData, NewData)). + OldData = reuse_data(#{1 => reuse_member(1)}, Tab), + NewData = reuse_data(#{1 => reuse_member(1)}, undefined), + ?assertEqual(NewData, reuse_members_table(OldData, NewData)). -carry_members_table_without_old_tid_leaves_new_data_test() -> - OldData = carry_data(#{1 => carry_member(1)}, undefined), - NewData = carry_data(#{2 => carry_member(2)}, undefined), - ?assertEqual(NewData, carry_members_table(OldData, NewData)). +reuse_members_table_without_old_tid_leaves_new_data_test() -> + OldData = reuse_data(#{1 => reuse_member(1)}, undefined), + NewData = reuse_data(#{2 => reuse_member(2)}, undefined), + ?assertEqual(NewData, reuse_members_table(OldData, NewData)). -carry_members_table_delta_failure_falls_back_to_new_data_test() -> +reuse_members_table_delta_failure_falls_back_to_new_data_test() -> Tab = ets:new(failing_members, [set, public]), true = ets:delete(Tab), - OldData = carry_data(#{}, undefined), - NewData = carry_data(#{1 => carry_member(1)}, undefined), + OldData = reuse_data(#{}, undefined), + NewData = reuse_data(#{1 => reuse_member(1)}, undefined), ?assertEqual(NewData, apply_members_table_delta(Tab, OldData, NewData)). -carry_delta_never_empties_table_test() -> +reuse_delta_never_empties_table_test() -> Tab = ets:new(delta_members, [set, public]), try - OldMap = #{1 => carry_member(1), 2 => carry_member(2), 3 => carry_member(3)}, + OldMap = #{1 => reuse_member(1), 2 => reuse_member(2), 3 => reuse_member(3)}, NewMap = #{ - 1 => carry_member(1), - 2 => carry_member(2, <<"changed">>), - 4 => carry_member(4) + 1 => reuse_member(1), + 2 => reuse_member(2, <<"changed">>), + 4 => reuse_member(4) }, - seed_carry_table(Tab, OldMap), + seed_reuse_table(Tab, OldMap), ?assertEqual(3, ets:info(Tab, size)), ok = insert_changed_members(Tab, OldMap, NewMap), ?assertEqual(4, ets:info(Tab, size)), - ?assertEqual([{1, carry_member(1)}], ets:lookup(Tab, 1)), + ?assertEqual([{1, reuse_member(1)}], ets:lookup(Tab, 1)), ok = delete_removed_members(Tab, OldMap, NewMap), ?assertEqual(3, ets:info(Tab, size)), - ?assertEqual([{1, carry_member(1)}], ets:lookup(Tab, 1)), - ?assertEqual([1, 2, 4], carry_table_ids(Tab)), - ?assertEqual([{2, carry_member(2, <<"changed">>)}], ets:lookup(Tab, 2)) + ?assertEqual([{1, reuse_member(1)}], ets:lookup(Tab, 1)), + ?assertEqual([1, 2, 4], reuse_table_ids(Tab)), + ?assertEqual([{2, reuse_member(2, <<"changed">>)}], ets:lookup(Tab, 2)) after ets:delete(Tab) end. -carry_delta_skips_unchanged_members_test() -> +reuse_delta_skips_unchanged_members_test() -> Tab = ets:new(unchanged_members, [set, public]), try - MemberMap = #{1 => carry_member(1)}, + MemberMap = #{1 => reuse_member(1)}, true = ets:insert(Tab, {1, sentinel}), ok = insert_changed_members(Tab, MemberMap, MemberMap), ?assertEqual([{1, sentinel}], ets:tab2list(Tab)) @@ -332,21 +332,21 @@ carry_delta_skips_unchanged_members_test() -> ets:delete(Tab) end. -carry_data(MemberMap, undefined) -> +reuse_data(MemberMap, undefined) -> #{<<"members">> => MemberMap, members_normalized => MemberMap}; -carry_data(MemberMap, Tab) -> +reuse_data(MemberMap, Tab) -> #{<<"members">> => MemberMap, members_normalized => MemberMap, members_ets => Tab}. -seed_carry_table(Tab, MemberMap) -> +seed_reuse_table(Tab, MemberMap) -> maps:foreach(fun(UserId, Member) -> ets:insert(Tab, {UserId, Member}) end, MemberMap). -carry_member(UserId) -> +reuse_member(UserId) -> #{<<"user">> => #{<<"id">> => UserId}}. -carry_table_ids(Tab) -> +reuse_table_ids(Tab) -> lists:sort([Id || {Id, _} <- ets:tab2list(Tab)]). -carry_member(UserId, Nick) -> +reuse_member(UserId, Nick) -> #{<<"user">> => #{<<"id">> => UserId}, <<"nick">> => Nick}. -endif. diff --git a/fluxer_gateway/src/guild/guild_maintenance.erl b/fluxer_gateway/src/guild/guild_maintenance.erl index 5f8ee74f0..e8de31e1d 100644 --- a/fluxer_gateway/src/guild/guild_maintenance.erl +++ b/fluxer_gateway/src/guild/guild_maintenance.erl @@ -604,7 +604,7 @@ prune_overwrite(TargetId, Type) -> <<"deny">> => <<"0">> }. -%% Members 30, 31, 99 and 4242 all carry the same roles term and reach only channel 600 +%% Members 30, 31, 99 and 4242 all have the same roles term and reach only channel 600 %% through it, but 99 holds virtual access to channel 500 and 4242 is a user-overwrite %% target on 500, so neither may be answered from that term. Member 7 owns the guild, %% 777 is subscribed without being a member, and session s3 has no cached viewable map. diff --git a/fluxer_gateway/src/guild/guild_manager_shard_lifecycle.erl b/fluxer_gateway/src/guild/guild_manager_shard_lifecycle.erl index 3a2fa5d4a..8a3e9279c 100644 --- a/fluxer_gateway/src/guild/guild_manager_shard_lifecycle.erl +++ b/fluxer_gateway/src/guild/guild_manager_shard_lifecycle.erl @@ -101,7 +101,9 @@ normalize_transferred_guild_state(GuildId, TransferState) -> virtual_channel_access_preserve, TransferState, #{} ), virtual_channel_access_move_pending => - maps:get(virtual_channel_access_move_pending, TransferState, #{}) + maps:get(virtual_channel_access_move_pending, TransferState, #{}), + virtual_channel_access_view_only => + maps:get(virtual_channel_access_view_only, TransferState, #{}) }. -spec reply_start_transferred(guild_id(), map(), state()) -> diff --git a/fluxer_gateway/src/guild/guild_presence.erl b/fluxer_gateway/src/guild/guild_presence.erl index 7deb50912..21574c381 100644 --- a/fluxer_gateway/src/guild/guild_presence.erl +++ b/fluxer_gateway/src/guild/guild_presence.erl @@ -16,7 +16,7 @@ -type list_sync() :: immediate | deferred. %% members_sorted_ids trims with the member map it indexes: a snapshot that kept it would -%% answer sorted_member_ids/2 with ids for members the snapshot no longer carries. +%% answer sorted_member_ids/2 with ids for members the snapshot no longer holds. -define(HEAVY_MEMBER_DATA_KEYS, [ <<"members">>, members_normalized, <<"member_role_index">>, members_sorted_ids ]). diff --git a/fluxer_gateway/src/guild/guild_state.erl b/fluxer_gateway/src/guild/guild_state.erl index 6c141df5b..f6fef7fce 100644 --- a/fluxer_gateway/src/guild/guild_state.erl +++ b/fluxer_gateway/src/guild/guild_state.erl @@ -171,8 +171,10 @@ post_update_channel(channel_update, EventData, OldState, NewState) -> post_update_channel(channel_update_bulk, EventData, OldState, NewState) -> ChanIds = guild_state_channels:extract_channel_ids_from_channel_update_bulk(EventData), resync_channels_after_permission_change(ChanIds, OldState, NewState); -post_update_channel(channel_delete, _EventData, _OldState, NewState) -> +post_update_channel(channel_delete, EventData, _OldState, NewState) -> maybe_sync_member_list_permission_state(NewState), + ChannelId = snowflake_id:parse_optional(maps:get(<<"id">>, EventData, undefined)), + ok = guild_voice_lifecycle:cast_disconnect_all_voice_users_in_channel(ChannelId, NewState), NewState. -spec resync_channels_after_permission_change([integer()], guild_state(), guild_state()) -> diff --git a/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl b/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl index 1fb559a7a..533a6c1cd 100644 --- a/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl +++ b/fluxer_gateway/src/guild/guild_subscription_mutual_channels.erl @@ -292,7 +292,7 @@ test_channel(ChannelId, Overwrites) -> }. %% Session user 10 and members 20 and 21 reach channel 500 through the viewer role. -%% Members 30, 31, 99 and 4242 all carry the same roles term and reach only channel +%% Members 30, 31, 99 and 4242 all have the same roles term and reach only channel %% 600 by role, but 99 holds virtual access to 500 and 4242 is a user-overwrite %% target on 500, so both must still come out true. Member 7 owns the guild. test_state() -> diff --git a/fluxer_gateway/src/guild/guild_virtual_channel_access.erl b/fluxer_gateway/src/guild/guild_virtual_channel_access.erl index eb77ebe2d..f885a8de8 100644 --- a/fluxer_gateway/src/guild/guild_virtual_channel_access.erl +++ b/fluxer_gateway/src/guild/guild_virtual_channel_access.erl @@ -5,8 +5,11 @@ -export([ add_virtual_access/3, + add_view_only_access/3, remove_virtual_access/3, has_virtual_access/3, + has_voice_access/3, + is_view_only/3, get_virtual_channels_for_user/2, get_users_with_virtual_access/2, dispatch_channel_visibility_change/4, @@ -33,12 +36,34 @@ add_virtual_access(UserId, ChannelId, State) -> VirtualAccess = maps:get(virtual_channel_access, State, #{}), UserChannels = maps:get(UserId, VirtualAccess, sets:new()), Updated = sets:add_element(ChannelId, UserChannels), - State1 = State#{virtual_channel_access => VirtualAccess#{UserId => Updated}}, + State1 = clear_view_only( + UserId, ChannelId, State#{virtual_channel_access => VirtualAccess#{UserId => Updated}} + ), State2 = update_user_session_view_cache(UserId, ChannelId, add, State1), mark_pending_join( UserId, ChannelId, guild_member_list_engine_inputs:mark_stale(ChannelId, State2) ). +-spec clear_view_only(user_id(), channel_id(), guild_state()) -> guild_state(). +clear_view_only(UserId, ChannelId, State) -> + case is_view_only(UserId, ChannelId, State) of + false -> + State; + true -> + ViewOnly = maps:get(virtual_channel_access_view_only, State), + State#{ + virtual_channel_access_view_only => + clear_from_user_set(UserId, ChannelId, ViewOnly) + } + end. + +-spec add_view_only_access(user_id(), channel_id(), guild_state()) -> guild_state(). +add_view_only_access(UserId, ChannelId, State) -> + State1 = add_virtual_access(UserId, ChannelId, State), + ViewOnly = maps:get(virtual_channel_access_view_only, State1, #{}), + UserViewOnly = sets:add_element(ChannelId, maps:get(UserId, ViewOnly, sets:new())), + State1#{virtual_channel_access_view_only => ViewOnly#{UserId => UserViewOnly}}. + -spec remove_virtual_access(user_id(), channel_id(), guild_state()) -> guild_state(). remove_virtual_access(UserId, ChannelId, State) -> VirtualAccess = maps:get(virtual_channel_access, State, #{}), @@ -65,13 +90,22 @@ remove_all_user_virtual_access(UserId, State) -> VCP = maps:get(virtual_channel_access_pending, State, #{}), VCPr = maps:get(virtual_channel_access_preserve, State, #{}), VCM = maps:get(virtual_channel_access_move_pending, State, #{}), - State1 = State#{ + State0 = State#{ virtual_channel_access => maps:remove(UserId, VCA), virtual_channel_access_pending => maps:remove(UserId, VCP), virtual_channel_access_preserve => maps:remove(UserId, VCPr), virtual_channel_access_move_pending => maps:remove(UserId, VCM) }, - clear_user_session_view_cache(UserId, State1). + clear_user_session_view_cache(UserId, remove_user_view_only(UserId, State0)). + +-spec remove_user_view_only(user_id(), guild_state()) -> guild_state(). +remove_user_view_only(UserId, State) -> + case maps:find(virtual_channel_access_view_only, State) of + {ok, ViewOnly} -> + State#{virtual_channel_access_view_only => maps:remove(UserId, ViewOnly)}; + error -> + State + end. -spec update_user_virtual_access(user_id(), channel_id(), sets:set(), guild_state()) -> guild_state(). @@ -80,12 +114,12 @@ update_user_virtual_access(UserId, ChannelId, UpdatedChans, State) -> VCP = maps:get(virtual_channel_access_pending, State, #{}), VCPr = maps:get(virtual_channel_access_preserve, State, #{}), VCM = maps:get(virtual_channel_access_move_pending, State, #{}), - State1 = State#{ + State1 = clear_view_only(UserId, ChannelId, State#{ virtual_channel_access => VCA#{UserId => UpdatedChans}, virtual_channel_access_pending => del_from_user_set(UserId, ChannelId, VCP), virtual_channel_access_preserve => del_from_user_set(UserId, ChannelId, VCPr), virtual_channel_access_move_pending => del_from_user_set(UserId, ChannelId, VCM) - }, + }), update_user_session_view_cache(UserId, ChannelId, remove, State1). -spec del_from_user_set(user_id(), channel_id(), map()) -> map(). @@ -106,6 +140,15 @@ clear_from_user_set(UserId, ChannelId, Map) -> has_virtual_access(UserId, ChannelId, State) -> user_channel_check(UserId, ChannelId, virtual_channel_access, State). +-spec has_voice_access(user_id(), channel_id(), guild_state()) -> boolean(). +has_voice_access(UserId, ChannelId, State) -> + has_virtual_access(UserId, ChannelId, State) andalso + not is_view_only(UserId, ChannelId, State). + +-spec is_view_only(user_id(), channel_id(), guild_state()) -> boolean(). +is_view_only(UserId, ChannelId, State) -> + user_channel_check(UserId, ChannelId, virtual_channel_access_view_only, State). + -spec get_virtual_channels_for_user(user_id(), guild_state()) -> [channel_id()]. get_virtual_channels_for_user(UserId, State) -> VirtualAccess = maps:get(virtual_channel_access, State, #{}), diff --git a/fluxer_gateway/src/guild/guild_visibility_channels.erl b/fluxer_gateway/src/guild/guild_visibility_channels.erl index bb624eedf..c44dd20fa 100644 --- a/fluxer_gateway/src/guild/guild_visibility_channels.erl +++ b/fluxer_gateway/src/guild/guild_visibility_channels.erl @@ -279,7 +279,9 @@ grant_virtual_access_if_needed(UserId, ChannelId, State) -> true -> State; false -> - State1 = guild_virtual_channel_access:add_virtual_access(UserId, ChannelId, State), + State1 = guild_virtual_channel_access:add_view_only_access( + UserId, ChannelId, State + ), guild_virtual_channel_access:clear_pending_join(UserId, ChannelId, State1) end. @@ -325,7 +327,9 @@ maybe_grant_virtual_access(UserId, ChannelId, State) -> true -> {State, true}; false -> - State1 = guild_virtual_channel_access:add_virtual_access(UserId, ChannelId, State), + State1 = guild_virtual_channel_access:add_view_only_access( + UserId, ChannelId, State + ), State2 = guild_virtual_channel_access:clear_pending_join(UserId, ChannelId, State1), {State2, true} end. diff --git a/fluxer_gateway/src/guild/guild_voice_lifecycle.erl b/fluxer_gateway/src/guild/guild_voice_lifecycle.erl index 7bf231f58..278675379 100644 --- a/fluxer_gateway/src/guild/guild_voice_lifecycle.erl +++ b/fluxer_gateway/src/guild/guild_voice_lifecycle.erl @@ -9,7 +9,8 @@ reply_voice_server_pid/1, clear_stale_cached_voice_states/2, authoritative_voice_states/1, - cast_disconnect_voice_user/2 + cast_disconnect_voice_user/2, + cast_disconnect_all_voice_users_in_channel/2 ]). -type guild_state() :: map(). @@ -188,6 +189,22 @@ cast_disconnect_voice_user(UserId, State) when is_integer(UserId), UserId > 0 -> cast_disconnect_voice_user(_UserId, _State) -> ok. +-spec cast_disconnect_all_voice_users_in_channel(integer() | undefined, guild_state()) -> ok. +cast_disconnect_all_voice_users_in_channel(ChannelId, State) when + is_integer(ChannelId), ChannelId > 0 +-> + case voice_server_pid(State) of + {ok, VoiceServerPid} -> + gen_server:cast( + VoiceServerPid, + {disconnect_all_voice_users_in_channel, #{channel_id => ChannelId}} + ); + error -> + ok + end; +cast_disconnect_all_voice_users_in_channel(_ChannelId, _State) -> + ok. + -spec voice_server_pid(guild_state()) -> {ok, pid()} | error. voice_server_pid(State) -> case maps:get(voice_server_pid, State, undefined) of diff --git a/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl b/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl index 053121089..80f5471e8 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_permission_sync.erl @@ -168,7 +168,7 @@ maybe_clear_self_stream(_ChId, _VoiceState, _VoicePermissions, _State) -> -spec user_has_base_voice_access(user_id(), channel_id(), guild_state()) -> boolean(). user_has_base_voice_access(UserId, ChannelId, State) -> - case guild_virtual_channel_access:has_virtual_access(UserId, ChannelId, State) of + case guild_virtual_channel_access:has_voice_access(UserId, ChannelId, State) of true -> true; false -> diff --git a/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl b/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl index 43a03ed28..9180eba1f 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_permissions.erl @@ -87,7 +87,7 @@ voice_connection_limit_allowed(UserId, ChannelIdValue, Channel, Stats, State, Is -spec has_view_and_connect_perms(integer(), integer(), guild_state()) -> boolean(). has_view_and_connect_perms(UserId, ChannelIdValue, State) -> - guild_virtual_channel_access:has_virtual_access(UserId, ChannelIdValue, State) orelse + guild_virtual_channel_access:has_voice_access(UserId, ChannelIdValue, State) orelse guild_virtual_channel_access:is_move_pending(UserId, ChannelIdValue, State) orelse has_resolved_view_and_connect_perms(UserId, ChannelIdValue, State). diff --git a/fluxer_gateway/src/guild/voice/guild_voice_server.erl b/fluxer_gateway/src/guild/voice/guild_voice_server.erl index 37e76ef09..96c4bd0b9 100644 --- a/fluxer_gateway/src/guild/voice/guild_voice_server.erl +++ b/fluxer_gateway/src/guild/voice/guild_voice_server.erl @@ -237,6 +237,11 @@ handle_cast({store_pending_connection, ConnId, Meta}, State) -> {noreply, State#{pending_voice_connections => NewPending}}; handle_cast({disconnect_voice_user, Request}, State) when is_map(Request) -> {noreply, delegate_voice_cast(fun guild_voice:disconnect_voice_user/2, Request, State)}; +handle_cast({disconnect_all_voice_users_in_channel, Request}, State) when is_map(Request) -> + {noreply, + delegate_voice_cast( + fun guild_voice:disconnect_all_voice_users_in_channel/2, Request, State + )}; handle_cast({cleanup_virtual_access_for_user, UserId}, State) when is_integer(UserId) -> GS = guild_voice_server_state:build_guild_state(State), NewGS = guild_voice_disconnect:cleanup_virtual_channel_access_for_user(UserId, GS), diff --git a/fluxer_gateway/src/guild/voice/voice_utils.erl b/fluxer_gateway/src/guild/voice/voice_utils.erl index f81bb0b71..867433271 100644 --- a/fluxer_gateway/src/guild/voice/voice_utils.erl +++ b/fluxer_gateway/src/guild/voice/voice_utils.erl @@ -231,7 +231,7 @@ compute_voice_permissions(UserId, ChannelId, State) -> IsAdmin = permission_bits:has(Permissions, AdminPerm), CanSpeak = IsAdmin orelse permission_bits:has(Permissions, SpeakPerm), CanStream = IsAdmin orelse permission_bits:has(Permissions, StreamPerm), - HasVirtualAccess = guild_virtual_channel_access:has_virtual_access( + HasVirtualAccess = guild_virtual_channel_access:has_voice_access( UserId, ChannelId, State ), FinalCanSpeak = CanSpeak orelse HasVirtualAccess, diff --git a/fluxer_gateway/src/push/push.erl b/fluxer_gateway/src/push/push.erl index 8119dca8d..36ce3cf7e 100644 --- a/fluxer_gateway/src/push/push.erl +++ b/fluxer_gateway/src/push/push.erl @@ -1325,7 +1325,7 @@ push_loss_counters_keep_a_genuine_zero_distinct_from_absent_test() -> ?assertEqual(0, maps:get(worker_pool_dropped, push_loss_counters())) end). -cache_stats_with_counters_carries_the_loss_surface_test() -> +cache_stats_with_counters_includes_the_loss_surface_test() -> push_ets_cache:init(), with_counter_table(fun() -> Stats = cache_stats_with_counters(), diff --git a/fluxer_gateway/src/push/push_job_publisher.erl b/fluxer_gateway/src/push/push_job_publisher.erl index abf5e6805..5114d3989 100644 --- a/fluxer_gateway/src/push/push_job_publisher.erl +++ b/fluxer_gateway/src/push/push_job_publisher.erl @@ -355,7 +355,7 @@ caller_fields_caps_the_caller_name_test() -> ?MAX_CALLER_NAME_BYTES, byte_size(maps:get(<<"caller_name">>, Fields)) ). -notification_fields_carry_title_body_and_tags_test() -> +notification_fields_include_title_body_and_tags_test() -> Fields = test_notification_fields( #{<<"content">> => <<"Hello world">>, <<"mentions">> => []}, 123, diff --git a/fluxer_gateway/src/utils/custom_status_expiry.erl b/fluxer_gateway/src/utils/custom_status_expiry.erl index b259adac5..e11fcf4ec 100644 --- a/fluxer_gateway/src/utils/custom_status_expiry.erl +++ b/fluxer_gateway/src/utils/custom_status_expiry.erl @@ -110,7 +110,7 @@ repair(WindowSeconds) when repair(_WindowSeconds) -> #{error => invalid_window_seconds}. -%% Only presences whose cached payload actually carries an expires_at are worth +%% Only presences whose cached payload actually has an expires_at are worth %% nudging. Nudging every local presence would cost one payload rebuild and one %% replicated cache write each, thousands per node, to correct a few dozen. %% Filtering on "has an expiry" rather than "is expired" is deliberate: it also @@ -129,7 +129,7 @@ expiring_presence_pids(UserIds) -> has_expires_at(UserId) -> try presence_cache:get(UserId) of {ok, Presence} when is_map(Presence) -> - presence_carries_expiry(Presence); + presence_has_expiry(Presence); _ -> false catch @@ -139,8 +139,8 @@ has_expires_at(UserId) -> %% presence_cache:get/1 returns {ok, map()} | not_found, never a bare map, and %% expires_at_ms/1 takes the timestamp value rather than the custom_status map. --spec presence_carries_expiry(map()) -> boolean(). -presence_carries_expiry(Presence) -> +-spec presence_has_expiry(map()) -> boolean(). +presence_has_expiry(Presence) -> case maps:get(<<"custom_status">>, Presence, null) of CustomStatus when is_map(CustomStatus) -> expires_at_ms(maps:get(<<"expires_at">>, CustomStatus, null)) =/= none; @@ -198,15 +198,15 @@ spread_ms(Index, Total, WindowMs) -> (Index * WindowMs) div Total. -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -presence_carries_expiry_detects_an_expiry_test() -> +presence_has_expiry_detects_an_expiry_test() -> ?assert( - presence_carries_expiry(#{ + presence_has_expiry(#{ <<"custom_status">> => #{<<"expires_at">> => <<"2026-05-13T13:02:27.497Z">>} }) ), - ?assertNot(presence_carries_expiry(#{<<"custom_status">> => #{<<"text">> => <<"hi">>}})), - ?assertNot(presence_carries_expiry(#{<<"custom_status">> => null})), - ?assertNot(presence_carries_expiry(#{})). + ?assertNot(presence_has_expiry(#{<<"custom_status">> => #{<<"text">> => <<"hi">>}})), + ?assertNot(presence_has_expiry(#{<<"custom_status">> => null})), + ?assertNot(presence_has_expiry(#{})). -define(LIVE_EXPIRES_AT, <<"2026-05-13T13:02:27.497Z">>). diff --git a/fluxer_gateway/test/guild_voice_access_revoke_tests.erl b/fluxer_gateway/test/guild_voice_access_revoke_tests.erl new file mode 100644 index 000000000..add3c47dc --- /dev/null +++ b/fluxer_gateway/test/guild_voice_access_revoke_tests.erl @@ -0,0 +1,268 @@ +%% SPDX-License-Identifier: AGPL-3.0-or-later + +-module(guild_voice_access_revoke_tests). +-typing([eqwalizer]). + +-include_lib("eunit/include/eunit.hrl"). + +-define(GUILD, 42). +-define(USER, 10). +-define(CHANNEL, 500). +-define(ROLE, 999). + +overwrite_deny_view_connect_disconnects_connected_user_test() -> + State = guild_state:update_state(channel_update, deny_overwrite_update(), base_state(true)), + Messages = collect_sync_messages(), + ?assert(disconnected(Messages)), + ?assertNot(granted_permissions(Messages)), + ?assert(guild_virtual_channel_access:has_virtual_access(?USER, ?CHANNEL, State)), + ?assertNot(guild_virtual_channel_access:has_voice_access(?USER, ?CHANNEL, State)). + +overwrite_deny_connect_only_disconnects_connected_user_test() -> + Update = channel_update_with_user_deny(constants:connect_permission()), + _ = guild_state:update_state(channel_update, Update, base_state(true)), + ?assert(disconnected(collect_sync_messages())). + +overwrite_deny_view_connect_disconnects_user_without_session_test() -> + _ = guild_state:update_state(channel_update, deny_overwrite_update(), base_state(false)), + ?assert(disconnected(collect_sync_messages())). + +role_removal_disconnects_connected_user_test() -> + Update = #{<<"user">> => #{<<"id">> => integer_to_binary(?USER)}, <<"roles">> => []}, + _ = guild_state:update_state(guild_member_update, Update, base_state(true)), + Messages = collect_sync_messages(), + ?assert(disconnected(Messages)), + ?assertNot(granted_permissions(Messages)). + +moderator_granted_access_survives_revoke_test() -> + State0 = guild_virtual_channel_access:add_virtual_access(?USER, ?CHANNEL, base_state(true)), + State = guild_state:update_state(channel_update, deny_overwrite_update(), State0), + Messages = collect_sync_messages(), + ?assertNot(disconnected(Messages)), + ?assert(guild_virtual_channel_access:has_voice_access(?USER, ?CHANNEL, State)). + +view_only_access_grants_no_publish_rights_test() -> + ViewConnect = constants:view_channel_permission() bor constants:connect_permission(), + State0 = with_role_permissions(ViewConnect, base_state(true)), + State1 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL, State0), + State = with_overwrites([user_deny(ViewConnect)], State1), + ?assertEqual( + #{can_speak => false, can_stream => false, can_video => false}, + voice_utils:compute_voice_permissions(?USER, ?CHANNEL, State) + ). + +explicit_access_replaces_view_only_mark_test() -> + State0 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL, #{}), + ?assert(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL, State0)), + State1 = guild_virtual_channel_access:add_virtual_access(?USER, ?CHANNEL, State0), + ?assertNot(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL, State1)), + ?assert(guild_virtual_channel_access:has_voice_access(?USER, ?CHANNEL, State1)). + +removing_access_clears_view_only_mark_test() -> + State0 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL, #{}), + State1 = guild_virtual_channel_access:add_view_only_access(?USER, ?CHANNEL + 1, State0), + State2 = guild_virtual_channel_access:remove_virtual_access(?USER, ?CHANNEL, State1), + ?assertNot(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL, State2)), + ?assert(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL + 1, State2)), + State3 = guild_virtual_channel_access:remove_virtual_access(?USER, ?CHANNEL + 1, State2), + ?assertNot(guild_virtual_channel_access:is_view_only(?USER, ?CHANNEL + 1, State3)). + +channel_delete_disconnects_everyone_in_channel_test() -> + Self = self(), + VoicePid = spawn(fun() -> + receive + Message -> Self ! {voice_server_got, Message} + end + end), + State0 = (base_state(true))#{voice_server_pid => VoicePid}, + _ = guild_state:update_state( + channel_delete, + #{<<"id">> => integer_to_binary(?CHANNEL), <<"type">> => 2}, + State0 + ), + receive + {voice_server_got, {'$gen_cast', {disconnect_all_voice_users_in_channel, Request}}} -> + ?assertEqual(#{channel_id => ?CHANNEL}, Request) + after 500 -> + exit(VoicePid, kill), + ?assert(false) + end. + +voice_server_disconnects_all_users_in_channel_test() -> + Self = self(), + ForceFun = fun(GId, ChId, UId, ConnId) -> + Self ! {force_disconnect, GId, ChId, UId, ConnId}, + {ok, #{success => true}} + end, + GuildPid = spawn(fun() -> guild_state_reply_loop(ForceFun) end), + State = #{ + guild_id => ?GUILD, + guild_pid => GuildPid, + voice_states => #{ + <<"conn-a">> => voice_state(<<"conn-a">>, 5, ?CHANNEL), + <<"conn-b">> => voice_state(<<"conn-b">>, 6, ?CHANNEL), + <<"conn-c">> => voice_state(<<"conn-c">>, 7, ?CHANNEL + 1) + }, + pending_voice_connections => #{}, + recently_disconnected_voice_states => #{}, + e2ee_room_keys => #{} + }, + try + {noreply, NewState} = guild_voice_server:handle_cast( + {disconnect_all_voice_users_in_channel, #{channel_id => ?CHANNEL}}, State + ), + ?assertEqual([<<"conn-c">>], maps:keys(maps:get(voice_states, NewState))), + Disconnected = lists:sort([receive_force_disconnect(), receive_force_disconnect()]), + ?assertEqual( + [{?GUILD, ?CHANNEL, 5, <<"conn-a">>}, {?GUILD, ?CHANNEL, 6, <<"conn-b">>}], + Disconnected + ) + after + exit(GuildPid, kill) + end. + +receive_force_disconnect() -> + receive + {force_disconnect, GId, ChId, UId, ConnId} -> {GId, ChId, UId, ConnId} + after 500 -> erlang:error(missing_force_disconnect) + end. + +guild_state_reply_loop(ForceFun) -> + GuildState = #{ + id => ?GUILD, + data => #{<<"guild">> => #{<<"owner_id">> => <<"1">>}}, + sessions => #{}, + test_force_disconnect_fun => ForceFun + }, + receive + {'$gen_call', From, {get_voice_guild_state}} -> + gen_server:reply(From, GuildState), + guild_state_reply_loop(ForceFun); + _ -> + guild_state_reply_loop(ForceFun) + end. + +voice_state(ConnId, UserId, ChannelId) -> + #{ + <<"guild_id">> => integer_to_binary(?GUILD), + <<"user_id">> => integer_to_binary(UserId), + <<"channel_id">> => integer_to_binary(ChannelId), + <<"connection_id">> => ConnId + }. + +base_state(WithSession) -> + ok = drain(), + Self = self(), + SyncFun = fun(GId, ChId, UId, ConnId, Perms) -> + Self ! {synced, GId, ChId, UId, ConnId, Perms} + end, + Sessions = + case WithSession of + true -> + #{ + <<"sess">> => #{ + session_id => <<"sess">>, + user_id => ?USER, + pid => spawn(fun sink/0), + viewable_channels => #{?CHANNEL => true} + } + }; + false -> + #{} + end, + RolePerms = + constants:view_channel_permission() bor constants:connect_permission() bor + constants:speak_permission(), + #{ + id => ?GUILD, + sessions => Sessions, + voice_states => #{ + <<"conn">> => (voice_state(<<"conn">>, ?USER, ?CHANNEL))#{<<"deaf">> => false} + }, + member_list_subscriptions => guild_member_list_subs:new(), + test_permission_sync_fun => SyncFun, + data => #{ + <<"guild">> => #{<<"owner_id">> => <<"1">>}, + <<"roles">> => roles(RolePerms), + <<"members">> => [ + #{ + <<"user">> => #{<<"id">> => integer_to_binary(?USER)}, + <<"roles">> => [integer_to_binary(?ROLE)] + } + ], + <<"channels">> => [voice_channel([])] + } + }. + +voice_channel(Overwrites) -> + #{ + <<"id">> => integer_to_binary(?CHANNEL), + <<"type">> => 2, + <<"permission_overwrites">> => Overwrites + }. + +with_overwrites(Overwrites, State) -> + Data = maps:get(data, State), + State#{data => Data#{<<"channels">> => [voice_channel(Overwrites)]}}. + +with_role_permissions(Permissions, State) -> + Data = maps:get(data, State), + State#{data => Data#{<<"roles">> => roles(Permissions)}}. + +roles(Permissions) -> + [ + #{ + <<"id">> => integer_to_binary(?ROLE), + <<"permissions">> => integer_to_binary(Permissions) + }, + #{<<"id">> => integer_to_binary(?GUILD), <<"permissions">> => <<"0">>} + ]. + +user_deny(Deny) -> + #{ + <<"id">> => integer_to_binary(?USER), + <<"type">> => 1, + <<"allow">> => <<"0">>, + <<"deny">> => integer_to_binary(Deny) + }. + +channel_update_with_user_deny(Deny) -> + voice_channel([user_deny(Deny)]). + +deny_overwrite_update() -> + channel_update_with_user_deny( + constants:view_channel_permission() bor constants:connect_permission() + ). + +sink() -> + receive + stop -> ok; + _ -> sink() + end. + +drain() -> + receive + _ -> drain() + after 0 -> ok + end. + +collect_sync_messages() -> + collect_sync_messages([]). + +collect_sync_messages(Acc) -> + receive + {synced, _, _, _, _, _} = Message -> collect_sync_messages([Message | Acc]) + after 300 -> lists:reverse(Acc) + end. + +disconnected(Messages) -> + lists:any( + fun({synced, _, _, _, _, Perms}) -> maps:get(disconnected, Perms, false) =:= true end, + Messages + ). + +granted_permissions(Messages) -> + lists:any( + fun({synced, _, _, _, _, Perms}) -> maps:is_key(can_speak, Perms) end, + Messages + ). diff --git a/fluxer_media_proxy/src/http_client/mod.rs b/fluxer_media_proxy/src/http_client/mod.rs index cab47acd7..7a1394aad 100644 --- a/fluxer_media_proxy/src/http_client/mod.rs +++ b/fluxer_media_proxy/src/http_client/mod.rs @@ -173,7 +173,7 @@ mod tests { } #[test] - fn default_options_carry_the_frozen_timeout_and_retry_budget() { + fn default_options_keep_the_frozen_timeout_and_retry_budget() { let options = HTTPClientOptions::default(); assert_eq!(options.connect_timeout_ms, millis(1_500)); assert_eq!( diff --git a/fluxer_media_proxy/src/http_headers.rs b/fluxer_media_proxy/src/http_headers.rs index 6c42cde4e..20db9ecb5 100644 --- a/fluxer_media_proxy/src/http_headers.rs +++ b/fluxer_media_proxy/src/http_headers.rs @@ -208,7 +208,7 @@ mod tests { } #[test] - fn media_headers_carry_the_frozen_policy_values_and_no_entity_tag() { + fn media_headers_have_the_frozen_policy_values_and_no_entity_tag() { let mut headers = HeaderMap::new(); add_media_headers(&mut headers, 100, "image/png", None); assert_eq!(value(&headers, "accept-ranges"), "bytes"); diff --git a/fluxer_media_proxy/src/media_limits.rs b/fluxer_media_proxy/src/media_limits.rs index 17f21994c..70cd07fcd 100644 --- a/fluxer_media_proxy/src/media_limits.rs +++ b/fluxer_media_proxy/src/media_limits.rs @@ -111,7 +111,7 @@ mod tests { use crate::constants::MAX_INTERNAL_REQUEST_BODY_BYTES; #[test] - fn default_limits_carry_the_frozen_old_era_numbers() { + fn default_limits_keep_the_frozen_old_era_numbers() { let limits = MediaLimits::default_from_config(); assert_eq!(16_384, limits.image_dimension()); assert_eq!(16_384 * 16_384, limits.image_pixels()); diff --git a/fluxer_media_proxy/src/media_process/apng.rs b/fluxer_media_proxy/src/media_process/apng.rs index 545defa45..f88626ef5 100644 --- a/fluxer_media_proxy/src/media_process/apng.rs +++ b/fluxer_media_proxy/src/media_process/apng.rs @@ -416,7 +416,7 @@ pub(super) fn encode_animated_apng( page_height: c_int, limits: AnimLimits, media_limits: &MediaLimits, - carried_loop_count: Option, + source_loop_count: Option, ) -> Result, MediaError> { let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; let total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; @@ -451,7 +451,7 @@ pub(super) fn encode_animated_apng( if frame_count == 0 { return Err(MediaError::MediaEncodeFailed); } - let num_plays = resolve_animation_loop_count(image, carried_loop_count); + let num_plays = resolve_animation_loop_count(image, source_loop_count); let expected_width = width as u32; let expected_height = page_height as u32; diff --git a/fluxer_media_proxy/src/media_process/encoding.rs b/fluxer_media_proxy/src/media_process/encoding.rs index ab4a1af23..19cbf9982 100644 --- a/fluxer_media_proxy/src/media_process/encoding.rs +++ b/fluxer_media_proxy/src/media_process/encoding.rs @@ -183,9 +183,9 @@ pub(super) fn encode_vips_image( pub(super) fn resolve_animation_loop_count( image: &VipsImageHandle<'_>, - carried: Option, + source_loop_count: Option, ) -> u32 { - if let Some(loop_count) = carried { + if let Some(loop_count) = source_loop_count { return loop_count; } let field = c"loop"; diff --git a/fluxer_media_proxy/src/media_process/tests/animated_apng.rs b/fluxer_media_proxy/src/media_process/tests/animated_apng.rs index e80215dff..9098d095c 100644 --- a/fluxer_media_proxy/src/media_process/tests/animated_apng.rs +++ b/fluxer_media_proxy/src/media_process/tests/animated_apng.rs @@ -135,7 +135,7 @@ fn patch_apng_num_plays(bytes: &[u8], num_plays: u32) -> Vec { let position = out .windows(4) .position(|window| window == b"acTL") - .expect("fixture carries an acTL chunk"); + .expect("fixture has an acTL chunk"); let payload_start = position + 4; out[payload_start + 4..payload_start + 8].copy_from_slice(&num_plays.to_be_bytes()); let payload: [u8; 8] = out[payload_start..payload_start + 8] @@ -147,7 +147,7 @@ fn patch_apng_num_plays(bytes: &[u8], num_plays: u32) -> Vec { } #[test] -fn animated_apng_transform_carries_the_source_num_plays() { +fn animated_apng_transform_keeps_the_source_num_plays() { let fixture_b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; let apng = base64::engine::general_purpose::STANDARD .decode(fixture_b64) diff --git a/fluxer_media_proxy/src/media_process/tests/animated_webp.rs b/fluxer_media_proxy/src/media_process/tests/animated_webp.rs index fdfd606db..cce8022cd 100644 --- a/fluxer_media_proxy/src/media_process/tests/animated_webp.rs +++ b/fluxer_media_proxy/src/media_process/tests/animated_webp.rs @@ -71,7 +71,7 @@ fn animated_webp_transforms_directly_and_falls_through_on_embedded_metadata() { assert_eq!(&direct.bytes[8..12], b"WEBP"); assert!(direct.bytes.windows(4).any(|chunk| chunk == b"ANIM")); let (canvas_width, canvas_height, feature_flags) = - webp_canvas_size(&direct.bytes).expect("direct output carries a VP8X canvas"); + webp_canvas_size(&direct.bytes).expect("direct output has a VP8X canvas"); assert_eq!((16, 16), (canvas_width, canvas_height)); assert_ne!(0, feature_flags & 0x02); let source_frames = webp_chunk_payloads(&source, b"ANMF").len(); @@ -110,12 +110,12 @@ fn animated_webp_transforms_directly_and_falls_through_on_embedded_metadata() { } #[test] -fn animated_webp_encode_carries_the_source_loop_count() { +fn animated_webp_encode_keeps_the_source_loop_count() { let mut gif = animated_gif_fixture(); let netscape = gif .windows(11) .position(|window| window == b"NETSCAPE2.0") - .expect("fixture carries a NETSCAPE application extension"); + .expect("fixture has a NETSCAPE application extension"); gif[netscape + 13] = 3; gif[netscape + 14] = 0; assert_eq!(Some(3), gif_loop_count(&gif)); diff --git a/fluxer_media_proxy/src/media_process/tests/heif.rs b/fluxer_media_proxy/src/media_process/tests/heif.rs index 6586c3bff..8ca855a47 100644 --- a/fluxer_media_proxy/src/media_process/tests/heif.rs +++ b/fluxer_media_proxy/src/media_process/tests/heif.rs @@ -206,10 +206,10 @@ fn hdr_pq_avif_tone_maps_even_when_the_colour_signal_is_one_libheif_cannot_model let nclx = avif .windows(8) .position(|window| window == b"colrnclx") - .expect("fixture carries an nclx colour box"); + .expect("fixture has an nclx colour box"); assert!( avif.windows(8).any(|window| window == b"colrprof"), - "fixture carries an icc colour box" + "fixture has an icc colour box" ); let mut cases = vec![("an icc profile", avif.clone())]; for (label, offset) in [ diff --git a/fluxer_media_proxy/src/media_process/tests/metadata.rs b/fluxer_media_proxy/src/media_process/tests/metadata.rs index 8af34c534..7990372dc 100644 --- a/fluxer_media_proxy/src/media_process/tests/metadata.rs +++ b/fluxer_media_proxy/src/media_process/tests/metadata.rs @@ -113,7 +113,7 @@ fn metadata_json_treats_mp4_attached_picture_as_audio_cover_art() { } #[test] -fn metadata_json_accepts_audio_carrying_png_cover_art() { +fn metadata_json_accepts_audio_with_png_cover_art() { let mp3 = fixture_audio_mp3_with_png_cover_art(); assert_eq!("audio/mpeg", mime::sniff(&mp3).mime); let probe = probe_av_metadata(&mp3, NSFW_PREVIEW_MAX_DIMENSION, &test_media_limits(), None) diff --git a/fluxer_media_proxy/src/metrics/tests.rs b/fluxer_media_proxy/src/metrics/tests.rs index a4f64971c..e13697659 100644 --- a/fluxer_media_proxy/src/metrics/tests.rs +++ b/fluxer_media_proxy/src/metrics/tests.rs @@ -220,7 +220,7 @@ fn render_keeps_every_frozen_series_name_and_shape() { } #[test] -fn render_carries_a_zero_series_for_every_signature_verdict() { +fn render_includes_a_zero_series_for_every_signature_verdict() { let text = Metrics::new().render(); for label in FROZEN_SIGNATURE_VERDICT_LABELS { assert!( diff --git a/fluxer_media_proxy/src/public_net_policy/mod.rs b/fluxer_media_proxy/src/public_net_policy/mod.rs index 87ee6f540..908ec8b68 100644 --- a/fluxer_media_proxy/src/public_net_policy/mod.rs +++ b/fluxer_media_proxy/src/public_net_policy/mod.rs @@ -11,7 +11,7 @@ pub use resolver::{PinnedDnsResolver, is_pinned_dns_failure}; use std::net::IpAddr; use thiserror::Error; -use url::Url; +use url::{Host, Url}; const MAX_URL_LEN: usize = 8192; const MAX_PUBLIC_HOSTNAME_BYTES: usize = 253; @@ -163,7 +163,17 @@ pub fn validate_url(url: &str) -> Result<(), Error> { return Err(Error::BlockedUrl); } let host = normalize_host(parsed.host)?; - if let Ok(address) = host.parse::() { + let canonical = Url::parse(url).map_err(|_| Error::InvalidUrl)?; + if canonical.port_or_known_default() != Some(port) { + return Err(Error::BlockedUrl); + } + let address = match canonical.host() { + Some(Host::Ipv4(address)) => Some(IpAddr::V4(address)), + Some(Host::Ipv6(address)) => Some(IpAddr::V6(address)), + Some(Host::Domain(_)) => None, + None => return Err(Error::InvalidUrl), + }; + if let Some(address) = address { return if ip_tables::is_public_ip(address) { Ok(()) } else { diff --git a/fluxer_media_proxy/src/public_net_policy/tests.rs b/fluxer_media_proxy/src/public_net_policy/tests.rs index e2cd48391..02fb0045d 100644 --- a/fluxer_media_proxy/src/public_net_policy/tests.rs +++ b/fluxer_media_proxy/src/public_net_policy/tests.rs @@ -217,7 +217,7 @@ fn public_urls_are_restricted_to_the_standard_web_ports() { } #[test] -fn urls_carrying_a_fragment_are_rejected() { +fn urls_with_a_fragment_are_rejected() { assert_eq!( Err(Error::BlockedUrl), validate_url("https://example.com/a#section") @@ -279,3 +279,37 @@ fn a_resolver_rejection_is_recognisable_however_deeply_the_transport_wraps_it() "tcp connect error" ))); } + +#[test] +fn url_validation_uses_the_canonical_ipv4_host() { + for url in [ + "http://0x7f.0x0.0x0.0x1/", + "http://0xa9.0xfe.0xa9.0xfe/latest/meta-data/", + "http://169.254.169.0xfe/latest/meta-data/", + "http://127.0.0.0x1/", + "http://0xa.0x0.0x0.0x1/", + "http://0xc0.0xa8.0x0.0x1/", + "http://0x0.0x0.0x0.0x0/", + "http://0x7f000001/", + "http://2130706433/", + "http://0177.0.0.1/", + "http://0x7f.1/", + "http://127.1/", + "https://0XA9.0XFE.0XA9.0XFE./", + ] { + assert_eq!(Err(Error::BlockedUrl), validate_url(url), "{url}"); + } + assert_eq!(Ok(()), validate_url("https://0x8.0x8.0x8.0x8/a")); +} + +#[test] +fn redirect_targets_are_checked_on_their_canonical_host() { + let next = resolve_redirect( + "https://example.com/start.gif", + "http://0xa9.0xfe.0xa9.0xfe/latest/meta-data/", + ) + .unwrap(); + assert_eq!(Err(Error::BlockedUrl), validate_url(&next)); + let next = resolve_redirect("https://example.com/start.gif", "//0x7f.0x0.0x0.0x1/a").unwrap(); + assert_eq!(Err(Error::BlockedUrl), validate_url(&next)); +} diff --git a/fluxer_media_proxy/src/server/attachment_signature.rs b/fluxer_media_proxy/src/server/attachment_signature.rs index c6e5b2dc9..0e828e350 100644 --- a/fluxer_media_proxy/src/server/attachment_signature.rs +++ b/fluxer_media_proxy/src/server/attachment_signature.rs @@ -320,7 +320,7 @@ mod tests { let reason = response .extensions() .get::() - .expect("a refusal carries an error reason") + .expect("a refusal has an error reason") .clone(); assert_eq!(refusal_code(verdict), reason.code); assert!(reason.code.starts_with("attachment_signature_")); @@ -343,7 +343,7 @@ mod tests { } #[test] - fn the_would_deny_line_carries_the_verdict_and_a_clipped_user_agent() { + fn the_would_deny_line_includes_the_verdict_and_a_clipped_user_agent() { let mut headers = HeaderMap::new(); headers.insert( header::USER_AGENT, diff --git a/fluxer_media_proxy/src/server/cors.rs b/fluxer_media_proxy/src/server/cors.rs index 650150cb5..54dd4f336 100644 --- a/fluxer_media_proxy/src/server/cors.rs +++ b/fluxer_media_proxy/src/server/cors.rs @@ -290,7 +290,7 @@ mod tests { let reason = response .extensions() .get::() - .expect("a refusal carries an error reason") + .expect("a refusal has an error reason") .clone(); assert_eq!("cors_origin_denied", reason.code); assert_eq!( diff --git a/fluxer_media_proxy/src/server/external/tests/mod.rs b/fluxer_media_proxy/src/server/external/tests/mod.rs index b5594382f..8e063977b 100644 --- a/fluxer_media_proxy/src/server/external/tests/mod.rs +++ b/fluxer_media_proxy/src/server/external/tests/mod.rs @@ -460,7 +460,7 @@ fn external_verbatim_range_forwards_the_upstream_partial_unchanged() { ); let multipart = validate_external_partial(multi, None, Some(4096), 100) - .expect("a multipart partial carries no Content-Range"); + .expect("a multipart partial has no Content-Range"); assert_eq!(None, multipart.content_length()); assert_eq!(None, multipart.header_value()); } diff --git a/fluxer_media_proxy/src/server/response/error.rs b/fluxer_media_proxy/src/server/response/error.rs index 7e17f36ee..e72cbd52c 100644 --- a/fluxer_media_proxy/src/server/response/error.rs +++ b/fluxer_media_proxy/src/server/response/error.rs @@ -237,7 +237,7 @@ mod tests { let source = line .split("source=") .nth(1) - .expect("a failure log line carries a source field") + .expect("a failure log line has a source field") .trim_end(); assert_eq!(513, source.len(), "{source}"); assert!(source.ends_with('~'), "{source}"); diff --git a/fluxer_media_proxy/src/server/response/mod.rs b/fluxer_media_proxy/src/server/response/mod.rs index 46e279bf1..5c75b1f01 100644 --- a/fluxer_media_proxy/src/server/response/mod.rs +++ b/fluxer_media_proxy/src/server/response/mod.rs @@ -171,7 +171,7 @@ mod tests { } #[test] - fn a_long_multi_byte_external_filename_still_carries_a_disposition() { + fn a_long_multi_byte_external_filename_still_gets_a_disposition() { let filename = "\u{e9}".repeat(1100); let inline = disposition_string("image/png", false, Some(&filename)); assert!(inline.starts_with("inline; filename=\"")); diff --git a/fluxer_media_proxy/src/server/routes/dispatch.rs b/fluxer_media_proxy/src/server/routes/dispatch.rs index 865332b99..cb9a45078 100644 --- a/fluxer_media_proxy/src/server/routes/dispatch.rs +++ b/fluxer_media_proxy/src/server/routes/dispatch.rs @@ -1893,7 +1893,7 @@ mod tests { } #[tokio::test] - async fn off_mode_serves_a_signature_carrying_request_unchanged() { + async fn off_mode_serves_a_signed_request_unchanged() { let tmp = tempfile::tempdir().expect("storage root"); let root = tmp.path().canonicalize().expect("canonical storage root"); let root = root.as_path(); diff --git a/fluxer_media_proxy/src/server/self_origin.rs b/fluxer_media_proxy/src/server/self_origin.rs index 095e1a576..e55095157 100644 --- a/fluxer_media_proxy/src/server/self_origin.rs +++ b/fluxer_media_proxy/src/server/self_origin.rs @@ -153,7 +153,7 @@ mod tests { } #[test] - fn own_avatars_carry_the_extension_fallback() { + fn own_avatars_get_the_extension_fallback() { let app = app(); match resolve( &app, diff --git a/fluxer_media_proxy/src/server/transform/cache_key.rs b/fluxer_media_proxy/src/server/transform/cache_key.rs index 34083c588..c08503725 100644 --- a/fluxer_media_proxy/src/server/transform/cache_key.rs +++ b/fluxer_media_proxy/src/server/transform/cache_key.rs @@ -61,7 +61,7 @@ fn serialize_asset_kind(route: TransformRoute, kind: Option) -> &'sta ( TransformRoute::Attachment | TransformRoute::External | TransformRoute::Stored, Some(_), - ) => panic!("a non-asset transform cache key cannot carry an asset kind"), + ) => panic!("a non-asset transform cache key cannot have an asset kind"), } } diff --git a/fluxer_media_proxy/src/spool.rs b/fluxer_media_proxy/src/spool.rs index b57ab9236..576f99f19 100644 --- a/fluxer_media_proxy/src/spool.rs +++ b/fluxer_media_proxy/src/spool.rs @@ -266,7 +266,7 @@ mod tests { } #[tokio::test] - async fn a_trailer_frame_carries_no_payload_towards_the_declared_length() { + async fn a_trailer_frame_adds_no_payload_towards_the_declared_length() { let dir = tempfile::tempdir().unwrap(); let short = spool_to_temp( body_with_trailers(&[b"hello".as_slice()]), diff --git a/fluxer_media_proxy/src/storage/response_body.rs b/fluxer_media_proxy/src/storage/response_body.rs index c49686294..c4f9c4531 100644 --- a/fluxer_media_proxy/src/storage/response_body.rs +++ b/fluxer_media_proxy/src/storage/response_body.rs @@ -554,7 +554,7 @@ fn exact_stream( expected_length: u64, end: ExactStreamEnd, ) -> impl Stream> + Send + 'static { - // Only a chunk that carries no bytes needs a count bound. The byte accounting below already + // Only a chunk with no bytes needs a count bound. The byte accounting below already // bounds every other chunk, and counting them all aborts a legitimate transfer whenever the // transport hands over reads smaller than the assumed average chunk size. let empty_chunks_remaining = response_body_limit::response_body_chunk_limit(expected_length); diff --git a/fluxer_media_proxy/src/tests/provisioning.rs b/fluxer_media_proxy/src/tests/provisioning.rs index f290e92bd..70a7f5257 100644 --- a/fluxer_media_proxy/src/tests/provisioning.rs +++ b/fluxer_media_proxy/src/tests/provisioning.rs @@ -510,7 +510,7 @@ fn the_cargo_cache_is_keyed_on_the_native_dependency_installer() { let (cargo_key, restore_keys) = cache_keys(&workflow_step(&workflow, "Cache cargo")); assert!( cargo_key.contains(NATIVE_INSTALLER_HASH), - "target/ carries the native shim archive built against the installed headers, so the cargo cache key must move with {NATIVE_INSTALLER_HASH}: {cargo_key}" + "target/ holds the native shim archive built against the installed headers, so the cargo cache key must move with {NATIVE_INSTALLER_HASH}: {cargo_key}" ); assert!( !restore_keys.is_empty(), diff --git a/fluxer_messages/src/shard_impl.rs b/fluxer_messages/src/shard_impl.rs index 49584b49e..a1e130b40 100644 --- a/fluxer_messages/src/shard_impl.rs +++ b/fluxer_messages/src/shard_impl.rs @@ -3437,7 +3437,7 @@ mod tests { } #[test] - fn mention_context_carries_embed_user_ids_for_message_and_snapshots() { + fn mention_context_includes_embed_user_ids_for_message_and_snapshots() { let message: Message = serde_json::from_value(json!({ "message_id": "10", "channel_id": "20", @@ -4055,7 +4055,7 @@ mod tests { assert!(!url.contains("/external/"), "{url}"); assert!(!url.ends_with('&'), "{url}"); assert!(!url.contains("&&"), "{url}"); - let query = url.split_once('?').expect("a signed url carries a query").1; + let query = url.split_once('?').expect("a signed url has a query").1; assert_eq!( fluxer_common::attachment_url_signature::Verdict::Valid, fluxer_common::attachment_url_signature::verify( @@ -4156,9 +4156,9 @@ mod tests { &options, &ResponseContext::default(), ) - .expect("an attachment carrying an id maps"); + .expect("an attachment with an id maps"); - let url = mapped.url.expect("a live attachment carries a url"); + let url = mapped.url.expect("a live attachment has a url"); assert_eq!(Some(url.clone()), mapped.proxy_url); assert_signs( &url, @@ -4169,7 +4169,7 @@ mod tests { } #[test] - fn an_own_url_whose_filename_carries_a_slash_is_signed() { + fn an_own_url_whose_filename_contains_a_slash_is_signed() { let options = signing_options(); let now = SIGNED_ANCHOR_SECS + 10; let key = "attachments/1544725486800732163/1544971349200470016/a/b.gif"; @@ -4189,7 +4189,7 @@ mod tests { ); let query = signed .split_once('?') - .expect("a signed url carries a query") + .expect("a signed url has a query") .1; assert_eq!( fluxer_common::attachment_url_signature::Verdict::Valid, @@ -4271,23 +4271,23 @@ mod tests { let now = now_epoch_secs(); let base = mapped.base; - let author = base.author.expect("the embed carries an author"); - let provider = base.provider.expect("the embed carries a provider"); - let footer = base.footer.expect("the embed carries a footer"); - let image = base.image.expect("the embed carries an image"); - let thumbnail = base.thumbnail.expect("the embed carries a thumbnail"); + let author = base.author.expect("the embed has an author"); + let provider = base.provider.expect("the embed has a provider"); + let footer = base.footer.expect("the embed has a footer"); + let image = base.image.expect("the embed has an image"); + let thumbnail = base.thumbnail.expect("the embed has a thumbnail"); for signed in [ - base.url.expect("the embed carries a url"), - author.url.expect("the author carries a url"), - author.icon_url.expect("the author carries an icon url"), + base.url.expect("the embed has a url"), + author.url.expect("the author has a url"), + author.icon_url.expect("the author has an icon url"), author .proxy_icon_url - .expect("the author carries a proxy icon url"), - provider.url.expect("the provider carries a url"), - footer.icon_url.expect("the footer carries an icon url"), + .expect("the author has a proxy icon url"), + provider.url.expect("the provider has a url"), + footer.icon_url.expect("the footer has an icon url"), footer .proxy_icon_url - .expect("the footer carries a proxy icon url"), + .expect("the footer has a proxy icon url"), image.url.clone(), image.proxy_url.clone(), thumbnail.url, @@ -4321,11 +4321,11 @@ mod tests { ); assert_eq!( Some("https://example.com/author".to_owned()), - base.author.expect("the embed carries an author").url + base.author.expect("the embed has an author").url ); assert_eq!( Some("https://example.com".to_owned()), - base.provider.expect("the embed carries a provider").url + base.provider.expect("the embed has a provider").url ); } @@ -4360,7 +4360,7 @@ mod tests { assert!(!signed.contains("/external/"), "{signed}"); let query = signed .split_once('?') - .expect("a signed url carries a query") + .expect("a signed url has a query") .1 .split_once('#') .expect("the fragment is kept") @@ -4430,7 +4430,7 @@ mod tests { &options, &ResponseContext::default(), ) - .expect("an attachment carrying an id maps"); + .expect("an attachment with an id maps"); assert_eq!(Some(unsigned.clone()), mapped.url); assert_eq!(Some(unsigned.clone()), mapped.proxy_url); @@ -4483,7 +4483,7 @@ mod tests { let signed = media_proxy_url_at(input, &options, now); let query = signed .split_once('?') - .expect("a signed url carries a query") + .expect("a signed url has a query") .1; assert_eq!( fluxer_common::attachment_url_signature::Verdict::Valid, @@ -4669,7 +4669,7 @@ mod tests { ); let url = attachment["url"] .as_str() - .expect("a live attachment carries a url"); + .expect("a live attachment has a url"); assert_eq!(attachment["proxy_url"], attachment["url"]); assert_eq!(attachment["id"], json!(SIGNED_ATTACHMENT_ID.to_string())); assert_eq!( @@ -4677,7 +4677,7 @@ mod tests { url.split_once("ex=").map(|(head, _)| head.to_owned()), "{url}" ); - let query = url.split_once('?').expect("a signed url carries a query").1; + let query = url.split_once('?').expect("a signed url has a query").1; assert_eq!( fluxer_common::attachment_url_signature::Verdict::Valid, fluxer_common::attachment_url_signature::verify( diff --git a/fluxer_push/src/vendor.rs b/fluxer_push/src/vendor.rs index a47881225..347a60412 100644 --- a/fluxer_push/src/vendor.rs +++ b/fluxer_push/src/vendor.rs @@ -297,7 +297,7 @@ mod tests { } #[tokio::test] - async fn a_logged_error_never_carries_the_device_token() { + async fn a_logged_error_never_contains_the_device_token() { const TOKEN: &str = "3dbc5a5ef1a1c1666afc26f466e1b3ebaaf4c66d92dddeb0fd1b69c49641d4cd"; let error = error_for(&format!("https://push.invalid/3/device/{TOKEN}")).await; assert!( diff --git a/fluxer_static/avatars/NOTICE.md b/fluxer_static/avatars/NOTICE.md index 40beea3b6..f5f8bceb6 100644 --- a/fluxer_static/avatars/NOTICE.md +++ b/fluxer_static/avatars/NOTICE.md @@ -1,5 +1,5 @@ # Avatar asset notice The avatar images in this directory are Fluxer-owned static assets. They are -covered by the root `LICENSE` notice unless a specific file later carries a +covered by the root `LICENSE` notice unless a specific file later has a more specific license notice. diff --git a/fluxer_svc/src/postgres.rs b/fluxer_svc/src/postgres.rs index 021631c38..197ea829d 100644 --- a/fluxer_svc/src/postgres.rs +++ b/fluxer_svc/src/postgres.rs @@ -876,7 +876,7 @@ mod tests { } #[test] - fn carries_the_prepared_statement_switch_onto_the_client() { + fn passes_the_prepared_statement_switch_onto_the_client() { let mut config = test_postgres_config("fluxer_kv"); config.prepared_statements = false; let pg = PgConfig::from_str("postgres://fluxer@127.0.0.1:5432/fluxer").unwrap(); diff --git a/fluxer_unfurl/src/cache_policy.rs b/fluxer_unfurl/src/cache_policy.rs index d30c3d109..479f6467e 100644 --- a/fluxer_unfurl/src/cache_policy.rs +++ b/fluxer_unfurl/src/cache_policy.rs @@ -154,7 +154,7 @@ mod tests { assert_ne!( unfurl_cache_key(URL, NsfwMode::Block, "none"), unfurl_cache_key(URL, NsfwMode::Allow, "none"), - "an unscanned result carries no nsfw media flag and must not serve a scanning request" + "an unscanned result has no nsfw media flag and must not serve a scanning request" ); } diff --git a/fluxer_unfurl/src/network_policy.rs b/fluxer_unfurl/src/network_policy.rs index b1a95c010..957417524 100644 --- a/fluxer_unfurl/src/network_policy.rs +++ b/fluxer_unfurl/src/network_policy.rs @@ -262,6 +262,29 @@ mod tests { } } + #[tokio::test] + async fn checks_the_canonical_ipv4_host() { + for raw in [ + "http://0x7f.0x0.0x0.0x1/", + "http://0xa9.0xfe.0xa9.0xfe/latest/meta-data/", + "http://169.254.169.0xfe/", + "http://127.0.0.0x1/", + "http://0x7f000001/", + "http://2130706433/", + "http://0177.0.0.1/", + "http://127.1/", + ] { + assert_eq!( + validate_url(&url(raw)).await, + Err(Error::BlockedUrl), + "{raw}" + ); + } + let base = url("https://example.com/start.gif"); + let next = resolve_redirect(&base, "http://0xa9.0xfe.0xa9.0xfe/latest/meta-data/").unwrap(); + assert_eq!(validate_url(&next).await, Err(Error::BlockedUrl)); + } + #[tokio::test] async fn allows_public_ip_literals_without_dns() { assert_eq!(validate_url(&url("https://8.8.8.8/")).await, Ok(())); diff --git a/fluxer_unfurl/src/resolvers/default_resolver.rs b/fluxer_unfurl/src/resolvers/default_resolver.rs index 236aaff5c..8f1139bca 100644 --- a/fluxer_unfurl/src/resolvers/default_resolver.rs +++ b/fluxer_unfurl/src/resolvers/default_resolver.rs @@ -321,7 +321,7 @@ fn build_embeds(content: ResolvedPageContent<'_>) -> Vec { video_media, audio_media, ); - if !carries_content(&embed) { + if !embed_has_content(&embed) { return Vec::new(); } let mut embeds = vec![embed]; @@ -329,7 +329,7 @@ fn build_embeds(content: ResolvedPageContent<'_>) -> Vec { embeds } -fn carries_content(embed: &MessageEmbed) -> bool { +fn embed_has_content(embed: &MessageEmbed) -> bool { embed.title.is_some() || embed.description.is_some() || embed.author.is_some() @@ -964,7 +964,7 @@ mod tests { } #[test] - fn drops_a_classified_embed_that_carries_no_content() { + fn drops_a_classified_embed_that_has_no_content() { for embed_type in ["article", "image", "link"] { assert!( build(embed_type, &OgMetadata::default()).is_empty(), diff --git a/fluxer_unfurl/src/router_impl.rs b/fluxer_unfurl/src/router_impl.rs index 629633855..c1b2ee747 100644 --- a/fluxer_unfurl/src/router_impl.rs +++ b/fluxer_unfurl/src/router_impl.rs @@ -310,7 +310,7 @@ mod tests { router.l1_insert(&unfurl_allowing_nsfw(url), &resolved_response()); assert!( router.l1_lookup(&unfurl(url, None)).is_none(), - "a result resolved with nsfw scanning off carries no nsfw flags and must not be reused" + "a result resolved with nsfw scanning off has no nsfw flags and must not be reused" ); assert!(router.l1_lookup(&unfurl_allowing_nsfw(url)).is_some()); } diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index 63cf31b56..286cd8ac7 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -692,7 +692,7 @@ describe('ConfigLoader', () => { } }); - test('rejects a cache purge endpoint that carries credentials', async () => { + test('rejects a cache purge endpoint that contains credentials', async () => { stubMinimalEnv({ FLUXER_CACHE_PURGE_ADAPTER: 'http', FLUXER_CACHE_PURGE_HTTP_ENDPOINT: 'https://purger:secret@purge.internal/purge', @@ -869,7 +869,7 @@ describe('ConfigLoader', () => { expect(config.integrations.voice.url).toBe('http://localhost:8088/livekit'); }); - test('inserts the public port into every other public url the config carries', async () => { + test('inserts the public port into every other public url the config contains', async () => { stubMinimalEnv({ FLUXER_S3_PUBLIC_ENDPOINT: 'http://localhost/s3', FLUXER_EMAIL_APP_BASE_URL: 'http://localhost', diff --git a/packages/config/src/__tests__/NonDefaultPortCompose.test.ts b/packages/config/src/__tests__/NonDefaultPortCompose.test.ts index fb61a6c58..7c5271e26 100644 --- a/packages/config/src/__tests__/NonDefaultPortCompose.test.ts +++ b/packages/config/src/__tests__/NonDefaultPortCompose.test.ts @@ -207,7 +207,7 @@ describe('the shipped compose stack expanded on a non-default port', () => { expect(starved).toEqual([]); }); - test('every public URL the stack hands a browser carries the port', () => { + test('every public URL the stack hands a browser includes the port', () => { const entries = serviceNames .filter((service) => !SERVICES_WITHOUT_ENDPOINT_REPAIR.has(service)) .flatMap((service) => repairedPublicUrls(service, PORT_ONLY_ENV)); @@ -226,7 +226,7 @@ describe('the shipped compose stack expanded on a non-default port', () => { }); }); -describe('a public origin carrying a port while FLUXER_PUBLIC_PORT stays standard', () => { +describe('a public origin with a port while FLUXER_PUBLIC_PORT stays standard', () => { beforeEach(() => { resetConfig(); }); @@ -236,7 +236,7 @@ describe('a public origin carrying a port while FLUXER_PUBLIC_PORT stays standar vi.unstubAllEnvs(); }); - test('the compose overrides all carry the origin port', () => { + test('the compose overrides all include the origin port', () => { const environment = expandedEnvironment('api', ORIGIN_ONLY_ENV); const entries = publicUrlNames(environment).map((name): [string, string] => [name, environment[name]]); expect(entries.length).toBeGreaterThan(0); diff --git a/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts b/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts index 4d72df359..103d960bf 100644 --- a/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts +++ b/packages/errors/src/__tests__/AppErrorHandlerLogging.test.ts @@ -89,6 +89,17 @@ describe('AppErrorHandler logging', () => { expect(details.status).toBe(400); }); + it('logs the matched route pattern instead of the request path', async () => { + const app = createApp(); + app.get('/reset/:token', () => { + throw new ServiceUnavailableError({message: 'unavailable'}); + }); + const response = await app.request('/reset/abc123'); + expect(response.status).toBe(503); + expect(logCalls.error).toHaveLength(1); + expect(logCalls.error[0]![0].path).toBe('/reset/:token'); + }); + it('still reports unexpected errors as unhandled', async () => { const app = createApp(); app.get('/thing', () => { diff --git a/packages/errors/src/__tests__/DomainErrors.test.ts b/packages/errors/src/__tests__/DomainErrors.test.ts index 536a1bd81..c1936f0ca 100644 --- a/packages/errors/src/__tests__/DomainErrors.test.ts +++ b/packages/errors/src/__tests__/DomainErrors.test.ts @@ -188,7 +188,7 @@ describe('PremiumPurchaseBlockedError', () => { }); }); - it('carries the blocking provider when one is given', () => { + it('includes the blocking provider when one is given', () => { const error = new PremiumPurchaseBlockedError('existing_subscription', {provider: 'app_store'}); expect(error.toJSON()).toEqual({ diff --git a/packages/errors/src/domains/core/ErrorHandlers.ts b/packages/errors/src/domains/core/ErrorHandlers.ts index a400ae589..2d8346014 100644 --- a/packages/errors/src/domains/core/ErrorHandlers.ts +++ b/packages/errors/src/domains/core/ErrorHandlers.ts @@ -15,6 +15,7 @@ import { resolveMessageVariables, } from '@fluxer/errors/src/error_handling/ErrorIntrospection'; import {createJsonErrorResponse} from '@fluxer/errors/src/error_handling/ErrorResponse'; +import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern'; import {FluxerError} from '@fluxer/errors/src/FluxerError'; import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings'; import {getErrorMessageUnsafe} from '@fluxer/errors/src/i18n/ErrorI18n'; @@ -281,7 +282,7 @@ function logErrorResponse(err: Error, resolved: ResolvedE err, status, method: ctx.req.method, - path: ctx.req.path, + path: resolveRoutePattern(ctx), requestId: ctx.get('requestId'), }; if (resolved.unexpected) { diff --git a/packages/errors/src/error_handling/RoutePattern.ts b/packages/errors/src/error_handling/RoutePattern.ts new file mode 100644 index 000000000..14f44f110 --- /dev/null +++ b/packages/errors/src/error_handling/RoutePattern.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Context} from 'hono'; +import {matchedRoutes} from 'hono/route'; +import {METHOD_NAME_ALL} from 'hono/router'; + +export function resolveRoutePattern(ctx: Context): string { + const routes = matchedRoutes(ctx); + const endpoint = routes.findLast((route) => route.method !== METHOD_NAME_ALL); + return (endpoint ?? routes.at(-1))?.path ?? '*'; +} diff --git a/packages/hono/src/middleware/ErrorHandler.ts b/packages/hono/src/middleware/ErrorHandler.ts index 2b5e2f876..b35b88895 100644 --- a/packages/hono/src/middleware/ErrorHandler.ts +++ b/packages/hono/src/middleware/ErrorHandler.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {createErrorHandler as createFluxerErrorHandler} from '@fluxer/errors/src/ErrorHandler'; +import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern'; import type {Context, ErrorHandler} from 'hono'; export interface ErrorHandlerOptions { @@ -19,7 +20,7 @@ export function createErrorHandler(options: ErrorHandlerOptions = {}): ErrorHand } if (captureException) { captureException(error, { - path: context.req.path, + path: resolveRoutePattern(context), method: context.req.method, status: context.res?.status, }); diff --git a/packages/hono/src/middleware/RequestLogger.ts b/packages/hono/src/middleware/RequestLogger.ts index 8c3aac889..e98c7b928 100644 --- a/packages/hono/src/middleware/RequestLogger.ts +++ b/packages/hono/src/middleware/RequestLogger.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {resolveRoutePattern} from '@fluxer/errors/src/error_handling/RoutePattern'; import {matchesAnyPathPattern} from '@fluxer/hono/src/middleware/utils/PathMatchers'; import type {MiddlewareHandler} from 'hono'; @@ -38,8 +39,7 @@ export function createInfoRequestLogger(logger: RequestInfoLogger): LogFunction export function requestLogger(options: RequestLoggerOptions): MiddlewareHandler { const {log, skip = []} = options; return async (c, next) => { - const path = c.req.path; - if (matchesAnyPathPattern(path, skip)) { + if (matchesAnyPathPattern(c.req.path, skip)) { return next(); } const startTime = Date.now(); @@ -47,6 +47,6 @@ export function requestLogger(options: RequestLoggerOptions): MiddlewareHandler await next(); const durationMs = Date.now() - startTime; const status = c.res.status; - log({method, path, status, durationMs}); + log({method, path: resolveRoutePattern(c), status, durationMs}); }; } diff --git a/packages/hono/src/middleware/tests/RequestLogger.test.ts b/packages/hono/src/middleware/tests/RequestLogger.test.ts new file mode 100644 index 000000000..253efe556 --- /dev/null +++ b/packages/hono/src/middleware/tests/RequestLogger.test.ts @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {RequestLogData} from '@fluxer/hono/src/middleware/RequestLogger'; +import {requestLogger} from '@fluxer/hono/src/middleware/RequestLogger'; +import {Hono} from 'hono'; +import {describe, expect, test} from 'vitest'; + +function createApp() { + const entries: Array = []; + const routes = new Hono(); + routes.use(requestLogger({log: (data) => entries.push(data), skip: ['/_health']})); + routes.get('/_health', (c) => c.text('OK')); + routes.get('/auth/reset/:token', (c) => c.json({ok: true})); + routes.post('/webhooks/:webhook_id/:token', (c) => c.body(null, 204)); + routes.get('/blocked/:code', (c) => c.text('unreachable')); + routes.use('/limited/*', async (c) => c.text('limited', 429)); + routes.get('/limited/:code', (c) => c.text('unreachable')); + const app = new Hono(); + app.route('/v1', routes); + app.route('/', routes); + return {app, entries}; +} + +describe('RequestLogger Middleware', () => { + test('logs the matched route pattern instead of the request path', async () => { + const {app, entries} = createApp(); + await app.request('/v1/auth/reset/abc123'); + await app.request('/webhooks/111/def456', {method: 'POST'}); + expect(entries).toEqual([ + expect.objectContaining({method: 'GET', path: '/v1/auth/reset/:token', status: 200}), + expect.objectContaining({method: 'POST', path: '/webhooks/:webhook_id/:token', status: 204}), + ]); + }); + test('logs the route pattern when middleware ends the request early', async () => { + const {app, entries} = createApp(); + await app.request('/v1/limited/ghi789'); + expect(entries).toEqual([expect.objectContaining({path: '/v1/limited/:code', status: 429})]); + }); + test('does not log raw segments for unmatched routes', async () => { + const {app, entries} = createApp(); + await app.request('/v1/unknown/jkl012'); + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + expect(entry.status).toBe(404); + expect(entry.path).not.toContain('jkl012'); + } + }); + test('skips configured paths', async () => { + const {app, entries} = createApp(); + await app.request('/_health'); + expect(entries).toEqual([]); + }); +}); diff --git a/packages/schema/src/domains/admin/AdminBlocklistSchemas.ts b/packages/schema/src/domains/admin/AdminBlocklistSchemas.ts index f28fc91e8..3e10f1a6c 100644 --- a/packages/schema/src/domains/admin/AdminBlocklistSchemas.ts +++ b/packages/schema/src/domains/admin/AdminBlocklistSchemas.ts @@ -61,7 +61,7 @@ export type AdminBlocklistEntryListQuery = z.infer value ?? '') .describe('The name of the channel'), - topic: z.string().nullish().describe('The channel topic'), - position: z.number().describe('The position of the channel'), + topic: z.string().max(CHANNEL_TOPIC_MAX_LENGTH).nullish().describe('The channel topic'), + position: Int32Type.describe('The position of the channel'), parent_id: TemplateEntityId.nullish().describe('The template-local ID of the parent category'), - bitrate: z.number().nullish().describe('The bitrate for voice channels'), - user_limit: z.number().nullish().describe('The user limit for voice channels'), - voice_connection_limit: z.number().nullish().describe('The per-user voice connection limit for voice channels'), + bitrate: z.number().int().nonnegative().nullish().describe('The bitrate for voice channels'), + user_limit: z + .number() + .int() + .min(VOICE_CHANNEL_USER_LIMIT_MIN) + .nullish() + .describe('The user limit for voice channels'), + voice_connection_limit: z + .number() + .int() + .min(VOICE_CHANNEL_CONNECTION_LIMIT_MIN) + .max(VOICE_CHANNEL_CONNECTION_LIMIT_MAX) + .nullish() + .describe('The per-user voice connection limit for voice channels'), nsfw: z.boolean().optional().describe('Whether the channel is NSFW'), - rate_limit_per_user: z.number().optional().describe('Slowmode rate limit in seconds'), + rate_limit_per_user: z + .number() + .int() + .min(CHANNEL_RATE_LIMIT_PER_USER_MIN) + .max(CHANNEL_RATE_LIMIT_PER_USER_MAX) + .optional() + .describe('Slowmode rate limit in seconds'), permission_overwrites: z .array(TemplatePermissionOverwrite) .optional() @@ -59,12 +87,13 @@ export const TemplateRole = z.object({ id: TemplateEntityId.describe('The template-local role ID'), name: z .string() + .max(TEMPLATE_NAME_MAX_LENGTH) .nullish() .transform((value) => value ?? '') .describe('The name of the role'), permissions: TemplatePermissionBitfield.optional().describe('The permissions bitfield as a string (legacy)'), permissions_new: TemplatePermissionBitfield.optional().describe('The permissions bitfield as a string (preferred)'), - color: z.number().optional().describe('The colour of the role as an integer'), + color: ColorType.optional().describe('The colour of the role as an integer'), hoist: z.boolean().optional().describe('Whether the role is hoisted'), mentionable: z.boolean().optional().describe('Whether the role is mentionable'), unicode_emoji: z.string().nullish().describe('The unicode emoji for the role icon'), diff --git a/packages/schema/src/domains/message/AttachmentSchemas.ts b/packages/schema/src/domains/message/AttachmentSchemas.ts index 4923cb148..31ad6f58d 100644 --- a/packages/schema/src/domains/message/AttachmentSchemas.ts +++ b/packages/schema/src/domains/message/AttachmentSchemas.ts @@ -69,7 +69,7 @@ export const RefreshedAttachmentUrl = z.object({ original: z.string().describe('The requested URL, echoed back unchanged'), refreshed: z .string() - .describe('The same URL carrying a fresh signature, or the original when it is not an attachment URL of ours'), + .describe('The same URL with a fresh signature, or the original when it is not an attachment URL of ours'), }); export type RefreshedAttachmentUrl = z.infer; diff --git a/packages/schema/src/domains/message/MessageResponseSchemas.ts b/packages/schema/src/domains/message/MessageResponseSchemas.ts index 8ff26e7db..e66997da5 100644 --- a/packages/schema/src/domains/message/MessageResponseSchemas.ts +++ b/packages/schema/src/domains/message/MessageResponseSchemas.ts @@ -166,7 +166,7 @@ export interface MessageResponse extends MessageBaseResponse { export const MessageResponseSchema = MessageBaseResponseSchema.extend({ referenced_message: MessageBaseResponseSchema.nullish().describe( - '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.', + '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.', ), }); const ChannelPinMessageResponse = MessageResponseSchema.omit({ diff --git a/packages/schema/src/domains/tests/AnnouncementChannelSchemas.test.ts b/packages/schema/src/domains/tests/AnnouncementChannelSchemas.test.ts index ca0709d8c..30bc39333 100644 --- a/packages/schema/src/domains/tests/AnnouncementChannelSchemas.test.ts +++ b/packages/schema/src/domains/tests/AnnouncementChannelSchemas.test.ts @@ -98,7 +98,7 @@ describe('webhook responses', () => { expect(WebhookCreateResponse.safeParse({...withoutToken, user: creator}).success).toBe(false); }); - it('omits the token and carries the source on follower webhooks', () => { + it('omits the token and includes the source on follower webhooks', () => { const {token: _token, ...withoutToken} = incomingWebhook; const follower = { ...withoutToken, diff --git a/packages/schema/src/domains/user/UserResponseSchemas.ts b/packages/schema/src/domains/user/UserResponseSchemas.ts index 9b49d1d50..ef7e7a88e 100644 --- a/packages/schema/src/domains/user/UserResponseSchemas.ts +++ b/packages/schema/src/domains/user/UserResponseSchemas.ts @@ -168,6 +168,21 @@ export const UserPrivateResponse = UserPartialResponse.extend({ export type UserPrivateResponse = z.infer; +export const UserUpdateResponse = UserPrivateResponse.extend({ + token: z + .string() + .optional() + .describe('Authentication token for the replacement session, present when the password was changed'), + auth_session_id_hash: z + .string() + .optional() + .describe( + 'Base64url-encoded hash of the replacement authentication session, present when the password was changed', + ), +}); + +export type UserUpdateResponse = z.infer; + export const EmailChangeStartResponse = z.object({ ticket: z.string().describe('Ticket returned for email change actions'), require_original: z.boolean().describe('Whether verification of the original email is required'), diff --git a/packages/voice_engine_v2/src/runtime/frameCoalescing.ts b/packages/voice_engine_v2/src/runtime/frameCoalescing.ts index fe2522ac9..5e09fe077 100644 --- a/packages/voice_engine_v2/src/runtime/frameCoalescing.ts +++ b/packages/voice_engine_v2/src/runtime/frameCoalescing.ts @@ -16,8 +16,8 @@ export function isVoiceEngineV2FrameReceivedEvent(event: VoiceEngineV2Event): ev export function canCoalesceVoiceEngineV2Events(tailEvent: VoiceEngineV2Event, nextEvent: VoiceEngineV2Event): boolean { if (!isVoiceEngineV2FrameReceivedEvent(nextEvent)) return false; if (!isVoiceEngineV2FrameReceivedEvent(tailEvent)) return false; - assert.equal(typeof tailEvent.frame.trackSid, 'string', 'tail frame event must carry a string trackSid'); - assert.equal(typeof nextEvent.frame.trackSid, 'string', 'next frame event must carry a string trackSid'); + assert.equal(typeof tailEvent.frame.trackSid, 'string', 'tail frame event must have a string trackSid'); + assert.equal(typeof nextEvent.frame.trackSid, 'string', 'next frame event must have a string trackSid'); return tailEvent.frame.trackSid === nextEvent.frame.trackSid; } diff --git a/tools/ci/src/app_proxy.rs b/tools/ci/src/app_proxy.rs index e5370b8cb..56359d7a5 100644 --- a/tools/ci/src/app_proxy.rs +++ b/tools/ci/src/app_proxy.rs @@ -688,7 +688,7 @@ mod tests { ] { assert!( manifest.contains(entry), - "the rust-builder workspace manifest replaces the repository root one, so it must carry {entry}" + "the rust-builder workspace manifest replaces the repository root one, so it must contain {entry}" ); } assert!( @@ -774,7 +774,7 @@ mod tests { } #[test] - fn uploaded_assets_carry_the_same_policy_the_app_proxy_serves() { + fn uploaded_assets_get_the_same_policy_the_app_proxy_serves() { assert_eq!( IMMUTABLE_ASSET_CACHE_CONTROL, "public, max-age=31536000, immutable" @@ -978,7 +978,7 @@ mod tests { ] { assert!( dockerfile.contains(entry), - "every architecture must serve the injected canonical tree, so the Dockerfile must carry {entry}" + "every architecture must serve the injected canonical tree, so the Dockerfile must contain {entry}" ); } } @@ -1016,7 +1016,7 @@ mod tests { "APP_ASSETS_REF = APP_ASSETS_REF", "APP_ASSETS_PLATFORM = APP_ASSETS_PLATFORM", ] { - assert!(bake.contains(entry), "docker-bake.hcl must carry {entry}"); + assert!(bake.contains(entry), "docker-bake.hcl must contain {entry}"); } } diff --git a/tools/ci/src/ci_workflow.rs b/tools/ci/src/ci_workflow.rs index 12a0e7e55..c2db6243b 100644 --- a/tools/ci/src/ci_workflow.rs +++ b/tools/ci/src/ci_workflow.rs @@ -335,7 +335,7 @@ mod tests { } #[test] - fn image_dockerfiles_carry_the_release_label_block() { + fn image_dockerfiles_include_the_release_label_block() { const REQUIRED: [&str; 9] = [ "LABEL org.opencontainers.image.licenses=\"AGPL-3.0-or-later\"", "LABEL org.opencontainers.image.vendor=\"Fluxer\"", diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index 527b40801..cc34c7934 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -2884,7 +2884,7 @@ fn assert_fluxer_signed(row: &SignatureRow) -> Result<()> { ); ensure!( row.ts_subject.is_some(), - "Authenticode signature carries no RFC3161 timestamp" + "Authenticode signature has no RFC3161 timestamp" ); let subject = row .subject diff --git a/tools/ci/src/image_set.rs b/tools/ci/src/image_set.rs index c6fa25a06..e5381f4b3 100644 --- a/tools/ci/src/image_set.rs +++ b/tools/ci/src/image_set.rs @@ -1313,7 +1313,7 @@ mod tests { ] { assert!( workflow.contains(entry), - "release-image-set.yaml must carry {entry}" + "release-image-set.yaml must contain {entry}" ); } assert!( diff --git a/tools/ci/src/release.rs b/tools/ci/src/release.rs index dedee6be6..9546eb416 100644 --- a/tools/ci/src/release.rs +++ b/tools/ci/src/release.rs @@ -1281,7 +1281,7 @@ mod tests { } #[test] - fn a_release_carrying_an_extra_route_is_refused() { + fn a_release_with_an_extra_route_is_refused() { let mut descriptor = sample_descriptor(); let extra = DesktopReleaseAsset { storage_key: format!("desktop/{SAMPLE_CHANNEL}/linux/x64/latest-linux.yml"),