feat: verify content asset uploads

This commit is contained in:
Codex
2026-06-29 03:30:06 +08:00
parent 7c41bf525f
commit 21c0634020
13 changed files with 988 additions and 35 deletions

View File

@@ -13,9 +13,13 @@ interface CatalogAssetRow {
fileName: string | null;
cdnUrl: string | null;
previewUrl: string | null;
previewObjectKey: string | null;
mimeType: string | null;
visibility: string;
regionId: string | null;
subjectId: string | null;
uploadStatus: string;
previewStatus: string;
}
async function hasActiveMembership(tenantId: string, userId: string) {
@@ -79,6 +83,17 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
return { userId, svip: true };
}
function assetPreviewable(asset: CatalogAssetRow) {
const mimeType = asset.mimeType?.split(';')[0]?.trim().toLowerCase() || '';
return asset.assetType === 'pdf' || mimeType === 'application/pdf' || asset.assetType === 'image' || mimeType.startsWith('image/');
}
function assertPublishedAsset(asset: CatalogAssetRow) {
if (asset.objectKey && asset.uploadStatus !== 'verified') {
throw new HttpError(409, 'Asset upload has not been verified', 'ASSET_UPLOAD_NOT_VERIFIED');
}
}
export async function assetsRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
@@ -128,6 +143,7 @@ export async function assetsRoute(ctx: RequestContext) {
title, category as "categoryLabel", description,
file_name as "fileName", preview_url as "previewUrl",
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
upload_status as "uploadStatus", preview_status as "previewStatus",
visibility, 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",
@@ -155,8 +171,10 @@ export async function assetDownloadRoute(ctx: RequestContext) {
`
select id, asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, file_name as "fileName",
cdn_url as "cdnUrl", preview_url as "previewUrl", visibility,
region_id as "regionId", subject_id as "subjectId"
cdn_url as "cdnUrl", preview_url as "previewUrl",
preview_object_key as "previewObjectKey", mime_type as "mimeType",
visibility, region_id as "regionId", subject_id as "subjectId",
upload_status as "uploadStatus", preview_status as "previewStatus"
from public.content_assets
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
@@ -169,6 +187,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
}
const access = await assertAssetAccess(ctx, asset);
assertPublishedAsset(asset);
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[tenantId, assetId],
@@ -181,6 +200,8 @@ export async function assetDownloadRoute(ctx: RequestContext) {
title: asset.title,
fileName: asset.fileName,
previewUrl: asset.previewUrl,
uploadStatus: asset.uploadStatus,
previewStatus: asset.previewStatus,
visibility: asset.visibility,
},
access,
@@ -192,6 +213,63 @@ export async function assetDownloadRoute(ctx: RequestContext) {
cdnUrl: asset.cdnUrl,
fileName: asset.fileName,
expiresInSec: 900,
disposition: 'attachment',
}),
};
}
export async function assetPreviewRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || '';
if (!assetId) {
throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD');
}
const asset = await queryOne<CatalogAssetRow>(
`
select id, asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, file_name as "fileName",
cdn_url as "cdnUrl", preview_url as "previewUrl",
preview_object_key as "previewObjectKey", mime_type as "mimeType",
visibility, region_id as "regionId", subject_id as "subjectId",
upload_status as "uploadStatus", preview_status as "previewStatus"
from public.content_assets
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
`,
[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 access = await assertAssetAccess(ctx, asset);
assertPublishedAsset(asset);
return {
item: {
id: asset.id,
assetType: asset.assetType,
title: asset.title,
fileName: asset.fileName,
previewUrl: asset.previewUrl,
previewStatus: asset.previewStatus,
visibility: asset.visibility,
},
access,
preview: await signStorageDownload({
tenantId,
provider: asset.storageProvider as StorageProviderName,
bucket: asset.bucket,
objectKey: asset.previewObjectKey || asset.objectKey,
cdnUrl: asset.previewUrl || asset.cdnUrl,
fileName: asset.fileName || asset.title || 'preview.pdf',
expiresInSec: 900,
disposition: 'inline',
}),
};
}

View File

@@ -1,5 +1,5 @@
import type { RouteDefinition } from '../../core/router.js';
import { assetDownloadRoute, assetsRoute } from './assets.js';
import { assetDownloadRoute, assetPreviewRoute, assetsRoute } from './assets.js';
import {
collectionQuestionsRoute,
contentEntriesRoute,
@@ -46,6 +46,7 @@ export const catalogRoutes: RouteDefinition[] = [
['GET', '/api/catalog/practice-blueprints', practiceBlueprintsRoute],
['GET', '/api/catalog/assets', assetsRoute],
['GET', '/api/catalog/assets/download', assetDownloadRoute],
['GET', '/api/catalog/assets/preview', assetPreviewRoute],
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],
['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute],
['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute],

View File

@@ -5,7 +5,8 @@ import { config } from '../../core/config.js';
import { HttpError } from '../../core/http.js';
export type StorageProviderName = 'external_url' | 'supabase_storage' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'local_dev';
export type StorageHttpMethod = 'GET' | 'PUT';
export type StorageHttpMethod = 'GET' | 'PUT' | 'HEAD';
export type StorageContentDisposition = 'attachment' | 'inline';
export interface AssetLocation {
provider: StorageProviderName;
@@ -34,6 +35,7 @@ export interface DownloadSignInput {
cdnUrl?: string | null;
fileName?: string | null;
expiresInSec: number;
disposition?: StorageContentDisposition;
}
export interface SignedStorageUrl {
@@ -48,6 +50,30 @@ export interface SignedStorageUrl {
signatureMode: string;
}
export interface HeadStorageObjectInput {
tenantId: string;
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
declaredMimeType?: string | null;
declaredFileSizeBytes?: number | null;
declaredChecksumSha256?: string | null;
}
export interface StorageObjectMetadata {
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
exists: boolean;
sizeBytes: number | null;
mimeType: string | null;
checksumSha256: string | null;
etag: string | null;
lastModified: string | null;
rawHeaders: Record<string, string>;
verificationSource: string;
}
const REAL_STORAGE_PROVIDERS = new Set<StorageProviderName>(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
const SUPPORTED_STORAGE_PROVIDERS = new Set<StorageProviderName>([
'external_url',
@@ -156,14 +182,15 @@ function localSignedUrl(input: {
objectKey: string | null;
method: StorageHttpMethod;
expiresInSec: number;
disposition?: StorageContentDisposition;
headers?: Record<string, string>;
}): SignedStorageUrl {
const expires = expiresAt(input.expiresInSec);
const base = config.storagePublicBaseUrl.replace(/\/+$/, '');
const path = `${encodeURIComponent(input.bucket || 'default')}/${encodeURI(input.objectKey || 'missing')}`;
const url = base
? `${base}/${path}?expiresAt=${encodeURIComponent(expires)}`
: `${input.provider}://${input.bucket || 'default'}/${input.objectKey || 'missing'}?expiresAt=${encodeURIComponent(expires)}`;
? `${base}/${path}?expiresAt=${encodeURIComponent(expires)}&disposition=${input.disposition || 'attachment'}`
: `${input.provider}://${input.bucket || 'default'}/${input.objectKey || 'missing'}?expiresAt=${encodeURIComponent(expires)}&disposition=${input.disposition || 'attachment'}`;
return {
provider: input.provider,
@@ -185,6 +212,7 @@ async function signAliyunOss(input: {
mimeType?: string;
fileName?: string | null;
expiresInSec: number;
disposition?: StorageContentDisposition;
}): Promise<SignedStorageUrl> {
requireConfigured(config.aliyunOssAccessKeyId, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_ID');
requireConfigured(config.aliyunOssAccessKeySecret, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_SECRET');
@@ -212,7 +240,7 @@ async function signAliyunOss(input: {
}
if (input.method === 'GET' && input.fileName) {
options.response = {
'content-disposition': `attachment; filename="${encodeURIComponent(input.fileName)}"`,
'content-disposition': `${input.disposition || 'attachment'}; filename="${encodeURIComponent(input.fileName)}"`,
};
}
@@ -259,6 +287,7 @@ function signTencentCos(input: {
method: StorageHttpMethod;
mimeType?: string;
expiresInSec: number;
responseContentDisposition?: string;
}): SignedStorageUrl {
requireConfigured(config.tencentCosSecretId, 'tencent_cos', 'TENCENT_COS_SECRET_ID');
requireConfigured(config.tencentCosSecretKey, 'tencent_cos', 'TENCENT_COS_SECRET_KEY');
@@ -275,10 +304,16 @@ function signTencentCos(input: {
const httpHeaders = headerKeys
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedHeaders[key]).toLowerCase()}`)
.join('&');
const urlParamList = config.tencentCosSecurityToken ? 'x-cos-security-token' : '';
const httpParameters = config.tencentCosSecurityToken
? `x-cos-security-token=${encodeURIComponent(config.tencentCosSecurityToken)}`
: '';
const signedQuery: Record<string, string> = {};
if (config.tencentCosSecurityToken) signedQuery['x-cos-security-token'] = config.tencentCosSecurityToken;
if (input.method === 'GET' && input.responseContentDisposition) {
signedQuery['response-content-disposition'] = input.responseContentDisposition;
}
const queryKeys = Object.keys(signedQuery).sort();
const urlParamList = queryKeys.join(';');
const httpParameters = queryKeys
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedQuery[key])}`)
.join('&');
const httpString = `${method}\n${pathname}\n${httpParameters}\n${httpHeaders}\n`;
const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`;
const signKey = hmacSha1Hex(config.tencentCosSecretKey, keyTime);
@@ -291,7 +326,7 @@ function signTencentCos(input: {
query.set('q-header-list', headerList);
query.set('q-url-param-list', urlParamList);
query.set('q-signature', signature);
if (config.tencentCosSecurityToken) query.set('x-cos-security-token', config.tencentCosSecurityToken);
for (const key of queryKeys) query.set(key, signedQuery[key]);
const headers: Record<string, string> = {};
if (input.method === 'PUT' && input.mimeType) headers['content-type'] = input.mimeType;
@@ -342,9 +377,10 @@ async function signSupabaseStorageDownload(input: DownloadSignInput): Promise<Si
if (!input.bucket || !input.objectKey) {
throw new HttpError(400, 'Supabase Storage asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED');
}
const response = await supabaseStorageClient().from(input.bucket).createSignedUrl(input.objectKey, input.expiresInSec, {
download: input.fileName || true,
});
const options = input.disposition === 'inline'
? undefined
: { download: input.fileName || true };
const response = await supabaseStorageClient().from(input.bucket).createSignedUrl(input.objectKey, input.expiresInSec, options);
if (response.error || !response.data) {
throw new HttpError(502, response.error?.message || 'Supabase Storage download signing failed', 'STORAGE_SIGN_FAILED');
}
@@ -374,6 +410,7 @@ export async function signStorageUpload(input: UploadSignInput): Promise<SignedS
objectKey: input.objectKey,
method: 'PUT',
expiresInSec: input.expiresInSec,
disposition: 'attachment',
headers: { 'content-type': mimeType },
});
}
@@ -424,6 +461,7 @@ export async function signStorageDownload(input: DownloadSignInput): Promise<Sig
objectKey: input.objectKey,
method: 'GET',
expiresInSec: input.expiresInSec,
disposition: input.disposition || 'attachment',
});
}
if (input.provider === 'aliyun_oss') {
@@ -434,6 +472,7 @@ export async function signStorageDownload(input: DownloadSignInput): Promise<Sig
method: 'GET',
fileName: input.fileName,
expiresInSec: input.expiresInSec,
disposition: input.disposition || 'attachment',
});
}
if (input.provider === 'tencent_cos') {
@@ -442,6 +481,9 @@ export async function signStorageDownload(input: DownloadSignInput): Promise<Sig
bucket: input.bucket,
objectKey: input.objectKey,
method: 'GET',
responseContentDisposition: input.fileName
? `${input.disposition || 'attachment'}; filename="${encodeURIComponent(input.fileName)}"`
: undefined,
expiresInSec: input.expiresInSec,
});
}
@@ -450,3 +492,207 @@ export async function signStorageDownload(input: DownloadSignInput): Promise<Sig
}
throw new HttpError(400, `${input.provider} asset requires cdnUrl`, 'ASSET_LOCATION_REQUIRED');
}
function normalizeHeaderMap(headers: Headers | Record<string, unknown>) {
const normalized: Record<string, string> = {};
if (headers instanceof Headers) {
headers.forEach((value, key) => {
normalized[key.toLowerCase()] = value;
});
return normalized;
}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined || value === null) continue;
normalized[key.toLowerCase()] = Array.isArray(value) ? String(value[0] || '') : String(value);
}
return normalized;
}
function numberHeader(headers: Record<string, string>, key: string) {
const value = Number(headers[key]);
return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : null;
}
function firstHeader(headers: Record<string, string>, keys: string[]) {
for (const key of keys) {
const value = headers[key.toLowerCase()];
if (value) return value;
}
return null;
}
function cleanEtag(value: string | null) {
return value ? value.replace(/^"+|"+$/g, '') : null;
}
function metadataFromHeaders(input: {
provider: StorageProviderName;
bucket: string | null;
objectKey: string | null;
headers: Record<string, string>;
verificationSource: string;
}): StorageObjectMetadata {
const checksumSha256 = firstHeader(input.headers, [
'x-oss-meta-sha256',
'x-oss-meta-checksum-sha256',
'x-cos-meta-sha256',
'x-cos-meta-checksum-sha256',
'x-amz-meta-sha256',
'x-amz-meta-checksum-sha256',
]);
return {
provider: input.provider,
bucket: input.bucket,
objectKey: input.objectKey,
exists: true,
sizeBytes: numberHeader(input.headers, 'content-length'),
mimeType: firstHeader(input.headers, ['content-type']),
checksumSha256: checksumSha256?.trim().toLowerCase() || null,
etag: cleanEtag(firstHeader(input.headers, ['etag'])),
lastModified: firstHeader(input.headers, ['last-modified']),
rawHeaders: input.headers,
verificationSource: input.verificationSource,
};
}
async function headAliyunOssObject(input: HeadStorageObjectInput): Promise<StorageObjectMetadata> {
if (!input.bucket || !input.objectKey) {
throw new HttpError(400, 'Aliyun OSS asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED');
}
requireConfigured(config.aliyunOssAccessKeyId, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_ID');
requireConfigured(config.aliyunOssAccessKeySecret, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_SECRET');
requireConfigured(config.aliyunOssRegion || config.aliyunOssEndpoint, 'aliyun_oss', 'ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT');
const client = new OSS({
region: config.aliyunOssRegion || undefined,
endpoint: config.aliyunOssEndpoint || undefined,
accessKeyId: config.aliyunOssAccessKeyId,
accessKeySecret: config.aliyunOssAccessKeySecret,
stsToken: config.aliyunOssStsToken || undefined,
bucket: input.bucket,
internal: config.aliyunOssInternal,
secure: true,
});
try {
const response = await client.head(input.objectKey);
const headers = normalizeHeaderMap(response.res?.headers || response);
return metadataFromHeaders({
provider: 'aliyun_oss',
bucket: input.bucket,
objectKey: input.objectKey,
headers,
verificationSource: 'aliyun-oss-head-object',
});
} catch (error) {
const status = Number((error as { status?: number; statusCode?: number }).status || (error as { statusCode?: number }).statusCode || 0);
if (status === 404) {
throw new HttpError(409, 'Uploaded object was not found in Aliyun OSS', 'STORAGE_OBJECT_NOT_FOUND');
}
throw error;
}
}
async function headTencentCosObject(input: HeadStorageObjectInput): Promise<StorageObjectMetadata> {
if (!input.bucket || !input.objectKey) {
throw new HttpError(400, 'Tencent COS asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED');
}
const signed = signTencentCos({
bucket: input.bucket,
objectKey: input.objectKey,
method: 'HEAD',
expiresInSec: 60,
});
const response = await fetch(signed.url, { method: 'HEAD' });
if (response.status === 404) {
throw new HttpError(409, 'Uploaded object was not found in Tencent COS', 'STORAGE_OBJECT_NOT_FOUND');
}
if (!response.ok) {
throw new HttpError(502, `Tencent COS object metadata check failed: ${response.status}`, 'STORAGE_HEAD_FAILED');
}
return metadataFromHeaders({
provider: 'tencent_cos',
bucket: input.bucket,
objectKey: input.objectKey,
headers: normalizeHeaderMap(response.headers),
verificationSource: 'tencent-cos-head-object',
});
}
async function headSupabaseStorageObject(input: HeadStorageObjectInput): Promise<StorageObjectMetadata> {
if (!input.bucket || !input.objectKey) {
throw new HttpError(400, 'Supabase Storage asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED');
}
const parts = input.objectKey.split('/');
const name = parts.pop() || '';
const folder = parts.join('/');
const response = await supabaseStorageClient().from(input.bucket).list(folder || undefined, {
search: name,
limit: 20,
});
if (response.error) {
throw new HttpError(502, response.error.message || 'Supabase Storage metadata check failed', 'STORAGE_HEAD_FAILED');
}
const file = response.data?.find(item => item.name === name);
if (!file) {
throw new HttpError(409, 'Uploaded object was not found in Supabase Storage', 'STORAGE_OBJECT_NOT_FOUND');
}
const metadata = (file as { metadata?: Record<string, unknown> }).metadata || {};
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined && value !== null) headers[key.toLowerCase()] = String(value);
}
const size = Number(metadata.size ?? metadata.contentLength);
return {
provider: 'supabase_storage',
bucket: input.bucket,
objectKey: input.objectKey,
exists: true,
sizeBytes: Number.isFinite(size) && size >= 0 ? Math.trunc(size) : null,
mimeType: typeof metadata.mimetype === 'string' ? metadata.mimetype : typeof metadata.contentType === 'string' ? metadata.contentType : null,
checksumSha256: typeof metadata.sha256 === 'string' ? metadata.sha256.trim().toLowerCase() : null,
etag: typeof metadata.eTag === 'string' ? metadata.eTag : typeof metadata.etag === 'string' ? metadata.etag : null,
lastModified: typeof file.updated_at === 'string' ? file.updated_at : null,
rawHeaders: headers,
verificationSource: 'supabase-storage-list-metadata',
};
}
export async function headStorageObject(input: HeadStorageObjectInput): Promise<StorageObjectMetadata> {
if (!input.objectKey) {
throw new HttpError(400, 'Asset requires objectKey', 'ASSET_LOCATION_REQUIRED');
}
const objectKey = validateObjectKey(input.tenantId, input.objectKey);
const fileSizeBytes = input.declaredFileSizeBytes === undefined
? null
: validateFileSize(input.declaredFileSizeBytes);
const mimeType = input.declaredMimeType ? validateMimeType(input.declaredMimeType) : null;
const checksumSha256 = input.declaredChecksumSha256?.trim().toLowerCase() || null;
if (input.provider === 'local_dev') {
return {
provider: input.provider,
bucket: input.bucket,
objectKey,
exists: true,
sizeBytes: fileSizeBytes,
mimeType,
checksumSha256,
etag: checksumSha256,
lastModified: new Date().toISOString(),
rawHeaders: {},
verificationSource: 'local-dev-declared-metadata',
};
}
if (input.provider === 'aliyun_oss') {
return headAliyunOssObject({ ...input, objectKey });
}
if (input.provider === 'tencent_cos') {
return headTencentCosObject({ ...input, objectKey });
}
if (input.provider === 'supabase_storage') {
return headSupabaseStorageObject({ ...input, objectKey });
}
throw new HttpError(400, `${input.provider} does not support upload confirmation`, 'UPLOAD_CONFIRM_PROVIDER_NOT_SUPPORTED');
}

View File

@@ -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',
}),
};
}

View File

@@ -1,7 +1,9 @@
import type { RouteDefinition } from '../../core/router.js';
import {
assetsAdminRoute,
confirmAssetUploadRoute,
signAssetDownloadAdminRoute,
signAssetPreviewAdminRoute,
signAssetUploadRoute,
upsertAssetRoute,
} from './assets.js';
@@ -73,7 +75,9 @@ export const tenantContentRoutes: RouteDefinition[] = [
['GET', '/api/tenant-content/assets', assetsAdminRoute],
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
['POST', '/api/tenant-content/assets/confirm-upload', confirmAssetUploadRoute],
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
['POST', '/api/tenant-content/assets/sign-preview', signAssetPreviewAdminRoute],
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
['POST', '/api/tenant-content/imports/preview/vocabulary', previewVocabularyImportRoute],

View File

@@ -17,8 +17,17 @@ declare module 'ali-oss' {
[key: string]: unknown;
}
interface HeadObjectResult {
res?: {
headers?: Record<string, string | string[]>;
status?: number;
};
[key: string]: unknown;
}
export default class OSS {
constructor(options: ClientOptions);
signatureUrl(name: string, options?: SignatureUrlOptions, strictObjectNameValidation?: boolean): string;
head(name: string): Promise<HeadObjectResult>;
}
}