Share with collective: fix union narrowing in open-room result handling
This commit is contained in:
parent
df54cb9b9e
commit
e3a6e9da97
4 changed files with 954 additions and 1 deletions
361
apps/api/src/group-files/collective-share.ts
Normal file
361
apps/api/src/group-files/collective-share.ts
Normal file
|
|
@ -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/<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 = ["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/<FILES_OCID_USER>/ — the hub's
|
||||
* own conventions: `hub-resources/<resourceId>` (a row of the group files
|
||||
* index) or a group folder path `hub-groups/<groupId>/<name>`.
|
||||
*/
|
||||
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<string, unknown>;
|
||||
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<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");
|
||||
Loading…
Add table
Add a link
Reference in a new issue