// SPDX-License-Identifier: AGPL-3.0-or-later export type AssetKind = | 'avatar' | 'guild_icon' | 'banner' | 'splash' | 'embed_splash' | 'emoji' | 'sticker' | 'attachment'; type AssetExtension = 'png' | 'jpeg' | 'webp' | 'gif' | 'apng' | 'avif' | 'heic' | 'heif' | 'jxl' | 'svg'; type CanonicalExtension = 'png' | 'jpeg' | 'webp' | 'gif' | 'apng'; type AnimationPolicy = 'never' | 'always' | 'premium'; type AssetExtensionLabelStyle = 'display' | 'extension'; interface AssetDimensionPolicy { min: number; max: number; } interface AssetFormatPolicyEntry { upload: ReadonlyArray; mimes: Readonly>; storeAs: ReadonlyArray; animated: AnimationPolicy; maxBytes: number; dims?: AssetDimensionPolicy; } interface AssetUploadExtensionOptions { animatedAllowed?: boolean; } interface AssetUploadExtensionFormatOptions extends AssetUploadExtensionOptions { labelStyle?: AssetExtensionLabelStyle; } const MIME_BY_EXTENSION: Readonly> = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', apng: 'image/apng', avif: 'image/avif', heic: 'image/heic', heif: 'image/heif', jxl: 'image/jxl', svg: 'image/svg+xml', }; const EXTENSION_DISPLAY: Readonly> = { png: 'PNG', jpeg: 'JPEG', webp: 'WebP', gif: 'GIF', apng: 'APNG', avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', jxl: 'JXL', svg: 'SVG', }; const RASTER_FULL = Object.freeze>([ 'png', 'jpeg', 'webp', 'gif', 'apng', 'avif', 'heic', 'heif', 'jxl', 'svg', ]); const RASTER_STATIC_ONLY = Object.freeze>([ 'png', 'jpeg', 'webp', 'avif', 'heic', 'heif', 'jxl', 'svg', ]); const STICKER_UPLOAD = Object.freeze>(['png', 'jpeg', 'apng', 'gif', 'webp', 'avif', 'svg']); const STORE_ANIMATED = Object.freeze>(['webp', 'apng', 'gif']); const STORE_STATIC = Object.freeze>(['webp', 'png', 'jpeg']); const STORE_STICKER = Object.freeze>(['webp', 'apng', 'gif', 'png']); const EXTENSIONS_FILTERED_WHEN_ANIMATION_IS_UNAVAILABLE: ReadonlySet = new Set(['gif', 'apng']); const EXTENSIONS_WITH_STATIC_ONLY_ANIMATION_POLICY: ReadonlySet = new Set(['avif']); const KNOWN_ANIMATED_EXTENSIONS = Object.freeze>(['gif', 'apng', 'webp']); function pickMimes(exts: ReadonlyArray): Readonly> { const out: Partial> = {}; for (const ext of exts) { out[ext] = MIME_BY_EXTENSION[ext]; } return Object.freeze(out as Record); } export const ASSET_FORMAT_POLICY: Readonly> = Object.freeze({ avatar: { upload: RASTER_FULL, mimes: pickMimes(RASTER_FULL), storeAs: STORE_ANIMATED, animated: 'premium', maxBytes: 10 * 1024 * 1024, dims: {min: 128, max: 1024}, }, guild_icon: { upload: RASTER_FULL, mimes: pickMimes(RASTER_FULL), storeAs: STORE_ANIMATED, animated: 'premium', maxBytes: 10 * 1024 * 1024, dims: {min: 128, max: 1024}, }, banner: { upload: RASTER_FULL, mimes: pickMimes(RASTER_FULL), storeAs: STORE_ANIMATED, animated: 'premium', maxBytes: 10 * 1024 * 1024, dims: {min: 480, max: 2400}, }, splash: { upload: RASTER_STATIC_ONLY, mimes: pickMimes(RASTER_STATIC_ONLY), storeAs: STORE_STATIC, animated: 'never', maxBytes: 10 * 1024 * 1024, dims: {min: 480, max: 2400}, }, embed_splash: { upload: RASTER_STATIC_ONLY, mimes: pickMimes(RASTER_STATIC_ONLY), storeAs: STORE_STATIC, animated: 'never', maxBytes: 10 * 1024 * 1024, dims: {min: 480, max: 2400}, }, emoji: { upload: RASTER_FULL, mimes: pickMimes(RASTER_FULL), storeAs: STORE_ANIMATED, animated: 'always', maxBytes: 512 * 1024, dims: {min: 32, max: 512}, }, sticker: { upload: STICKER_UPLOAD, mimes: pickMimes(STICKER_UPLOAD), storeAs: STORE_STICKER, animated: 'always', maxBytes: 512 * 1024, dims: {min: 128, max: 512}, }, attachment: { upload: RASTER_FULL, mimes: pickMimes(RASTER_FULL), storeAs: STORE_ANIMATED, animated: 'always', maxBytes: 500 * 1024 * 1024, }, }); export function getPolicy(kind: AssetKind): AssetFormatPolicyEntry { return ASSET_FORMAT_POLICY[kind]; } export function getUploadExtensions( kind: AssetKind, options: AssetUploadExtensionOptions = {}, ): ReadonlyArray { const policy = ASSET_FORMAT_POLICY[kind]; if (options.animatedAllowed !== false) return policy.upload; return policy.upload.filter((ext) => !EXTENSIONS_FILTERED_WHEN_ANIMATION_IS_UNAVAILABLE.has(ext)); } export function getAcceptString(kind: AssetKind, options: AssetUploadExtensionOptions = {}): string { const policy = ASSET_FORMAT_POLICY[kind]; const upload = getUploadExtensions(kind, options); const exts = upload.map((ext) => `.${ext}`); const mimes = upload.map((ext) => policy.mimes[ext]); return [...exts, ...mimes].join(','); } export function getMimeWhitelist(kind: AssetKind): ReadonlyArray { const policy = ASSET_FORMAT_POLICY[kind]; return policy.upload.map((ext) => policy.mimes[ext]); } export function getExtensionWhitelist(kind: AssetKind): ReadonlyArray { return ASSET_FORMAT_POLICY[kind].upload; } function isExtensionStaticOnlyForAnimatedAsset(kind: AssetKind, ext: AssetExtension): boolean { return ASSET_FORMAT_POLICY[kind].animated !== 'never' && EXTENSIONS_WITH_STATIC_ONLY_ANIMATION_POLICY.has(ext); } function getExtensionDisplayName( ext: AssetExtension, options: Pick = {}, ): string { return options.labelStyle === 'extension' ? ext : EXTENSION_DISPLAY[ext]; } export function formatAssetUploadExtensions(kind: AssetKind, options: AssetUploadExtensionFormatOptions = {}): string { return getUploadExtensions(kind, options) .map((ext) => getExtensionDisplayName(ext, options)) .join(', '); } export function formatKnownAnimatedAssetExtensions(kind: AssetKind): string { const policy = ASSET_FORMAT_POLICY[kind]; return KNOWN_ANIMATED_EXTENSIONS.filter( (ext) => policy.upload.includes(ext) && !isExtensionStaticOnlyForAnimatedAsset(kind, ext), ) .map((ext) => EXTENSION_DISPLAY[ext]) .join(', '); } export function isExtensionAllowed(kind: AssetKind, ext: string): boolean { const withoutDot = ext.toLowerCase().replace(/^\./, ''); const normalized = (withoutDot === 'jpg' ? 'jpeg' : withoutDot) as AssetExtension; return ASSET_FORMAT_POLICY[kind].upload.includes(normalized); } export function isMimeAllowed(kind: AssetKind, mime: string): boolean { const normalized = mime.toLowerCase(); const policy = ASSET_FORMAT_POLICY[kind]; for (const ext of policy.upload) { if (policy.mimes[ext] === normalized) return true; } return false; }