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
1.5 KiB
TypeScript
44 lines
1.5 KiB
TypeScript
export const MAX_SAFE_NAME_BYTES = 120;
|
|
const FALLBACK = "attachment";
|
|
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
|
|
|
function truncateUtf8(text: string, maxBytes: number): string {
|
|
let out = "";
|
|
for (const ch of text) {
|
|
if (Buffer.byteLength(out + ch, "utf8") > maxBytes) break;
|
|
out += ch;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Library-safe single path segment: no directories, control or reserved
|
|
* characters, leading dots, or unbounded length. Extension is preserved when
|
|
* truncating; a sniffed extension is appended when the name has none.
|
|
*/
|
|
export function safeFileName(raw: string, sniffedExtension: string | null = null): string {
|
|
const basename = raw.normalize("NFKC").split(/[/\\]/).pop() ?? "";
|
|
let name = basename
|
|
.replace(/[\p{Cc}\p{Cf}]/gu, "")
|
|
.replace(/[<>:"|?*#%{}^~[\]`]/g, "_")
|
|
.replace(/\s+/g, " ")
|
|
.replace(/_+/g, "_")
|
|
.trim()
|
|
.replace(/^[.\s_-]+/, "")
|
|
.replace(/[.\s]+$/, "");
|
|
if (name === "") name = FALLBACK;
|
|
if (WINDOWS_RESERVED.test(name)) name = `_${name}`;
|
|
|
|
const dot = name.lastIndexOf(".");
|
|
let stem = dot > 0 ? name.slice(0, dot) : name;
|
|
let ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
if (ext === "" && sniffedExtension !== null) ext = sniffedExtension;
|
|
if (Buffer.byteLength(ext, "utf8") > 16) {
|
|
stem = name;
|
|
ext = sniffedExtension ?? "";
|
|
}
|
|
const suffix = ext === "" ? "" : `.${ext}`;
|
|
stem = truncateUtf8(stem, MAX_SAFE_NAME_BYTES - Buffer.byteLength(suffix, "utf8")).trim();
|
|
if (stem === "") stem = FALLBACK;
|
|
return `${stem}${suffix}`;
|
|
}
|