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:
glass beadian 2026-10-11 10:09:24 +00:00
parent 688a2ce996
commit e7e703c105
21 changed files with 473 additions and 227 deletions

View file

@ -200,11 +200,14 @@ describe("ocis-webdav config + streaming verification", () => {
it("uploads hash the actual streamed bytes and surface oc-fileid/oc-etag", async () => {
const bytes = Buffer.from("group file payload");
const fetcher = (async () =>
new Response(null, {
const calls: string[] = [];
const fetcher = (async (url: string, init: RequestInit) => {
calls.push(`${init.method} ${url}`);
return new Response(null, {
status: 201,
headers: { "oc-fileid": "res-1", "oc-etag": "v-9" },
})) as unknown as typeof fetch;
headers: init.method === "MKCOL" ? new Headers() : { "oc-fileid": "res-1", "oc-etag": "v-9" },
});
}) as unknown as typeof fetch;
const client = new OcisWebdavClient(
{ webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" },
fetcher,
@ -215,10 +218,24 @@ describe("ocis-webdav config + streaming verification", () => {
expect(result.sizeBytes).toBe(bytes.byteLength);
expect(result.resourceId).toBe("res-1");
expect(result.version).toBe("v-9");
expect(calls[0]).toBe("MKCOL https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1");
expect(calls[1]).toBe("PUT https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1/clip.mp4");
expect(client.uploadUrl("g", "n.mp4")).toContain("/remote.php/dav/files/svc/hub-groups/g/n.mp4");
expect(client.libraryUrl("res-1")).toBe("https://files.example.org/f/res-1");
});
it("fails the upload when the group folder cannot be created", async () => {
const fetcher = (async (url: string, init: RequestInit) =>
new Response(null, { status: init.method === "MKCOL" ? 500 : 201 })) as unknown as typeof fetch;
const client = new OcisWebdavClient(
{ webdavUrl: "https://files.example.org", user: "svc", password: "pw", publicBase: "https://files.example.org" },
fetcher,
);
await expect(
client.upload("group-1", "clip.mp4", Readable.from(["x"]), "text/plain"),
).rejects.toThrow("oCIS folder creation failed with 500");
});
it("verifyWhileStreaming passes on match and aborts on tamper", async () => {
const { createHash } = await import("node:crypto");
const payload = Buffer.from("0123456789");