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
479
apps/api/src/group-files/collective-share.service.ts
Normal file
479
apps/api/src/group-files/collective-share.service.ts
Normal 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)}`;
|
||||
}
|
||||
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");
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue