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; verifiedSizeBytes: number | null; verifiedChecksumSha256: string | null; previewStatus: string; accessRules?: Record; metadata?: Record; } 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) { 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( ` 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) { 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( ` 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( ` 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( ` 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, }; }