/** * "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 = ["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; 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 = 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 === "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");