Share with collective: fix union narrowing in open-room result handling

This commit is contained in:
glass beadian 2026-10-12 22:35:32 +00:00
parent df54cb9b9e
commit e3a6e9da97
4 changed files with 954 additions and 1 deletions

View file

@ -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<ResolvedCollective> {
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<CollectiveRow>(
"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<RoomRow>("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<CollectiveShareResult> {
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<CollectiveShareItemResult> {
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<ReturnType<OcisWebdavClient["copyIntoGroup"]>> | 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<Array<{ id: string; name: string }>> {
return withTenant(workspaceId, async (client) => {
if (WORKSPACE_WIDE_ROLES.includes(role)) {
const all = await client.query<CollectiveRow>(
"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<CollectiveRow>(
`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)}`;
}

View 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");

View file

@ -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<void> {
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<void> {
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<string, string> {
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 = `<?xml version="1.0" encoding="utf-8"?>\n<d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getcontenttype/></d:prop></d:propfind>`;
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<OcisCopyResult> {
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 <tag>…</tag> 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[^>]*)?>([^<]*)</(?:[\\w.-]+:)?${tag}>`, "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

View file

@ -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;