fix(api): support int53 attachment sizes (#1261)

This commit is contained in:
Hampus authored and GitHub committed 2026-07-02 23:41:55 +02:00
1 parent 40d1e90a3c
commit 3a8ed1012f
22 files changed
+289 -89

No files matched your search

@@ -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(),
@@ -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'),
@@ -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'),
});
@@ -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)
@@ -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)'),
@@ -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);
});
});
@@ -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'),
@@ -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 {
@@ -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));