From 3a8ed1012f37efcffc6748bd292a26520b4de785 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 2 Jul 2026 23:41:55 +0200 Subject: [PATCH] fix(api): support int53 attachment sizes (#1261) --- fluxer_admin/openapi-admin.json | 78 ++++++++-------- .../api/admin/services/AdminMessageService.ts | 3 +- .../api/admin/services/AdminReportService.ts | 3 +- .../api/favorite_meme/FavoriteMemeModel.ts | 3 +- fluxer_api/src/api/openapi/openapi.json | 75 +++++++++------- ...ositoryBackedMessageResponseDataService.ts | 3 +- fluxer_api/src/api/utils/ByteSizeUtils.ts | 16 ++++ fluxer_messages/src/shard_impl.rs | 11 ++- .../openapi/src/converters/BuiltInSchemas.ts | 7 ++ .../openapi/src/converters/ZodToOpenAPI.ts | 8 +- .../src/converters/ZodToOpenAPICustomTypes.ts | 4 + .../src/generator/OpenAPIGeneratorCatalog.ts | 2 + .../src/generator/OpenAPIOperationBuilder.ts | 3 + .../schema/src/domains/admin/AdminSchemas.ts | 5 +- .../schema/src/domains/meme/MemeSchemas.ts | 9 +- .../src/domains/message/AttachmentSchemas.ts | 3 +- .../message/AttachmentUploadSchemas.ts | 13 ++- .../domains/message/MessageResponseSchemas.ts | 9 +- .../tests/AttachmentSizeSchemas.test.ts | 90 +++++++++++++++++++ .../domains/webhook/WebhookRequestSchemas.ts | 3 +- .../schema/src/primitives/SchemaPrimitives.ts | 6 ++ .../primitives/tests/SchemaPrimitives.test.ts | 24 +++++ 22 files changed, 289 insertions(+), 89 deletions(-) create mode 100644 fluxer_api/src/api/utils/ByteSizeUtils.ts create mode 100644 packages/schema/src/domains/tests/AttachmentSizeSchemas.test.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 184761c30..b51b8ac7a 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -8977,7 +8977,7 @@ "type": "integer", "minimum": 0, "maximum": 8640000000000000, - "format": "int64", + "format": "int53", "description": "Inclusive start timestamp in milliseconds" } }, @@ -8989,7 +8989,7 @@ "type": "integer", "minimum": 0, "maximum": 8640000000000000, - "format": "int64", + "format": "int53", "description": "Inclusive end timestamp in milliseconds" } }, @@ -9087,7 +9087,7 @@ "type": "integer", "minimum": 0, "maximum": 8640000000000000, - "format": "int64", + "format": "int53", "description": "Inclusive start timestamp in milliseconds" } }, @@ -9099,7 +9099,7 @@ "type": "integer", "minimum": 0, "maximum": 8640000000000000, - "format": "int64", + "format": "int53", "description": "Inclusive end timestamp in milliseconds" } }, @@ -10613,7 +10613,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Number of entries to skip" } } @@ -10645,7 +10645,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Number of entries to skip" } } @@ -11147,7 +11147,7 @@ "amount_cents": { "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -11981,7 +11981,7 @@ "properties": { "guild_id": {"$ref": "#/components/schemas/SnowflakeType"}, "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32"}, - "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} }, "required": ["guild_id"] }, @@ -12219,7 +12219,7 @@ "properties": { "query": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32"}, - "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} } }, "ShutdownGuildRequest": { @@ -12439,9 +12439,9 @@ "type": "integer", "minimum": 1, "maximum": 9007199254740991, - "format": "int64" + "format": "int53" }, - "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, "revoked_at": {"nullable": true, "type": "string", "format": "date-time"}, "approval_required": {"type": "boolean"}, "last_used_at": {"nullable": true, "type": "string", "format": "date-time"}, @@ -12668,7 +12668,7 @@ "logo_uri": {"nullable": true, "type": "string"}, "tos_uri": {"nullable": true, "type": "string"}, "policy_uri": {"nullable": true, "type": "string"}, - "key_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + "key_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} }, "required": [ "enabled", @@ -12698,7 +12698,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -12706,7 +12706,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -12715,7 +12715,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -12723,7 +12723,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -12737,14 +12737,14 @@ "min_lifetime_days": { "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, "max_lifetime_days": { "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -12752,14 +12752,14 @@ "renew_threshold_days": { "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, "renew_window_days": { "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true } @@ -12871,9 +12871,9 @@ "type": "integer", "minimum": 1, "maximum": 9007199254740991, - "format": "int64" + "format": "int53" }, - "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + "use_count": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, "revoked_at": {"nullable": true, "type": "string", "format": "date-time"}, "approval_required": {"type": "boolean"}, "last_used_at": {"nullable": true, "type": "string", "format": "date-time"}, @@ -13064,7 +13064,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -13072,7 +13072,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -13081,7 +13081,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true }, @@ -13089,7 +13089,7 @@ "nullable": true, "type": "integer", "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "minimum": 0, "exclusiveMinimum": true } @@ -13296,7 +13296,7 @@ }, "limits": { "type": "object", - "additionalProperties": {"type": "number", "minimum": 0}, + "additionalProperties": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "description": "Per-limit key values" }, "modifiedFields": {"type": "array", "items": {"type": "string"}} @@ -13352,6 +13352,7 @@ "limit_keys" ] }, + "NonNegativeSafeIntegerType": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, "LimitConfigUpdateRequest": { "type": "object", "properties": { @@ -13387,7 +13388,7 @@ }, "limits": { "type": "object", - "additionalProperties": {"type": "number", "minimum": 0}, + "additionalProperties": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "description": "Per-limit key values" } }, @@ -13455,7 +13456,7 @@ "content_type": {"nullable": true, "type": "string"}, "width": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, "height": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, - "size": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "size": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}]}, "ncmec_status": { "type": "string", "enum": ["not_submitted", "submitted", "failed"], @@ -14124,8 +14125,8 @@ "size": { "type": "integer", "minimum": 0, - "maximum": 2147483647, - "format": "int32", + "maximum": 9007199254740991, + "format": "int53", "description": "The size of the attachment in bytes" }, "url": {"description": "The URL of the attachment", "nullable": true, "type": "string"}, @@ -14387,7 +14388,7 @@ "items": {"$ref": "#/components/schemas/MessageResponseSchema"}, "maxItems": 100 }, - "total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + "total": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} }, "required": ["messages", "total"] }, @@ -14405,7 +14406,7 @@ "properties": { "success": {"type": "boolean", "enum": [true]}, "job_id": {"type": "string"}, - "requested": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + "requested": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} }, "required": ["success", "job_id"] }, @@ -14558,7 +14559,10 @@ "content_type": {"nullable": true, "type": "string"}, "width": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, "height": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, - "size": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}, + "size": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}] + }, "ncmec_status": { "type": "string", "enum": ["not_submitted", "submitted", "failed"], @@ -14664,7 +14668,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Number of reports to skip" } } @@ -14712,7 +14716,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Number of entries to skip" }, "reporter_id": {"$ref": "#/components/schemas/SnowflakeType"}, @@ -15435,7 +15439,7 @@ "email": {"type": "string"}, "last_active_ip": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 200, "format": "int32"}, - "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + "offset": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} } }, "SendPasswordResetRequest": { diff --git a/fluxer_api/src/api/admin/services/AdminMessageService.ts b/fluxer_api/src/api/admin/services/AdminMessageService.ts index 3544e83f5..a736e82b3 100644 --- a/fluxer_api/src/api/admin/services/AdminMessageService.ts +++ b/fluxer_api/src/api/admin/services/AdminMessageService.ts @@ -35,6 +35,7 @@ import {getPurgeQueue, getStorageService} from '../../middleware/ServiceSingleto import {getMessageSearchService} from '../../SearchFactory'; import {deleteMessageSearchDocuments} from '../../search/MessageSearchIndexCleanup'; import {searchExistingMessages} from '../../search/MessageSearchResultReconciler'; +import {assertSafeByteSize} from '../../utils/ByteSizeUtils'; import type {AdminAuditService} from './AdminAuditService'; interface AdminMessageServiceDeps { @@ -330,7 +331,7 @@ export class AdminMessageService { content_type: attachment.content_type ?? null, width: attachment.width ?? null, height: attachment.height ?? null, - size: attachment.size ?? null, + size: attachment.size == null ? null : assertSafeByteSize(attachment.size, 'admin message attachment size'), ncmec_status: attachmentStatuses.get(attachment.id)?.status ?? 'not_submitted', ncmec_report_id: attachmentStatuses.get(attachment.id)?.ncmec_report_id ?? null, ncmec_failure_reason: attachmentStatuses.get(attachment.id)?.failure_reason ?? null, diff --git a/fluxer_api/src/api/admin/services/AdminReportService.ts b/fluxer_api/src/api/admin/services/AdminReportService.ts index 9b00f273d..378560b58 100644 --- a/fluxer_api/src/api/admin/services/AdminReportService.ts +++ b/fluxer_api/src/api/admin/services/AdminReportService.ts @@ -38,6 +38,7 @@ import type {IARMessageContext, IARSubmission} from '../../report/IReportReposit import type {ReportService} from '../../report/ReportService'; import {getReportSearchService} from '../../SearchFactory'; import type {UserChannelService} from '../../user/services/UserChannelService'; +import {assertSafeByteSize} from '../../utils/ByteSizeUtils'; import type {AdminAuditService} from './AdminAuditService'; interface AdminReportServiceDeps { @@ -580,7 +581,7 @@ export class AdminReportService { content_type: attachment.content_type ?? null, width: attachment.width ?? null, height: attachment.height ?? null, - size: attachment.size != null ? Number(attachment.size) : null, + size: attachment.size != null ? assertSafeByteSize(attachment.size, 'admin report attachment size') : null, ncmec_status: attachmentStatusesById.get(attachment.attachment_id.toString())?.status ?? 'not_submitted', ncmec_report_id: attachmentStatusesById.get(attachment.attachment_id.toString())?.ncmec_report_id ?? null, ncmec_failure_reason: attachmentStatusesById.get(attachment.attachment_id.toString())?.failure_reason ?? null, diff --git a/fluxer_api/src/api/favorite_meme/FavoriteMemeModel.ts b/fluxer_api/src/api/favorite_meme/FavoriteMemeModel.ts index 76ed9f41f..3850a0918 100644 --- a/fluxer_api/src/api/favorite_meme/FavoriteMemeModel.ts +++ b/fluxer_api/src/api/favorite_meme/FavoriteMemeModel.ts @@ -4,6 +4,7 @@ import type {FavoriteMemeResponse} from '@fluxer/schema/src/domains/meme/MemeSch import {userIdToChannelId} from '../BrandedTypes'; import {makeAttachmentCdnUrl} from '../channel/services/message/MessageHelpers'; import type {FavoriteMeme} from '../models/FavoriteMeme'; +import {assertSafeByteSize} from '../utils/ByteSizeUtils'; export function mapFavoriteMemeToResponse(meme: FavoriteMeme): FavoriteMemeResponse { const url = makeAttachmentCdnUrl(userIdToChannelId(meme.userId), meme.attachmentId, meme.filename); @@ -17,7 +18,7 @@ export function mapFavoriteMemeToResponse(meme: FavoriteMeme): FavoriteMemeRespo filename: meme.filename, content_type: meme.contentType, content_hash: meme.contentHash ?? null, - size: Number(meme.size), + size: assertSafeByteSize(meme.size, 'favorite meme size'), width: meme.width ?? null, height: meme.height ?? null, duration: meme.duration ?? null, diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index a366ffbbf..dc0aae343 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -6224,7 +6224,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Pagination offset" } } @@ -27115,8 +27115,8 @@ "size": { "type": "integer", "minimum": 0, - "maximum": 2147483647, - "format": "int32", + "maximum": 9007199254740991, + "format": "int53", "description": "The size of the attachment in bytes" }, "url": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "The URL of the attachment"}, @@ -27900,7 +27900,7 @@ "type": "string", "description": "Temporary upload key to reference in message send payloads" }, - "file_size": {"$ref": "#/components/schemas/Int32Type"}, + "file_size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "content_type": {"type": "string", "description": "Expected MIME type for this upload"}, "upload_mode": {"type": "string", "enum": ["singlepart"]}, "upload_url": { @@ -27910,6 +27910,7 @@ }, "required": ["id", "filename", "upload_filename", "file_size", "content_type", "upload_mode", "upload_url"] }, + "NonNegativeSafeIntegerType": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, "MultipartPresignedAttachmentUploadResponseItem": { "type": "object", "properties": { @@ -27919,14 +27920,14 @@ "type": "string", "description": "Temporary upload key to reference in message send payloads" }, - "file_size": {"$ref": "#/components/schemas/Int32Type"}, + "file_size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "content_type": {"type": "string", "description": "Expected MIME type for this upload"}, "upload_mode": {"type": "string", "enum": ["multipart"]}, "upload_id": { "type": "string", "description": "S3 multipart upload identifier; required to complete the upload" }, - "part_size": {"$ref": "#/components/schemas/Int32Type"}, + "part_size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "parts": { "type": "array", "items": { @@ -27972,7 +27973,7 @@ "properties": { "id": {"$ref": "#/components/schemas/Int32Type"}, "filename": {"type": "string", "description": "The name of the file that will be uploaded"}, - "file_size": {"$ref": "#/components/schemas/Int32Type"}, + "file_size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "content_type": {"type": "string", "description": "MIME type the client will upload"} }, "required": ["id", "filename", "file_size", "content_type"] @@ -28482,7 +28483,7 @@ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" }, - {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int53"}, {"type": "null"} ], "description": "ISO8601 timestamp for the embed" @@ -28599,7 +28600,7 @@ "type": "string", "description": "Temporary upload key returned by the attachment upload endpoint" }, - "file_size": {"$ref": "#/components/schemas/Int32Type"}, + "file_size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "content_type": {"type": "string", "description": "MIME type of the uploaded file"} }, "required": ["id", "filename", "upload_filename", "file_size", "content_type"] @@ -29234,7 +29235,13 @@ "anyOf": [{"type": "string"}, {"type": "null"}], "description": "Hash of the file content for deduplication" }, - "size": {"type": "number", "description": "File size in bytes"}, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "format": "int53", + "description": "File size in bytes" + }, "width": { "anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}], "description": "Width of the image or video in pixels" @@ -29311,7 +29318,7 @@ }, "embed_index": { "anyOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "null"} ], "description": "Index of the message embed to save as a meme" @@ -29529,14 +29536,14 @@ }, "started_at_ms": { "anyOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "null"} ], "description": "Session start Unix timestamp in milliseconds" }, "expires_at_ms": { "anyOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "null"} ], "description": "Session expiration Unix timestamp in milliseconds" @@ -29605,7 +29612,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Unix timestamp in milliseconds when this heartbeat expires" } }, @@ -30183,7 +30190,7 @@ "explicit_content_filter": {"type": "number", "description": "The explicit content filter level"}, "system_channel_id": { "oneOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "string"}, {"type": "null"} ], @@ -30209,7 +30216,7 @@ "properties": { "id": { "oneOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "string"} ], "description": "The template-local role ID" @@ -30218,14 +30225,14 @@ "permissions": { "oneOf": [ {"type": "string"}, - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} ], "description": "The permissions bitfield as a string (legacy)" }, "permissions_new": { "oneOf": [ {"type": "string"}, - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} ], "description": "The permissions bitfield as a string (preferred)" }, @@ -30244,7 +30251,7 @@ "properties": { "id": { "oneOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "string"} ], "description": "The template-local channel ID" @@ -30255,7 +30262,7 @@ "position": {"type": "number", "description": "The position of the channel"}, "parent_id": { "oneOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "string"}, {"type": "null"} ], @@ -30279,7 +30286,7 @@ "properties": { "id": { "oneOf": [ - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"}, {"type": "string"} ], "description": "The ID of the role or user for this overwrite" @@ -30291,14 +30298,14 @@ "allow": { "oneOf": [ {"type": "string"}, - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} ], "description": "The allowed permissions bitfield as a string" }, "deny": { "oneOf": [ {"type": "string"}, - {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int64"} + {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"} ], "description": "The denied permissions bitfield as a string" } @@ -30960,7 +30967,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "New position for the channel" }, "parent_id": { @@ -31336,7 +31343,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Number of results to skip for pagination" }, "role_ids": { @@ -33959,7 +33966,7 @@ "type": "integer", "minimum": 1, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Page number for pagination (ignored when cursor is provided)" }, "cursor": { @@ -35434,7 +35441,7 @@ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" }, - {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int53"}, {"type": "null"} ], "description": "When the mute expires" @@ -35479,7 +35486,7 @@ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" }, - {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int53"}, {"type": "null"} ], "description": "When the mute expires" @@ -35530,7 +35537,7 @@ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" }, - {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int53"}, {"type": "null"} ], "description": "When the mute expires" @@ -36416,7 +36423,7 @@ "type": "integer", "maximum": 9007199254740991, "exclusiveMinimum": 0, - "format": "int64" + "format": "int53" } } }, @@ -36977,7 +36984,7 @@ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" }, - {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int53"}, {"type": "null"} ], "description": "When status resets" @@ -37157,7 +37164,7 @@ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d+)?(?:Z|[+-]\\d{2}:?\\d{2})?$" }, - {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int64"}, + {"type": "integer", "minimum": 0, "maximum": 8640000000000000, "format": "int53"}, {"type": "null"} ], "description": "When the custom status expires" @@ -37423,7 +37430,7 @@ "description": "Description for the attachment (max 4096 characters)" }, "content_type": {"type": "string", "description": "MIME type of the file"}, - "size": {"type": "integer", "format": "int53", "description": "Size of the file in bytes"}, + "size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, "url": {"type": "string", "description": "URL of the attachment"}, "proxy_url": {"type": "string", "description": "Proxied URL of the attachment"}, "height": {"type": "integer", "format": "int53", "description": "Height of the image/video in pixels"}, @@ -37896,7 +37903,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "format": "int64", + "format": "int53", "description": "Unix timestamp for the attachment footer" }, "author_name": {"type": "string", "description": "Name of the author"}, diff --git a/fluxer_api/src/api/test/mocks/RepositoryBackedMessageResponseDataService.ts b/fluxer_api/src/api/test/mocks/RepositoryBackedMessageResponseDataService.ts index 6eb9c5dad..c1e06347f 100644 --- a/fluxer_api/src/api/test/mocks/RepositoryBackedMessageResponseDataService.ts +++ b/fluxer_api/src/api/test/mocks/RepositoryBackedMessageResponseDataService.ts @@ -42,6 +42,7 @@ import type {Message} from '../../models/Message'; import type {MessageSnapshot} from '../../models/MessageSnapshot'; import type {StickerItem} from '../../models/StickerItem'; import {mapUserToPartialResponse} from '../../user/UserMappers'; +import {assertSafeByteSize} from '../../utils/ByteSizeUtils'; class NoopNatsConnectionManager implements INatsConnectionManager { async connect(): Promise {} @@ -373,7 +374,7 @@ export class RepositoryBackedMessageResponseDataService extends MessageResponseD description: attachment.description, content_type: attachment.contentType, content_hash: attachment.contentHash, - size: Number(attachment.size), + size: assertSafeByteSize(attachment.size, 'message attachment size'), url, proxy_url: url, width: attachment.width, diff --git a/fluxer_api/src/api/utils/ByteSizeUtils.ts b/fluxer_api/src/api/utils/ByteSizeUtils.ts new file mode 100644 index 000000000..cd59a2725 --- /dev/null +++ b/fluxer_api/src/api/utils/ByteSizeUtils.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); + +export function assertSafeByteSize(value: bigint | number, fieldName = 'byte size'): number { + if (typeof value === 'bigint') { + if (value < 0n || value > MAX_SAFE_INTEGER_BIGINT) { + throw new Error(`${fieldName} must fit a non-negative JavaScript safe integer`); + } + return Number(value); + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${fieldName} must fit a non-negative JavaScript safe integer`); + } + return value; +} diff --git a/fluxer_messages/src/shard_impl.rs b/fluxer_messages/src/shard_impl.rs index b20a52ab2..98a32e72e 100644 --- a/fluxer_messages/src/shard_impl.rs +++ b/fluxer_messages/src/shard_impl.rs @@ -58,6 +58,15 @@ const ENRICHMENT_QUERY_CONCURRENCY: usize = 16; const REACTION_MESSAGE_BATCH_SIZE: usize = 64; const ATTACHMENT_DECAY_BATCH_SIZE: usize = 128; const BUCKET_INDEX_PAGE_SIZE: u32 = 200; +const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +fn assert_safe_byte_size(value: i64) -> i64 { + assert!( + (0..=JS_MAX_SAFE_INTEGER).contains(&value), + "attachment size must fit a non-negative JavaScript safe integer" + ); + value +} #[cfg(feature = "scylla")] const MESSAGE_COLUMNS: &str = "\ @@ -1232,7 +1241,7 @@ impl MessagesShard { description: attachment.description.clone(), content_type: Some(content_type), content_hash: attachment.content_hash.clone(), - size: attachment.size.unwrap_or_default(), + size: assert_safe_byte_size(attachment.size.unwrap_or_default()), url: (!expired).then_some(url.clone()), proxy_url: (!expired).then_some(url), width: (!is_audio).then_some(attachment.width).flatten(), diff --git a/packages/openapi/src/converters/BuiltInSchemas.ts b/packages/openapi/src/converters/BuiltInSchemas.ts index c65c4edae..d9d4553a8 100644 --- a/packages/openapi/src/converters/BuiltInSchemas.ts +++ b/packages/openapi/src/converters/BuiltInSchemas.ts @@ -17,6 +17,13 @@ export const Int32TypeSchema: OpenAPISchema = { format: 'int32', }; export const Int32TypeRef: OpenAPIRef = {$ref: '#/components/schemas/Int32Type'}; +export const NonNegativeSafeIntegerTypeSchema: OpenAPISchema = { + type: 'integer', + minimum: 0, + maximum: 9007199254740991, + format: 'int53', +}; +export const NonNegativeSafeIntegerTypeRef: OpenAPIRef = {$ref: '#/components/schemas/NonNegativeSafeIntegerType'}; export const Int64TypeSchema: OpenAPISchema = { type: 'string', format: 'int64', diff --git a/packages/openapi/src/converters/ZodToOpenAPI.ts b/packages/openapi/src/converters/ZodToOpenAPI.ts index 9d584b162..886d73b04 100644 --- a/packages/openapi/src/converters/ZodToOpenAPI.ts +++ b/packages/openapi/src/converters/ZodToOpenAPI.ts @@ -371,7 +371,13 @@ export function zodToOpenAPISchema(schema: ZodTypeAny, depth = 0): OpenAPISchema const min = result.minimum ?? result.exclusiveMinimum; const max = result.maximum ?? result.exclusiveMaximum; if (min != null && max != null) { - result.format = min >= -2147483648 && max <= 2147483647 ? 'int32' : 'int64'; + if (min >= -2147483648 && max <= 2147483647) { + result.format = 'int32'; + } else if (min >= Number.MIN_SAFE_INTEGER && max <= Number.MAX_SAFE_INTEGER) { + result.format = 'int53'; + } else { + result.format = 'int64'; + } } else if (min == null && max == null) { result.format = 'int53'; } else { diff --git a/packages/openapi/src/converters/ZodToOpenAPICustomTypes.ts b/packages/openapi/src/converters/ZodToOpenAPICustomTypes.ts index 9457c8526..421d2167c 100644 --- a/packages/openapi/src/converters/ZodToOpenAPICustomTypes.ts +++ b/packages/openapi/src/converters/ZodToOpenAPICustomTypes.ts @@ -7,6 +7,7 @@ import { Int64StringTypeRef, Int64TypeRef, LocaleRef, + NonNegativeSafeIntegerTypeRef, PasswordTypeRef, PhoneNumberTypeRef, SnowflakeTypeRef, @@ -51,6 +52,8 @@ function getRefForCustomTypeName(typeName: string): OpenAPISchemaOrRef | null { return SnowflakeTypeRef; case 'Int32Type': return Int32TypeRef; + case 'NonNegativeSafeIntegerType': + return NonNegativeSafeIntegerTypeRef; case 'Int64Type': return Int64TypeRef; case 'Int64StringType': @@ -217,6 +220,7 @@ const FLUXER_CUSTOM_TYPES: Record = { BitflagStringType: {type: 'string', format: 'int64', pattern: '^[0-9]+$'}, ColorType: {type: 'integer', minimum: 0, maximum: 16777215, format: 'int32'}, Int32Type: {type: 'integer', minimum: 0, maximum: 2147483647, format: 'int32'}, + NonNegativeSafeIntegerType: {type: 'integer', minimum: 0, maximum: 9007199254740991, format: 'int53'}, EmailType: {type: 'string', format: 'email'}, PasswordType: {type: 'string', minLength: 8, maxLength: 256}, UsernameType: {type: 'string', minLength: 1, maxLength: 32, pattern: '^[a-zA-Z0-9_]+$'}, diff --git a/packages/openapi/src/generator/OpenAPIGeneratorCatalog.ts b/packages/openapi/src/generator/OpenAPIGeneratorCatalog.ts index 334f478e0..6523065be 100644 --- a/packages/openapi/src/generator/OpenAPIGeneratorCatalog.ts +++ b/packages/openapi/src/generator/OpenAPIGeneratorCatalog.ts @@ -8,6 +8,7 @@ import { Int64StringTypeSchema, Int64TypeSchema, LocaleSchema, + NonNegativeSafeIntegerTypeSchema, PasswordTypeSchema, PhoneNumberTypeSchema, SnowflakeTypeSchema, @@ -127,6 +128,7 @@ const BUILT_IN_SCHEMAS: ReadonlyArray = [ ['APIErrorCode', APIErrorCodeSchema], ['SnowflakeType', SnowflakeTypeSchema], ['Int32Type', Int32TypeSchema], + ['NonNegativeSafeIntegerType', NonNegativeSafeIntegerTypeSchema], ['Int64Type', Int64TypeSchema], ['Int64StringType', Int64StringTypeSchema], ['UnsignedInt64Type', UnsignedInt64TypeSchema], diff --git a/packages/openapi/src/generator/OpenAPIOperationBuilder.ts b/packages/openapi/src/generator/OpenAPIOperationBuilder.ts index a2ae18a06..1076ea620 100644 --- a/packages/openapi/src/generator/OpenAPIOperationBuilder.ts +++ b/packages/openapi/src/generator/OpenAPIOperationBuilder.ts @@ -253,6 +253,9 @@ export class OpenAPIOperationBuilder { if (typeString.includes('UnsignedInt64Type')) { return {type: 'string', format: 'int64', pattern: '^[0-9]+$'}; } + if (typeString.includes('NonNegativeSafeIntegerType')) { + return {type: 'integer', minimum: 0, maximum: 9007199254740991, format: 'int53'}; + } if (typeString.includes('Int64StringType')) { return {type: 'string', format: 'int64', pattern: '^-?[0-9]+$'}; } diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index 1eaa97ff2..d9d55fda9 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -36,6 +36,7 @@ import { createStringType, Int32Type, Int64StringType, + NonNegativeSafeIntegerType, SnowflakeStringType, SnowflakeType, withOpenApiType, @@ -800,7 +801,7 @@ const LimitRuleSchema = z.object({ id: z.string().min(1).describe('Unique rule identifier'), filters: LimitFilterSchema.optional().describe('Optional filters that scope the rule'), limits: z - .record(z.string(), z.number().min(0)) + .record(z.string(), NonNegativeSafeIntegerType) .refine( (limits) => { const limitKeys = Object.keys(limits); @@ -1258,7 +1259,7 @@ const AdminMessageAttachmentSchema = z.object({ content_type: z.string().nullable(), width: Int32Type.nullable(), height: Int32Type.nullable(), - size: Int32Type.nullable().optional(), + size: NonNegativeSafeIntegerType.nullable().optional(), ncmec_status: NcmecSubmissionStatusEnum, ncmec_report_id: createStringType(1, 256).nullable(), ncmec_failure_reason: createStringType(1, 4000).nullable(), diff --git a/packages/schema/src/domains/meme/MemeSchemas.ts b/packages/schema/src/domains/meme/MemeSchemas.ts index ec4af8e99..24100aa9f 100644 --- a/packages/schema/src/domains/meme/MemeSchemas.ts +++ b/packages/schema/src/domains/meme/MemeSchemas.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {GifMediaFormat} from '@fluxer/schema/src/domains/gif/GifSchemas'; -import {createStringType, SnowflakeStringType, SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import { + createStringType, + NonNegativeSafeIntegerType, + SnowflakeStringType, + SnowflakeType, +} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; const FavoriteMemeBase = z.object({ @@ -67,7 +72,7 @@ export const FavoriteMemeResponse = z.object({ filename: z.string().describe('Original filename of the meme'), content_type: z.string().describe('MIME type of the meme file'), content_hash: z.string().nullish().describe('Hash of the file content for deduplication'), - size: z.number().describe('File size in bytes'), + size: NonNegativeSafeIntegerType.describe('File size in bytes'), width: z.number().int().nullish().describe('Width of the image or video in pixels'), height: z.number().int().nullish().describe('Height of the image or video in pixels'), duration: z.number().nullish().describe('Duration of the video in seconds'), diff --git a/packages/schema/src/domains/message/AttachmentSchemas.ts b/packages/schema/src/domains/message/AttachmentSchemas.ts index 4945cacff..c7ae8f089 100644 --- a/packages/schema/src/domains/message/AttachmentSchemas.ts +++ b/packages/schema/src/domains/message/AttachmentSchemas.ts @@ -7,6 +7,7 @@ import { createBitflagInt32Type, createStringType, Int32Type, + NonNegativeSafeIntegerType, SnowflakeType, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; @@ -41,7 +42,7 @@ export const ClientUploadedAttachmentRequest = ClientAttachmentBase.extend({ upload_filename: createStringType(1, 4096).describe( 'Temporary upload key returned by the attachment upload endpoint', ), - file_size: coerceNumberFromString(Int32Type).describe('Uploaded file size in bytes'), + file_size: coerceNumberFromString(NonNegativeSafeIntegerType).describe('Uploaded file size in bytes'), content_type: createStringType(1, 255).describe('MIME type of the uploaded file'), }); diff --git a/packages/schema/src/domains/message/AttachmentUploadSchemas.ts b/packages/schema/src/domains/message/AttachmentUploadSchemas.ts index 985af75f7..018790861 100644 --- a/packages/schema/src/domains/message/AttachmentUploadSchemas.ts +++ b/packages/schema/src/domains/message/AttachmentUploadSchemas.ts @@ -2,14 +2,19 @@ import {ATTACHMENT_UPLOAD_MAX_CHUNKS, MAX_ATTACHMENTS_PER_MESSAGE} from '@fluxer/constants/src/LimitConstants'; import {FilenameType} from '@fluxer/schema/src/primitives/FileValidators'; -import {coerceNumberFromString, createStringType, Int32Type} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import { + coerceNumberFromString, + createStringType, + Int32Type, + NonNegativeSafeIntegerType, +} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {URLType} from '@fluxer/schema/src/primitives/UrlValidators'; import {z} from 'zod'; export const PresignedAttachmentUploadRequestItem = z.object({ id: coerceNumberFromString(Int32Type).describe('The client-side identifier for this attachment'), filename: FilenameType.describe('The name of the file that will be uploaded'), - file_size: coerceNumberFromString(Int32Type).describe('Expected file size in bytes'), + file_size: coerceNumberFromString(NonNegativeSafeIntegerType).describe('Expected file size in bytes'), content_type: createStringType(1, 255).describe('MIME type the client will upload'), }); @@ -29,7 +34,7 @@ const PresignedAttachmentUploadBase = z.object({ id: coerceNumberFromString(Int32Type).describe('The client-side identifier for this attachment'), filename: FilenameType.describe('The original filename for this upload'), upload_filename: createStringType(1, 4096).describe('Temporary upload key to reference in message send payloads'), - file_size: coerceNumberFromString(Int32Type).describe('Expected file size in bytes'), + file_size: coerceNumberFromString(NonNegativeSafeIntegerType).describe('Expected file size in bytes'), content_type: createStringType(1, 255).describe('Expected MIME type for this upload'), }); const PresignedAttachmentUploadSinglepart = PresignedAttachmentUploadBase.extend({ @@ -45,7 +50,7 @@ const PresignedAttachmentUploadPart = z.object({ const PresignedAttachmentUploadMultipart = PresignedAttachmentUploadBase.extend({ upload_mode: z.literal('multipart'), upload_id: createStringType(1, 1024).describe('S3 multipart upload identifier; required to complete the upload'), - part_size: coerceNumberFromString(Int32Type).describe('Size in bytes of each part except the last'), + part_size: coerceNumberFromString(NonNegativeSafeIntegerType).describe('Size in bytes of each part except the last'), parts: z .array(PresignedAttachmentUploadPart) .min(1) diff --git a/packages/schema/src/domains/message/MessageResponseSchemas.ts b/packages/schema/src/domains/message/MessageResponseSchemas.ts index c1fc7c206..9610052c9 100644 --- a/packages/schema/src/domains/message/MessageResponseSchemas.ts +++ b/packages/schema/src/domains/message/MessageResponseSchemas.ts @@ -11,7 +11,12 @@ import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMember import {type MessageEmbed, MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas'; import {type UserPartial, UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {MessageReferenceTypeSchema, MessageTypeSchema} from '@fluxer/schema/src/primitives/MessageValidators'; -import {createBitflagInt32Type, Int32Type, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives'; +import { + createBitflagInt32Type, + Int32Type, + NonNegativeSafeIntegerType, + SnowflakeStringType, +} from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {z} from 'zod'; export const MessageAttachmentResponse = z.object({ @@ -21,7 +26,7 @@ export const MessageAttachmentResponse = z.object({ description: z.string().nullish().describe('The description of the attachment'), content_type: z.string().nullish().describe('The MIME type of the attachment'), content_hash: z.string().nullish().describe('The hash of the attachment content'), - size: Int32Type.describe('The size of the attachment in bytes'), + size: NonNegativeSafeIntegerType.describe('The size of the attachment in bytes'), url: z.string().nullish().describe('The URL of the attachment'), proxy_url: z.string().nullish().describe('The proxied URL of the attachment'), width: Int32Type.nullish().describe('The width of the attachment in pixels (for images/videos)'), diff --git a/packages/schema/src/domains/tests/AttachmentSizeSchemas.test.ts b/packages/schema/src/domains/tests/AttachmentSizeSchemas.test.ts new file mode 100644 index 000000000..630a4c3b6 --- /dev/null +++ b/packages/schema/src/domains/tests/AttachmentSizeSchemas.test.ts @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + PresignedAttachmentUploadRequest, + PresignedAttachmentUploadResponse, +} from '@fluxer/schema/src/domains/message/AttachmentUploadSchemas'; +import {ClientUploadedAttachmentRequest} from '@fluxer/schema/src/domains/message/AttachmentSchemas'; +import {MessageAttachmentResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {describe, expect, it} from 'vitest'; + +describe('attachment byte size schemas', () => { + it('accepts upload request file sizes above int32', () => { + const result = PresignedAttachmentUploadRequest.safeParse({ + attachments: [ + { + id: 0, + filename: 'large.bin', + file_size: 2147483648, + content_type: 'application/octet-stream', + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts upload response file and part sizes above int32', () => { + const result = PresignedAttachmentUploadResponse.safeParse({ + attachments: [ + { + id: 0, + filename: 'large.bin', + upload_filename: 'uploads/tmp-large', + file_size: 2147483648, + content_type: 'application/octet-stream', + upload_mode: 'multipart', + upload_id: 'multipart-id', + part_size: 2147483648, + parts: [{part_number: 1, upload_url: 'https://uploads.example.test/part'}], + }, + ], + }); + expect(result.success).toBe(true); + }); + + it('accepts completed upload file sizes up to the JavaScript safe integer limit', () => { + const result = ClientUploadedAttachmentRequest.safeParse({ + id: 0, + filename: 'max.bin', + upload_filename: 'uploads/tmp-max', + file_size: Number.MAX_SAFE_INTEGER, + content_type: 'application/octet-stream', + }); + expect(result.success).toBe(true); + }); + + it('rejects unsafe upload file sizes', () => { + const result = ClientUploadedAttachmentRequest.safeParse({ + id: 0, + filename: 'unsafe.bin', + upload_filename: 'uploads/tmp-unsafe', + file_size: Number.MAX_SAFE_INTEGER + 1, + content_type: 'application/octet-stream', + }); + expect(result.success).toBe(false); + }); + + it('emits message attachment sizes as safe integer numbers', () => { + const result = MessageAttachmentResponse.safeParse({ + id: '123456789012345678', + filename: 'large.bin', + title: null, + description: null, + content_type: 'application/octet-stream', + content_hash: null, + size: Number.MAX_SAFE_INTEGER, + url: 'https://cdn.example.test/large.bin', + proxy_url: 'https://cdn.example.test/large.bin', + width: null, + height: null, + placeholder: null, + flags: 0, + nsfw: null, + duration: null, + waveform: null, + expires_at: null, + expired: null, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts b/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts index 1527ecad5..77183c454 100644 --- a/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts +++ b/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts @@ -21,6 +21,7 @@ import { createStringType, createUnboundedStringType, Int32Type, + NonNegativeSafeIntegerType, SnowflakeType, } from '@fluxer/schema/src/primitives/SchemaPrimitives'; import {URLType} from '@fluxer/schema/src/primitives/UrlValidators'; @@ -68,7 +69,7 @@ const WebhookAttachmentRequest = z.object({ filename: createStringType(1, 1024).optional().describe('Name of the file (1-1024 characters)'), description: createStringType(1, 4096).optional().describe('Description for the attachment (max 4096 characters)'), content_type: createStringType(1, 256).optional().describe('MIME type of the file'), - size: z.number().int().optional().describe('Size of the file in bytes'), + size: NonNegativeSafeIntegerType.optional().describe('Size of the file in bytes'), url: URLType.optional().describe('URL of the attachment'), proxy_url: URLType.optional().describe('Proxied URL of the attachment'), height: z.number().int().optional().describe('Height of the image/video in pixels'), diff --git a/packages/schema/src/primitives/SchemaPrimitives.ts b/packages/schema/src/primitives/SchemaPrimitives.ts index ee95ba32c..57caea024 100644 --- a/packages/schema/src/primitives/SchemaPrimitives.ts +++ b/packages/schema/src/primitives/SchemaPrimitives.ts @@ -171,6 +171,12 @@ export const ColorType = z .describe('fluxer:ColorType'); export const Int32Type = z.number().int().min(0).max(2147483647).describe('fluxer:Int32Type'); export const SignedInt32Type = z.number().int().min(-2147483648).max(2147483647).describe('fluxer:SignedInt32Type'); +export const NonNegativeSafeIntegerType = z + .number() + .int() + .min(0) + .max(Number.MAX_SAFE_INTEGER) + .describe('fluxer:NonNegativeSafeIntegerType'); const INTEGER_STRING_REGEX = /^[+-]?\d+$/; function coerceNumericStringToNumber(value: unknown): unknown { diff --git a/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts b/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts index 6bb1ac711..59d56dbff 100644 --- a/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts +++ b/packages/schema/src/primitives/tests/SchemaPrimitives.test.ts @@ -9,6 +9,7 @@ import { Int32Type, Int64StringType, Int64Type, + NonNegativeSafeIntegerType, normalizeString, normalizeWhitespace, removeStandaloneSurrogates, @@ -214,6 +215,29 @@ describe('Int32Type', () => { }); }); +describe('NonNegativeSafeIntegerType', () => { + it('accepts maximum JavaScript safe integer', () => { + const result = NonNegativeSafeIntegerType.safeParse(Number.MAX_SAFE_INTEGER); + expect(result.success).toBe(true); + }); + it('accepts values above int32', () => { + const result = NonNegativeSafeIntegerType.safeParse(2147483648); + expect(result.success).toBe(true); + }); + it('rejects unsafe JavaScript integers', () => { + const result = NonNegativeSafeIntegerType.safeParse(Number.MAX_SAFE_INTEGER + 1); + expect(result.success).toBe(false); + }); + it('rejects negative values', () => { + const result = NonNegativeSafeIntegerType.safeParse(-1); + expect(result.success).toBe(false); + }); + it('rejects non-integer values', () => { + const result = NonNegativeSafeIntegerType.safeParse(1.5); + expect(result.success).toBe(false); + }); +}); + describe('coerceNumberFromString', () => { it('coerces valid integer strings to numbers', () => { const schema = coerceNumberFromString(z.number().int().min(0).max(100));