Files
gongxue-base/apps/api/src/features/tenant-content/assets.ts
2026-06-29 19:42:05 +08:00

1167 lines
45 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
import {
assertUploadProvider,
configuredDefaultStorageBucket,
assertWritableLocation,
configuredDefaultStorageProvider,
headStorageObject,
normalizeStorageProvider,
signStorageDownload,
signStorageUpload,
validateFileSize,
validateMimeType,
validateObjectKey,
type StorageProviderName,
} from '../storage/service.js';
import {
assetAccessTtl,
assertCdnAccessAllowed,
recordAssetAccessEvent,
signedAssetFingerprint,
} from '../storage/asset-access.js';
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
const ASSET_STATUSES = ['draft', 'active', 'archived'];
const UPLOAD_STATUSES = ['not_required', 'pending', 'verified', 'failed'];
const PREVIEW_STATUSES = ['none', 'pending', 'ready', 'failed'];
const SECURITY_SCAN_STATUSES = ['not_required', 'pending', 'scanning', 'passed', 'failed', 'skipped'];
interface AssetRow {
id: string;
tenantId: string;
assetType: string;
storageProvider: string;
bucket: string | null;
objectKey: string | null;
previewObjectKey: string | null;
title: string | null;
fileName: string | null;
cdnUrl: string | null;
previewUrl: string | null;
mimeType: string | null;
fileSizeBytes: number | null;
checksumSha256: string | null;
visibility: string;
status: string;
uploadStatus: string;
securityScanStatus: string;
securityScanProvider: string | null;
securityScanSummary: Record<string, unknown>;
verifiedSizeBytes: number | null;
verifiedChecksumSha256: string | null;
previewStatus: string;
accessRules?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function nullableUuid(value: unknown) {
return nullableString(value);
}
function assertUuidParam(value: string, label: string) {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
throw new HttpError(400, `${label} must be a valid UUID`, 'INVALID_UUID');
}
}
function safeFileName(fileName: string) {
return fileName
.trim()
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, '-')
.slice(0, 160) || 'asset';
}
function isManagedObjectProvider(provider: StorageProviderName) {
return provider === 'local_dev' || provider === 'supabase_storage' || provider === 'aliyun_oss' || provider === 'tencent_cos';
}
function checksumSha256(value: unknown) {
const raw = nullableString(value);
const checksum = raw ? raw.toLowerCase() : null;
if (!checksum) return null;
if (!/^[a-f0-9]{64}$/.test(checksum)) {
throw new HttpError(400, 'checksumSha256 must be a lowercase hex SHA-256 digest', 'INVALID_CHECKSUM');
}
return checksum;
}
function canonicalMime(value: string | null) {
return value?.split(';')[0]?.trim().toLowerCase() || null;
}
function nullableNumber(value: unknown) {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
}
function assetPreviewable(asset: Pick<AssetRow, 'assetType' | 'mimeType'>) {
const mime = canonicalMime(asset.mimeType);
return asset.assetType === 'pdf' || mime === 'application/pdf' || asset.assetType === 'image' || Boolean(mime?.startsWith('image/'));
}
async function existingAssetForUpsert(tenantId: string, id: string | null) {
if (!id) return null;
return queryOne<AssetRow>(
`
select id, tenant_id as "tenantId", asset_type as "assetType",
storage_provider as "storageProvider", bucket, object_key as "objectKey",
preview_object_key as "previewObjectKey", title, file_name as "fileName",
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus"
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[tenantId, id],
);
}
function sameObjectLocation(existing: AssetRow | null, provider: StorageProviderName, bucket: string | null, objectKey: string | null) {
return Boolean(
existing &&
existing.storageProvider === provider &&
(existing.bucket || null) === (bucket || null) &&
(existing.objectKey || null) === (objectKey || null),
);
}
function objectUploadStatus(input: {
existing: AssetRow | null;
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
cdnUrl: string | null;
}) {
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return 'not_required';
if (sameObjectLocation(input.existing, input.provider, input.bucket, input.objectKey)) {
return input.existing?.uploadStatus || 'pending';
}
return 'pending';
}
function objectSecurityScanStatus(input: {
existing: AssetRow | null;
provider: StorageProviderName;
objectKey: string | null;
uploadStatus: string;
}) {
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return 'not_required';
if (input.uploadStatus !== 'verified') return 'pending';
return input.existing?.securityScanStatus || 'pending';
}
export function assertAssetSecurityScanPassed(input: {
provider: StorageProviderName;
objectKey: string | null;
securityScanStatus?: string | null;
}) {
if (!input.objectKey || !isManagedObjectProvider(input.provider)) return;
if (input.securityScanStatus === 'passed') return;
if (input.securityScanStatus === 'failed') {
throw new HttpError(409, 'Asset security scan failed', 'ASSET_SECURITY_SCAN_FAILED');
}
throw new HttpError(409, 'Asset security scan is required before access', 'ASSET_SECURITY_SCAN_REQUIRED');
}
function assertPublishableManagedObject(
status: string,
uploadStatus: string,
scanStatus: string,
provider: StorageProviderName,
objectKey: string | null,
) {
if (status !== 'active' || !objectKey || !isManagedObjectProvider(provider)) return;
if (uploadStatus !== 'verified') {
throw new HttpError(
409,
'Managed storage asset must be confirmed before publishing',
'ASSET_UPLOAD_CONFIRMATION_REQUIRED',
);
}
assertAssetSecurityScanPassed({ provider, objectKey, securityScanStatus: scanStatus });
}
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
if (!id) return;
const row = await queryOne<{ id: string }>(
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
[tenantId, id],
);
if (!row) {
throw new HttpError(400, `${table} reference is not in this tenant`, code);
}
}
async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
await query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_asset', $4, $5::jsonb)
`,
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)],
);
}
async function recordDeniedAdminAssetAccess(ctx: RequestContext, auth: TenantContentAuth, asset: AssetRow, accessType: 'admin_download' | 'admin_preview', error: unknown) {
const denyCode = error instanceof HttpError ? error.code : 'ASSET_ACCESS_DENIED';
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId: asset.id,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType,
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
result: 'denied',
denyCode,
metadata: {
title: asset.title,
fileName: asset.fileName,
},
});
}
export async function assetsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetType = stringParam(ctx, 'assetType');
const status = stringParam(ctx, 'status');
const visibility = stringParam(ctx, 'visibility');
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const entryId = stringParam(ctx, 'entryId');
const contentNodeId = stringParam(ctx, 'contentNodeId');
const securityScanStatus = stringParam(ctx, 'securityScanStatus');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetType) {
params.push(assetType);
filters.push(`asset_type = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
if (visibility) {
params.push(visibility);
filters.push(`visibility = $${params.length}`);
}
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`category_id = $${params.length}`);
}
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (contentNodeId) {
params.push(contentNodeId);
filters.push(`content_node_id = $${params.length}`);
}
if (securityScanStatus) {
if (!SECURITY_SCAN_STATUSES.includes(securityScanStatus)) {
throw new HttpError(400, `Invalid securityScanStatus: ${securityScanStatus}`, 'INVALID_FIELD_VALUE');
}
params.push(securityScanStatus);
filters.push(`security_scan_status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
upload_status as "uploadStatus", verified_at as "verifiedAt",
verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
verification_details as "verificationDetails",
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
security_scan_status as "securityScanStatus",
security_scanned_at as "securityScannedAt",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
security_flags as "securityFlags",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
entry_id as "entryId", content_node_id as "contentNodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.content_assets
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function assetSecurityScanEventsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetId = stringParam(ctx, 'assetId');
const scanStatus = stringParam(ctx, 'scanStatus');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetId) {
assertUuidParam(assetId, 'assetId');
params.push(assetId);
filters.push(`asset_id = $${params.length}::uuid`);
}
if (scanStatus) {
if (!SECURITY_SCAN_STATUSES.includes(scanStatus) || scanStatus === 'not_required') {
throw new HttpError(400, `Invalid scanStatus: ${scanStatus}`, 'INVALID_FIELD_VALUE');
}
params.push(scanStatus);
filters.push(`scan_status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, asset_id as "assetId", provider,
scan_status as "scanStatus", risk_level as "riskLevel",
issue_codes as "issueCodes", details, created_at as "createdAt"
from public.content_asset_security_scan_events
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function assetAccessEventsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetId = stringParam(ctx, 'assetId');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetId) {
assertUuidParam(assetId, 'assetId');
params.push(assetId);
filters.push(`asset_id = $${params.length}::uuid`);
}
params.push(limit);
const items = await query(
`
select id, asset_id as "assetId", user_id as "userId",
actor_role as "actorRole", access_type as "accessType",
visibility, asset_type as "assetType",
storage_provider as "storageProvider", disposition,
expires_in_sec as "expiresInSec", signature_mode as "signatureMode",
result, deny_code as "denyCode", ip_address as "ipAddress",
user_agent as "userAgent", metadata, created_at as "createdAt"
from public.content_asset_access_events
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertAssetRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(
body.storageProvider,
STORAGE_PROVIDERS,
nullableString(body.cdnUrl) ? 'external_url' : configuredDefaultStorageProvider(),
'storageProvider',
) as StorageProviderName;
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
const bucket = nullableString(body.bucket);
const objectKey = nullableString(body.objectKey);
const cdnUrl = nullableString(body.cdnUrl);
const title = requiredString(body, 'title');
const existing = await existingAssetForUpsert(auth.tenantId, nullableString(body.id));
const regionId = nullableUuid(body.regionId);
const subjectId = nullableUuid(body.subjectId);
const categoryId = nullableUuid(body.categoryId);
const nodeId = nullableUuid(body.nodeId);
const entryId = nullableUuid(body.entryId);
const contentNodeId = nullableUuid(body.contentNodeId);
const cleanObjectKey = objectKey ? validateObjectKey(auth.tenantId, objectKey) : null;
const previewObjectKey = nullableString(body.previewObjectKey)
? validateObjectKey(auth.tenantId, requiredString(body, 'previewObjectKey'))
: null;
const mimeType = nullableString(body.mimeType);
if (mimeType) validateMimeType(mimeType);
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
const checksum = checksumSha256(body.checksumSha256);
assertWritableLocation({ provider: storageProvider, bucket, objectKey: cleanObjectKey, cdnUrl });
const nextUploadStatus = objectUploadStatus({
existing,
provider: storageProvider,
bucket,
objectKey: cleanObjectKey,
cdnUrl,
});
const nextSecurityScanStatus = objectSecurityScanStatus({
existing,
provider: storageProvider,
objectKey: cleanObjectKey,
uploadStatus: nextUploadStatus,
});
const nextPreviewStatus = previewObjectKey || nullableString(body.previewUrl)
? choice(body.previewStatus, PREVIEW_STATUSES, existing?.previewStatus || 'ready', 'previewStatus')
: 'none';
const statusFallback = cleanObjectKey
&& isManagedObjectProvider(storageProvider)
&& (nextUploadStatus !== 'verified' || nextSecurityScanStatus !== 'passed')
? 'draft'
: 'active';
const status = choice(body.status, ASSET_STATUSES, statusFallback, 'status');
assertPublishableManagedObject(status, nextUploadStatus, nextSecurityScanStatus, storageProvider, cleanObjectKey);
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
}
await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
const item = await queryOne(
`
insert into public.content_assets (
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
bucket, object_key, title, category, description, file_name, cdn_url,
preview_url, mime_type, file_size_bytes, checksum_sha256,
upload_status, security_scan_status, security_scanned_at,
security_scan_provider, security_scan_summary,
preview_object_key, preview_status, visibility,
is_public, region_id, subject_id, category_id, node_id, entry_id,
content_node_id, status,
sort_order, access_rules, metadata, created_by, updated_by, source
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18,
$19, case when $19 in ('passed', 'not_required') then now() else null end,
$20, $21::jsonb,
$22, $23, $24,
$25, $26::uuid, $27::uuid, $28::uuid, $29::uuid, $30::uuid,
$31::uuid, $32,
$33, $34::jsonb, $35::jsonb, $36, $36, $37
)
on conflict (id)
do update set legacy_id = excluded.legacy_id,
asset_key = excluded.asset_key,
asset_type = excluded.asset_type,
storage_provider = excluded.storage_provider,
bucket = excluded.bucket,
object_key = excluded.object_key,
title = excluded.title,
category = excluded.category,
description = excluded.description,
file_name = excluded.file_name,
cdn_url = excluded.cdn_url,
preview_url = excluded.preview_url,
mime_type = excluded.mime_type,
file_size_bytes = excluded.file_size_bytes,
checksum_sha256 = excluded.checksum_sha256,
upload_status = excluded.upload_status,
security_scan_status = excluded.security_scan_status,
security_scanned_at = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
and excluded.security_scan_status = public.content_assets.security_scan_status
then public.content_assets.security_scanned_at
else excluded.security_scanned_at
end,
security_scan_provider = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
and excluded.security_scan_status = public.content_assets.security_scan_status
then public.content_assets.security_scan_provider
else excluded.security_scan_provider
end,
security_scan_summary = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
and excluded.security_scan_status = public.content_assets.security_scan_status
then public.content_assets.security_scan_summary
else excluded.security_scan_summary
end,
verified_at = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
then public.content_assets.verified_at
else null
end,
verified_by = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
then public.content_assets.verified_by
else null
end,
verified_size_bytes = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
then public.content_assets.verified_size_bytes
else null
end,
verified_checksum_sha256 = case
when public.content_assets.storage_provider = excluded.storage_provider
and coalesce(public.content_assets.bucket, '') = coalesce(excluded.bucket, '')
and coalesce(public.content_assets.object_key, '') = coalesce(excluded.object_key, '')
then public.content_assets.verified_checksum_sha256
else null
end,
preview_object_key = excluded.preview_object_key,
preview_status = excluded.preview_status,
visibility = excluded.visibility,
is_public = excluded.is_public,
region_id = excluded.region_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
node_id = excluded.node_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
status = excluded.status,
sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
source = excluded.source,
updated_at = now()
where public.content_assets.tenant_id = excluded.tenant_id
returning id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
upload_status as "uploadStatus", verified_at as "verifiedAt",
verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
verification_details as "verificationDetails",
preview_object_key as "previewObjectKey", preview_status as "previewStatus",
security_scan_status as "securityScanStatus",
security_scanned_at as "securityScannedAt",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
security_flags as "securityFlags",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
entry_id as "entryId", content_node_id as "contentNodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.legacyId),
nullableString(body.assetKey),
assetType,
storageProvider,
bucket,
cleanObjectKey,
title,
nullableString(body.categoryLabel) || nullableString(body.category),
nullableString(body.description),
nullableString(body.fileName),
cdnUrl,
nullableString(body.previewUrl),
mimeType,
fileSizeBytes,
checksum,
nextUploadStatus,
nextSecurityScanStatus,
nextSecurityScanStatus === 'not_required' ? null : existing?.securityScanProvider || 'metadata_rules',
JSON.stringify(
nextSecurityScanStatus === 'not_required'
? {}
: nextSecurityScanStatus === 'passed'
? existing?.securityScanSummary || { riskLevel: 'none', issueCodes: [] }
: { riskLevel: 'unknown', issueCodes: [], pendingReason: 'asset_upsert' },
),
previewObjectKey,
nextPreviewStatus,
visibility,
visibility === 'public',
regionId,
subjectId,
categoryId,
nodeId,
entryId,
contentNodeId,
status,
intValue(body.order, 0),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata),
auth.userId,
nullableString(body.source) || 'manual',
],
);
if (!item) {
throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND');
}
await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), {
title,
assetType,
visibility,
status,
});
return { item };
}
export async function confirmAssetUploadRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const asset = await queryOne<AssetRow>(
`
select id, tenant_id as "tenantId", asset_type as "assetType",
storage_provider as "storageProvider", bucket, object_key as "objectKey",
preview_object_key as "previewObjectKey", title, file_name as "fileName",
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus"
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, assetId],
);
if (!asset) throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
const provider = asset.storageProvider as StorageProviderName;
if (!asset.objectKey || !isManagedObjectProvider(provider)) {
throw new HttpError(400, 'Asset does not require upload confirmation', 'UPLOAD_CONFIRM_NOT_REQUIRED');
}
const declaredFileSize = body.fileSizeBytes === undefined
? asset.fileSizeBytes
: validateFileSize(intValue(body.fileSizeBytes, 0));
const registeredFileSize = nullableNumber(asset.fileSizeBytes);
const normalizedDeclaredFileSize = nullableNumber(declaredFileSize);
const declaredMimeType = nullableString(body.mimeType) || asset.mimeType;
const declaredChecksum = checksumSha256(body.checksumSha256) || asset.checksumSha256;
const metadata = await headStorageObject({
tenantId: auth.tenantId,
provider,
bucket: asset.bucket,
objectKey: asset.objectKey,
declaredFileSizeBytes: normalizedDeclaredFileSize,
declaredMimeType,
declaredChecksumSha256: declaredChecksum,
});
const issues: string[] = [];
if (registeredFileSize !== null && body.fileSizeBytes !== undefined && normalizedDeclaredFileSize !== registeredFileSize) {
issues.push('declared_file_size_changed');
}
if (asset.mimeType && body.mimeType !== undefined && canonicalMime(declaredMimeType) !== canonicalMime(asset.mimeType)) {
issues.push('declared_mime_type_changed');
}
if (asset.checksumSha256 && body.checksumSha256 !== undefined && declaredChecksum !== asset.checksumSha256) {
issues.push('declared_checksum_changed');
}
if (normalizedDeclaredFileSize !== null && metadata.sizeBytes !== null && normalizedDeclaredFileSize !== metadata.sizeBytes) {
issues.push('file_size_mismatch');
}
const expectedMime = declaredMimeType ? canonicalMime(validateMimeType(declaredMimeType)) : null;
const observedMime = canonicalMime(metadata.mimeType);
if (expectedMime && observedMime && expectedMime !== observedMime) {
issues.push('mime_type_mismatch');
}
if (declaredChecksum && metadata.checksumSha256 && declaredChecksum !== metadata.checksumSha256) {
issues.push('checksum_mismatch');
}
const verificationDetails = {
metadata,
declared: {
fileSizeBytes: normalizedDeclaredFileSize,
mimeType: expectedMime,
checksumSha256: declaredChecksum,
},
issues,
checksumVerified: Boolean(declaredChecksum && metadata.checksumSha256 && declaredChecksum === metadata.checksumSha256),
checksumUnavailable: Boolean(declaredChecksum && !metadata.checksumSha256),
};
if (issues.length) {
await query(
`
update public.content_assets
set upload_status = 'failed',
security_scan_status = 'skipped',
security_scanned_at = null,
security_scan_provider = 'metadata_rules',
security_scan_summary = $5::jsonb,
verification_details = $3::jsonb,
security_flags = jsonb_set(coalesce(security_flags, '{}'::jsonb), '{uploadVerificationFailed}', 'true'::jsonb, true),
updated_by = $4,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
assetId,
JSON.stringify(verificationDetails),
auth.userId,
JSON.stringify({ riskLevel: 'medium', issueCodes: issues, skippedReason: 'upload_verification_failed' }),
],
);
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'upload_confirm',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
result: 'denied',
denyCode: 'UPLOAD_VERIFICATION_FAILED',
metadata: {
provider,
bucket: asset.bucket,
objectKey: asset.objectKey,
issues,
},
});
throw new HttpError(409, `Upload verification failed: ${issues.join(', ')}`, 'UPLOAD_VERIFICATION_FAILED');
}
const publish = body.publish === true;
const item = await queryOne(
`
update public.content_assets
set upload_status = 'verified',
verified_at = now(),
verified_by = $3,
verified_size_bytes = coalesce($4::bigint, verified_size_bytes),
verified_checksum_sha256 = coalesce($5, verified_checksum_sha256),
file_size_bytes = coalesce(file_size_bytes, $4::bigint),
mime_type = coalesce(mime_type, $6),
checksum_sha256 = coalesce(checksum_sha256, $5),
verification_details = $7::jsonb,
security_scan_status = 'pending',
security_scanned_at = null,
security_scan_provider = 'metadata_rules',
security_scan_summary = '{"riskLevel":"unknown","issueCodes":[],"pendingReason":"upload_confirmed"}'::jsonb,
security_flags = coalesce(security_flags, '{}'::jsonb) - 'assetSecurityScanFailed',
status = case when status = 'archived' then status else 'draft' end,
updated_by = $3,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, file_name as "fileName",
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
checksum_sha256 as "checksumSha256", visibility, status,
upload_status as "uploadStatus", verified_at as "verifiedAt",
verified_by as "verifiedBy", verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
verification_details as "verificationDetails",
security_scan_status as "securityScanStatus",
security_scanned_at as "securityScannedAt",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary"
`,
[
auth.tenantId,
assetId,
auth.userId,
metadata.sizeBytes ?? normalizedDeclaredFileSize,
metadata.checksumSha256 ?? declaredChecksum,
observedMime || expectedMime,
JSON.stringify(verificationDetails),
],
);
await recordAssetAudit(auth, 'content.asset.upload_confirmed', assetId, {
provider,
bucket: asset.bucket,
objectKey: asset.objectKey,
publish,
checksumVerified: verificationDetails.checksumVerified,
checksumUnavailable: verificationDetails.checksumUnavailable,
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'upload_confirm',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
result: 'granted',
metadata: {
provider,
bucket: asset.bucket,
objectKey: asset.objectKey,
publish,
checksumVerified: verificationDetails.checksumVerified,
checksumUnavailable: verificationDetails.checksumUnavailable,
},
});
return {
item,
metadata,
verification: verificationDetails,
securityScan: {
status: 'pending',
provider: 'metadata_rules',
requiredBeforePublish: true,
},
};
}
export async function signAssetUploadRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const fileName = requiredString(body, 'fileName');
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = normalizeStorageProvider(nullableString(body.storageProvider), configuredDefaultStorageProvider());
assertUploadProvider(storageProvider);
const bucket = nullableString(body.bucket) || configuredDefaultStorageBucket();
const objectKey =
nullableString(body.objectKey) ||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
const mimeType = validateMimeType(nullableString(body.mimeType) || 'application/octet-stream');
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
const cleanObjectKey = validateObjectKey(auth.tenantId, objectKey);
const upload = await signStorageUpload({
tenantId: auth.tenantId,
provider: storageProvider,
bucket,
objectKey: cleanObjectKey,
fileName,
mimeType,
fileSizeBytes,
expiresInSec,
upsert: body.upsert === true,
});
await recordAssetAudit(auth, 'content.asset.upload_signed', null, {
provider: storageProvider,
bucket,
objectKey: cleanObjectKey,
assetType,
fileName,
mimeType,
fileSizeBytes,
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'upload_sign',
assetType,
storageProvider,
disposition: 'attachment',
expiresInSec: upload.expiresInSec,
signatureMode: upload.signatureMode,
result: 'granted',
metadata: {
bucket,
objectKey: cleanObjectKey,
fileName,
mimeType,
fileSizeBytes,
signature: signedAssetFingerprint(upload),
},
});
return {
upload,
assetDraft: {
assetType,
storageProvider,
bucket,
objectKey: cleanObjectKey,
fileName,
mimeType,
fileSizeBytes,
checksumSha256: nullableString(body.checksumSha256),
},
};
}
export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const requestedExpiresInSec = intValue(body.expiresInSec, 900);
const asset = await queryOne<AssetRow>(
`
select id, tenant_id as "tenantId", asset_type as "assetType",
storage_provider as "storageProvider", bucket, object_key as "objectKey",
preview_object_key as "previewObjectKey",
title, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus",
access_rules as "accessRules", metadata
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, assetId],
);
if (!asset) {
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
}
const expiresInSec = assetAccessTtl({
actorRole: 'tenant_content_editor',
accessType: 'admin_download',
visibility: asset.visibility,
assetType: asset.assetType,
disposition: 'attachment',
requestedExpiresInSec,
});
try {
assertCdnAccessAllowed({
assetId,
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider as StorageProviderName,
objectKey: asset.objectKey,
cdnUrl: asset.cdnUrl,
metadata: asset.metadata,
accessRules: asset.accessRules,
});
assertAssetSecurityScanPassed({
provider: asset.storageProvider as StorageProviderName,
objectKey: asset.objectKey,
securityScanStatus: asset.securityScanStatus,
});
} catch (error) {
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_download', error);
throw error;
}
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, assetId],
);
const download = await signStorageDownload({
tenantId: auth.tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
objectKey: asset.objectKey,
cdnUrl: asset.cdnUrl,
fileName: asset.fileName,
expiresInSec,
disposition: 'attachment',
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_download',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
disposition: 'attachment',
expiresInSec: download.expiresInSec,
signatureMode: download.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(download) },
});
return { item: asset, download };
}
export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const requestedExpiresInSec = intValue(body.expiresInSec, 900);
const asset = await queryOne<AssetRow>(
`
select id, tenant_id as "tenantId", asset_type as "assetType",
storage_provider as "storageProvider", bucket, object_key as "objectKey",
preview_object_key as "previewObjectKey", title, file_name as "fileName",
cdn_url as "cdnUrl", preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, status, upload_status as "uploadStatus",
security_scan_status as "securityScanStatus",
security_scan_provider as "securityScanProvider",
security_scan_summary as "securityScanSummary",
verified_size_bytes as "verifiedSizeBytes",
verified_checksum_sha256 as "verifiedChecksumSha256",
preview_status as "previewStatus",
access_rules as "accessRules", metadata
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, assetId],
);
if (!asset) throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
if (!assetPreviewable(asset)) {
throw new HttpError(400, 'Asset type does not support inline preview', 'ASSET_PREVIEW_NOT_SUPPORTED');
}
const objectKey = asset.previewObjectKey || asset.objectKey;
const cdnUrl = asset.previewUrl || asset.cdnUrl;
const fileName = asset.fileName || asset.title || 'preview.pdf';
const expiresInSec = assetAccessTtl({
actorRole: 'tenant_content_editor',
accessType: 'admin_preview',
visibility: asset.visibility,
assetType: asset.assetType,
disposition: 'inline',
requestedExpiresInSec,
});
try {
assertCdnAccessAllowed({
assetId,
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider as StorageProviderName,
objectKey,
cdnUrl,
metadata: asset.metadata,
accessRules: asset.accessRules,
});
assertAssetSecurityScanPassed({
provider: asset.storageProvider as StorageProviderName,
objectKey,
securityScanStatus: asset.securityScanStatus,
});
} catch (error) {
await recordDeniedAdminAssetAccess(ctx, auth, asset, 'admin_preview', error);
throw error;
}
const preview = await signStorageDownload({
tenantId: auth.tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
objectKey,
cdnUrl,
fileName,
expiresInSec,
disposition: 'inline',
});
await recordAssetAccessEvent({
ctx,
tenantId: auth.tenantId,
assetId,
userId: auth.userId,
actorRole: 'tenant_content_editor',
accessType: 'admin_preview',
visibility: asset.visibility,
assetType: asset.assetType,
storageProvider: asset.storageProvider,
disposition: 'inline',
expiresInSec: preview.expiresInSec,
signatureMode: preview.signatureMode,
result: 'granted',
metadata: { signature: signedAssetFingerprint(preview) },
});
return {
item: {
id: asset.id,
assetType: asset.assetType,
title: asset.title,
fileName: asset.fileName,
previewStatus: asset.previewStatus,
},
preview,
};
}