T-HUB/apps/api/src/group-files/collective-share.service.ts
glass beadian 18afff5e49 feat: Files payloads move from oCIS WebDAV to R2 object storage
The Files tab was the only payload path not on object storage, violating the
2026-10-06 directive (object storage is the ONLY media I/O path) — and oCIS's
WebDAV surface is currently 500ing server-side, which made every upload fail.

Uploads now seal + dedupe into R2 via StorageService (SPENC1, content-
addressed); downloads read R2 when storage_key is set, falling back to oCIS
for legacy rows. Share-into-library copies oCIS Drive sources into R2 and
records the real sha256 instead of a placeholder. Migration 041 adds
chat_media_archive.storage_key.
2026-10-15 22:11:07 +00:00

481 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* "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,
wantsLibrary,
wantsOpenRoom,
type CollectiveRoomPoster,
type CollectiveShareInput,
type CollectiveShareItem,
type CollectiveShareItemResult,
type CollectiveShareResult,
type ShareErrorCode,
} from "./collective-share.js";
import { OcisWebdavClient, ocisConfigFromEnv, type FetchLike } from "./ocis-webdav.js";
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.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; storage?: StorageService },
) {}
private store: StorageService | null = null;
/** R2 object storage — the library payload store (041). */
private storage(): StorageService {
if (this.store === null) {
this.store = this.deps?.storage ?? new S3Storage(s3ConfigFromEnv());
}
return this.store;
}
private libraryUrlFor(resourceId: string): string {
const base = (process.env.FILES_PUBLIC_BASE ?? "").replace(/\/+$/, "");
return base === "" ? resourceId : `${base}/f/${encodeURIComponent(resourceId)}`;
}
/** 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 name = safeFileName(segments[segments.length - 1] ?? "attachment");
// oCIS stays the Drive source; the library destination is R2 (041).
const source = await this.webdav().download(segments.join("/")).catch(() => null);
if (source === null || source.stream === null) {
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 chunks: Buffer[] = [];
for await (const chunk of source.stream as unknown as AsyncIterable<unknown>) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const bytes = Buffer.concat(chunks);
const stored = await this.storage().putContent(bytes, source.contentType ?? "application/octet-stream").catch(() => null);
if (stored === null) {
return {
title: name,
error: {
code: "copy-failed",
message: collective.language === "es"
? "La copia en la biblioteca del colectivo falló."
: "Copying into the collective's library failed.",
},
};
}
const indexed = await this.indexCopy(workspaceId, collective, actor, {
folder,
name,
storageKey: stored.key,
sha256: stored.hash,
sizeBytes: stored.sizeBytes,
contentType: source.contentType ?? "application/octet-stream",
sourcePath: item.path,
senderMxid,
});
if ("error" in indexed) return { title: name, error: indexed.error };
return { title: name, 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;
storageKey: string;
sha256: string;
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.libraryUrlFor(copy.storageKey);
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, storage_key)
VALUES ($1,$2,NULL,NULL,'upload',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now(),$13)
RETURNING id`,
[
workspaceId,
collective.id,
copy.senderMxid,
JSON.stringify(origin),
copy.name,
copy.name,
mime,
mimeClassOf(mime),
copy.sizeBytes,
copy.sha256,
copy.storageKey,
null,
copy.storageKey,
],
);
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)}`;
}