T-HUB/apps/api/src/group-files/collective-share.ts
glass beadian e7bf870de9 feat: retire oCIS — the hub runs entirely on R2 object storage
Files payloads moved to R2 in 041; this removes the remaining oCIS coupling:
- delete the oCIS WebDAV client; verifyWhileStreaming moves to its own
  storage-agnostic module (verify-stream.ts)
- group-files: storage_key is the only payload source; legacy rows answer
  'retired storage' instead of touching oCIS
- collective share: the ocis-resource item kind is gone (peertube links stay);
  share-into-library was R2-backed since the previous commit
- import-drive endpoint removed (no web caller); import-chat (homeserver) stays
- web: Drive tab + embedded oCIS page removed; Library is the Files view
Legacy e2e rows (2) deleted; every remaining archive row is R2-backed.
2026-10-16 01:32:56 +00:00

340 lines
15 KiB
TypeScript

/**
* "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/<groupId>), 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<string, unknown>;
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<string, unknown>;
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<void>;
/** PUT /_matrix/client/v3/rooms/:id/send/m.room.message/:txnId */
sendRoomMessage(roomId: string, txnId: string, content: Record<string, unknown>): Promise<{ eventId: string }>;
}
/** Unconfigured deployments: 503 with the env contract, mirroring NullProvisioner. */
export class NullCollectiveRoomPoster implements CollectiveRoomPoster {
readonly botUserId = "";
async joinRoom(): Promise<void> {
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");