POST /video/import-drive {resourceId} pulls through the oCIS WebDAV
service-account client; POST /video/import-chat {mxcUri} pulls from the
Matrix content repository (MATRIX_MEDIA_TOKEN, homeserver falls back to
ARCHIVE_PROVISIONER_HOMESERVER_URL). Both cap at 256 MiB (buffered v1,
streaming follow-up), store through the same encrypted content-
addressed path as uploads, probe, and audit video.imported with
provenance.
85 lines
4.2 KiB
TypeScript
85 lines
4.2 KiB
TypeScript
import { Readable } from "node:stream";
|
|
import { BadRequestException, ServiceUnavailableException } from "@nestjs/common";
|
|
import { OcisNotFoundError, OcisWebdavClient, ocisConfigFromEnv } from "../group-files/ocis-webdav.js";
|
|
/** Imports buffer the whole file in memory; 256 MiB is the v1 cap (streaming is a follow-up). */
|
|
export const IMPORT_MAX_BYTES = 256 * 1024 * 1024;
|
|
|
|
export interface MxcUri {
|
|
server: string;
|
|
mediaId: string;
|
|
}
|
|
|
|
/** Strict mxc://server/mediaId (v3 media ids are opaque non-slash segments). */
|
|
export function parseMxcUri(raw: string): MxcUri {
|
|
const match = /^mxc:\/\/([^/\s]+)\/([A-Za-z0-9_-]+)$/.exec(raw.trim());
|
|
if (match === null) throw new BadRequestException("mxcUri must look like mxc://server/mediaId");
|
|
return { server: match[1]!, mediaId: match[2]! };
|
|
}
|
|
|
|
export async function bufferWithCap(stream: Readable, capBytes: number): Promise<Buffer> {
|
|
return new Promise<Buffer>((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
stream.on("data", (chunk: Buffer) => {
|
|
total += chunk.length;
|
|
if (total > capBytes) {
|
|
stream.destroy();
|
|
reject(new BadRequestException(`file exceeds the ${Math.round(capBytes / (1024 * 1024))} MiB import cap`));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
stream.on("error", reject);
|
|
});
|
|
}
|
|
|
|
let ocisClient: OcisWebdavClient | null = null;
|
|
|
|
/** Drive import: pull one oCIS resource through the service-account WebDAV client. */
|
|
export async function loadDriveBytes(resourceId: string): Promise<{ bytes: Buffer; mime: string }> {
|
|
ocisClient ??= new OcisWebdavClient(ocisConfigFromEnv());
|
|
const download = await ocisClient.download(resourceId).catch((error: unknown) => {
|
|
if (error instanceof OcisNotFoundError) throw new BadRequestException("no such Drive resource");
|
|
throw new ServiceUnavailableException("Drive is unreachable — try again later");
|
|
});
|
|
const bytes = await bufferWithCap(download.stream, IMPORT_MAX_BYTES);
|
|
return { bytes, mime: download.contentType ?? "application/octet-stream" };
|
|
}
|
|
|
|
export function matrixMediaConfig(): { homeserver: string; token: string } | null {
|
|
const token = process.env.MATRIX_MEDIA_TOKEN?.trim() ?? "";
|
|
if (token === "") return null;
|
|
const homeserver = (process.env.MATRIX_MEDIA_HOMESERVER_URL ?? process.env.ARCHIVE_PROVISIONER_HOMESERVER_URL ?? "").replace(/\/+$/, "");
|
|
if (homeserver === "") return null;
|
|
return { homeserver, token };
|
|
}
|
|
|
|
/** Chat import: pull one MXC from the homeserver's authenticated media repository. */
|
|
export async function loadMatrixBytes(mxc: MxcUri): Promise<{ bytes: Buffer; mime: string }> {
|
|
const config = matrixMediaConfig();
|
|
if (config === null) throw new ServiceUnavailableException("chat media import is not configured: set MATRIX_MEDIA_TOKEN");
|
|
const encode = (value: string) => encodeURIComponent(value);
|
|
const paths = [
|
|
`/_matrix/client/v1/media/download/${encode(mxc.server)}/${encode(mxc.mediaId)}`,
|
|
`/_matrix/client/v3/media/download/${encode(mxc.server)}/${encode(mxc.mediaId)}`,
|
|
];
|
|
let lastStatus = 0;
|
|
for (const path of paths) {
|
|
const res = await fetch(`${config.homeserver}${path}`, {
|
|
headers: { authorization: `Bearer ${config.token}`, accept: "application/octet-stream" },
|
|
redirect: "error",
|
|
}).catch(() => null);
|
|
if (res === null) throw new ServiceUnavailableException("chat homeserver unreachable — try again later");
|
|
if (res.status === 404 && path.startsWith("/_matrix/client/v1/")) { lastStatus = 404; continue; }
|
|
if (!res.ok) {
|
|
lastStatus = res.status;
|
|
if (res.status === 404) throw new BadRequestException("no such chat media (or it has expired)");
|
|
throw new ServiceUnavailableException(`chat media download failed (HTTP ${String(lastStatus)})`);
|
|
}
|
|
if (res.body === null) throw new ServiceUnavailableException("chat media download returned an empty body");
|
|
const bytes = await bufferWithCap(Readable.fromWeb(res.body as import("node:stream/web").ReadableStream), IMPORT_MAX_BYTES);
|
|
return { bytes, mime: res.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream" };
|
|
}
|
|
throw new BadRequestException("no such chat media (or it has expired)");
|
|
}
|