Integrate group files: nullable room for uploads, archive class, claim idempotency, upload route
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).
This commit is contained in:
parent
688a2ce996
commit
e7e703c105
21 changed files with 473 additions and 227 deletions
44
packages/core/src/file-mime.ts
Normal file
44
packages/core/src/file-mime.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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;
|
||||
}
|
||||
44
packages/core/src/file-names.ts
Normal file
44
packages/core/src/file-names.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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}`;
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ export * from "./capabilities.js";
|
|||
export * from "./chat-contract.js";
|
||||
export * from "./chat-contract.schema.js";
|
||||
export * from "./connection.js";
|
||||
export * from "./file-mime.js";
|
||||
export * from "./file-names.js";
|
||||
export * from "./messaging.js";
|
||||
export * from "./publishing.js";
|
||||
export * from "./shared-files.js";
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export interface GroupFile {
|
|||
id: string;
|
||||
workspaceId: string;
|
||||
groupId: string;
|
||||
roomId: string;
|
||||
/** Matrix room when the file arrived through one; null for direct uploads. */
|
||||
roomId: string | null;
|
||||
senderId: string;
|
||||
/** Null for a direct upload/export until a link is posted in the room. */
|
||||
eventId: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue