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.
340 lines
15 KiB
TypeScript
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");
|