T-HUB/apps/api/src/virtual-channels/import.service.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

460 lines
20 KiB
TypeScript

import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException, Optional, ServiceUnavailableException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import { isUuid, requireString } from "../http/validation.js";
import { appendTenantAudit } from "../audit/audit.service.js";
import { logger } from "../telemetry/logger.js";
import {
dedupeSyntactic,
IMPORT_ROW_LIMIT,
InvalidRowError,
normalizeImportRow,
parseImportBody,
type ImportRowKind,
type NormalizedRow,
} from "./import-normalize.js";
import { egressFailure, IMPORT_HOST_CLASS, importEgress, type ImportEgress } from "./import-egress.js";
import {
PeerTubeResolveError,
resolvePeerTubeSource,
type ResolvedPeerTubeSource,
} from "../../../../packages/connector-peertube/src/resolve.js";
/** Resolution cache: 10 minutes for live-capable rows, 1 minute otherwise. */
export const RESOLUTION_CACHE_TTL_MS = 10 * 60 * 1000;
export const RESOLUTION_CACHE_SHORT_TTL_MS = 60 * 1000;
/** Per-import concurrency bound; per-host bound comes from the egress budgets. */
export const IMPORT_MAX_CONCURRENCY = 16;
/** Cap on per-entry detail rows stored in the report document. */
export const IMPORT_REPORT_ENTRY_LIMIT = 200;
export type ImportOutcome = "imported" | "duplicate" | "invalid" | "failed";
export interface ImportReportEntry {
raw: string;
key?: string;
remoteRef?: string;
outcome: ImportOutcome;
reason?: string;
message?: string;
}
export interface ImportJobRecord {
jobId: string;
workspaceId: string;
virtualChannelId: string;
connectionId: string | null;
status: "running" | "completed" | "failed" | "abandoned";
format: string;
total: number;
processed: number;
imported: number;
duplicate: number;
invalid: number;
failed: number;
entries: ImportReportEntry[];
error?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
}
/** Injected transport seam: production binds the egress client lazily. */
export type ImportResolver = (kind: ImportRowKind, name: string, host: string, egress: ImportEgress) => Promise<ResolvedPeerTubeSource>;
export interface VirtualChannelImportDependencies {
tenantTransaction: typeof withTenant;
/** Audit sink; defaults to the shared appendTenantAudit. */
audit?: (workspaceId: string, event: { actorUserId: string | null; actorRole: string; action: string; targetType: string; targetId: string; diff?: Record<string, unknown> | null }) => Promise<unknown>;
egress?: () => Promise<ImportEgress>;
resolver?: ImportResolver;
/** Unit seam: deterministic clock + scheduler. */
now?: () => number;
/** In-process resolution cache (host -> key -> resolution + expiry). */
cache?: Map<string, { value: ResolvedPeerTubeSource; expiresAt: number }>;
/** Progress-flush cadence; 0 flushes every row. */
progressEvery?: number;
}
export const VIRTUAL_CHANNEL_IMPORT_DEPENDENCIES = Symbol("VIRTUAL_CHANNEL_IMPORT_DEPENDENCIES");
interface RowResult {
outcome?: ImportOutcome;
reason?: string;
message?: string;
remoteRef?: string;
}
/**
* Bulk import of PeerTube sources into a virtual channel (WP7).
*
* The HTTP request parses + normalizes + syntactically dedupes and creates the
* job row synchronously; resolution and inserts then run in the background
* (see runImportJob). Concurrency is bounded twice: at most 16 resolutions in
* flight per import, and per host by the shared egress budget rows (drawn
* through egressFetch inside the resolver). The DB's UNIQUE
* (workspace_id, virtual_channel_id, remote_ref) is the final dedupe
* authority: INSERT ... ON CONFLICT DO NOTHING RETURNING id, and a conflict is
* counted `duplicate`.
*/
@Injectable()
export class VirtualChannelImportService {
private readonly deps: VirtualChannelImportDependencies;
/** jobId -> cancellation flag for graceful shutdown of running jobs. */
private readonly running = new Map<string, boolean>();
constructor(@Optional() @Inject(VIRTUAL_CHANNEL_IMPORT_DEPENDENCIES) dependencies?: Partial<VirtualChannelImportDependencies>) {
this.deps = {
tenantTransaction: withTenant,
audit: (workspaceId, event) => appendTenantAudit(workspaceId, event),
egress: () => importEgress(),
resolver: async (kind, name, host, egress) =>
resolvePeerTubeSource(kind, name, host, {
fetch: (url, init) => egress.egressFetch(url, init, { hostClass: IMPORT_HOST_CLASS, pinnedOrigin: true }),
}),
now: Date.now,
cache: new Map(),
progressEvery: 25,
...dependencies,
};
}
/** Creates the job and schedules the background run. Returns immediately. */
async createImport(
workspaceId: string,
virtualChannelId: string,
input: { contentType?: string; body: string; connectionId?: string; actor: { userId: string | null; role: string } },
): Promise<ImportJobRecord> {
const virtualChannel = parseId(virtualChannelId, "virtualChannelId");
const connectionId = input.connectionId === undefined || input.connectionId === ""
? undefined
: isUuid(input.connectionId) ? input.connectionId : (() => { throw new BadRequestException("connectionId must be a UUID"); })();
const body = requireString(input.body, "body");
if (body.length > 2_000_000) throw new BadRequestException("body exceeds 2 MB");
const parsed = parseImportBody(input.contentType, body);
if (parsed.rows.length > IMPORT_ROW_LIMIT) {
throw new BulkTooLargeException(parsed.rows.length, IMPORT_ROW_LIMIT);
}
if (parsed.rows.length === 0) throw new BadRequestException("import body contains no rows");
const created = await this.deps.tenantTransaction(workspaceId, async (client) => {
const channel = await client.query("SELECT id FROM virtual_channels WHERE workspace_id = $1 AND id = $2", [workspaceId, virtualChannel]);
if (channel.rowCount !== 1) throw new NotFoundException("virtual channel not found");
// Default host comes from the connection's instance (the only provider
// this slice resolves); no connection means bare handles are invalid.
let defaultHost: string | undefined;
if (connectionId !== undefined) {
const connection = await client.query<{ fields: Record<string, string> }>(
"SELECT fields FROM connections WHERE workspace_id = $1 AND id = $2 AND disconnected_at IS NULL",
[workspaceId, connectionId],
);
if (connection.rowCount !== 1) throw new NotFoundException("connection not found");
const instanceUrl = connection.rows[0]?.fields.instanceUrl;
if (instanceUrl !== undefined) {
defaultHost = new URL(instanceUrl).hostname.toLowerCase();
}
}
const row = await client.query(
`INSERT INTO virtual_channel_imports (workspace_id, virtual_channel_id, connection_id, status, total, created_by)
VALUES ($1, $2, $3, 'running', $4, $5) RETURNING id, created_at`,
[workspaceId, virtualChannel, connectionId ?? null, parsed.rows.length, input.actor.userId],
);
return { jobId: String(row.rows[0]?.id), createdAt: row.rows[0]?.created_at as Date, defaultHost };
});
// Background execution: never awaited by the request. Failures land on the
// job row (status failed + error), not on the caller.
const execution = this.execute(workspaceId, created.jobId, virtualChannel, connectionId, created.defaultHost, parsed.rows, input.actor)
.catch((error: unknown) => logger.error({ err: String(error), jobId: created.jobId }, "virtual channel import crashed"));
this.running.set(created.jobId, true);
void execution;
return {
jobId: created.jobId,
workspaceId,
virtualChannelId: virtualChannel,
connectionId: connectionId ?? null,
status: "running",
format: parsed.format,
total: parsed.rows.length,
processed: 0,
imported: 0,
duplicate: 0,
invalid: 0,
failed: 0,
entries: [],
createdAt: created.createdAt.toISOString(),
updatedAt: created.createdAt.toISOString(),
};
}
/** Job status + report; a stale `running` row is reaped to `abandoned`. */
async getImport(workspaceId: string, jobId: string): Promise<ImportJobRecord> {
if (!isUuid(jobId)) throw new BadRequestException("jobId must be a UUID");
return this.deps.tenantTransaction(workspaceId, async (client) => {
const found = await client.query("SELECT * FROM virtual_channel_imports WHERE workspace_id = $1 AND id = $2", [workspaceId, jobId]);
const row = found.rows[0] as ImportRow | undefined;
if (row === undefined) throw new NotFoundException("import job not found");
if (row.status === "running" && !this.running.has(row.id)
&& Date.now() - row.updated_at.getTime() > 5 * 60_000) {
await client.query(
"UPDATE virtual_channel_imports SET status = 'abandoned', error = 'job runner stopped', updated_at = now() WHERE id = $1 AND status = 'running'",
[row.id],
);
row.status = "abandoned";
}
return toRecord(row);
});
}
private async execute(
workspaceId: string,
jobId: string,
virtualChannelId: string,
connectionId: string | undefined,
defaultHost: string | undefined,
rawRows: string[],
actor: { userId: string | null; role: string },
): Promise<void> {
try {
const egress = await this.loadEgress();
// Stage 1: normalize + syntactic validation (egress policy for URLs, no
// network) and pre-resolution dedupe. Rows still count toward `total`.
const normalized: NormalizedRow[] = [];
const invalid: RowResult[] = [];
for (const raw of rawRows) {
try {
normalized.push(normalizeImportRow(raw, defaultHost, egress.policy));
} catch (error) {
if (error instanceof InvalidRowError) invalid.push({ outcome: "invalid", reason: error.reason, message: error.message });
else invalid.push({ outcome: "failed", reason: "normalize_error", message: "Row could not be processed" });
}
}
// Pre-resolution dedupe on the syntactic key: first occurrence wins,
// every later spelling of the same key counts duplicate.
const { unique } = dedupeSyntactic(normalized);
const syntacticDuplicates: Array<{ row: NormalizedRow }> = [];
const seenKeys = new Set<string>();
for (const row of normalized) {
if (seenKeys.has(row.key)) syntacticDuplicates.push({ row });
else seenKeys.add(row.key);
}
// Stage 2: resolve the unique survivors with bounded concurrency; the
// per-host bound is the egress budget drawn inside egressFetch.
const resolved: Array<{ row: NormalizedRow; result?: ResolvedPeerTubeSource; outcome?: ImportOutcome; reason?: string; message?: string }> = [];
let cursor = 0;
const worker = async (): Promise<void> => {
for (;;) {
const index = cursor++;
if (index >= unique.length) return;
const row = unique[index]!;
const cached = this.cacheGet(row);
if (cached !== undefined) {
resolved[index] = { row, result: cached };
continue;
}
try {
const resolve = this.deps.resolver;
if (resolve === undefined) throw new Error("import resolver is not configured");
const result = await resolve(row.kind, row.name, row.host, egress);
this.cachePut(row, result);
resolved[index] = { row, result };
} catch (error) {
if (error instanceof PeerTubeResolveError) {
resolved[index] = { row, outcome: error.reason === "not_found" ? "invalid" : "failed", reason: error.reason, message: error.message };
} else {
const mapped = egressFailure(error, egress.isEgressError);
resolved[index] = { row, outcome: mapped.reason === "not_found" ? "invalid" : "failed", reason: mapped.reason, message: mapped.message };
}
}
}
};
const lanes = Math.min(IMPORT_MAX_CONCURRENCY, Math.max(1, unique.length));
await Promise.all(Array.from({ length: lanes }, worker));
// Stage 3: post-resolution dedupe on remote_ref, then insert. The DB
// constraint stays authoritative: ON CONFLICT DO NOTHING RETURNING id,
// and a conflict (same remote identity reached two ways) is duplicate.
const report: ImportReportEntry[] = [];
const counters = { imported: 0, duplicate: 0, invalid: 0, failed: 0 };
const push = (entry: ImportReportEntry): void => {
if (report.length < IMPORT_REPORT_ENTRY_LIMIT) report.push(entry);
};
for (const bad of invalid) {
counters[bad.outcome ?? "invalid"] += 1;
push({ raw: "", outcome: bad.outcome ?? "invalid", reason: bad.reason, message: bad.message });
}
for (const dup of syntacticDuplicates) {
counters.duplicate += 1;
push({ raw: dup.row.raw, key: dup.row.key, outcome: "duplicate", reason: "duplicate_input" });
}
const seenRefs = new Set<string>();
const toInsert: Array<{ row: NormalizedRow; result: ResolvedPeerTubeSource }> = [];
for (const entry of resolved) {
if (entry === undefined) continue;
if (entry.outcome !== undefined) {
counters[entry.outcome] += 1;
push({ raw: entry.row.raw, key: entry.row.key, outcome: entry.outcome, reason: entry.reason, message: entry.message });
continue;
}
const result = entry.result!;
if (seenRefs.has(result.remoteRef)) {
counters.duplicate += 1;
push({ raw: entry.row.raw, key: entry.row.key, remoteRef: result.remoteRef, outcome: "duplicate", reason: "duplicate_remote_ref" });
continue;
}
seenRefs.add(result.remoteRef);
toInsert.push({ row: entry.row, result });
}
const inserted = await this.deps.tenantTransaction(workspaceId, async (client) => {
let imported = 0;
let duplicate = 0;
for (const { row, result } of toInsert) {
const label = sanitizeLabel(result.label ?? row.label);
const insert = await client.query(
`INSERT INTO virtual_channel_sources
(workspace_id, virtual_channel_id, kind, label, remote_ref, hls_url, connection_id)
VALUES ($1, $2, 'hls', $3, $4, NULLIF($5, ''), $6)
ON CONFLICT (workspace_id, virtual_channel_id, remote_ref) DO NOTHING
RETURNING id`,
[workspaceId, virtualChannelId, label, result.remoteRef, result.playbackHlsUrl ?? "", connectionId ?? null],
);
if (insert.rowCount === 1) {
imported += 1;
push({ raw: row.raw, key: row.key, remoteRef: result.remoteRef, outcome: "imported" });
} else {
duplicate += 1;
push({ raw: row.raw, key: row.key, remoteRef: result.remoteRef, outcome: "duplicate", reason: "already_imported" });
}
}
return { imported, duplicate };
});
counters.imported += inserted.imported;
counters.duplicate += inserted.duplicate;
const failedCounters = counters;
await this.deps.tenantTransaction(workspaceId, (client) =>
client.query(
`UPDATE virtual_channel_imports
SET status = 'completed', processed = total, imported = $3, duplicate = $4,
invalid = $5, failed = $6, entries = $7, completed_at = now(), updated_at = now()
WHERE workspace_id = $1 AND id = $2`,
[workspaceId, jobId, failedCounters.imported, failedCounters.duplicate, failedCounters.invalid, failedCounters.failed, JSON.stringify(report)],
));
await (this.deps.audit ?? noopAudit)(workspaceId, {
actorUserId: actor.userId, actorRole: actor.role,
action: "virtualchannel.import.completed", targetType: "virtual_channel_import", targetId: jobId,
diff: { channelId: virtualChannelId, connectionId, ...failedCounters },
}).catch(() => undefined);
} catch (error) {
const message = error instanceof Error ? error.message : "import failed";
await this.deps.tenantTransaction(workspaceId, (client) =>
client.query(
`UPDATE virtual_channel_imports SET status = 'failed', error = $3, updated_at = now(), completed_at = now()
WHERE workspace_id = $1 AND id = $2 AND status = 'running'`,
[workspaceId, jobId, message],
)).catch(() => undefined);
await (this.deps.audit ?? noopAudit)(workspaceId, {
actorUserId: actor.userId, actorRole: actor.role,
action: "virtualchannel.import.failed", targetType: "virtual_channel_import", targetId: jobId,
diff: { channelId: virtualChannelId, error: message },
}).catch(() => undefined);
} finally {
this.running.delete(jobId);
}
}
private async loadEgress(): Promise<ImportEgress> {
const load = this.deps.egress;
if (load === undefined) throw new ServiceUnavailableException("import egress is not configured");
return await load();
}
private cacheGet(row: NormalizedRow): ResolvedPeerTubeSource | undefined {
const cache = this.deps.cache;
if (cache === undefined) return undefined;
const entry = cache.get(`${row.host}|${row.key}`);
if (entry === undefined) return undefined;
if (entry.expiresAt <= (this.deps.now?.() ?? Date.now())) {
cache.delete(`${row.host}|${row.key}`);
return undefined;
}
return entry.value;
}
private cachePut(row: NormalizedRow, result: ResolvedPeerTubeSource): void {
const cache = this.deps.cache;
if (cache === undefined) return;
const now = this.deps.now?.() ?? Date.now();
const ttl = row.kind === "live" ? RESOLUTION_CACHE_TTL_MS : RESOLUTION_CACHE_SHORT_TTL_MS;
cache.set(`${row.host}|${row.key}`, { value: result, expiresAt: now + ttl });
}
}
interface ImportRow {
id: string;
workspace_id: string;
status: "running" | "completed" | "failed" | "abandoned";
total: number;
processed: number;
imported: number;
duplicate: number;
invalid: number;
failed: number;
entries: ImportReportEntry[];
error: string | null;
created_at: Date;
updated_at: Date;
completed_at: Date | null;
connection_id: string | null;
virtual_channel_id: string;
}
function toRecord(row: ImportRow): ImportJobRecord {
const record: ImportJobRecord = {
jobId: row.id,
workspaceId: row.workspace_id,
virtualChannelId: row.virtual_channel_id,
connectionId: row.connection_id,
status: row.status,
format: "import",
total: row.total,
processed: row.processed,
imported: row.imported,
duplicate: row.duplicate,
invalid: row.invalid,
failed: row.failed,
entries: Array.isArray(row.entries) ? row.entries : [],
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
if (row.error !== null) record.error = row.error;
if (row.completed_at !== null) record.completedAt = row.completed_at.toISOString();
return record;
}
/** 413 with a machine-readable body (proposal §4.4 cap). */
export class BulkTooLargeException extends Error {
constructor(readonly received: number, readonly limit: number) {
super(`import body has ${received} rows; the limit is ${limit}`);
this.name = "BulkTooLargeException";
}
}
function parseId(value: string, field: string): string {
if (!isUuid(value)) throw new BadRequestException(`${field} must be a UUID`);
return value;
}
/** Label CHECK in 013: alnum first, 64 chars, safe charset only. */
function sanitizeLabel(label: string): string {
const cleaned = label.replace(/[^\w .\-]/g, "").trim().slice(0, 64);
return cleaned === "" ? "imported source" : cleaned;
}
const noopAudit = async (): Promise<void> => undefined;
export type { PoolClient };