From the logic audit (HEAD 04f5f7e):
- P0 CORS: enableCors methods lacked PUT — browsers could not chunk-upload
at all (cross-origin preflight refused)
- P0 chunked: validateUpload enforced the 2 GiB whole-body cap on chunked
completes, so every >2 GiB chunked upload failed after full upload
- P1 presignPut: the SDK's flexible-checksum default added an
x-amz-checksum-crc32 header browsers cannot reproduce; S3Client now uses
requestChecksumCalculation WHEN_REQUIRED
- P1 refresh rotation race: concurrent refreshes both succeeded (consume
ignored rowCount) forking the token family; consume now returns whether
it won and the loser revokes the chain
- P2 logout: awaits the revocation and ignores missing/forged bearers
honestly
263 lines
13 KiB
TypeScript
263 lines
13 KiB
TypeScript
/**
|
|
* Runtime validation for the shared group-files contracts
|
|
* (packages/core/src/shared-files.ts — imported as @tactical-media-broadcast-hub/core).
|
|
*
|
|
* The shared file is TYPES ONLY; the task contract says "future HTTP/DB
|
|
* adapters must validate these shapes at runtime". This module is that
|
|
* adapter boundary: every value entering or leaving the DB/HTTP seam passes
|
|
* through one of these guards, so a stray row can never render an
|
|
* invalid GroupFile.
|
|
*
|
|
* Style follows the api's existing house convention: `unknown` in, typed
|
|
* value out, BadRequestException on bad shape (http/validation.ts), never
|
|
* `any`, and DB CHECK constraints (029) are coarse backstops only.
|
|
*/
|
|
import { BadRequestException } from "@nestjs/common";
|
|
import { MIME_CLASSES } from "@tactical-media-broadcast-hub/core";
|
|
import type {
|
|
FileRetention,
|
|
GroupFileMapping,
|
|
MatrixAttachmentCandidate,
|
|
MimeClass,
|
|
} from "@tactical-media-broadcast-hub/core";
|
|
import type { GroupFile } from "./group-files.types.js";
|
|
|
|
// FileRetention (packages/core/src/shared-files.ts): mode is keep|until and
|
|
// legalHold is an orthogonal boolean flag — NOT a third mode.
|
|
export const RETENTION_MODES = ["keep", "until"] as const;
|
|
export type RetentionMode = (typeof RETENTION_MODES)[number];
|
|
|
|
export const ORIGIN_KINDS = ["matrix", "bridge", "upload", "pad-export"] as const;
|
|
export type OriginKind = (typeof ORIGIN_KINDS)[number];
|
|
|
|
export { MIME_CLASSES, type MimeClass };
|
|
|
|
export const FILE_STATUSES = ["available", "revoked", "expired"] as const;
|
|
export type FileStatus = (typeof FILE_STATUSES)[number];
|
|
|
|
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
const MXID = /^@[^@\s]+:[^@\s]+$/;
|
|
const MXC_URI = /^mxc:\/\/[^/\s]+\/[^/\s?#]+$/;
|
|
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function requireField(container: Record<string, unknown>, key: string): unknown {
|
|
if (!(key in container)) throw new BadRequestException(`${key} is required`);
|
|
return container[key];
|
|
}
|
|
|
|
function stringField(container: Record<string, unknown>, key: string, field = key): string {
|
|
const value = requireField(container, key);
|
|
if (typeof value !== "string" || value === "") throw new BadRequestException(`${field} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
function isoField(container: Record<string, unknown>, key: string, field = key): string {
|
|
const value = stringField(container, key, field);
|
|
if (!ISO_TIMESTAMP.test(value)) throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`);
|
|
return value;
|
|
}
|
|
|
|
function enumField<T extends string>(container: Record<string, unknown>, key: string, allowed: readonly T[], field = key): T {
|
|
const value = requireField(container, key);
|
|
if (typeof value !== "string" || !(allowed as readonly string[]).includes(value)) {
|
|
throw new BadRequestException(`${field} must be one of: ${allowed.join(", ")}`);
|
|
}
|
|
return value as T;
|
|
}
|
|
|
|
function integerField(container: Record<string, unknown>, key: string, field = key): number {
|
|
const value = requireField(container, key);
|
|
const n = typeof value === "number" ? value : NaN;
|
|
if (!Number.isSafeInteger(n) || n < 0) throw new BadRequestException(`${field} must be a non-negative safe integer`);
|
|
return n;
|
|
}
|
|
|
|
/** sha256 lowercase hex, per the contract ("verified payload bytes ... hex"). */
|
|
export function isSha256Hex(value: unknown): value is string {
|
|
return typeof value === "string" && SHA256_HEX.test(value);
|
|
}
|
|
|
|
export function requireSha256(value: unknown, field = "sha256"): string {
|
|
if (!isSha256Hex(value)) throw new BadRequestException(`${field} must be lowercase sha256 hex (64 chars)`);
|
|
return value;
|
|
}
|
|
|
|
export function requireMxid(value: unknown, field = "senderMxid"): string {
|
|
if (typeof value !== "string" || !MXID.test(value)) throw new BadRequestException(`${field} must be a Matrix user id (@user:host)`);
|
|
return value;
|
|
}
|
|
|
|
export function requireMxcUri(value: unknown, field = "mxcUri"): string {
|
|
if (typeof value !== "string" || !MXC_URI.test(value)) throw new BadRequestException(`${field} must be an mxc:// URI`);
|
|
return value;
|
|
}
|
|
|
|
export function requireRoomId(value: unknown, field = "roomId"): string {
|
|
if (typeof value !== "string" || !/^![^:\s]+:[^:\s]+$/.test(value)) {
|
|
throw new BadRequestException(`${field} must be an immutable Matrix room id (!localpart:host) — never an alias`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Retention is decoupled from link expiry; legalHold blocks deletion. */
|
|
export function parseRetention(value: unknown, field = "retention"): FileRetention {
|
|
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
|
|
const mode = enumField(value, "mode", RETENTION_MODES, `${field}.mode`);
|
|
const legalHold = requireField(value, "legalHold");
|
|
if (typeof legalHold !== "boolean") throw new BadRequestException(`${field}.legalHold must be a boolean`);
|
|
if (mode === "until") return { mode, legalHold, expiresAt: isoField(value, "expiresAt", `${field}.expiresAt`) };
|
|
if (value.expiresAt !== undefined && value.expiresAt !== null) {
|
|
throw new BadRequestException(`${field}.expiresAt must not be set when mode is "keep"`);
|
|
}
|
|
return { mode, legalHold };
|
|
}
|
|
|
|
/** POST /files/:id/retention body {mode, until?, legalHold?} → FileRetention. */
|
|
export function parseRetentionInput(value: unknown): FileRetention {
|
|
if (!isRecord(value)) throw new BadRequestException("body must be an object");
|
|
const mode = enumField(value, "mode", RETENTION_MODES, "mode");
|
|
const legalHold = value.legalHold === undefined ? false : value.legalHold;
|
|
if (typeof legalHold !== "boolean") throw new BadRequestException("legalHold must be a boolean");
|
|
const rawUntil = value.until;
|
|
if (mode === "until") {
|
|
if (rawUntil === undefined || rawUntil === null) throw new BadRequestException("until is required when mode is 'until'");
|
|
return { mode, legalHold, expiresAt: requireIsoTimestamp(rawUntil, "until") };
|
|
}
|
|
if (rawUntil !== undefined && rawUntil !== null) {
|
|
throw new BadRequestException("until must not be set when mode is 'keep'");
|
|
}
|
|
return { mode, legalHold };
|
|
}
|
|
|
|
export function requireIsoTimestamp(value: unknown, field: string): string {
|
|
if (typeof value !== "string" || !ISO_TIMESTAMP.test(value) || !Number.isFinite(Date.parse(value))) {
|
|
throw new BadRequestException(`${field} must be a UTC ISO-8601 timestamp (…Z)`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** The four SharedFileOrigin variants, discriminated by `kind`. */
|
|
export function parseSharedFileOrigin(value: unknown, field = "origin"): GroupFile["origin"] {
|
|
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
|
|
const kind = enumField(value, "kind", ORIGIN_KINDS, `${field}.kind`);
|
|
if (kind === "matrix") return { kind, mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`) };
|
|
if (kind === "bridge") {
|
|
return {
|
|
kind,
|
|
mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`),
|
|
providerId: stringField(value, "providerId", `${field}.providerId`),
|
|
...(value.remoteEventId === undefined || value.remoteEventId === null ? {} : { remoteEventId: stringField(value, "remoteEventId", `${field}.remoteEventId`) }),
|
|
};
|
|
}
|
|
if (kind === "upload") {
|
|
if (value.sharedFrom !== undefined && value.sharedFrom !== "personal") throw new BadRequestException(`${field}.sharedFrom must be personal`);
|
|
return { kind, ...(value.sharedFrom === "personal" ? { sharedFrom: "personal" as const } : {}) };
|
|
}
|
|
return { kind: "pad-export", padId: stringField(value, "padId", `${field}.padId`), revision: stringField(value, "revision", `${field}.revision`) };
|
|
}
|
|
|
|
/** Matrix event eligibility input for the hub-archive seam. Unknown state fails closed (core helper). */
|
|
export function parseMatrixAttachmentCandidate(value: unknown, field = "event"): MatrixAttachmentCandidate {
|
|
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
|
|
const encryption = enumField(value, "roomEncryption", ["unencrypted", "encrypted", "unknown"] as const, `${field}.roomEncryption`);
|
|
const encryptedFile = requireField(value, "encryptedFile");
|
|
const redacted = requireField(value, "redacted");
|
|
if (typeof encryptedFile !== "boolean") throw new BadRequestException(`${field}.encryptedFile must be a boolean`);
|
|
if (typeof redacted !== "boolean") throw new BadRequestException(`${field}.redacted must be a boolean`);
|
|
return {
|
|
roomId: requireRoomId(value.roomId, `${field}.roomId`),
|
|
eventId: stringField(value, "eventId", `${field}.eventId`),
|
|
senderId: requireMxid(value.senderId, `${field}.senderId`),
|
|
eventType: stringField(value, "eventType", `${field}.eventType`),
|
|
msgtype: stringField(value, "msgtype", `${field}.msgtype`),
|
|
roomEncryption: encryption,
|
|
encryptedFile,
|
|
redacted,
|
|
mxcUri: requireMxcUri(value.mxcUri, `${field}.mxcUri`),
|
|
};
|
|
}
|
|
|
|
export function parseGroupFileMapping(value: unknown, field = "mapping"): GroupFileMapping {
|
|
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
|
|
const policy = enumField(value, "archivePolicy", ["disabled", "unencrypted-archive"] as const, `${field}.archivePolicy`);
|
|
return {
|
|
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
|
|
groupId: stringField(value, "groupId", `${field}.groupId`),
|
|
roomId: requireRoomId(value.roomId, `${field}.roomId`),
|
|
spaceId: stringField(value, "spaceId", `${field}.spaceId`),
|
|
folderId: stringField(value, "folderId", `${field}.folderId`),
|
|
archivePolicy: policy,
|
|
retention: parseRetention(value.retention, `${field}.retention`),
|
|
};
|
|
}
|
|
|
|
/** The served GroupFile wire shape, guarded on the way out of the DB. */
|
|
export function parseGroupFile(value: unknown, field = "item"): GroupFile {
|
|
if (!isRecord(value)) throw new BadRequestException(`${field} must be an object`);
|
|
const origin = parseSharedFileOrigin(value.origin, `${field}.origin`);
|
|
const eventId = value.eventId;
|
|
if (eventId !== undefined && eventId !== null && typeof eventId !== "string") {
|
|
throw new BadRequestException(`${field}.eventId must be a string or null`);
|
|
}
|
|
const file = value.file;
|
|
if (!isRecord(file)) throw new BadRequestException(`${field}.file must be an object`);
|
|
const shareLink = value.shareLink;
|
|
if (!isRecord(shareLink)) throw new BadRequestException(`${field}.shareLink must be an object`);
|
|
const access = enumField(shareLink, "access", ["members", "public"] as const, `${field}.shareLink.access`);
|
|
const shareExpires = shareLink.expiresAt;
|
|
if (shareExpires !== undefined && shareExpires !== null && (typeof shareExpires !== "string" || !ISO_TIMESTAMP.test(shareExpires))) {
|
|
throw new BadRequestException(`${field}.shareLink.expiresAt must be a UTC ISO-8601 timestamp or null`);
|
|
}
|
|
const checksum = value.checksum;
|
|
if (!isRecord(checksum)) throw new BadRequestException(`${field}.checksum must be an object`);
|
|
return {
|
|
id: stringField(value, "id", `${field}.id`),
|
|
workspaceId: stringField(value, "workspaceId", `${field}.workspaceId`),
|
|
groupId: value.groupId === null ? null : stringField(value, "groupId", `${field}.groupId`),
|
|
ownerId: nullableUuid(value.ownerId, `${field}.ownerId`),
|
|
folderId: nullableUuid(value.folderId, `${field}.folderId`),
|
|
createdBy: nullableUuid(value.createdBy, `${field}.createdBy`),
|
|
shared: value.shared === undefined ? false : booleanField(value.shared, `${field}.shared`),
|
|
roomId: value.roomId === undefined || value.roomId === null ? null : requireRoomId(value.roomId, `${field}.roomId`),
|
|
senderId: requireMxid(value.senderId, `${field}.senderId`),
|
|
eventId: eventId === undefined || eventId === null ? null : eventId,
|
|
origin,
|
|
name: stringField(value, "name", `${field}.name`),
|
|
mimeType: stringField(value, "mimeType", `${field}.mimeType`),
|
|
mimeClass: enumField(value, "mimeClass", MIME_CLASSES, `${field}.mimeClass`),
|
|
sizeBytes: integerField(value, "sizeBytes", `${field}.sizeBytes`),
|
|
checksum: { algorithm: "sha256", value: requireSha256(checksum.value, `${field}.checksum.value`) },
|
|
createdAt: isoField(value, "createdAt", `${field}.createdAt`),
|
|
indexedAt: isoField(value, "indexedAt", `${field}.indexedAt`),
|
|
file: {
|
|
spaceId: file.spaceId === null ? null : stringField(file, "spaceId", `${field}.file.spaceId`),
|
|
resourceId: stringField(file, "resourceId", `${field}.file.resourceId`),
|
|
version: stringField(file, "version", `${field}.file.version`),
|
|
...(file.storageKey === undefined ? {} : { storageKey: stringField(file, "storageKey", `${field}.file.storageKey`) }),
|
|
},
|
|
shareLink: {
|
|
url: stringField(shareLink, "url", `${field}.shareLink.url`),
|
|
access,
|
|
expiresAt: shareExpires === undefined || shareExpires === null ? null : shareExpires,
|
|
},
|
|
retention: parseRetention(value.retention, `${field}.retention`),
|
|
status: enumField(value, "status", FILE_STATUSES, `${field}.status`),
|
|
};
|
|
}
|
|
|
|
function nullableUuid(value: unknown, field: string): string | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
|
|
throw new BadRequestException(`${field} must be a UUID or null`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function booleanField(value: unknown, field: string): boolean {
|
|
if (typeof value !== "boolean") throw new BadRequestException(`${field} must be a boolean`);
|
|
return value;
|
|
}
|