From e3a6e9da9792d917b0af70f31dbb2b9a81706511 Mon Sep 17 00:00:00 2001 From: glass beadian Date: Mon, 12 Oct 2026 22:35:32 +0000 Subject: [PATCH] Share with collective: fix union narrowing in open-room result handling --- .../group-files/collective-share.service.ts | 479 ++++++++++++++++++ apps/api/src/group-files/collective-share.ts | 361 +++++++++++++ apps/api/src/group-files/ocis-webdav.ts | 113 +++++ infra/occupymedia/edge/nginx.conf | 2 +- 4 files changed, 954 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/group-files/collective-share.service.ts create mode 100644 apps/api/src/group-files/collective-share.ts diff --git a/apps/api/src/group-files/collective-share.service.ts b/apps/api/src/group-files/collective-share.service.ts new file mode 100644 index 0000000..fc581a4 --- /dev/null +++ b/apps/api/src/group-files/collective-share.service.ts @@ -0,0 +1,479 @@ +/** + * "Share with collective" service — the member-gated push of a caller's own + * content into a collective's library (oCIS group folder + chat_media_archive + * + group_file_shares) and/or its OPEN room (provisioner bot post). + * + * ACL ordering follows the getFile pattern exactly, so the failure a stranger + * sees is indistinguishable from any other unknown collective: + * + * 1. resolve the collective row (workspace-scoped; absent => NotFound) + * 2. member gate: group_members row for (collective, caller) — non-members + * (and non-Owner/Admin) get Forbidden, BEFORE any room identity or + * folder name is disclosed + * 3. only then read group_rooms (which room is 'open' vs 'closed') and + * perform the copy / post + * + * Every item is settled independently: one bad path never blocks the rest, and + * the response carries a per-item result the UI renders row by row. + */ +import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional } from "@nestjs/common"; +import { safeFileName } from "@tactical-media-broadcast-hub/core"; +import type { ManualLanguage } from "@tactical-media-broadcast-hub/core"; +import type { PoolClient } from "pg"; +import { withTenant } from "../db/client.js"; +import { isUuid } from "../http/validation.js"; +import { decodeLanguage } from "../collectives/collectives.service.js"; +import { appendTenantAudit } from "../audit/audit.service.js"; +import { + COLLECTIVE_ROOM_POSTER, + davSegments, + e2eeRefusalMessage, + mimeClassOf, + noLibraryTargetMessage, + openRoomNoticeContent, + suffixedName, + wantsLibrary, + wantsOpenRoom, + MAX_COLLISION_ATTEMPTS, + type CollectiveRoomPoster, + type CollectiveShareInput, + type CollectiveShareItem, + type CollectiveShareItemResult, + type CollectiveShareResult, + type ShareErrorCode, +} from "./collective-share.js"; +import { OcisNotFoundError, OcisPreconditionFailedError, OcisWebdavClient, ocisConfigFromEnv, type FetchLike } from "./ocis-webdav.js"; + +interface CollectiveRow { + id: string; + name: string; + description: string | null; + kind: string; +} + +interface RoomRow { + room_type: string; + room_id: string; +} + +/** The ACL-checked slice of a collective this service acts on. */ +interface ResolvedCollective { + id: string; + name: string; + language: ManualLanguage; + openRoomId: string | null; +} + +export const COLLECTIVE_SHARE_DEPENDENCIES: unique symbol = Symbol("COLLECTIVE_SHARE_DEPENDENCIES"); + +@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 }, + ) {} + + /** 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) { + // Distinguishable at a glance in a stack trace: a module wiring bug, not a runtime condition. + throw new NotFoundException("collective room poster is not wired"); + } + return this.poster; + } + + /** + * ACL + room resolution (steps 1–2 of the ordering above). Strangers and + * non-members learn nothing beyond "no such collective". + */ + private async resolveCollective(workspaceId: string, collectiveId: string, userId: string, role: string): Promise { + if (!isUuid(collectiveId)) throw new BadRequestException("collectiveId must be a UUID"); + const { row, rooms, isMember } = await withTenant(workspaceId, async (client) => { + const groupResult = await client.query( + "SELECT id, name, description, kind FROM groups WHERE id = $1 AND kind IN ('collective','subcollective')", + [collectiveId], + ); + const group = groupResult.rows[0]; + const memberResult = await client.query( + "SELECT 1 FROM group_members WHERE group_id = $1 AND user_id = $2 LIMIT 1", + [collectiveId, userId], + ); + const roomResult = group === undefined + ? { rows: [] as RoomRow[] } + : await client.query("SELECT room_type, room_id FROM group_rooms WHERE group_id = $1", [collectiveId]); + return { row: group, rooms: roomResult.rows, isMember: (memberResult.rowCount ?? 0) > 0 }; + }); + // Ordering: 404 first (the row must exist AND belong to this workspace's + // tree — RLS has already filtered other tenants), then the member gate. + if (row === undefined) throw new NotFoundException(`collective ${collectiveId} not found`); + if (!isMember && !WORKSPACE_WIDE_ROLES.includes(role)) { + throw new ForbiddenException("you are not a member of this collective"); + } + const openRoom = rooms.find((room) => room.room_type === "open"); + return { + id: row.id, + name: row.name, + language: decodeLanguage(row.description), + openRoomId: openRoom === undefined ? null : openRoom.room_id, + }; + } + + /** + * Share items into a collective. Library copies are indexed + linked per + * item; open-room notices are posted per item; 'both' links the notice to + * the library copy that was just made. + */ + async shareWithCollective( + workspaceId: string, + collectiveId: string, + actor: { userId: string; role: string; username: string }, + input: CollectiveShareInput, + ): Promise { + const collective = await this.resolveCollective(workspaceId, collectiveId, actor.userId, actor.role); + const doLibrary = wantsLibrary(input.target); + const doRoom = wantsOpenRoom(input.target); + + if (doRoom && collective.openRoomId === null) { + // A collective without an open room cannot receive any bot post; this is + // a whole-request refusal, not per-item noise. + throw new BadRequestException( + collective.language === "es" + ? "Este colectivo no tiene sala abierta configurada." + : "This collective has no open room configured.", + ); + } + + 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)); + } + + await appendTenantAudit(workspaceId, { + actorUserId: actor.userId, + actorRole: actor.role, + action: "collective.shared", + targetType: "group", + targetId: collective.id, + // Names + outcome counts only: no paths, no room ids, no note text. + diff: { + target: input.target, + collective: collective.name, + items: results.length, + libraryCopies: results.filter((r) => r.library !== null).length, + openRoomPosts: results.filter((r) => r.openRoom !== null).length, + }, + }); + + return { collectiveId: collective.id, collectiveName: collective.name, target: input.target, items: results }; + } + + private async shareOne( + workspaceId: string, + collective: ResolvedCollective, + actor: { userId: string; role: string }, + 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. + return { + index, + kind: item.kind, + title: item.title, + library: null, + openRoom: null, + error: { code: "no-library-target", message: noLibraryTargetMessage(collective.language) }, + }; + } + + 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" }); + 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 }; + } + return { + index, + kind: item.kind, + title: item.kind === "peertube-video" ? item.title : library?.name ?? titleFromPath(item.path), + library, + 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 baseName = safeFileName(segments[segments.length - 1] ?? "attachment"); + let copied: Awaited> | null = null; + let finalName = baseName; + for (let attempt = 0; attempt <= MAX_COLLISION_ATTEMPTS; attempt += 1) { + const candidate = suffixedName(baseName, attempt); + try { + copied = await this.webdav().copyIntoGroup(collective.id, segments, folder, candidate); + finalName = candidate; + break; + } catch (err) { + if (err instanceof OcisPreconditionFailedError) continue; // try the next suffix + if (err instanceof OcisNotFoundError) { + 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}).`, + }, + }; + } + return { + title: titleFromPath(item.path), + error: { + code: "copy-failed", + message: collective.language === "es" + ? "La copia en la biblioteca del colectivo falló." + : "Copying into the collective's library failed.", + }, + }; + } + } + if (copied === null) { + return { + title: titleFromPath(item.path), + error: { + code: "copy-failed", + message: collective.language === "es" + ? `Ya existe un archivo llamado ${baseName} (y hasta 100 variantes); renómbralo primero.` + : `A file named ${baseName} already exists (plus 100 variants); rename it first.`, + }, + }; + } + + const indexed = await this.indexCopy(workspaceId, collective, actor, { + folder, + name: finalName, + resourceId: copied.resourceId, + version: copied.version, + sizeBytes: copied.sizeBytes, + contentType: copied.contentType, + sourcePath: item.path, + senderMxid, + }); + if ("error" in indexed) return { title: finalName, error: indexed.error }; + return { title: finalName, 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; + resourceId: string; + version: string | null; + 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.webdav().libraryUrl(copy.resourceId); + 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) + VALUES ($1,$2,NULL,NULL,'upload',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now()) + RETURNING id`, + [ + workspaceId, + collective.id, + copy.senderMxid, + JSON.stringify(origin), + copy.name, + copy.name, + mime, + mimeClassOf(mime), + copy.sizeBytes, + digestPlaceholder(copy.resourceId), + copy.resourceId, + copy.version, + ], + ); + 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 + * before any Matrix call: this service is only ever handed the OPEN room + * id, and the refusal message is the domain text the UI shows verbatim. + */ + private async postToOpenRoom( + collective: ResolvedCollective, + notice: { title: string; url: string; note: string | null; linkKind: "library" | "video" }, + ): Promise<{ result: { roomId: string; eventId: string } } | { error: { code: ShareErrorCode; message: string } }> { + if (collective.openRoomId === null) { + // Only ever reached for a collective whose room set is broken; the + // E2EE-shaped refusal text is the clearest honest answer. + return { error: { code: "room-missing", message: e2eeRefusalMessage(collective.language) } }; + } + const content = openRoomNoticeContent({ + language: collective.language, + title: notice.title, + url: notice.url, + note: notice.note, + linkKind: notice.linkKind, + }); + const poster = this.roomPoster(); + const txnId = `hub-share-${collective.id}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + try { + await poster.joinRoom(collective.openRoomId); + const sent = await poster.sendRoomMessage(collective.openRoomId, txnId, content); + return { result: { roomId: collective.openRoomId, eventId: sent.eventId } }; + } catch { + return { + error: { + code: "matrix-unavailable", + message: collective.language === "es" + ? "No se pudo publicar en la sala abierta del colectivo." + : "Posting into the collective's open room failed.", + }, + }; + } + } + + /** The collectives a caller may share into: members of, workspace-wide for Owner/Admin. */ + async myCollectives(workspaceId: string, userId: string, role: string): Promise> { + return withTenant(workspaceId, async (client) => { + if (WORKSPACE_WIDE_ROLES.includes(role)) { + const all = await client.query( + "SELECT id, name FROM groups WHERE kind IN ('collective','subcollective') ORDER BY name ASC", + ); + return all.rows.map((row) => ({ id: row.id, name: row.name })); + } + const mine = await client.query( + `SELECT g.id, g.name FROM groups g + WHERE g.kind IN ('collective','subcollective') + AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = g.id AND gm.user_id = $1) + ORDER BY g.name ASC`, + [userId], + ); + return mine.rows.map((row) => ({ id: row.id, name: row.name })); + }); + } +} + +const WORKSPACE_WIDE_ROLES: readonly string[] = ["Owner", "Admin"]; + +/** Last path segment as a display title. */ +function titleFromPath(path: string): string { + const segment = path.split("/").filter((part) => part !== "").at(-1) ?? path; + return segment; +} + +/** + * A server-side COPY never streams bytes through the API, so no digest is + * computed here. The column is NOT NULL, so the row stores a deterministic + * marker derived from the stable resource id — visibly not a payload digest + * (it is not 64 lowercase hex) and never presented as one. + */ +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 new file mode 100644 index 0000000..ec70dbd --- /dev/null +++ b/apps/api/src/group-files/collective-share.ts @@ -0,0 +1,361 @@ +/** + * "Share with collective" — a member pushing their OWN content into a + * collective they belong to: + * + * * library → WebDAV COPY of an oCIS resource into the collective's group + * folder (hub-groups/), indexed in chat_media_archive + * + one group_file_shares row so it surfaces in the group's + * files index with a members library link; + * * open-room→ one m.text notice posted by the provisioner bot into the + * collective's OPEN room (join-if-not-in-room first); + * * both → the copy, then the notice carrying the library link. + * + * Domain invariants owned here: + * * Only the OPEN room is ever posted into. A collective's closed room is + * born E2EE (m.megolm.v1.aes-sha2 at creation) and can NEVER receive a bot + * post — that refusal is a first-class domain error (E2EE_ROOM code) the + * UI shows verbatim, never a silent skip. + * * Source dav paths are caller-supplied, so they are reduced to sanitized + * relative segments before they ever touch a URL ('..'/'.'/absolute/backslash + * rejected) — no traversal into another identity's space by name. + * * Collision policy on COPY: stem-1, stem-2 … (never overwrite; the client + * sends Overwrite: F and a 412 race retries with the next suffix). + * + * No new migrations: chat_media_archive + group_file_shares (029) and + * group_rooms (030) already carry everything this surface writes. + */ +import { BadRequestException, ServiceUnavailableException } from "@nestjs/common"; +import type { ManualLanguage, MimeClass } from "@tactical-media-broadcast-hub/core"; +import type { MimeClass as ValidationMimeClass } from "./group-files.validation.js"; + +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 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). */ + readonly url: string; + readonly title: string; +} + +export type CollectiveShareItem = OcisResourceItem | PeertubeVideoItem; + +export interface CollectiveShareInput { + readonly target: ShareTarget; + readonly items: readonly CollectiveShareItem[]; + readonly note: string | null; + /** Sanitized folder segments relative to the collective's group folder. */ + readonly folder: readonly string[]; +} + +export function wantsLibrary(target: ShareTarget): boolean { + return target === "library" || target === "both"; +} + +export function wantsOpenRoom(target: ShareTarget): boolean { + return target === "open-room" || target === "both"; +} + +// --------------------------------------------------------------------------- +// dav path sanitizing — the only way a caller-supplied path reaches a URL +// --------------------------------------------------------------------------- + +const DAV_PATH_MAX = 1024; +const DAV_SEGMENT_MAX = 255; + +/** + * Relative dav path → sanitized segments. Rejects absolute paths, empty / + * '.' / '..' segments, backslashes and control characters: a share can only + * name resources inside the hub's own WebDAV root, never traverse out of it. + */ +export function davSegments(raw: string, field: string): string[] { + if (typeof raw !== "string") throw new BadRequestException(`${field} must be a string`); + const value = raw.trim(); + if (value === "") throw new BadRequestException(`${field} must not be empty`); + if (value.length > DAV_PATH_MAX) throw new BadRequestException(`${field} is too long (max ${DAV_PATH_MAX} characters)`); + if (value.includes("\\")) throw new BadRequestException(`${field} must use '/' as the path separator`); + const segments: string[] = []; + for (const segment of value.split("/")) { + if (segment === "" || segment === "." || segment === "..") { + throw new BadRequestException(`${field} must be a relative path with no '.' or '..' segments`); + } + if (segment.length > DAV_SEGMENT_MAX || /[\p{Cc}\p{Cf}]/u.test(segment)) { + throw new BadRequestException(`${field} contains an invalid path segment`); + } + segments.push(segment); + } + return segments; +} + +/** PeerTube canonical video paths (short or long UUIDs both resolve). */ +const PEERTUBE_VIDEO_PATH = /^\/(?:w|videos\/watch|videos\/embed)\/[A-Za-z0-9][A-Za-z0-9-]*\/?$/; + +/** Accepts only canonical PeerTube video URLs — no query, no fragment, no proxy paths. */ +export function parsePeertubeVideoUrl(raw: string, field = "url"): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new BadRequestException(`${field} must be an absolute URL`); + } + if (url.protocol !== "https:" && url.protocol !== "http:") throw new BadRequestException(`${field} must be an http(s) URL`); + if (url.username !== "" || url.password !== "") throw new BadRequestException(`${field} must not carry credentials`); + if (url.search !== "" || url.hash !== "") { + throw new BadRequestException(`${field} must be the canonical video URL without query or fragment`); + } + if (!PEERTUBE_VIDEO_PATH.test(url.pathname)) { + throw new BadRequestException(`${field} must be a PeerTube video URL (/w/:id, /videos/watch/:id or /videos/embed/:id)`); + } + return `${url.protocol}//${url.host}${url.pathname}`; +} + +// --------------------------------------------------------------------------- +// request parsing +// --------------------------------------------------------------------------- + +function boundedText(value: unknown, field: string, max: number): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") throw new BadRequestException(`${field} must be a string`); + const clean = value.trim(); + if (clean === "") return null; + if (clean.length > max) throw new BadRequestException(`${field} must be at most ${max} characters`); + return clean; +} + +function parseItem(value: unknown, index: number): CollectiveShareItem { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new BadRequestException(`items[${index}] must be an object`); + } + 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`); + return { kind, url: parsePeertubeVideoUrl(String(record.url ?? ""), `items[${index}].url`), title }; + } + throw new BadRequestException(`items[${index}].kind must be one of: ${SHARE_ITEM_KINDS.join(", ")}`); +} + +/** Body → typed input. Shape errors are BadRequest before any ACL or I/O runs. */ +export function parseCollectiveShareInput(body: unknown): CollectiveShareInput { + if (typeof body !== "object" || body === null || Array.isArray(body)) throw new BadRequestException("body must be an object"); + const record = body as Record; + const target = record.target; + if (typeof target !== "string" || !(SHARE_TARGETS as readonly string[]).includes(target)) { + throw new BadRequestException(`target must be one of: ${SHARE_TARGETS.join(", ")}`); + } + if (!Array.isArray(record.items) || record.items.length === 0) { + throw new BadRequestException("items must be a non-empty array"); + } + if (record.items.length > MAX_SHARE_ITEMS) { + throw new BadRequestException(`items must hold at most ${MAX_SHARE_ITEMS} entries`); + } + const items = record.items.map((value, index) => parseItem(value, index)); + const folder = record.folder === undefined || record.folder === null ? [] : davSegments(String(record.folder), "folder"); + return { target: target as ShareTarget, items, note: boundedText(record.note, "note", MAX_NOTE_LENGTH), folder }; +} + +// --------------------------------------------------------------------------- +// collision naming +// --------------------------------------------------------------------------- + +/** + * `clip.mp4` → `clip.mp4`, `clip-1.mp4`, `clip-2.mp4` … (extension preserved, + * mirroring core's safeFileName stem/ext split). attempt 0 is the bare name. + */ +export function suffixedName(name: string, attempt: number): string { + if (attempt <= 0) return name; + const dot = name.lastIndexOf("."); + const stem = dot > 0 ? name.slice(0, dot) : name; + const extension = dot > 0 ? name.slice(dot) : ""; + return `${stem}-${attempt}${extension}`; +} + +// --------------------------------------------------------------------------- +// open-room notice (m.text, never a reply relation) +// --------------------------------------------------------------------------- + +export type ShareLinkKind = "library" | "video"; + +export interface OpenRoomNoticeInput { + language: ManualLanguage; + title: string; + url: string; + note: string | null; + /** What the link points at: the collective's library copy, or the video itself. */ + linkKind: ShareLinkKind; +} + +/** + * The exact m.room.message content posted into the OPEN room: plain m.text + * (title + URL + optional note). Deliberately no `m.relates_to` — a share is a + * new message, not a reply to anything. + */ +export function openRoomNoticeContent(input: OpenRoomNoticeInput): { msgtype: "m.text"; body: string } { + return { msgtype: "m.text", body: openRoomNoticeBody(input) }; +} + +export function openRoomNoticeBody(input: OpenRoomNoticeInput): string { + const es = input.language === "es"; + const header = + input.linkKind === "library" + ? es + ? `[Nuevo en la biblioteca] ${input.title}` + : `[New in the library] ${input.title}` + : es + ? `[Vídeo] ${input.title}` + : `[Video] ${input.title}`; + const lines = [header, input.url]; + if (input.note !== null) lines.push(es ? `Nota: ${input.note}` : `Note: ${input.note}`); + return lines.join("\n"); +} + +/** + * The E2EE refusal, in the collective's own language. A closed room is + * encrypted from creation and structurally cannot receive a bot post; this is + * shown to the member as-is instead of a generic failure. + */ +export function e2eeRefusalMessage(language: ManualLanguage): string { + return language === "es" + ? "La sala cerrada del colectivo está cifrada de extremo a extremo desde su creación, así que ninguna cuenta bot puede publicar en ella. Comparte en la sala abierta o en la biblioteca." + : "The collective's closed room is end-to-end encrypted from creation, so no bot account can post into it. Share to the open room or the library instead."; +} + +/** A PeerTube video has no library bytes to copy — library-only targets are refused per item. */ +export function noLibraryTargetMessage(language: ManualLanguage): string { + return language === "es" + ? "Un vídeo de PeerTube no se copia a la biblioteca: compártelo en la sala abierta." + : "A PeerTube video is not copied into the library: share it to the open room."; +} + +// --------------------------------------------------------------------------- +// MIME class (a COPY never re-reads magic bytes, so the class comes from the +// stored Content-Type reported by WebDAV) +// --------------------------------------------------------------------------- + +const DOCUMENT_MIMES = new Set([ + "application/pdf", + "application/json", + "application/rtf", + "application/msword", + "application/vnd.oasis.opendocument.text", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +]); + +const ARCHIVE_MIMES = new Set([ + "application/zip", + "application/gzip", + "application/x-tar", + "application/x-7z-compressed", + "application/x-rar-compressed", + "application/vnd.rar", +]); + +export function mimeClassOf(contentType: string | null): MimeClass | ValidationMimeClass { + const mime = (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? ""; + if (mime === "") return "other"; + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + if (mime.startsWith("text/") || DOCUMENT_MIMES.has(mime)) return "document"; + if (ARCHIVE_MIMES.has(mime)) return "archive"; + return "other"; +} + +// --------------------------------------------------------------------------- +// per-item results (the UI shows these one by one) +// --------------------------------------------------------------------------- + +export const SHARE_ERROR_CODES = [ + "e2ee-room", + "room-missing", + "source-not-found", + "no-library-target", + "copy-failed", + "storage-unconfigured", + "matrix-unavailable", + "index-failed", +] as const; +export type ShareErrorCode = (typeof SHARE_ERROR_CODES)[number]; + +export interface CollectiveShareItemResult { + /** Position in the request items array, so the UI can line results up with inputs. */ + index: number; + kind: ShareItemKind; + title: string; + library: { itemId: string; name: string; url: string } | null; + openRoom: { roomId: string; eventId: string } | null; + error: { code: ShareErrorCode; message: string } | null; +} + +export interface CollectiveShareResult { + collectiveId: string; + collectiveName: string; + target: ShareTarget; + items: CollectiveShareItemResult[]; +} + +// --------------------------------------------------------------------------- +// provisioner seam — the room-posting half of MatrixProvisioner +// --------------------------------------------------------------------------- + +/** + * The narrow posting surface this feature needs from the provisioner account. + * MatrixProvisioner implements it structurally (botUserId + joinRoom + + * sendRoomMessage); tests swap a fake. The bot is crypto-blind by construction + * and therefore can only ever be pointed at the OPEN room. + */ +export interface CollectiveRoomPoster { + readonly botUserId: string; + /** POST /_matrix/client/v3/join/:roomId — idempotent when already joined. */ + joinRoom(roomId: string): Promise; + /** PUT /_matrix/client/v3/rooms/:id/send/m.room.message/:txnId */ + sendRoomMessage(roomId: string, txnId: string, content: Record): Promise<{ eventId: string }>; +} + +/** Unconfigured deployments: 503 with the env contract, mirroring NullProvisioner. */ +export class NullCollectiveRoomPoster implements CollectiveRoomPoster { + readonly botUserId = ""; + + async joinRoom(): Promise { + throw new ServiceUnavailableException( + "collective rooms are not configured: set ARCHIVE_PROVISIONER_HOMESERVER_URL, ARCHIVE_PROVISIONER_USER and ARCHIVE_PROVISIONER_PASSWORD", + ); + } + + async sendRoomMessage(): Promise<{ eventId: string }> { + throw new ServiceUnavailableException( + "collective rooms are not configured: set ARCHIVE_PROVISIONER_HOMESERVER_URL, ARCHIVE_PROVISIONER_USER and ARCHIVE_PROVISIONER_PASSWORD", + ); + } +} + +export const COLLECTIVE_ROOM_POSTER: unique symbol = Symbol("COLLECTIVE_ROOM_POSTER"); diff --git a/apps/api/src/group-files/ocis-webdav.ts b/apps/api/src/group-files/ocis-webdav.ts index 46f452d..65c75ef 100644 --- a/apps/api/src/group-files/ocis-webdav.ts +++ b/apps/api/src/group-files/ocis-webdav.ts @@ -7,6 +7,12 @@ * 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): * @@ -66,6 +72,15 @@ export interface OcisDownload { 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}`); @@ -84,6 +99,26 @@ 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. */ @@ -102,6 +137,11 @@ export class OcisWebdavClient { 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 url = `${this.config.webdavUrl}/remote.php/dav/files/${encodeURIComponent(this.config.user)}/hub-groups/${encodeURIComponent(groupId)}`; @@ -110,6 +150,14 @@ export class OcisWebdavClient { 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}` }; @@ -228,12 +276,77 @@ export class OcisWebdavClient { }; } + /** + * 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 diff --git a/infra/occupymedia/edge/nginx.conf b/infra/occupymedia/edge/nginx.conf index 2c595a5..f357c8e 100644 --- a/infra/occupymedia/edge/nginx.conf +++ b/infra/occupymedia/edge/nginx.conf @@ -192,7 +192,7 @@ http { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto http; + proxy_set_header X-Forwarded-Proto https; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s;