diff --git a/apps/api/src/group-files/collective-share.service.ts b/apps/api/src/group-files/collective-share.service.ts index 24a9618..657af3c 100644 --- a/apps/api/src/group-files/collective-share.service.ts +++ b/apps/api/src/group-files/collective-share.service.ts @@ -40,8 +40,6 @@ import { type CollectiveShareResult, type ShareErrorCode, } from "./collective-share.js"; -import { OcisWebdavClient, ocisConfigFromEnv, type FetchLike } from "./ocis-webdav.js"; -import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js"; interface CollectiveRow { id: string; @@ -67,36 +65,10 @@ export const COLLECTIVE_SHARE_DEPENDENCIES: unique symbol = Symbol("COLLECTIVE_S @Injectable() export class CollectiveShareService { - private client: OcisWebdavClient | null = null; - constructor( @Optional() @Inject(COLLECTIVE_ROOM_POSTER) private readonly poster?: CollectiveRoomPoster, - @Optional() @Inject(COLLECTIVE_SHARE_DEPENDENCIES) private readonly deps?: { fetcher?: FetchLike; storage?: StorageService }, ) {} - private store: StorageService | null = null; - - /** R2 object storage — the library payload store (041). */ - private storage(): StorageService { - if (this.store === null) { - this.store = this.deps?.storage ?? new S3Storage(s3ConfigFromEnv()); - } - return this.store; - } - - private libraryUrlFor(resourceId: string): string { - const base = (process.env.FILES_PUBLIC_BASE ?? "").replace(/\/+$/, ""); - return base === "" ? resourceId : `${base}/f/${encodeURIComponent(resourceId)}`; - } - - /** Lazily bound: the oCIS env may be absent in dev/test contexts. */ - private webdav(): OcisWebdavClient { - if (this.client === null) { - const fetcher: FetchLike = this.deps?.fetcher ?? ((url, init) => fetch(url, init)); - this.client = new OcisWebdavClient(ocisConfigFromEnv(), fetcher); - } - return this.client; - } private roomPoster(): CollectiveRoomPoster { if (this.poster === undefined) { @@ -167,10 +139,9 @@ export class CollectiveShareService { ); } - const senderMxid = `@${actor.username}:tmbh.local`; const results: CollectiveShareItemResult[] = []; for (const [index, item] of input.items.entries()) { - results.push(await this.shareOne(workspaceId, collective, actor, input, item, index, senderMxid, doLibrary, doRoom)); + results.push(await this.shareOne(workspaceId, collective, actor, input, item, index, doLibrary, doRoom)); } await appendTenantAudit(workspaceId, { @@ -199,12 +170,11 @@ export class CollectiveShareService { input: CollectiveShareInput, item: CollectiveShareItem, index: number, - senderMxid: string, doLibrary: boolean, doRoom: boolean, ): Promise { - if (item.kind === "peertube-video" && doLibrary && !doRoom) { - // A video has no library bytes to copy; the target combination is invalid for it. + if (doLibrary && !doRoom) { + // A video link has no library bytes to copy; the target combination is invalid for it. return { index, kind: item.kind, @@ -215,182 +185,21 @@ export class CollectiveShareService { }; } - let library: CollectiveShareItemResult["library"] = null; - if (doLibrary && item.kind === "ocis-resource") { - const copied = await this.copyIntoLibrary(workspaceId, collective, actor, item, input.folder, senderMxid); - if ("error" in copied) { - return { index, kind: item.kind, title: copied.title, library: null, openRoom: null, error: copied.error }; - } - library = copied.result; - } - let openRoom: CollectiveShareItemResult["openRoom"] = null; let failure: { code: ShareErrorCode; message: string } | null = null; if (doRoom) { - const title = item.kind === "peertube-video" ? item.title : library?.name ?? titleFromPath(item.path); - const url = item.kind === "peertube-video" - ? item.url - : library?.url ?? libraryUrlFor(webdavSafePath(input.folder, item.path)); - const posted = await this.postToOpenRoom(collective, { title, url, note: input.note, linkKind: item.kind === "peertube-video" ? "video" : "library" }); + const posted = await this.postToOpenRoom(collective, { title: item.title, url: item.url, note: input.note, linkKind: "video" }); if ("error" in posted) failure = posted.error; else openRoom = posted.result; } - if (failure !== null && library === null) { - return { index, kind: item.kind, title: item.kind === "peertube-video" ? item.title : titleFromPath(item.path), library: null, openRoom: null, error: failure }; + if (failure !== null) { + return { index, kind: item.kind, title: item.title, library: null, openRoom: null, error: failure }; } - return { - index, - kind: item.kind, - title: item.kind === "peertube-video" ? item.title : library?.name ?? titleFromPath(item.path), - library, - openRoom, - error: failure, - }; + return { index, kind: item.kind, title: item.title, library: null, openRoom, error: failure }; } - /** - * COPY + index for one oCIS resource. Collision naming (stem-1, stem-2 …) - * is applied here; the caller's chosen destination folder is honored - * relative to the collective's group root. - */ - private async copyIntoLibrary( - workspaceId: string, - collective: ResolvedCollective, - actor: { userId: string; role: string }, - item: { kind: "ocis-resource"; path: string }, - folder: readonly string[], - senderMxid: string, - ): Promise< - | { title: string; result: { itemId: string; name: string; url: string } } - | { title: string; error: { code: ShareErrorCode; message: string } } - > { - const segments = davSegments(item.path, "path"); - const stat = await this.webdav().statPath(segments).catch(() => null); - if (stat === null || !stat.exists) { - return { - title: titleFromPath(item.path), - error: { - code: "source-not-found", - message: collective.language === "es" - ? `No se encontró el archivo de origen (${item.path}).` - : `The source file was not found (${item.path}).`, - }, - }; - } - const name = safeFileName(segments[segments.length - 1] ?? "attachment"); - // oCIS stays the Drive source; the library destination is R2 (041). - const source = await this.webdav().download(segments.join("/")).catch(() => null); - if (source === null || source.stream === null) { - return { - title: titleFromPath(item.path), - error: { - code: "source-not-found", - message: collective.language === "es" - ? `No se encontró el archivo de origen (${item.path}).` - : `The source file was not found (${item.path}).`, - }, - }; - } - const chunks: Buffer[] = []; - for await (const chunk of source.stream as unknown as AsyncIterable) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)); - } - const bytes = Buffer.concat(chunks); - const stored = await this.storage().putContent(bytes, source.contentType ?? "application/octet-stream").catch(() => null); - if (stored === null) { - return { - title: name, - error: { - code: "copy-failed", - message: collective.language === "es" - ? "La copia en la biblioteca del colectivo falló." - : "Copying into the collective's library failed.", - }, - }; - } - - const indexed = await this.indexCopy(workspaceId, collective, actor, { - folder, - name, - storageKey: stored.key, - sha256: stored.hash, - sizeBytes: stored.sizeBytes, - contentType: source.contentType ?? "application/octet-stream", - sourcePath: item.path, - senderMxid, - }); - if ("error" in indexed) return { title: name, error: indexed.error }; - return { title: name, result: indexed.result }; - } - - /** - * Index the copy in chat_media_archive (origin 'upload' — the caller put it - * there themselves) and mint the members library link row in - * group_file_shares so the collective's files index lists it. - */ - private async indexCopy( - workspaceId: string, - collective: ResolvedCollective, - actor: { userId: string; role: string }, - copy: { - folder: readonly string[]; - name: string; - storageKey: string; - sha256: string; - sizeBytes: number; - contentType: string; - sourcePath: string; - senderMxid: string; - }, - ): Promise<{ result: { itemId: string; name: string; url: string } } | { error: { code: ShareErrorCode; message: string } }> { - const mime = copy.contentType === "" ? "application/octet-stream" : copy.contentType; - const libraryUrl = this.libraryUrlFor(copy.storageKey); - const folderPath = copy.folder.length === 0 ? "" : `${copy.folder.join("/")}/`; - const origin = { - kind: "upload", - sharedFrom: copy.sourcePath, - sharedInto: `hub-groups/${collective.id}/${folderPath}${copy.name}`, - }; - const inserted = await withTenant(workspaceId, async (client: PoolClient) => { - const row = await client.query<{ id: string }>( - `INSERT INTO chat_media_archive - (workspace_id, group_id, room_id, event_id, origin, sender_mxid, matrix_event_json, - original_name, safe_name, mime, mime_class, byte_size, sha256, ocis_resource_id, - ocis_resource_version, created_at, indexed_at, storage_key) - VALUES ($1,$2,NULL,NULL,'upload',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now(),$13) - RETURNING id`, - [ - workspaceId, - collective.id, - copy.senderMxid, - JSON.stringify(origin), - copy.name, - copy.name, - mime, - mimeClassOf(mime), - copy.sizeBytes, - copy.sha256, - copy.storageKey, - null, - copy.storageKey, - ], - ); - return row.rows[0]?.id; - }).catch(() => undefined); - if (inserted === undefined) { - return { - error: { - code: "index-failed", - message: collective.language === "es" - ? "La copia se realizó pero no se pudo registrar en el índice." - : "The copy succeeded but indexing it failed.", - }, - }; - } - return { result: { itemId: inserted, name: copy.name, url: libraryUrl } }; - } /** * JOIN-if-not-in-room, then one m.text notice. A closed room is refused @@ -470,12 +279,4 @@ function digestPlaceholder(resourceId: string): string { return `copied:${resourceId}`; } -function webdavSafePath(folder: readonly string[], path: string): string { - const segments = davSegments(path, "path"); - return folder.length === 0 ? segments.join("/") : `${folder.join("/")}/${segments.join("/")}`; -} -function libraryUrlFor(path: string): string { - const base = process.env.FILES_PUBLIC_BASE ?? ""; - return base === "" ? path : `${base.replace(/\/+$/, "")}/f/${encodeURIComponent(path)}`; -} diff --git a/apps/api/src/group-files/collective-share.ts b/apps/api/src/group-files/collective-share.ts index ec70dbd..13cf246 100644 --- a/apps/api/src/group-files/collective-share.ts +++ b/apps/api/src/group-files/collective-share.ts @@ -31,26 +31,13 @@ import type { MimeClass as ValidationMimeClass } from "./group-files.validation. export const SHARE_TARGETS = ["library", "open-room", "both"] as const; export type ShareTarget = (typeof SHARE_TARGETS)[number]; -export const SHARE_ITEM_KINDS = ["ocis-resource", "peertube-video"] as const; +export const SHARE_ITEM_KINDS = ["peertube-video"] as const; export type ShareItemKind = (typeof SHARE_ITEM_KINDS)[number]; /** Per-request caps; a share is a person acting on a handful of items, not a bulk ingest. */ export const MAX_SHARE_ITEMS = 20; export const MAX_NOTE_LENGTH = 500; export const MAX_TITLE_LENGTH = 200; -/** Collision suffix ceiling: stem-1 … stem-100, then a per-item error. */ -export const MAX_COLLISION_ATTEMPTS = 100; - -export interface OcisResourceItem { - readonly kind: "ocis-resource"; - /** - * dav path RELATIVE to /remote.php/dav/files// — the hub's - * own conventions: `hub-resources/` (a row of the group files - * index) or a group folder path `hub-groups//`. - */ - readonly path: string; -} - export interface PeertubeVideoItem { readonly kind: "peertube-video"; /** Canonical video URL (…/w/:id, /videos/watch/:id or /videos/embed/:id). */ @@ -58,7 +45,7 @@ export interface PeertubeVideoItem { readonly title: string; } -export type CollectiveShareItem = OcisResourceItem | PeertubeVideoItem; +export type CollectiveShareItem = PeertubeVideoItem; export interface CollectiveShareInput { readonly target: ShareTarget; @@ -148,14 +135,6 @@ function parseItem(value: unknown, index: number): CollectiveShareItem { } const record = value as Record; const kind = record.kind; - if (kind === "ocis-resource") { - if (typeof record.path !== "string" || record.path.trim() === "") { - throw new BadRequestException(`items[${index}].path must be a non-empty string`); - } - // Segment shape is validated here so a traversal attempt never reaches the service. - davSegments(record.path, `items[${index}].path`); - return { kind, path: record.path.trim() }; - } if (kind === "peertube-video") { const title = boundedText(record.title, `items[${index}].title`, MAX_TITLE_LENGTH); if (title === null) throw new BadRequestException(`items[${index}].title must be a non-empty string`); diff --git a/apps/api/src/group-files/group-files.controller.ts b/apps/api/src/group-files/group-files.controller.ts index 8c2233e..49fe0d7 100644 --- a/apps/api/src/group-files/group-files.controller.ts +++ b/apps/api/src/group-files/group-files.controller.ts @@ -5,7 +5,7 @@ import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac import { requireObject, requirePositiveInt, requireSession, requireString } from "../http/validation.js"; import { GroupFilesService, type FilePage } from "./group-files.service.js"; import type { GroupFile } from "@tactical-media-broadcast-hub/core"; -import { verifyWhileStreaming } from "./ocis-webdav.js"; +import { verifyWhileStreaming } from "./verify-stream.js"; const MAX_FILE_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024; diff --git a/apps/api/src/group-files/group-files.service.test.ts b/apps/api/src/group-files/group-files.service.test.ts index e30eae5..86bb36d 100644 --- a/apps/api/src/group-files/group-files.service.test.ts +++ b/apps/api/src/group-files/group-files.service.test.ts @@ -322,12 +322,20 @@ describe("GroupFilesService invariants", () => { const { createHash } = await import("node:crypto"); const digest = createHash("sha256").update(payload).digest("hex"); const { Readable } = await import("node:stream"); - const fetcher = (async () => - new Response(Readable.toWeb(Readable.from([payload])) as unknown as ReadableStream, { - status: 200, - headers: { "content-type": "application/octet-stream", "content-length": String(payload.byteLength) }, - })) as unknown as typeof fetch; - const service = new GroupFilesService({ fetcher }); + // The R2-backed payload path: the storage stub serves the exact bytes. + const storage = { + putContent: async () => { throw new Error("not used in this test"); }, + getObject: async () => payload, + hasContent: async () => true, + presign: async () => "", + }; + behavior = (sql) => { + if (sql.includes("share_for_public_claim")) { + return { rows: [{ id: "s1", workspace_id: row.workspace_id, item_id: row.id, access: "public", expires_at: new Date(Date.now() + 3600_000), revoked_at: null }], rowCount: 1 }; + } + return { rows: [{ ...row, sha256: digestOf("payload-bytes"), storage_key: "sha256/testing" }], rowCount: 1 }; + }; + const service = new GroupFilesService({ storage: storage as never }); const handle = await service.claimPublicDownload("some-token"); expect(handle.item.id).toBe(row.id); expect(handle.item.checksum.value).toBe(digest); diff --git a/apps/api/src/group-files/group-files.service.ts b/apps/api/src/group-files/group-files.service.ts index 02f38e1..123fdf5 100644 --- a/apps/api/src/group-files/group-files.service.ts +++ b/apps/api/src/group-files/group-files.service.ts @@ -28,8 +28,8 @@ import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js"; import { withSystem, withTenant } from "../db/client.js"; import { isUuid, parseLimit } from "../http/validation.js"; import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js"; -import { ocisConfigFromEnv, OcisWebdavClient, type FetchLike, type OcisDownload } from "./ocis-webdav.js"; import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js"; +import type { PayloadDownload } from "./verify-stream.js"; import { hashShareToken, mintShareToken } from "./share-token.js"; import { MIME_CLASSES, ORIGIN_KINDS, parseRetentionInput, type MimeClass, type OriginKind } from "./group-files.validation.js"; @@ -38,7 +38,7 @@ export const GROUP_FILES_DEPENDENCIES: unique symbol = Symbol("GROUP_FILES_DEPEN /** Wraps the raw payload handle the controller streams out. */ export interface FileDownloadHandle { item: GroupFile; - download: OcisDownload; + download: PayloadDownload; } export interface ShareCreated { @@ -187,21 +187,11 @@ function rowToGroupFile(row: ArchiveRow, libraryUrl: (resourceId: string) => str @Injectable() export class GroupFilesService { - private client: OcisWebdavClient | null = null; private store: StorageService | null = null; - constructor(@Optional() @Inject(GROUP_FILES_DEPENDENCIES) private readonly deps?: { fetcher?: FetchLike; storage?: StorageService }) {} + constructor(@Optional() @Inject(GROUP_FILES_DEPENDENCIES) private readonly deps?: { storage?: StorageService }) {} - /** Lazily bound: the oCIS env may be absent in dev/test contexts. Legacy rows only. */ - private webdav(): OcisWebdavClient { - if (this.client === null) { - const fetcher: FetchLike = this.deps?.fetcher ?? ((url, init) => fetch(url, init)); - this.client = new OcisWebdavClient(ocisConfigFromEnv(), fetcher); - } - return this.client; - } - - /** R2 object storage — the primary payload store (041). */ + /** R2 object storage — the only payload store (041; oCIS retired). */ private storage(): StorageService { if (this.store === null) { this.store = this.deps?.storage ?? new S3Storage(s3ConfigFromEnv()); @@ -318,20 +308,18 @@ export class GroupFilesService { async openDownload(workspaceId: string, userId: string, role: string, itemId: string): Promise { const item = await this.getFile(workspaceId, userId, role, itemId); if (item.status !== "available") throw new GoneException(`file is ${item.status}`); - const download = await this.openPayload(item.file.storageKey ?? null, item.file.resourceId, item.mimeType).catch(() => { + const download = await this.openPayload(item.file.storageKey, item.mimeType).catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); }); return { item, download }; } - /** R2 for rows with a storage key (041+); oCIS WebDAV for legacy rows. */ - private async openPayload(storageKey: string | null | undefined, resourceId: string, mime: string): Promise { - const key = storageKey ?? null; - if (key !== null) { - const bytes = await this.storage().getObject(key); - return { stream: Readable.from(bytes), sizeBytes: bytes.byteLength, contentType: mime }; + private async openPayload(storageKey: string | null | undefined, mime: string): Promise { + if (storageKey === null || storageKey === undefined) { + throw new ServiceUnavailableException("file was stored on retired storage and is no longer served"); } - return this.webdav().download(resourceId); + const bytes = await this.storage().getObject(storageKey); + return { stream: Readable.from(bytes), sizeBytes: bytes.byteLength, contentType: mime }; } // ------------------------------------------------------------------------- @@ -358,7 +346,7 @@ export class GroupFilesService { }, }); const after = await this.requireRow(client, itemId); - return rowToGroupFile(after, (resourceId) => this.webdav().libraryUrl(resourceId)); + return rowToGroupFile(after, (resourceId) => this.libraryUrlFor(resourceId)); }); } @@ -375,7 +363,7 @@ export class GroupFilesService { diff: { name: before.safe_name, roomId: before.room_id, eventId: before.event_id }, }); const after = await this.requireRow(client, itemId); - return rowToGroupFile(after, (resourceId) => this.webdav().libraryUrl(resourceId)); + return rowToGroupFile(after, (resourceId) => this.libraryUrlFor(resourceId)); }); } @@ -470,7 +458,7 @@ export class GroupFilesService { throw new GoneException("file retention has expired"); } const item = rowToGroupFile(row, (resourceId) => this.libraryUrlFor(resourceId)); - const download = await this.openPayload(row.storage_key, item.file.resourceId, item.mimeType).catch(() => { + const download = await this.openPayload(row.storage_key, item.mimeType).catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); }); return { item, download }; @@ -590,7 +578,7 @@ export class GroupFilesService { const safe = safeFileName(file.originalName, sniff.extension); // Object storage is the only payload path (user directive 2026-10-06): // SPENC1-sealed, content-addressed, deduplicated. ocis_resource_id keeps - // carrying the resource handle (now the R2 key) for legacy readers. + // carrying the resource handle (the R2 key) for legacy readers. const stored = await this.storage().putContent(file.buffer, sniff.mime).catch(() => { throw new ServiceUnavailableException("file storage is unavailable"); }); diff --git a/apps/api/src/group-files/group-files.test.ts b/apps/api/src/group-files/group-files.test.ts index 8fe713b..8d05a78 100644 --- a/apps/api/src/group-files/group-files.test.ts +++ b/apps/api/src/group-files/group-files.test.ts @@ -16,7 +16,7 @@ import { requireRoomId, requireSha256, } from "./group-files.validation.js"; -import { OcisVerificationError, ocisConfigFromEnv, OcisWebdavClient, verifyWhileStreaming } from "./ocis-webdav.js"; +import { OcisVerificationError, verifyWhileStreaming } from "./verify-stream.js"; import { hashShareToken, isPlausibleShareToken, mintShareToken, sameToken } from "./share-token.js"; /** Deterministic 64-hex helper: repeat a pattern to exactly 64 chars. */ @@ -185,59 +185,7 @@ describe("share tokens", () => { }); }); -describe("ocis-webdav config + streaming verification", () => { - it("reads env lazily and throws 503 when unconfigured", () => { - expect(() => ocisConfigFromEnv({})).toThrow(ServiceUnavailableException); - expect(() => ocisConfigFromEnv({ FILES_OCID_WEBDAV_URL: "https://files.example.org" })).toThrow(ServiceUnavailableException); - const config = ocisConfigFromEnv({ - FILES_OCID_WEBDAV_URL: "https://files.example.org/", - FILES_OCID_USER: "svc", - FILES_OCID_PASSWORD: "pw", - FILES_PUBLIC_BASE: "https://files.example.org/", - }); - expect(config.webdavUrl).toBe("https://files.example.org"); - expect(config.publicBase).toBe("https://files.example.org"); - }); - - it("uploads hash the actual streamed bytes and surface oc-fileid/oc-etag", async () => { - const bytes = Buffer.from("group file payload"); - const calls: string[] = []; - const fetcher = (async (url: string, init: RequestInit) => { - calls.push(`${init.method} ${url}`); - return new Response(null, { - status: 201, - 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, - ); - const result = await client.upload("group-1", "clip.mp4", Readable.from([bytes]), "video/mp4"); - const { createHash } = await import("node:crypto"); - expect(result.sha256).toBe(createHash("sha256").update(bytes).digest("hex")); - 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"); - expect(calls[1]).toBe("MKCOL https://files.example.org/remote.php/dav/files/svc/hub-groups/group-1"); - expect(calls[2]).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"); - }); - +describe("verify-stream (storage-agnostic streaming verification)", () => { it("verifyWhileStreaming passes on match and aborts on tamper", async () => { const { createHash } = await import("node:crypto"); const payload = Buffer.from("0123456789"); diff --git a/apps/api/src/group-files/ocis-webdav.ts b/apps/api/src/group-files/ocis-webdav.ts deleted file mode 100644 index af576c4..0000000 --- a/apps/api/src/group-files/ocis-webdav.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * oCIS WebDAV port — the ONLY payload I/O path for group files. - * - * The API streams bytes through itself on both upload and download: it NEVER - * mints or persists a presigned object URL (docs/occupymedia-storage.md - * "Persist library links/share IDs and expiry, not presigned object URLs"). - * Downloads re-verify the SHA-256 while streaming so storage-level tampering - * is detected instead of silently relayed. - * - * Server-side COPY ("share into a collective's library") moves no bytes - * through the API at all: oCIS performs the copy in the storage backend and - * the API only learns the resulting file id / etag / length, which is exactly - * the metadata the group files index stores. The copy is verified by oCIS - * itself; the index records what the server committed, never a claimed length. - * - * ENV CONTRACT (lazy getters — read per call so tests/parallel deployments - * can reconfigure without a rebuild; nothing is read at import time): - * - * FILES_OCID_WEBDAV_URL oCIS WebDAV root, e.g. https://files.example.org - * FILES_OCID_USER service account username (the scoped oCIS identity) - * FILES_OCID_PASSWORD service account password - * FILES_PUBLIC_BASE public base of the file library, used to build the - * persistent (authenticated) library link returned in - * GroupFile.shareLink.url — a stable resource URL, - * NEVER a presigned object URL. - * - * The oCIS deployment lands in parallel; when the env is unset the port - * reports unconfigured and routes fail 503 rather than silently no-op. - * - * WebDAV shape: PUT returns the stable resource id (oc-fileid) and ETag - * (oc-etag / etag) this index needs; plain PUT/GET carry the bytes. The - * transport is injectable (OCIS_FETCH token) so tests stub it without HTTP. - */ -import { ServiceUnavailableException } from "@nestjs/common"; -import { createHash } from "node:crypto"; -import { Readable } from "node:stream"; - -export interface OcisEnvConfig { - webdavUrl: string; - user: string; - password: string; - publicBase: string; -} - -export function ocisConfigFromEnv(env: Readonly> = process.env): OcisEnvConfig { - const webdavUrl = env.FILES_OCID_WEBDAV_URL; - const user = env.FILES_OCID_USER; - const password = env.FILES_OCID_PASSWORD; - if (webdavUrl === undefined || webdavUrl === "") throw new ServiceUnavailableException("FILES_OCID_WEBDAV_URL is not configured"); - if (user === undefined || user === "") throw new ServiceUnavailableException("FILES_OCID_USER is not configured"); - if (password === undefined || password === "") throw new ServiceUnavailableException("FILES_OCID_PASSWORD is not configured"); - return { - webdavUrl: webdavUrl.replace(/\/+$/, ""), - user, - password, - publicBase: (env.FILES_PUBLIC_BASE ?? "").replace(/\/+$/, ""), - }; -} - -/** Result of a committed upload: stable identity + digest over bytes sent. */ -export interface OcisUploadResult { - resourceId: string; - version: string | null; - sha256: string; - sizeBytes: number; -} - -/** An opened download: verifyWhileStreaming checks the digest before relay. */ -export interface OcisDownload { - stream: Readable; - sizeBytes: number | null; - contentType: string | null; -} - -/** A server-side COPY result: identity + size, with the digest oCIS reports. */ -export interface OcisCopyResult { - resourceId: string; - version: string | null; - sizeBytes: number; - sha256: string | null; - contentType: string; -} - -export class OcisVerificationError extends Error { - constructor(expected: string, actual: string) { - super(`oCIS payload verification failed: expected sha256 ${expected}, got ${actual}`); - this.name = "OcisVerificationError"; - } -} - -/** Minimal fetch surface so tests can stub transport without HTTP. */ -export type FetchLike = (url: string, init: RequestInit) => Promise; - -export const OCIS_FETCH: unique symbol = Symbol("OCIS_FETCH"); - -/** Bytes allowed in flight to the transport before the pump pauses. */ -export const UPLOAD_BUFFER_CAP = 8 * 1024 * 1024; - -/** Node's undici fetch needs this hint when a stream body is supplied. */ -type FetchInitWithDuplex = RequestInit & { duplex?: "half" }; - -/** Distinguishing marker for "the source path does not exist (404)". */ -export class OcisNotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "OcisNotFoundError"; - } -} - -/** Distinguishing marker for "destination exists and Overwrite:F was sent (412)". */ -export class OcisPreconditionFailedError extends Error { - constructor(message: string) { - super(message); - this.name = "OcisPreconditionFailedError"; - } -} - -function encodeSegments(segments: readonly string[]): string { - return segments.map((segment) => encodeURIComponent(segment)).join("/"); -} - -/** - * WebDAV client. `fetcher` defaults to the global fetch. - */ -export class OcisWebdavClient { - constructor( - private readonly config: OcisEnvConfig, - private readonly fetcher: FetchLike = (url, init) => fetch(url, init), - ) {} - - /** Group folder path under the service identity's WebDAV root. */ - uploadUrl(groupId: string, safeName: string): string { - return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-groups/${encodeURIComponent(groupId)}/${encodeURIComponent(safeName)}`; - } - - downloadUrl(resourceId: string): string { - return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-resources/${encodeURIComponent(resourceId)}`; - } - - /** Any path, as sanitized segments, under the service identity's WebDAV root. */ - private rootUrl(segments: readonly string[]): string { - return `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/${encodeSegments(segments)}`; - } - - /** MKCOL the group folder; 405 (already exists) is fine. */ - private async ensureGroupFolder(groupId: string): Promise { - const base = `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}`; - // MKCOL returns 409 when the parent collection is missing — create the - // hub-groups root (405 = already exists), then the per-group folder. - const root = await this.fetcher(`${base}/hub-groups`, { method: "MKCOL", headers: this.authHeaders() }); - if (!root.ok && root.status !== 405) { - throw new Error(`oCIS folder creation failed with ${root.status}`); - } - const url = `${base}/hub-groups/${encodeURIComponent(groupId)}`; - const response = await this.fetcher(url, { method: "MKCOL", headers: this.authHeaders() }); - if (response.ok || response.status === 405) return; - throw new Error(`oCIS folder creation failed with ${response.status}`); - } - - /** MKCOL a folder path (segments already sanitized); 405 = already there. */ - async ensureFolderPath(segments: readonly string[]): Promise { - if (segments.length === 0) return; - const response = await this.fetcher(this.rootUrl(segments), { method: "MKCOL", headers: this.authHeaders() }); - if (response.ok || response.status === 405) return; - throw new Error(`oCIS folder creation failed with ${response.status}`); - } - - private authHeaders(): Record { - const basic = Buffer.from(`${this.config.user}:${this.config.password}`, "utf8").toString("base64"); - return { authorization: `Basic ${basic}` }; - } - - /** - * Stream-to-storage PUT, hashing while streaming (contract: "Hash actual - * bytes while streaming"). The digest covers the exact bytes handed to the - * store — never a claimed Content-Length. - * - * Backpressure: the pump pauses when more than UPLOAD_BUFFER_CAP bytes are - * queued for the transport, so RAM stays bounded for large payloads. The - * pump is driven by our own loop, so the digest completes even if the - * transport returns without draining a small body (a 2xx store that never - * reads its request body is broken and will surface as an integrity error). - */ - async upload(groupId: string, safeName: string, body: Readable, contentType: string): Promise { - await this.ensureGroupFolder(groupId); - // reva rejects chunked PUTs, so the source drains into a bounded buffer - // (the upload cap is 2 GiB) and goes out with an explicit content-length. - const hash = createHash("sha256"); - const chunks: Buffer[] = []; - let sizeBytes = 0; - for await (const chunk of body) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array | string); - hash.update(buffer); - sizeBytes += buffer.byteLength; - chunks.push(buffer); - } - const payload = Buffer.concat(chunks); - const response = await this.fetcher(this.uploadUrl(groupId, safeName), { - method: "PUT", - headers: { ...this.authHeaders(), "content-type": contentType, "content-length": String(payload.byteLength) }, - body: payload, - }); - if (!response.ok) throw new Error(`oCIS upload failed with ${response.status}`); - const etag = response.headers.get("oc-etag") ?? response.headers.get("etag"); - const resourceId = response.headers.get("oc-fileid"); - return { - resourceId: resourceId ?? safeName, - version: etag, - sha256: hash.digest("hex"), - sizeBytes, - }; - } - - /** Open a GET. The digest is re-checked by verifyWhileStreaming downstream. */ - async download(resourceId: string): Promise { - const response = await this.fetcher(this.downloadUrl(resourceId), { method: "GET", headers: this.authHeaders() }); - if (!response.ok || response.body === null) throw new Error(`oCIS download failed with ${response.status}`); - const length = response.headers.get("content-length"); - return { - stream: response.body as unknown as Readable, - sizeBytes: length === null ? null : Number(length), - contentType: response.headers.get("content-type"), - }; - } - - /** - * PROPFIND (Depth: 0) on a path: existence + Content-Length + Content-Type. - * Used by the share-into-library path to size and type the copy without - * streaming a single payload byte through the API. - */ - async statPath(segments: readonly string[]): Promise<{ exists: boolean; sizeBytes: number; contentType: string }> { - const body = `\n`; - const response = await this.fetcher(this.rootUrl(segments), { - method: "PROPFIND", - headers: { ...this.authHeaders(), depth: "0", "content-type": "application/xml" }, - body, - }); - if (response.status === 404) return { exists: false, sizeBytes: 0, contentType: "" }; - if (!response.ok) throw new Error(`oCIS PROPFIND failed with ${response.status}`); - const xml = await response.text(); - const sizeBytes = Number(firstTag(xml, "getcontentlength") ?? "0"); - const contentType = firstTag(xml, "getcontenttype") ?? "application/octet-stream"; - if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0) throw new Error("oCIS PROPFIND returned an invalid content length"); - return { exists: true, sizeBytes, contentType }; - } - - /** - * Server-side COPY of a source path into the collective's group folder - * (optionally inside a sub-folder). `Overwrite: F` makes a collision a 412 - * the caller can retry with a suffixed name — a share never clobbers an - * existing library file. - */ - async copyIntoGroup( - groupId: string, - sourceSegments: readonly string[], - destinationFolder: readonly string[], - name: string, - ): Promise { - await this.ensureFolderPath(["hub-groups", groupId, ...destinationFolder]); - const destination = this.rootUrl(["hub-groups", groupId, ...destinationFolder, name]); - const response = await this.fetcher(destination, { - method: "COPY", - headers: { - ...this.authHeaders(), - destination, - "overwrite": "F", - }, - }); - if (response.status === 404) throw new OcisNotFoundError(`oCIS source not found: /${encodeSegments(sourceSegments)}`); - if (response.status === 412) throw new OcisPreconditionFailedError(`oCIS destination already exists: ${name}`); - if (!response.ok) throw new Error(`oCIS copy failed with ${response.status}`); - const stat = await this.statPath(["hub-groups", groupId, ...destinationFolder, name]); - if (!stat.exists) throw new Error("oCIS copy reported success but the destination is not present"); - const etag = response.headers.get("oc-etag") ?? response.headers.get("etag"); - const resourceId = response.headers.get("oc-fileid") ?? ["hub-groups", groupId, ...destinationFolder, name].join("/"); - return { - resourceId, - version: etag, - sizeBytes: stat.sizeBytes, - sha256: null, - contentType: stat.contentType, - }; - } - - /** Stable resource link for GroupFile.shareLink.url — never presigned. */ - libraryUrl(resourceId: string): string { - return this.config.publicBase === "" ? resourceId : `${this.config.publicBase}/f/${encodeURIComponent(resourceId)}`; - } -} - -/** First … value in a small XML body, namespace-agnostic. */ -function firstTag(xml: string, tag: string): string | null { - const match = xml.match(new RegExp(`<(?:[\\w.-]+:)?${tag}(?:\\s[^>]*)?>([^<]*)`, "i")); - return match === null ? null : match[1]!.trim(); -} - -/** - * Pipe a download forward, verifying sha256 chunk-by-chunk. Contract: - * "SHA-256 verified while streaming" — a mismatch aborts the relay rather - * than handing over tampered bytes (the sink sees them, but the caller - * treats the error as fatal and closes the response). - */ -export async function verifyWhileStreaming( - download: OcisDownload, - expectedSha256: string, - sink: (chunk: Buffer) => void, -): Promise<{ sizeBytes: number }> { - const hash = createHash("sha256"); - let sizeBytes = 0; - for await (const chunk of download.stream) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); - hash.update(buffer); - sizeBytes += buffer.byteLength; - sink(buffer); - } - const actual = hash.digest("hex"); - if (actual !== expectedSha256) throw new OcisVerificationError(expectedSha256, actual); - return { sizeBytes }; -} - -export function isOcisVerificationError(err: unknown): err is OcisVerificationError { - return err instanceof OcisVerificationError; -} diff --git a/apps/api/src/group-files/verify-stream.ts b/apps/api/src/group-files/verify-stream.ts new file mode 100644 index 0000000..3fa5451 --- /dev/null +++ b/apps/api/src/group-files/verify-stream.ts @@ -0,0 +1,44 @@ +/** + * Streaming payload verification: sha256 is computed over exactly the bytes + * served, and a mismatch aborts the relay rather than handing over tampered + * bytes. Storage-agnostic since 041 (was the oCIS client's contract; kept for + * R2-backed rows). + */ +import { createHash } from "node:crypto"; +import type { Readable } from "node:stream"; + +/** An opened payload: streamed to the caller with metadata for the relay. */ +export interface PayloadDownload { + stream: Readable; + sizeBytes: number | null; + contentType: string | null; +} + +export class OcisVerificationError extends Error { + constructor(expected: string, actual: string) { + super(`payload verification failed: expected sha256 ${expected}, got ${actual}`); + this.name = "OcisVerificationError"; + } +} + +export function isOcisVerificationError(err: unknown): err is OcisVerificationError { + return err instanceof OcisVerificationError; +} + +export async function verifyWhileStreaming( + download: PayloadDownload, + expectedSha256: string, + sink: (chunk: Buffer) => void, +): Promise<{ sizeBytes: number }> { + const hash = createHash("sha256"); + let sizeBytes = 0; + for await (const chunk of download.stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array); + hash.update(buffer); + sizeBytes += buffer.byteLength; + sink(buffer); + } + const actual = hash.digest("hex"); + if (actual !== expectedSha256) throw new OcisVerificationError(expectedSha256, actual); + return { sizeBytes }; +} diff --git a/apps/api/src/video/import-sources.ts b/apps/api/src/video/import-sources.ts index 1522409..e739f43 100644 --- a/apps/api/src/video/import-sources.ts +++ b/apps/api/src/video/import-sources.ts @@ -1,6 +1,5 @@ import { Readable } from "node:stream"; import { BadRequestException, ServiceUnavailableException } from "@nestjs/common"; -import { OcisNotFoundError, OcisWebdavClient, ocisConfigFromEnv } from "../group-files/ocis-webdav.js"; /** Imports buffer the whole file in memory; 256 MiB is the v1 cap (streaming is a follow-up). */ export const IMPORT_MAX_BYTES = 256 * 1024 * 1024; @@ -34,19 +33,6 @@ export async function bufferWithCap(stream: Readable, capBytes: number): Promise }); } -let ocisClient: OcisWebdavClient | null = null; - -/** Drive import: pull one oCIS resource through the service-account WebDAV client. */ -export async function loadDriveBytes(resourceId: string): Promise<{ bytes: Buffer; mime: string }> { - ocisClient ??= new OcisWebdavClient(ocisConfigFromEnv()); - const download = await ocisClient.download(resourceId).catch((error: unknown) => { - if (error instanceof OcisNotFoundError) throw new BadRequestException("no such Drive resource"); - throw new ServiceUnavailableException("Drive is unreachable — try again later"); - }); - const bytes = await bufferWithCap(download.stream, IMPORT_MAX_BYTES); - return { bytes, mime: download.contentType ?? "application/octet-stream" }; -} - export function matrixMediaConfig(): { homeserver: string; token: string } | null { const token = process.env.MATRIX_MEDIA_TOKEN?.trim() ?? ""; if (token === "") return null; diff --git a/apps/api/src/video/video.controller.ts b/apps/api/src/video/video.controller.ts index 01468d2..0b35dc6 100644 --- a/apps/api/src/video/video.controller.ts +++ b/apps/api/src/video/video.controller.ts @@ -38,7 +38,7 @@ import { type ChunkReceipt, type InitChunkedUploadRequest, } from "./chunked-upload.js"; -import { loadDriveBytes, loadMatrixBytes, parseMxcUri } from "./import-sources.js"; +import { loadMatrixBytes, parseMxcUri } from "./import-sources.js"; interface VideoFile { buffer: Buffer; @@ -148,16 +148,6 @@ export class VideoController { return this.video.uploadVideo(session.workspaceId, dto.storageKey, dto.mimeType, dto.sizeBytes); } - @Post("import-drive") - @RequirePermission("media.manage") - async importDrive(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise { - const session = requireSession(request); - const input = requireObject(body, "body"); - const resourceId = requireString(input.resourceId, "resourceId"); - const { bytes, mime } = await loadDriveBytes(resourceId); - return this.video.importVideoBytes(session.workspaceId, bytes, mime, "drive", resourceId); - } - @Post("import-chat") @RequirePermission("media.manage") async importChat(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise { diff --git a/apps/web/src/app/(main)/files/drive/page.tsx b/apps/web/src/app/(main)/files/drive/page.tsx deleted file mode 100644 index 651061f..0000000 --- a/apps/web/src/app/(main)/files/drive/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { EmbeddedApp } from "../../../../components/embed/embedded-app"; -import { useFilesLang } from "../../../../components/files/i18n"; -import { useApiResource } from "../../../../lib/use-api"; -import { useIsMobile } from "../../../../hooks/use-is-mobile"; - -/** - * Drive — the oCIS web UI (personal space, shares, office documents) embedded - * full height under the Files tabs. The group Library tab keeps the Hub-owned - * features (upload into a collective, share with a collective, retention). - * - * Sign-in follows GET /drive/session's authMode: "interactive" means oCIS's - * own identity provider — the user signs in once inside the frame and oCIS - * web keeps its tokens (tokenStorageLocal) on the files origin, which this - * frame shares because app.* and files.* are the same site. "oidc" means oCIS - * and the Hub share an issuer and the frame signs in silently. - */ - -/** Same host FILES_PUBLIC_BASE defaults to; used only while the API is offline. */ -const FALLBACK_DRIVE_URL = "https://files.occupymedia.cc"; - -interface DriveSession { - driveUrl: string | null; - authMode: "interactive" | "oidc"; -} - -const driveView = (session: DriveSession): DriveSession | null => session; - -export default function DrivePage(): React.ReactElement { - const { t } = useFilesLang(); - const drive = useApiResource("/drive/session", driveView, null); - const isMobile = useIsMobile(); - - const driveUrl = drive.source === "sample" ? FALLBACK_DRIVE_URL : drive.data?.driveUrl ?? null; - const notConfigured = drive.source === "live" && drive.error === null && driveUrl === null; - const error = - drive.error ?? - (notConfigured ? t({ en: "The drive is not configured on this server (FILES_DRIVE_URL).", es: "La unidad no está configurada en este servidor (FILES_DRIVE_URL)." }) : null); - const notice = - drive.data?.authMode === "oidc" - ? t({ en: "Collective files live in the Library tab.", es: "Los archivos de los colectivos están en la pestaña Biblioteca." }) - : t({ - en: "Sign in once inside the drive — it stays signed in. Collective files live in the Library tab.", - es: "Inicia sesión una vez dentro de la unidad y quedará abierta. Los archivos de los colectivos están en la pestaña Biblioteca.", - }); - - if (isMobile) { - // Mobile: the oCIS file browser under the bottom tab bar breaks (nested - // scrolling, double chrome) — hand off to the standalone drive instead - // (panel consensus: gateway pattern). The Library tab is fully native. - return ( -
-

{t({ en: "Drive", es: "Unidad" })}

-

{error ?? notice}

- -
- ); - } - - return ( - - ); -} diff --git a/apps/web/src/app/(main)/files/layout.tsx b/apps/web/src/app/(main)/files/layout.tsx index 9df5711..47ec201 100644 --- a/apps/web/src/app/(main)/files/layout.tsx +++ b/apps/web/src/app/(main)/files/layout.tsx @@ -8,7 +8,7 @@ import { FilesTabs } from "../../../components/files/files-tabs"; * Files shell. Like the wiki, the language lives in localStorage per browser * (lang-context explains why it is not an account setting) and is provided here * so every Files screen and sub-component reads it through one context. The tab - * strip switches between the group Library and the embedded oCIS Drive. + * strip switches between the group Library views. */ export default function FilesLayout({ children }: { children: ReactNode }): React.ReactElement { return ( diff --git a/apps/web/src/components/files/files-tabs.tsx b/apps/web/src/components/files/files-tabs.tsx index 41c86bd..26cb301 100644 --- a/apps/web/src/components/files/files-tabs.tsx +++ b/apps/web/src/components/files/files-tabs.tsx @@ -6,20 +6,16 @@ import { useFilesLang, type LocalizedText } from "./i18n"; const TABS: { href: string; label: LocalizedText }[] = [ { href: "/files", label: { en: "Library", es: "Biblioteca" } }, - { href: "/files/drive", label: { en: "Drive", es: "Unidad" } }, ]; -/** - * Files section switcher: the Hub's group library (upload, share with a - * collective, retention) and the embedded oCIS drive side by side. - */ +/** Files section switcher: the Hub's group library (upload, share, retention). */ export function FilesTabs(): React.ReactElement { const pathname = usePathname(); const { t } = useFilesLang(); return (