T-HUB/apps/api/src/group-files/collective-share.service.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

282 lines
11 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";
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 {
constructor(
@Optional() @Inject(COLLECTIVE_ROOM_POSTER) private readonly poster?: CollectiveRoomPoster,
) {}
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 results: CollectiveShareItemResult[] = [];
for (const [index, item] of input.items.entries()) {
results.push(await this.shareOne(workspaceId, collective, actor, input, item, index, 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,
doLibrary: boolean,
doRoom: boolean,
): Promise<CollectiveShareItemResult> {
if (doLibrary && !doRoom) {
// A video link 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 openRoom: CollectiveShareItemResult["openRoom"] = null;
let failure: { code: ShareErrorCode; message: string } | null = null;
if (doRoom) {
const posted = await this.postToOpenRoom(collective, { title: item.title, url: item.url, note: input.note, linkKind: "video" });
if ("error" in posted) failure = posted.error;
else openRoom = posted.result;
}
if (failure !== null) {
return { index, kind: item.kind, title: item.title, library: null, openRoom: null, error: failure };
}
return { index, kind: item.kind, title: item.title, library: null, openRoom, error: failure };
}
/**
* 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}`;
}