forked from wangziqi/gongxue-base
feat: verify content asset uploads
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
configuredDefaultStorageBucket,
|
||||
assertWritableLocation,
|
||||
configuredDefaultStorageProvider,
|
||||
headStorageObject,
|
||||
normalizeStorageProvider,
|
||||
signStorageDownload,
|
||||
signStorageUpload,
|
||||
@@ -22,6 +23,8 @@ const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'l
|
||||
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'];
|
||||
|
||||
interface AssetRow {
|
||||
id: string;
|
||||
@@ -30,12 +33,20 @@ interface AssetRow {
|
||||
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;
|
||||
verifiedSizeBytes: number | null;
|
||||
verifiedChecksumSha256: string | null;
|
||||
previewStatus: string;
|
||||
}
|
||||
|
||||
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
||||
@@ -58,6 +69,89 @@ function safeFileName(fileName: string) {
|
||||
.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",
|
||||
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 assertPublishableManagedObject(status: string, uploadStatus: string, provider: StorageProviderName, objectKey: string | null) {
|
||||
if (status === 'active' && objectKey && isManagedObjectProvider(provider) && uploadStatus !== 'verified') {
|
||||
throw new HttpError(
|
||||
409,
|
||||
'Managed storage asset must be confirmed before publishing',
|
||||
'ASSET_UPLOAD_CONFIRMATION_REQUIRED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
|
||||
if (!id) return;
|
||||
const row = await queryOne<{ id: string }>(
|
||||
@@ -135,6 +229,12 @@ export async function assetsAdminRoute(ctx: RequestContext) {
|
||||
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_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",
|
||||
@@ -165,11 +265,11 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
'storageProvider',
|
||||
) as StorageProviderName;
|
||||
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
|
||||
const status = choice(body.status, ASSET_STATUSES, 'active', 'status');
|
||||
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);
|
||||
@@ -178,10 +278,29 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
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 nextPreviewStatus = previewObjectKey || nullableString(body.previewUrl)
|
||||
? choice(body.previewStatus, PREVIEW_STATUSES, existing?.previewStatus || 'ready', 'previewStatus')
|
||||
: 'none';
|
||||
const statusFallback = cleanObjectKey && isManagedObjectProvider(storageProvider) && nextUploadStatus !== 'verified'
|
||||
? 'draft'
|
||||
: 'active';
|
||||
const status = choice(body.status, ASSET_STATUSES, statusFallback, 'status');
|
||||
assertPublishableManagedObject(status, nextUploadStatus, storageProvider, cleanObjectKey);
|
||||
|
||||
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
||||
@@ -199,7 +318,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
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, visibility,
|
||||
preview_url, mime_type, file_size_bytes, checksum_sha256,
|
||||
upload_status, 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
|
||||
@@ -208,9 +328,10 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18,
|
||||
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24::uuid,
|
||||
$25::uuid, $26,
|
||||
$27, $28::jsonb, $29::jsonb, $30, $30, $31
|
||||
$19, $20, $21,
|
||||
$22, $23::uuid, $24::uuid, $25::uuid, $26::uuid, $27::uuid,
|
||||
$28::uuid, $29,
|
||||
$30, $31::jsonb, $32::jsonb, $33, $33, $34
|
||||
)
|
||||
on conflict (id)
|
||||
do update set legacy_id = excluded.legacy_id,
|
||||
@@ -228,6 +349,37 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
mime_type = excluded.mime_type,
|
||||
file_size_bytes = excluded.file_size_bytes,
|
||||
checksum_sha256 = excluded.checksum_sha256,
|
||||
upload_status = excluded.upload_status,
|
||||
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,
|
||||
@@ -250,6 +402,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
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_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",
|
||||
@@ -275,7 +433,10 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
nullableString(body.previewUrl),
|
||||
mimeType,
|
||||
fileSizeBytes,
|
||||
nullableString(body.checksumSha256),
|
||||
checksum,
|
||||
nextUploadStatus,
|
||||
previewObjectKey,
|
||||
nextPreviewStatus,
|
||||
visibility,
|
||||
visibility === 'public',
|
||||
regionId,
|
||||
@@ -307,6 +468,153 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
||||
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",
|
||||
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',
|
||||
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],
|
||||
);
|
||||
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,
|
||||
status = case when $8::boolean and status <> 'archived' then 'active' else status 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"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
assetId,
|
||||
auth.userId,
|
||||
metadata.sizeBytes ?? normalizedDeclaredFileSize,
|
||||
metadata.checksumSha256 ?? declaredChecksum,
|
||||
observedMime || expectedMime,
|
||||
JSON.stringify(verificationDetails),
|
||||
publish,
|
||||
],
|
||||
);
|
||||
|
||||
await recordAssetAudit(auth, 'content.asset.upload_confirmed', assetId, {
|
||||
provider,
|
||||
bucket: asset.bucket,
|
||||
objectKey: asset.objectKey,
|
||||
publish,
|
||||
checksumVerified: verificationDetails.checksumVerified,
|
||||
checksumUnavailable: verificationDetails.checksumUnavailable,
|
||||
});
|
||||
|
||||
return { item, metadata, verification: verificationDetails };
|
||||
}
|
||||
|
||||
export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
@@ -369,8 +677,14 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
`
|
||||
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", visibility, status
|
||||
preview_url as "previewUrl", mime_type as "mimeType",
|
||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
||||
visibility, status, upload_status as "uploadStatus",
|
||||
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
|
||||
@@ -397,6 +711,61 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
||||
cdnUrl: asset.cdnUrl,
|
||||
fileName: asset.fileName,
|
||||
expiresInSec,
|
||||
disposition: 'attachment',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function signAssetPreviewAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const assetId = requiredString(body, 'assetId');
|
||||
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
|
||||
|
||||
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",
|
||||
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');
|
||||
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';
|
||||
|
||||
return {
|
||||
item: {
|
||||
id: asset.id,
|
||||
assetType: asset.assetType,
|
||||
title: asset.title,
|
||||
fileName: asset.fileName,
|
||||
previewStatus: asset.previewStatus,
|
||||
},
|
||||
preview: await signStorageDownload({
|
||||
tenantId: auth.tenantId,
|
||||
provider: asset.storageProvider as StorageProviderName,
|
||||
bucket: asset.bucket,
|
||||
objectKey,
|
||||
cdnUrl,
|
||||
fileName,
|
||||
expiresInSec,
|
||||
disposition: 'inline',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user