Add group cloud: files index api, web UI, archive sync bot
The Telegram-like group file contract (packages/core shared-files.ts) becomes a working subsystem, built in three parallel tracks: API (NestJS): migration 029 group_files (chat_media_archive metadata-only index with partial replay-unique + RLS, group_file_shares with sha256 token persistence, archive_sync_state/outbox durable-claim seam incl. a unique claim_key index), group-files module (list/search with cursor pagination, direct upload ≤20 MiB streamed+hashed to oCIS WebDAV, download with ACL + retention rechecks, share links with expiry, retention modes incl. legalHold) — RBAC group-membership authz + audit chain events. Web: Files section per group — group picker, drag-drop upload (20 MiB cap), search + MIME/origin filters, cursor pagination, download via authenticated blob fetch, share dialog with expiry + shown-once token, admin retention dialog; bilingual EN/ES (wiki i18n pattern); nav sidebar/palette/bottom-nav wiring; MIME classes aligned to the DB CHECK (document/other). Archive bot (apps/archive-bot): plain sync-loop Matrix bot mirroring attachments from explicitly-mapped unencrypted archive rooms — raw /sync with durable since-cursor, outbox-claimed ingestion (hash-verified stream, no temp files), magic-byte MIME sniffing, safe-name normalization, WebDAV upload, indexed + replied with link, redaction→unavailable; health endpoint; Dockerfile. Tests 74/74, typecheck clean across api/web/bot.
This commit is contained in:
parent
eb1b60f67d
commit
d58db63fe6
58 changed files with 5543 additions and 4 deletions
110
apps/archive-bot/src/config.ts
Normal file
110
apps/archive-bot/src/config.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { GroupFileMapping } from "@tactical-media-broadcast-hub/core";
|
||||
|
||||
/** Explicit, immutable room → library mapping. Only rooms listed here are ever read. */
|
||||
export interface RoomMapping {
|
||||
roomId: string;
|
||||
workspaceId: string;
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
export interface BotConfig {
|
||||
databaseUrl: string;
|
||||
homeserverUrl: URL;
|
||||
matrixUser: string;
|
||||
matrixPassword: string;
|
||||
/** Stable device so Matrix transaction-ID idempotency survives restarts. */
|
||||
matrixDeviceId: string;
|
||||
/** Extra hosts (e.g. the R2 media endpoint) media redirects may target; auth is always dropped. */
|
||||
mediaRedirectHosts: string[];
|
||||
mappings: RoomMapping[];
|
||||
webdav: { baseUrl: URL; user: string; password: string };
|
||||
healthPort: number;
|
||||
}
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
/** Room IDs only: `!opaque:server`. Aliases (`#…`) are rejected — mappings must be immutable. */
|
||||
const ROOM_ID = /^![^:\s]+:[^\s]+$/;
|
||||
|
||||
export class ConfigError extends Error {}
|
||||
|
||||
function required(env: NodeJS.ProcessEnv, name: string): string {
|
||||
const value = env[name];
|
||||
if (value === undefined || value.trim() === "") throw new ConfigError(`${name} is required`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function httpUrl(name: string, raw: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new ConfigError(`${name} must be an absolute URL`);
|
||||
}
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") throw new ConfigError(`${name} must be http(s)`);
|
||||
return url;
|
||||
}
|
||||
|
||||
export function parseRoomMappings(raw: string): RoomMapping[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new ConfigError("ROOM_MAPPINGS must be JSON");
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) throw new ConfigError("ROOM_MAPPINGS must be a non-empty array");
|
||||
const seen = new Set<string>();
|
||||
return parsed.map((entry: unknown, index) => {
|
||||
if (typeof entry !== "object" || entry === null) throw new ConfigError(`ROOM_MAPPINGS[${index}] must be an object`);
|
||||
const { roomId, workspaceId, groupId } = entry as Record<string, unknown>;
|
||||
if (typeof roomId !== "string" || !ROOM_ID.test(roomId)) {
|
||||
throw new ConfigError(`ROOM_MAPPINGS[${index}].roomId must be a room ID (!id:server), not an alias`);
|
||||
}
|
||||
if (typeof workspaceId !== "string" || !UUID.test(workspaceId)) {
|
||||
throw new ConfigError(`ROOM_MAPPINGS[${index}].workspaceId must be a UUID`);
|
||||
}
|
||||
if (typeof groupId !== "string" || !UUID.test(groupId)) {
|
||||
throw new ConfigError(`ROOM_MAPPINGS[${index}].groupId must be a UUID`);
|
||||
}
|
||||
if (seen.has(roomId)) throw new ConfigError(`ROOM_MAPPINGS has duplicate roomId ${roomId}`);
|
||||
seen.add(roomId);
|
||||
return { roomId, workspaceId: workspaceId.toLowerCase(), groupId: groupId.toLowerCase() };
|
||||
});
|
||||
}
|
||||
|
||||
/** The env mapping only exists for rooms explicitly enabled as unencrypted archive rooms. */
|
||||
export function toGroupFileMapping(mapping: RoomMapping): GroupFileMapping {
|
||||
return {
|
||||
workspaceId: mapping.workspaceId,
|
||||
groupId: mapping.groupId,
|
||||
roomId: mapping.roomId,
|
||||
spaceId: mapping.workspaceId,
|
||||
folderId: `${mapping.workspaceId}/${mapping.groupId}`,
|
||||
archivePolicy: "unencrypted-archive",
|
||||
retention: { legalHold: false, mode: "keep" },
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): BotConfig {
|
||||
const healthPort = Number(env.ARCHIVE_BOT_HEALTH_PORT ?? 8085);
|
||||
if (!Number.isInteger(healthPort) || healthPort <= 0 || healthPort > 65535) {
|
||||
throw new ConfigError("ARCHIVE_BOT_HEALTH_PORT must be a TCP port");
|
||||
}
|
||||
return {
|
||||
databaseUrl: required(env, "DATABASE_URL"),
|
||||
homeserverUrl: httpUrl("MATRIX_HOMESERVER_URL", required(env, "MATRIX_HOMESERVER_URL")),
|
||||
matrixUser: required(env, "MATRIX_USER"),
|
||||
matrixPassword: required(env, "MATRIX_PASSWORD"),
|
||||
matrixDeviceId: env.MATRIX_DEVICE_ID?.trim() || "HUB_ARCHIVE_BOT",
|
||||
mediaRedirectHosts: (env.MATRIX_MEDIA_REDIRECT_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter((h) => h !== ""),
|
||||
mappings: parseRoomMappings(required(env, "ROOM_MAPPINGS")),
|
||||
webdav: {
|
||||
baseUrl: httpUrl("FILES_OCID_WEBDAV_URL", required(env, "FILES_OCID_WEBDAV_URL")),
|
||||
user: required(env, "FILES_OCID_USER"),
|
||||
password: required(env, "FILES_OCID_PASSWORD"),
|
||||
},
|
||||
healthPort,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue