403 lines
11 KiB
TypeScript
403 lines
11 KiB
TypeScript
import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose";
|
|
|
|
/** Community avatar cap (bytes). */
|
|
const COMMUNITY_AVATAR_MAX_BYTES = 5 * 1024 * 1024;
|
|
/** Custom-method attachment cap (bytes). */
|
|
const CUSTOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
|
|
export const COMMUNITY_AVATAR_ACCEPT =
|
|
"image/jpeg,image/png,image/webp,image/gif";
|
|
export const CUSTOM_ATTACHMENT_ACCEPT = `${COMMUNITY_AVATAR_ACCEPT},application/pdf`;
|
|
|
|
export type CreateFlowUploadValidationReason =
|
|
| "empty"
|
|
| "tooLarge"
|
|
| "svg"
|
|
| "invalidType"
|
|
| "undecodable";
|
|
|
|
type SniffedUploadKind =
|
|
| "jpeg"
|
|
| "png"
|
|
| "gif"
|
|
| "webp"
|
|
| "pdf"
|
|
| "svg"
|
|
| "empty"
|
|
| "unknown";
|
|
|
|
type CreateFlowUploadRasterKind = "jpeg" | "png" | "gif" | "webp";
|
|
|
|
type CreateFlowUploadValidationOk = {
|
|
ok: true;
|
|
kind: CreateFlowUploadRasterKind | "pdf";
|
|
mimeType: string;
|
|
};
|
|
|
|
type CreateFlowUploadValidationFail = {
|
|
ok: false;
|
|
reason: CreateFlowUploadValidationReason;
|
|
};
|
|
|
|
export type CreateFlowUploadValidationResult =
|
|
| CreateFlowUploadValidationOk
|
|
| CreateFlowUploadValidationFail;
|
|
|
|
export class CreateFlowUploadValidationError extends Error {
|
|
readonly reason: CreateFlowUploadValidationReason;
|
|
|
|
constructor(reason: CreateFlowUploadValidationReason) {
|
|
super(reason);
|
|
this.name = "CreateFlowUploadValidationError";
|
|
this.reason = reason;
|
|
}
|
|
}
|
|
|
|
const SVG_MIME = new Set(["image/svg+xml", "image/svg"]);
|
|
|
|
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
|
|
return purpose === "communityAvatar"
|
|
? COMMUNITY_AVATAR_MAX_BYTES
|
|
: CUSTOM_ATTACHMENT_MAX_BYTES;
|
|
}
|
|
|
|
function mimeTypeForSniffedKind(
|
|
kind: CreateFlowUploadRasterKind | "pdf",
|
|
): string {
|
|
switch (kind) {
|
|
case "jpeg":
|
|
return "image/jpeg";
|
|
case "png":
|
|
return "image/png";
|
|
case "gif":
|
|
return "image/gif";
|
|
case "webp":
|
|
return "image/webp";
|
|
case "pdf":
|
|
return "application/pdf";
|
|
}
|
|
}
|
|
|
|
export function messageKeyForCreateFlowUploadReason(
|
|
reason: CreateFlowUploadValidationReason,
|
|
): `errors.${CreateFlowUploadValidationReason}` {
|
|
return `errors.${reason}`;
|
|
}
|
|
|
|
function fileLooksLikeSvg(file: File): boolean {
|
|
const declared = file.type.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
if (SVG_MIME.has(declared)) return true;
|
|
return /\.svgz?$/i.test(file.name);
|
|
}
|
|
|
|
function readU16LE(bytes: Uint8Array, offset: number): number {
|
|
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
|
}
|
|
|
|
function readU16BE(bytes: Uint8Array, offset: number): number {
|
|
return (bytes[offset]! << 8) | bytes[offset + 1]!;
|
|
}
|
|
|
|
function readU24LE(bytes: Uint8Array, offset: number): number {
|
|
return (
|
|
bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16)
|
|
);
|
|
}
|
|
|
|
function readU32BE(bytes: Uint8Array, offset: number): number {
|
|
return (
|
|
((bytes[offset]! << 24) |
|
|
(bytes[offset + 1]! << 16) |
|
|
(bytes[offset + 2]! << 8) |
|
|
bytes[offset + 3]!) >>>
|
|
0
|
|
);
|
|
}
|
|
|
|
function startsWith(bytes: Uint8Array, offset: number, ascii: string): boolean {
|
|
if (offset + ascii.length > bytes.length) return false;
|
|
for (let i = 0; i < ascii.length; i++) {
|
|
if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function looksLikeSvgBytes(bytes: Uint8Array): boolean {
|
|
let i = 0;
|
|
if (
|
|
bytes.length >= 3 &&
|
|
bytes[0] === 0xef &&
|
|
bytes[1] === 0xbb &&
|
|
bytes[2] === 0xbf
|
|
) {
|
|
i = 3;
|
|
}
|
|
while (
|
|
i < bytes.length &&
|
|
(bytes[i] === 0x20 ||
|
|
bytes[i] === 0x09 ||
|
|
bytes[i] === 0x0d ||
|
|
bytes[i] === 0x0a)
|
|
) {
|
|
i += 1;
|
|
}
|
|
const head = new TextDecoder("utf-8", { fatal: false })
|
|
.decode(bytes.subarray(i, Math.min(i + 512, bytes.length)))
|
|
.toLowerCase();
|
|
return head.includes("<svg") || head.includes("<!doctype svg");
|
|
}
|
|
|
|
/**
|
|
* True-type sniff from magic bytes. Raster/PDF signatures win over a later
|
|
* `<svg` substring so binary comments cannot be classified as SVG.
|
|
*/
|
|
function sniffCreateFlowUploadBytes(
|
|
bytes: Uint8Array,
|
|
): SniffedUploadKind {
|
|
if (bytes.length === 0) return "empty";
|
|
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
return "jpeg";
|
|
}
|
|
if (
|
|
bytes.length >= 8 &&
|
|
bytes[0] === 0x89 &&
|
|
bytes[1] === 0x50 &&
|
|
bytes[2] === 0x4e &&
|
|
bytes[3] === 0x47 &&
|
|
bytes[4] === 0x0d &&
|
|
bytes[5] === 0x0a &&
|
|
bytes[6] === 0x1a &&
|
|
bytes[7] === 0x0a
|
|
) {
|
|
return "png";
|
|
}
|
|
if (startsWith(bytes, 0, "GIF87a") || startsWith(bytes, 0, "GIF89a")) {
|
|
return "gif";
|
|
}
|
|
if (
|
|
bytes.length >= 12 &&
|
|
startsWith(bytes, 0, "RIFF") &&
|
|
startsWith(bytes, 8, "WEBP")
|
|
) {
|
|
return "webp";
|
|
}
|
|
if (bytes.length >= 5 && startsWith(bytes, 0, "%PDF-")) {
|
|
return "pdf";
|
|
}
|
|
if (looksLikeSvgBytes(bytes)) return "svg";
|
|
return "unknown";
|
|
}
|
|
|
|
function pngDimensions(
|
|
bytes: Uint8Array,
|
|
): { width: number; height: number } | null {
|
|
if (bytes.length < 24 || !startsWith(bytes, 12, "IHDR")) return null;
|
|
const width = readU32BE(bytes, 16);
|
|
const height = readU32BE(bytes, 20);
|
|
if (width < 1 || height < 1) return null;
|
|
return { width, height };
|
|
}
|
|
|
|
function gifDimensions(
|
|
bytes: Uint8Array,
|
|
): { width: number; height: number } | null {
|
|
if (bytes.length < 10) return null;
|
|
const width = readU16LE(bytes, 6);
|
|
const height = readU16LE(bytes, 8);
|
|
if (width < 1 || height < 1) return null;
|
|
return { width, height };
|
|
}
|
|
|
|
function jpegDimensions(
|
|
bytes: Uint8Array,
|
|
): { width: number; height: number } | null {
|
|
if (bytes.length < 4) return null;
|
|
let offset = 2;
|
|
while (offset + 8 < bytes.length) {
|
|
if (bytes[offset] !== 0xff) {
|
|
offset += 1;
|
|
continue;
|
|
}
|
|
const marker = bytes[offset + 1]!;
|
|
if (marker === 0xff) {
|
|
offset += 1;
|
|
continue;
|
|
}
|
|
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
offset += 2;
|
|
continue;
|
|
}
|
|
if (offset + 3 >= bytes.length) return null;
|
|
const size = readU16BE(bytes, offset + 2);
|
|
if (size < 2) return null;
|
|
const isSof =
|
|
(marker >= 0xc0 && marker <= 0xc3) ||
|
|
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
(marker >= 0xcd && marker <= 0xcf);
|
|
if (isSof) {
|
|
if (offset + 8 >= bytes.length) return null;
|
|
const height = readU16BE(bytes, offset + 5);
|
|
const width = readU16BE(bytes, offset + 7);
|
|
if (width < 1 || height < 1) return null;
|
|
return { width, height };
|
|
}
|
|
offset += 2 + size;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function webpDimensions(
|
|
bytes: Uint8Array,
|
|
): { width: number; height: number } | null {
|
|
if (bytes.length < 20) return null;
|
|
if (startsWith(bytes, 12, "VP8X")) {
|
|
if (bytes.length < 30) return null;
|
|
const width = readU24LE(bytes, 24) + 1;
|
|
const height = readU24LE(bytes, 27) + 1;
|
|
if (width < 1 || height < 1) return null;
|
|
return { width, height };
|
|
}
|
|
if (startsWith(bytes, 12, "VP8L")) {
|
|
if (bytes.length < 25 || bytes[20] !== 0x2f) return null;
|
|
const bits =
|
|
bytes[21]! |
|
|
(bytes[22]! << 8) |
|
|
(bytes[23]! << 16) |
|
|
(bytes[24]! << 24);
|
|
const width = (bits & 0x3fff) + 1;
|
|
const height = ((bits >> 14) & 0x3fff) + 1;
|
|
if (width < 1 || height < 1) return null;
|
|
return { width, height };
|
|
}
|
|
if (startsWith(bytes, 12, "VP8 ")) {
|
|
if (bytes.length < 30) return null;
|
|
if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) {
|
|
return null;
|
|
}
|
|
const width = readU16LE(bytes, 26) & 0x3fff;
|
|
const height = readU16LE(bytes, 28) & 0x3fff;
|
|
if (width < 1 || height < 1) return null;
|
|
return { width, height };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function rasterDimensions(
|
|
kind: CreateFlowUploadRasterKind,
|
|
bytes: Uint8Array,
|
|
): { width: number; height: number } | null {
|
|
switch (kind) {
|
|
case "png":
|
|
return pngDimensions(bytes);
|
|
case "gif":
|
|
return gifDimensions(bytes);
|
|
case "jpeg":
|
|
return jpegDimensions(bytes);
|
|
case "webp":
|
|
return webpDimensions(bytes);
|
|
}
|
|
}
|
|
|
|
function purposeAllowsKind(
|
|
purpose: CreateFlowUploadPurpose,
|
|
kind: CreateFlowUploadRasterKind | "pdf",
|
|
): boolean {
|
|
if (kind === "pdf") return purpose === "customMethodAttachment";
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Size, true-type, SVG, and header-decode checks shared by the browser and
|
|
* `POST /api/uploads`. Call {@link validateCreateFlowUploadFile} on the client
|
|
* so oversized files are rejected before they are read into memory.
|
|
*/
|
|
export function validateCreateFlowUploadBytes(
|
|
purpose: CreateFlowUploadPurpose,
|
|
bytes: Uint8Array,
|
|
): CreateFlowUploadValidationResult {
|
|
if (bytes.length === 0) return { ok: false, reason: "empty" };
|
|
if (bytes.length > maxBytesForPurpose(purpose)) {
|
|
return { ok: false, reason: "tooLarge" };
|
|
}
|
|
|
|
const sniffed = sniffCreateFlowUploadBytes(bytes);
|
|
if (sniffed === "empty") return { ok: false, reason: "empty" };
|
|
if (sniffed === "svg") return { ok: false, reason: "svg" };
|
|
if (sniffed === "unknown") return { ok: false, reason: "invalidType" };
|
|
if (!purposeAllowsKind(purpose, sniffed)) {
|
|
return { ok: false, reason: "invalidType" };
|
|
}
|
|
if (sniffed === "pdf") {
|
|
return { ok: true, kind: "pdf", mimeType: mimeTypeForSniffedKind("pdf") };
|
|
}
|
|
|
|
if (rasterDimensions(sniffed, bytes) == null) {
|
|
return { ok: false, reason: "undecodable" };
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
kind: sniffed,
|
|
mimeType: mimeTypeForSniffedKind(sniffed),
|
|
};
|
|
}
|
|
|
|
async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
|
|
if (typeof blob.arrayBuffer === "function") {
|
|
return new Uint8Array(await blob.arrayBuffer());
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
resolve(new Uint8Array(reader.result as ArrayBuffer));
|
|
};
|
|
reader.onerror = () => {
|
|
reject(reader.error ?? new Error("FileReader failed"));
|
|
};
|
|
reader.readAsArrayBuffer(blob);
|
|
});
|
|
}
|
|
|
|
async function decodeRasterInBrowser(
|
|
bytes: Uint8Array,
|
|
mimeType: string,
|
|
): Promise<boolean> {
|
|
if (typeof createImageBitmap !== "function") return true;
|
|
try {
|
|
const copy = new Uint8Array(bytes);
|
|
const bitmap = await createImageBitmap(
|
|
new Blob([copy], { type: mimeType }),
|
|
);
|
|
const ok = bitmap.width > 0 && bitmap.height > 0;
|
|
bitmap.close();
|
|
return ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Client-side validation: size and SVG name/type before reading bytes, then
|
|
* the same sniff/decode path as the server, plus `createImageBitmap` when
|
|
* available.
|
|
*/
|
|
export async function validateCreateFlowUploadFile(
|
|
file: File,
|
|
purpose: CreateFlowUploadPurpose,
|
|
): Promise<CreateFlowUploadValidationResult> {
|
|
if (file.size === 0) return { ok: false, reason: "empty" };
|
|
if (file.size > maxBytesForPurpose(purpose)) {
|
|
return { ok: false, reason: "tooLarge" };
|
|
}
|
|
if (fileLooksLikeSvg(file)) return { ok: false, reason: "svg" };
|
|
|
|
const bytes = await readBlobBytes(file);
|
|
const result = validateCreateFlowUploadBytes(purpose, bytes);
|
|
if (result.ok === false || result.kind === "pdf") return result;
|
|
|
|
const decoded = await decodeRasterInBrowser(bytes, result.mimeType);
|
|
if (!decoded) return { ok: false, reason: "undecodable" };
|
|
return result;
|
|
}
|