fix(api)!: align error codes with throw sites and image bounds (#2488)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:21:38 +02:00
1 parent 4534822355
commit d5bfa5a73d
99 files changed
+857 -4853

No files matched your search

@@ -20,7 +20,7 @@ import {
import {ChannelNicknameOverrides} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {ReadStateResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
import {ChannelOverwriteTypeSchema, GeneralChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {base64LengthForBytes, createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {ContentWarningLevelSchema} from '@fluxer/schema/src/primitives/GuildValidators';
import {QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
@@ -118,7 +118,7 @@ const ChannelUpdateCommon = ChannelCommonBase.extend({
'Legacy: setting true maps to nsfw_override=true; setting false maps to nsfw_override=null (inherit). Prefer nsfw_override.',
),
...ChannelContentWarningFields,
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
icon: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded icon image for group DM channels'),
owner_id: SnowflakeType.nullish().describe('ID of the new owner for group DM channels'),
@@ -181,7 +181,7 @@ const ChannelUpdateLinkRequest = ChannelUpdateCommon.extend({
const ChannelUpdateGroupDmRequest = z.object({
type: createNamedLiteral(ChannelTypes.GROUP_DM, 'GROUP_DM', 'Channel type (group DM)'),
name: GeneralChannelNameType.nullish().describe('The name of the group DM'),
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
icon: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded icon image for the group DM'),
owner_id: SnowflakeType.nullish().describe('ID of the new owner of the group DM'),
@@ -20,7 +20,7 @@ import {SudoVerificationSchema} from '@fluxer/schema/src/domains/auth/AuthSchema
import {GuildFeatureSchema} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {TemplateSerializedGuild} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
import {VanityURLCodeType} from '@fluxer/schema/src/primitives/ChannelValidators';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {base64LengthForBytes, createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {
ContentWarningLevelSchema,
DefaultMessageNotificationsSchema,
@@ -52,7 +52,7 @@ function coerceBlankStringToNull(value: unknown): unknown {
export const GuildCreateRequest = z.object({
name: createStringType(1, 100).describe('The name of the guild (1-100 characters)'),
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
icon: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded image data for the guild icon'),
empty_features: z.boolean().optional().describe('Whether to create the guild without default features'),
@@ -64,7 +64,7 @@ export type GuildCreateRequest = z.infer<typeof GuildCreateRequest>;
export const GuildUpdateRequest = z
.object({
name: createStringType(1, 100).describe('The name of the guild (1-100 characters)'),
icon: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
icon: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded image data for the guild icon'),
system_channel_id: SnowflakeType.nullish().describe('The ID of the channel where system messages are sent'),
@@ -108,13 +108,13 @@ export const GuildUpdateRequest = z
GuildExplicitContentFilterSchema,
'Level of content filtering for explicit media',
),
banner: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
banner: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded image data for the guild banner'),
splash: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
splash: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded image data for the guild splash screen'),
embed_splash: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
embed_splash: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded image data for the embedded invite splash'),
splash_card_alignment: SplashCardAlignmentSchema.optional().describe(
@@ -148,10 +148,10 @@ export const GuildMemberUpdateRequest = z.object({
.optional()
.transform((ids) => (ids ? new Set(ids) : undefined))
.describe(`Array of role IDs to assign to the member (max ${MAX_GUILD_ROLES})`),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
avatar: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded image data for the member guild avatar'),
banner: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
banner: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded image data for the member guild banner'),
bio: createStringType(1, 320).nullish().describe('The member guild profile bio (1-320 characters)'),
@@ -208,7 +208,7 @@ export const GuildEmojiCreateRequest = z.object({
name: createStringType(2, 32)
.refine((value) => /^[a-zA-Z0-9_]+$/.test(value), 'Emoji name can only contain letters, numbers, and underscores')
.describe('The name of the emoji (2-32 characters, alphanumeric and underscores only)'),
image: createBase64StringType(1, Math.ceil(EMOJI_MAX_SIZE * (4 / 3))).describe(
image: createBase64StringType(1, base64LengthForBytes(EMOJI_MAX_SIZE)).describe(
'Base64-encoded image data for the emoji',
),
});
@@ -247,7 +247,7 @@ export const GuildStickerCreateRequest = z.object({
.optional()
.default([])
.describe(`Array of autocomplete/suggestion tags (max ${MAX_GUILD_STICKER_TAGS} tags, each 1-30 characters)`),
image: createBase64StringType(1, Math.ceil(STICKER_MAX_SIZE * (4 / 3))).describe(
image: createBase64StringType(1, base64LengthForBytes(STICKER_MAX_SIZE)).describe(
'Base64-encoded image data for the sticker',
),
});
@@ -9,7 +9,7 @@ import {
UserAuthenticatorTypesDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {base64LengthForBytes, createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {
createBitflagInt32Type,
createInt32EnumType,
@@ -448,10 +448,10 @@ export type ApplicationUpdateRequest = z.infer<typeof ApplicationUpdateRequest>;
export const BotProfileUpdateRequest = z.object({
username: UsernameType.optional().describe('The username of the bot'),
discriminator: DiscriminatorType.optional().describe('The discriminator of the bot'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
avatar: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('The avatar image as base64'),
banner: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
banner: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('The banner image as base64'),
bio: createStringType(0, 1024).nullish().describe('The bio or description of the bot'),
@@ -24,7 +24,7 @@ import {
SYNCED_PREFERENCES_MAX_ENCODED_LENGTH,
} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
import {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {base64LengthForBytes, createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {LocaleSchema} from '@fluxer/schema/src/primitives/LocaleSchema';
import {createQueryIntegerType, DateTimeType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
@@ -68,10 +68,10 @@ export const UserUpdateRequest = z
email: EmailType.describe('The email address for the account'),
new_password: PasswordType.describe('The new password to set'),
password: PasswordType.describe('The current password for verification'),
avatar: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33)
avatar: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded avatar image'),
banner: createBase64StringType(1, AVATAR_MAX_SIZE * 1.33)
banner: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('Base64-encoded profile banner image'),
bio: createStringType(1, 320).nullish().describe('User biography text (max 320 characters)'),
@@ -14,7 +14,7 @@ import {
RichEmbedRequest,
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
import {AllowedMentionsRequest, MessageReferenceRequest} from '@fluxer/schema/src/domains/message/SharedMessageSchemas';
import {createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {base64LengthForBytes, createBase64StringType} from '@fluxer/schema/src/primitives/FileValidators';
import {QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {
coerceNumberFromString,
@@ -31,7 +31,7 @@ import {z} from 'zod';
export const WebhookCreateRequest = z.object({
name: WebhookNameType.describe('The name of the webhook'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
avatar: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('The avatar image as a base64-encoded data URI'),
});
@@ -41,7 +41,7 @@ export type WebhookCreateRequest = z.infer<typeof WebhookCreateRequest>;
export const WebhookUpdateRequest = z
.object({
name: WebhookNameType.describe('The new name of the webhook'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
avatar: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('The new avatar image as a base64-encoded data URI'),
channel_id: SnowflakeType.describe('The ID of the channel to move the webhook to'),
@@ -53,7 +53,7 @@ export type WebhookUpdateRequest = z.infer<typeof WebhookUpdateRequest>;
export const WebhookTokenUpdateRequest = z
.object({
name: WebhookNameType.describe('The new name of the webhook'),
avatar: createBase64StringType(1, Math.ceil(AVATAR_MAX_SIZE * (4 / 3)))
avatar: createBase64StringType(1, base64LengthForBytes(AVATAR_MAX_SIZE))
.nullish()
.describe('The new avatar image as a base64-encoded data URI'),
})
@@ -13,7 +13,7 @@ const NON_FILENAME_CHARS_REGEX = /[^\p{L}\p{N}\p{M}_.-]/gu;
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
function isValidBase64(value: string): boolean {
export function isValidBase64(value: string): boolean {
if (value.length % 4 !== 0) {
return false;
}
@@ -87,6 +87,10 @@ export const FilenameType = withStringLengthRangeValidation(
.refine((value) => value.length >= 1, ValidationErrorCodes.FILENAME_EMPTY_AFTER_NORMALIZATION)
.refine((value) => FILENAME_SAFE_REGEX.test(value), ValidationErrorCodes.FILENAME_INVALID_CHARACTERS);
export function base64LengthForBytes(maxBytes: number): number {
return 4 * Math.ceil(maxBytes / 3);
}
export function createBase64StringType(minLength = 1, maxLength = 256) {
return withOpenApiType(
z
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AVATAR_MAX_SIZE, EMOJI_MAX_SIZE, STICKER_MAX_SIZE} from '@fluxer/constants/src/LimitConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {createBase64StringType, FilenameType} from '@fluxer/schema/src/primitives/FileValidators';
import {base64LengthForBytes, createBase64StringType, FilenameType} from '@fluxer/schema/src/primitives/FileValidators';
import {describe, expect, it} from 'vitest';
describe('FilenameType', () => {
@@ -164,3 +165,27 @@ describe('createBase64StringType', () => {
}
});
});
describe('base64LengthForBytes', () => {
it('returns the exact encoded length of the byte ceiling', () => {
expect(base64LengthForBytes(EMOJI_MAX_SIZE)).toBe(699052);
expect(base64LengthForBytes(AVATAR_MAX_SIZE)).toBe(13981016);
});
it('returns a multiple of four so isValidBase64 can accept the bound', () => {
for (const bytes of [1, 2, 3, 255, 256, EMOJI_MAX_SIZE, STICKER_MAX_SIZE, AVATAR_MAX_SIZE]) {
expect(base64LengthForBytes(bytes) % 4).toBe(0);
}
});
it('accepts an encoding of the full byte ceiling', () => {
const Base64Type = createBase64StringType(1, base64LengthForBytes(EMOJI_MAX_SIZE));
const encoded = Buffer.alloc(EMOJI_MAX_SIZE, 1).toString('base64');
expect(encoded.length).toBeLessThanOrEqual(base64LengthForBytes(EMOJI_MAX_SIZE));
expect(Base64Type.safeParse(encoded).success).toBe(true);
});
it('accepts an encoding one byte over the ceiling so the byte check stays authoritative', () => {
const Base64Type = createBase64StringType(1, base64LengthForBytes(EMOJI_MAX_SIZE));
const encoded = Buffer.alloc(EMOJI_MAX_SIZE + 1, 1).toString('base64');
expect(encoded.length).toBe(base64LengthForBytes(EMOJI_MAX_SIZE));
expect(Base64Type.safeParse(encoded).success).toBe(true);
});
});