forked from wangziqi/gongxue-base
1167 lines
45 KiB
TypeScript
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,
|
|
};
|
|
}
|