Wires the three parallel group-cloud tracks to one surface and fixes the seams between them: - chat_media_archive.room_id becomes nullable (direct web uploads have no Matrix room yet — the contract's null-event pattern extends to the room); core GroupFile.roomId and ArchiveEntryInput.roomId follow. - mime_class gains 'archive' (zip) across the 029 CHECK, validation MIME_CLASSES and the bot classifier — one vocabulary everywhere. - registerArchiveEntry claim keys are null-safe for room-less uploads. - New: POST /files/groups/:groupId/upload — FileInterceptor (20 MiB) streams through magic-byte sniffing, library-safe naming, oCIS upload and the metadata index, behind group-membership ACL (Owner/Admin bypass). - safeFileName/sniffMime promoted to packages/core (file-names.ts, file-mime.ts) so the bot and the api share one implementation; the bot's local modules become re-export shims; @types/node added to core. Verified: api typecheck clean, group-files 22/22, bot 74/74, web typecheck clean; 029 re-applied clean on a fresh scratch DB (room_id nullable, archive class, claim_key unique all confirmed in the catalog).
44 lines
2 KiB
TypeScript
44 lines
2 KiB
TypeScript
export const MIME_CLASSES = ["image", "video", "audio", "document", "archive", "other"] as const;
|
|
export type MimeClass = (typeof MIME_CLASSES)[number];
|
|
|
|
export interface SniffedMime {
|
|
mime: string;
|
|
mimeClass: MimeClass;
|
|
extension: string | null;
|
|
}
|
|
|
|
/** Bytes needed to recognise every supported signature. */
|
|
export const SNIFF_BYTES = 16;
|
|
|
|
const UNKNOWN: SniffedMime = { mime: "application/octet-stream", mimeClass: "other", extension: null };
|
|
|
|
function startsWith(head: Uint8Array, signature: readonly number[], offset = 0): boolean {
|
|
if (head.length < offset + signature.length) return false;
|
|
return signature.every((byte, i) => head[offset + i] === byte);
|
|
}
|
|
|
|
function ascii(text: string): number[] {
|
|
return [...text].map((c) => c.charCodeAt(0));
|
|
}
|
|
|
|
/**
|
|
* MIME is decided by magic bytes only; the sender's declared `info.mimetype`
|
|
* is never trusted. Unrecognised payloads are stored as octet-stream/other.
|
|
*/
|
|
export function sniffMime(head: Uint8Array): SniffedMime {
|
|
if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
return { mime: "image/png", mimeClass: "image", extension: "png" };
|
|
}
|
|
if (startsWith(head, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", mimeClass: "image", extension: "jpg" };
|
|
if (startsWith(head, ascii("GIF87a")) || startsWith(head, ascii("GIF89a"))) {
|
|
return { mime: "image/gif", mimeClass: "image", extension: "gif" };
|
|
}
|
|
if (startsWith(head, ascii("%PDF-"))) return { mime: "application/pdf", mimeClass: "document", extension: "pdf" };
|
|
if (startsWith(head, [0x50, 0x4b, 0x03, 0x04])) {
|
|
return { mime: "application/zip", mimeClass: "archive", extension: "zip" };
|
|
}
|
|
if (startsWith(head, ascii("ftyp"), 4)) return { mime: "video/mp4", mimeClass: "video", extension: "mp4" };
|
|
if (startsWith(head, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "video/webm", mimeClass: "video", extension: "webm" };
|
|
if (startsWith(head, ascii("OggS"))) return { mime: "audio/ogg", mimeClass: "audio", extension: "ogg" };
|
|
return UNKNOWN;
|
|
}
|