forked from wangziqi/gongxue-base
feat: verify content asset uploads
This commit is contained in:
@@ -13,9 +13,13 @@ interface CatalogAssetRow {
|
|||||||
fileName: string | null;
|
fileName: string | null;
|
||||||
cdnUrl: string | null;
|
cdnUrl: string | null;
|
||||||
previewUrl: string | null;
|
previewUrl: string | null;
|
||||||
|
previewObjectKey: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
visibility: string;
|
visibility: string;
|
||||||
regionId: string | null;
|
regionId: string | null;
|
||||||
subjectId: string | null;
|
subjectId: string | null;
|
||||||
|
uploadStatus: string;
|
||||||
|
previewStatus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasActiveMembership(tenantId: string, userId: string) {
|
async function hasActiveMembership(tenantId: string, userId: string) {
|
||||||
@@ -79,6 +83,17 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
|
|||||||
return { userId, svip: true };
|
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) {
|
export async function assetsRoute(ctx: RequestContext) {
|
||||||
const tenantId = await tenantIdFrom(ctx);
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
const limit = intParam(ctx, 'limit', 100, 500);
|
const limit = intParam(ctx, 'limit', 100, 500);
|
||||||
@@ -128,6 +143,7 @@ export async function assetsRoute(ctx: RequestContext) {
|
|||||||
title, category as "categoryLabel", description,
|
title, category as "categoryLabel", description,
|
||||||
file_name as "fileName", preview_url as "previewUrl",
|
file_name as "fileName", preview_url as "previewUrl",
|
||||||
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
|
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",
|
visibility, region_id as "regionId", subject_id as "subjectId",
|
||||||
category_id as "categoryId", node_id as "nodeId",
|
category_id as "categoryId", node_id as "nodeId",
|
||||||
entry_id as "entryId", content_node_id as "contentNodeId",
|
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",
|
select id, asset_type as "assetType", storage_provider as "storageProvider",
|
||||||
bucket, object_key as "objectKey", title, file_name as "fileName",
|
bucket, object_key as "objectKey", title, file_name as "fileName",
|
||||||
cdn_url as "cdnUrl", preview_url as "previewUrl", visibility,
|
cdn_url as "cdnUrl", preview_url as "previewUrl",
|
||||||
region_id as "regionId", subject_id as "subjectId"
|
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
|
from public.content_assets
|
||||||
where tenant_id = $1 and id = $2 and status = 'active'
|
where tenant_id = $1 and id = $2 and status = 'active'
|
||||||
limit 1
|
limit 1
|
||||||
@@ -169,6 +187,7 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const access = await assertAssetAccess(ctx, asset);
|
const access = await assertAssetAccess(ctx, asset);
|
||||||
|
assertPublishedAsset(asset);
|
||||||
await query(
|
await query(
|
||||||
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
|
||||||
[tenantId, assetId],
|
[tenantId, assetId],
|
||||||
@@ -181,6 +200,8 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
|||||||
title: asset.title,
|
title: asset.title,
|
||||||
fileName: asset.fileName,
|
fileName: asset.fileName,
|
||||||
previewUrl: asset.previewUrl,
|
previewUrl: asset.previewUrl,
|
||||||
|
uploadStatus: asset.uploadStatus,
|
||||||
|
previewStatus: asset.previewStatus,
|
||||||
visibility: asset.visibility,
|
visibility: asset.visibility,
|
||||||
},
|
},
|
||||||
access,
|
access,
|
||||||
@@ -192,6 +213,63 @@ export async function assetDownloadRoute(ctx: RequestContext) {
|
|||||||
cdnUrl: asset.cdnUrl,
|
cdnUrl: asset.cdnUrl,
|
||||||
fileName: asset.fileName,
|
fileName: asset.fileName,
|
||||||
expiresInSec: 900,
|
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',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RouteDefinition } from '../../core/router.js';
|
import type { RouteDefinition } from '../../core/router.js';
|
||||||
import { assetDownloadRoute, assetsRoute } from './assets.js';
|
import { assetDownloadRoute, assetPreviewRoute, assetsRoute } from './assets.js';
|
||||||
import {
|
import {
|
||||||
collectionQuestionsRoute,
|
collectionQuestionsRoute,
|
||||||
contentEntriesRoute,
|
contentEntriesRoute,
|
||||||
@@ -46,6 +46,7 @@ export const catalogRoutes: RouteDefinition[] = [
|
|||||||
['GET', '/api/catalog/practice-blueprints', practiceBlueprintsRoute],
|
['GET', '/api/catalog/practice-blueprints', practiceBlueprintsRoute],
|
||||||
['GET', '/api/catalog/assets', assetsRoute],
|
['GET', '/api/catalog/assets', assetsRoute],
|
||||||
['GET', '/api/catalog/assets/download', assetDownloadRoute],
|
['GET', '/api/catalog/assets/download', assetDownloadRoute],
|
||||||
|
['GET', '/api/catalog/assets/preview', assetPreviewRoute],
|
||||||
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],
|
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],
|
||||||
['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute],
|
['GET', '/api/catalog/vocabulary-words', vocabularyWordsRoute],
|
||||||
['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute],
|
['GET', '/api/catalog/handbook-subjects', handbookSubjectsRoute],
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { config } from '../../core/config.js';
|
|||||||
import { HttpError } from '../../core/http.js';
|
import { HttpError } from '../../core/http.js';
|
||||||
|
|
||||||
export type StorageProviderName = 'external_url' | 'supabase_storage' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'local_dev';
|
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 {
|
export interface AssetLocation {
|
||||||
provider: StorageProviderName;
|
provider: StorageProviderName;
|
||||||
@@ -34,6 +35,7 @@ export interface DownloadSignInput {
|
|||||||
cdnUrl?: string | null;
|
cdnUrl?: string | null;
|
||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
expiresInSec: number;
|
expiresInSec: number;
|
||||||
|
disposition?: StorageContentDisposition;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignedStorageUrl {
|
export interface SignedStorageUrl {
|
||||||
@@ -48,6 +50,30 @@ export interface SignedStorageUrl {
|
|||||||
signatureMode: string;
|
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 REAL_STORAGE_PROVIDERS = new Set<StorageProviderName>(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
|
||||||
const SUPPORTED_STORAGE_PROVIDERS = new Set<StorageProviderName>([
|
const SUPPORTED_STORAGE_PROVIDERS = new Set<StorageProviderName>([
|
||||||
'external_url',
|
'external_url',
|
||||||
@@ -156,14 +182,15 @@ function localSignedUrl(input: {
|
|||||||
objectKey: string | null;
|
objectKey: string | null;
|
||||||
method: StorageHttpMethod;
|
method: StorageHttpMethod;
|
||||||
expiresInSec: number;
|
expiresInSec: number;
|
||||||
|
disposition?: StorageContentDisposition;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}): SignedStorageUrl {
|
}): SignedStorageUrl {
|
||||||
const expires = expiresAt(input.expiresInSec);
|
const expires = expiresAt(input.expiresInSec);
|
||||||
const base = config.storagePublicBaseUrl.replace(/\/+$/, '');
|
const base = config.storagePublicBaseUrl.replace(/\/+$/, '');
|
||||||
const path = `${encodeURIComponent(input.bucket || 'default')}/${encodeURI(input.objectKey || 'missing')}`;
|
const path = `${encodeURIComponent(input.bucket || 'default')}/${encodeURI(input.objectKey || 'missing')}`;
|
||||||
const url = base
|
const url = base
|
||||||
? `${base}/${path}?expiresAt=${encodeURIComponent(expires)}`
|
? `${base}/${path}?expiresAt=${encodeURIComponent(expires)}&disposition=${input.disposition || 'attachment'}`
|
||||||
: `${input.provider}://${input.bucket || 'default'}/${input.objectKey || 'missing'}?expiresAt=${encodeURIComponent(expires)}`;
|
: `${input.provider}://${input.bucket || 'default'}/${input.objectKey || 'missing'}?expiresAt=${encodeURIComponent(expires)}&disposition=${input.disposition || 'attachment'}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
@@ -185,6 +212,7 @@ async function signAliyunOss(input: {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileName?: string | null;
|
fileName?: string | null;
|
||||||
expiresInSec: number;
|
expiresInSec: number;
|
||||||
|
disposition?: StorageContentDisposition;
|
||||||
}): Promise<SignedStorageUrl> {
|
}): Promise<SignedStorageUrl> {
|
||||||
requireConfigured(config.aliyunOssAccessKeyId, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_ID');
|
requireConfigured(config.aliyunOssAccessKeyId, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_ID');
|
||||||
requireConfigured(config.aliyunOssAccessKeySecret, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_SECRET');
|
requireConfigured(config.aliyunOssAccessKeySecret, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_SECRET');
|
||||||
@@ -212,7 +240,7 @@ async function signAliyunOss(input: {
|
|||||||
}
|
}
|
||||||
if (input.method === 'GET' && input.fileName) {
|
if (input.method === 'GET' && input.fileName) {
|
||||||
options.response = {
|
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;
|
method: StorageHttpMethod;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
expiresInSec: number;
|
expiresInSec: number;
|
||||||
|
responseContentDisposition?: string;
|
||||||
}): SignedStorageUrl {
|
}): SignedStorageUrl {
|
||||||
requireConfigured(config.tencentCosSecretId, 'tencent_cos', 'TENCENT_COS_SECRET_ID');
|
requireConfigured(config.tencentCosSecretId, 'tencent_cos', 'TENCENT_COS_SECRET_ID');
|
||||||
requireConfigured(config.tencentCosSecretKey, 'tencent_cos', 'TENCENT_COS_SECRET_KEY');
|
requireConfigured(config.tencentCosSecretKey, 'tencent_cos', 'TENCENT_COS_SECRET_KEY');
|
||||||
@@ -275,10 +304,16 @@ function signTencentCos(input: {
|
|||||||
const httpHeaders = headerKeys
|
const httpHeaders = headerKeys
|
||||||
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedHeaders[key]).toLowerCase()}`)
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedHeaders[key]).toLowerCase()}`)
|
||||||
.join('&');
|
.join('&');
|
||||||
const urlParamList = config.tencentCosSecurityToken ? 'x-cos-security-token' : '';
|
const signedQuery: Record<string, string> = {};
|
||||||
const httpParameters = config.tencentCosSecurityToken
|
if (config.tencentCosSecurityToken) signedQuery['x-cos-security-token'] = config.tencentCosSecurityToken;
|
||||||
? `x-cos-security-token=${encodeURIComponent(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 httpString = `${method}\n${pathname}\n${httpParameters}\n${httpHeaders}\n`;
|
||||||
const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`;
|
const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`;
|
||||||
const signKey = hmacSha1Hex(config.tencentCosSecretKey, keyTime);
|
const signKey = hmacSha1Hex(config.tencentCosSecretKey, keyTime);
|
||||||
@@ -291,7 +326,7 @@ function signTencentCos(input: {
|
|||||||
query.set('q-header-list', headerList);
|
query.set('q-header-list', headerList);
|
||||||
query.set('q-url-param-list', urlParamList);
|
query.set('q-url-param-list', urlParamList);
|
||||||
query.set('q-signature', signature);
|
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> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (input.method === 'PUT' && input.mimeType) headers['content-type'] = input.mimeType;
|
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) {
|
if (!input.bucket || !input.objectKey) {
|
||||||
throw new HttpError(400, 'Supabase Storage asset requires bucket and objectKey', 'ASSET_OBJECT_LOCATION_REQUIRED');
|
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, {
|
const options = input.disposition === 'inline'
|
||||||
download: input.fileName || true,
|
? undefined
|
||||||
});
|
: { download: input.fileName || true };
|
||||||
|
const response = await supabaseStorageClient().from(input.bucket).createSignedUrl(input.objectKey, input.expiresInSec, options);
|
||||||
if (response.error || !response.data) {
|
if (response.error || !response.data) {
|
||||||
throw new HttpError(502, response.error?.message || 'Supabase Storage download signing failed', 'STORAGE_SIGN_FAILED');
|
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,
|
objectKey: input.objectKey,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
expiresInSec: input.expiresInSec,
|
expiresInSec: input.expiresInSec,
|
||||||
|
disposition: 'attachment',
|
||||||
headers: { 'content-type': mimeType },
|
headers: { 'content-type': mimeType },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -424,6 +461,7 @@ export async function signStorageDownload(input: DownloadSignInput): Promise<Sig
|
|||||||
objectKey: input.objectKey,
|
objectKey: input.objectKey,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
expiresInSec: input.expiresInSec,
|
expiresInSec: input.expiresInSec,
|
||||||
|
disposition: input.disposition || 'attachment',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (input.provider === 'aliyun_oss') {
|
if (input.provider === 'aliyun_oss') {
|
||||||
@@ -434,6 +472,7 @@ export async function signStorageDownload(input: DownloadSignInput): Promise<Sig
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
fileName: input.fileName,
|
fileName: input.fileName,
|
||||||
expiresInSec: input.expiresInSec,
|
expiresInSec: input.expiresInSec,
|
||||||
|
disposition: input.disposition || 'attachment',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (input.provider === 'tencent_cos') {
|
if (input.provider === 'tencent_cos') {
|
||||||
@@ -442,6 +481,9 @@ export async function signStorageDownload(input: DownloadSignInput): Promise<Sig
|
|||||||
bucket: input.bucket,
|
bucket: input.bucket,
|
||||||
objectKey: input.objectKey,
|
objectKey: input.objectKey,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
responseContentDisposition: input.fileName
|
||||||
|
? `${input.disposition || 'attachment'}; filename="${encodeURIComponent(input.fileName)}"`
|
||||||
|
: undefined,
|
||||||
expiresInSec: input.expiresInSec,
|
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');
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
configuredDefaultStorageBucket,
|
configuredDefaultStorageBucket,
|
||||||
assertWritableLocation,
|
assertWritableLocation,
|
||||||
configuredDefaultStorageProvider,
|
configuredDefaultStorageProvider,
|
||||||
|
headStorageObject,
|
||||||
normalizeStorageProvider,
|
normalizeStorageProvider,
|
||||||
signStorageDownload,
|
signStorageDownload,
|
||||||
signStorageUpload,
|
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 STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
|
||||||
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
|
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
|
||||||
const ASSET_STATUSES = ['draft', 'active', 'archived'];
|
const ASSET_STATUSES = ['draft', 'active', 'archived'];
|
||||||
|
const UPLOAD_STATUSES = ['not_required', 'pending', 'verified', 'failed'];
|
||||||
|
const PREVIEW_STATUSES = ['none', 'pending', 'ready', 'failed'];
|
||||||
|
|
||||||
interface AssetRow {
|
interface AssetRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,12 +33,20 @@ interface AssetRow {
|
|||||||
storageProvider: string;
|
storageProvider: string;
|
||||||
bucket: string | null;
|
bucket: string | null;
|
||||||
objectKey: string | null;
|
objectKey: string | null;
|
||||||
|
previewObjectKey: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
fileName: string | null;
|
fileName: string | null;
|
||||||
cdnUrl: string | null;
|
cdnUrl: string | null;
|
||||||
previewUrl: string | null;
|
previewUrl: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
fileSizeBytes: number | null;
|
||||||
|
checksumSha256: string | null;
|
||||||
visibility: string;
|
visibility: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
uploadStatus: string;
|
||||||
|
verifiedSizeBytes: number | null;
|
||||||
|
verifiedChecksumSha256: string | null;
|
||||||
|
previewStatus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
|
||||||
@@ -58,6 +69,89 @@ function safeFileName(fileName: string) {
|
|||||||
.slice(0, 160) || 'asset';
|
.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) {
|
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const row = await queryOne<{ id: string }>(
|
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",
|
description, file_name as "fileName", cdn_url as "cdnUrl",
|
||||||
preview_url as "previewUrl", mime_type as "mimeType",
|
preview_url as "previewUrl", mime_type as "mimeType",
|
||||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
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",
|
visibility, is_public as "isPublic", region_id as "regionId",
|
||||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||||
entry_id as "entryId", content_node_id as "contentNodeId",
|
entry_id as "entryId", content_node_id as "contentNodeId",
|
||||||
@@ -165,11 +265,11 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
|||||||
'storageProvider',
|
'storageProvider',
|
||||||
) as StorageProviderName;
|
) as StorageProviderName;
|
||||||
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
|
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 bucket = nullableString(body.bucket);
|
||||||
const objectKey = nullableString(body.objectKey);
|
const objectKey = nullableString(body.objectKey);
|
||||||
const cdnUrl = nullableString(body.cdnUrl);
|
const cdnUrl = nullableString(body.cdnUrl);
|
||||||
const title = requiredString(body, 'title');
|
const title = requiredString(body, 'title');
|
||||||
|
const existing = await existingAssetForUpsert(auth.tenantId, nullableString(body.id));
|
||||||
const regionId = nullableUuid(body.regionId);
|
const regionId = nullableUuid(body.regionId);
|
||||||
const subjectId = nullableUuid(body.subjectId);
|
const subjectId = nullableUuid(body.subjectId);
|
||||||
const categoryId = nullableUuid(body.categoryId);
|
const categoryId = nullableUuid(body.categoryId);
|
||||||
@@ -178,10 +278,29 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
|||||||
const contentNodeId = nullableUuid(body.contentNodeId);
|
const contentNodeId = nullableUuid(body.contentNodeId);
|
||||||
|
|
||||||
const cleanObjectKey = objectKey ? validateObjectKey(auth.tenantId, objectKey) : null;
|
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);
|
const mimeType = nullableString(body.mimeType);
|
||||||
if (mimeType) validateMimeType(mimeType);
|
if (mimeType) validateMimeType(mimeType);
|
||||||
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
|
const fileSizeBytes = body.fileSizeBytes === undefined ? null : validateFileSize(intValue(body.fileSizeBytes, 0));
|
||||||
|
const checksum = checksumSha256(body.checksumSha256);
|
||||||
assertWritableLocation({ provider: storageProvider, bucket, objectKey: cleanObjectKey, cdnUrl });
|
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) {
|
if (status === 'active' && !cdnUrl && !objectKey) {
|
||||||
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
|
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 (
|
insert into public.content_assets (
|
||||||
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
|
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
|
||||||
bucket, object_key, title, category, description, file_name, cdn_url,
|
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,
|
is_public, region_id, subject_id, category_id, node_id, entry_id,
|
||||||
content_node_id, status,
|
content_node_id, status,
|
||||||
sort_order, access_rules, metadata, created_by, updated_by, source
|
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,
|
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10, $11, $12, $13,
|
$7, $8, $9, $10, $11, $12, $13,
|
||||||
$14, $15, $16, $17, $18,
|
$14, $15, $16, $17, $18,
|
||||||
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24::uuid,
|
$19, $20, $21,
|
||||||
$25::uuid, $26,
|
$22, $23::uuid, $24::uuid, $25::uuid, $26::uuid, $27::uuid,
|
||||||
$27, $28::jsonb, $29::jsonb, $30, $30, $31
|
$28::uuid, $29,
|
||||||
|
$30, $31::jsonb, $32::jsonb, $33, $33, $34
|
||||||
)
|
)
|
||||||
on conflict (id)
|
on conflict (id)
|
||||||
do update set legacy_id = excluded.legacy_id,
|
do update set legacy_id = excluded.legacy_id,
|
||||||
@@ -228,6 +349,37 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
|||||||
mime_type = excluded.mime_type,
|
mime_type = excluded.mime_type,
|
||||||
file_size_bytes = excluded.file_size_bytes,
|
file_size_bytes = excluded.file_size_bytes,
|
||||||
checksum_sha256 = excluded.checksum_sha256,
|
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,
|
visibility = excluded.visibility,
|
||||||
is_public = excluded.is_public,
|
is_public = excluded.is_public,
|
||||||
region_id = excluded.region_id,
|
region_id = excluded.region_id,
|
||||||
@@ -250,6 +402,12 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
|||||||
description, file_name as "fileName", cdn_url as "cdnUrl",
|
description, file_name as "fileName", cdn_url as "cdnUrl",
|
||||||
preview_url as "previewUrl", mime_type as "mimeType",
|
preview_url as "previewUrl", mime_type as "mimeType",
|
||||||
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
|
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",
|
visibility, is_public as "isPublic", region_id as "regionId",
|
||||||
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
|
||||||
entry_id as "entryId", content_node_id as "contentNodeId",
|
entry_id as "entryId", content_node_id as "contentNodeId",
|
||||||
@@ -275,7 +433,10 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
|||||||
nullableString(body.previewUrl),
|
nullableString(body.previewUrl),
|
||||||
mimeType,
|
mimeType,
|
||||||
fileSizeBytes,
|
fileSizeBytes,
|
||||||
nullableString(body.checksumSha256),
|
checksum,
|
||||||
|
nextUploadStatus,
|
||||||
|
previewObjectKey,
|
||||||
|
nextPreviewStatus,
|
||||||
visibility,
|
visibility,
|
||||||
visibility === 'public',
|
visibility === 'public',
|
||||||
regionId,
|
regionId,
|
||||||
@@ -307,6 +468,153 @@ export async function upsertAssetRoute(ctx: RequestContext) {
|
|||||||
return { item };
|
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) {
|
export async function signAssetUploadRoute(ctx: RequestContext) {
|
||||||
const auth = await requireTenantContentEditor(ctx);
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
const body = await readJsonBody(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",
|
select id, tenant_id as "tenantId", asset_type as "assetType",
|
||||||
storage_provider as "storageProvider", bucket, object_key as "objectKey",
|
storage_provider as "storageProvider", bucket, object_key as "objectKey",
|
||||||
|
preview_object_key as "previewObjectKey",
|
||||||
title, file_name as "fileName", cdn_url as "cdnUrl",
|
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
|
from public.content_assets
|
||||||
where tenant_id = $1 and id = $2
|
where tenant_id = $1 and id = $2
|
||||||
limit 1
|
limit 1
|
||||||
@@ -397,6 +711,61 @@ export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
|
|||||||
cdnUrl: asset.cdnUrl,
|
cdnUrl: asset.cdnUrl,
|
||||||
fileName: asset.fileName,
|
fileName: asset.fileName,
|
||||||
expiresInSec,
|
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',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { RouteDefinition } from '../../core/router.js';
|
import type { RouteDefinition } from '../../core/router.js';
|
||||||
import {
|
import {
|
||||||
assetsAdminRoute,
|
assetsAdminRoute,
|
||||||
|
confirmAssetUploadRoute,
|
||||||
signAssetDownloadAdminRoute,
|
signAssetDownloadAdminRoute,
|
||||||
|
signAssetPreviewAdminRoute,
|
||||||
signAssetUploadRoute,
|
signAssetUploadRoute,
|
||||||
upsertAssetRoute,
|
upsertAssetRoute,
|
||||||
} from './assets.js';
|
} from './assets.js';
|
||||||
@@ -73,7 +75,9 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
|||||||
['GET', '/api/tenant-content/assets', assetsAdminRoute],
|
['GET', '/api/tenant-content/assets', assetsAdminRoute],
|
||||||
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
|
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
|
||||||
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
|
['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-download', signAssetDownloadAdminRoute],
|
||||||
|
['POST', '/api/tenant-content/assets/sign-preview', signAssetPreviewAdminRoute],
|
||||||
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
|
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
|
||||||
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
|
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
|
||||||
['POST', '/api/tenant-content/imports/preview/vocabulary', previewVocabularyImportRoute],
|
['POST', '/api/tenant-content/imports/preview/vocabulary', previewVocabularyImportRoute],
|
||||||
|
|||||||
9
apps/api/src/types/ali-oss.d.ts
vendored
9
apps/api/src/types/ali-oss.d.ts
vendored
@@ -17,8 +17,17 @@ declare module 'ali-oss' {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HeadObjectResult {
|
||||||
|
res?: {
|
||||||
|
headers?: Record<string, string | string[]>;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export default class OSS {
|
export default class OSS {
|
||||||
constructor(options: ClientOptions);
|
constructor(options: ClientOptions);
|
||||||
signatureUrl(name: string, options?: SignatureUrlOptions, strictObjectNameValidation?: boolean): string;
|
signatureUrl(name: string, options?: SignatureUrlOptions, strictObjectNameValidation?: boolean): string;
|
||||||
|
head(name: string): Promise<HeadObjectResult>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,8 +91,9 @@
|
|||||||
| 阿里云 OSS 签名 | 可联调 | `aliyun_oss` provider |
|
| 阿里云 OSS 签名 | 可联调 | `aliyun_oss` provider |
|
||||||
| 腾讯 COS 签名 | 可联调 | `tencent_cos` provider |
|
| 腾讯 COS 签名 | 可联调 | `tencent_cos` provider |
|
||||||
| Supabase Storage 签名 | 可联调 | `supabase_storage` provider |
|
| Supabase Storage 签名 | 可联调 | `supabase_storage` provider |
|
||||||
| PDF 预览/防盗链/水印 | 待补齐 | 商用上线前补齐 |
|
| 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 |
|
||||||
| 上传后对象校验 | 待补齐 | 需 worker 或 API 回调确认 size/hash/mime |
|
| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview`、`/api/tenant-content/assets/sign-preview`;使用 inline 短期签名 |
|
||||||
|
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前补 worker、CDN 防盗链、动态水印和安全扫描 |
|
||||||
|
|
||||||
## 订单、会员、营销
|
## 订单、会员、营销
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
||||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路已有;缺退款/对账/补偿任务和前端收银台体验 |
|
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路已有;缺退款/对账/补偿任务和前端收银台体验 |
|
||||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
|
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 |
|
||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、水印、防盗链和上传后对象校验 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||||
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
|
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
|
||||||
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 |
|
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 |
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 已有 |
|
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 已有 |
|
||||||
| 视频维护/绑定 | 已覆盖 | video CRUD 和 question-video 绑定已有 |
|
| 视频维护/绑定 | 已覆盖 | video CRUD 和 question-video 绑定已有 |
|
||||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 |
|
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 |
|
||||||
| 对象存储配置 | 部分覆盖 | 系统 env provider 已有;租户级存储策略、上传后校验待补 |
|
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认和预览下载签名已有;租户级存储策略、CDN/水印/杀毒待补 |
|
||||||
|
|
||||||
## 平台 SaaS 后台功能
|
## 平台 SaaS 后台功能
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
|
|
||||||
2. 对象存储
|
2. 对象存储
|
||||||
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
|
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
|
||||||
- 继续补上传后对象存在性校验、PDF 预览地址、视频深度防盗链、动态水印和 worker 校验。
|
- 已补上传后对象确认接口、托管对象发布前 verified 校验、PDF/图片 inline 预览签名。
|
||||||
|
- 继续补视频深度防盗链、动态水印、worker 复检、杀毒扫描、CDN 刷新和对象生命周期策略。
|
||||||
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
|
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
|
||||||
|
|
||||||
3. 真实导入 dry-run
|
3. 真实导入 dry-run
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 对象存储接入说明
|
# 对象存储接入说明
|
||||||
|
|
||||||
更新时间:2026-06-22
|
更新时间:2026-06-29
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
@@ -22,18 +22,30 @@
|
|||||||
POST /api/tenant-content/assets/sign-upload
|
POST /api/tenant-content/assets/sign-upload
|
||||||
```
|
```
|
||||||
|
|
||||||
后台登记资源:
|
后台登记资源草稿:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PUT /api/tenant-content/assets
|
PUT /api/tenant-content/assets
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后台确认上传并发布:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/tenant-content/assets/confirm-upload
|
||||||
|
```
|
||||||
|
|
||||||
学生端下载:
|
学生端下载:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/catalog/assets/download?assetId=...
|
GET /api/catalog/assets/download?assetId=...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
学生端预览:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/catalog/assets/preview?assetId=...
|
||||||
|
```
|
||||||
|
|
||||||
学生端视频播放:
|
学生端视频播放:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -46,12 +58,51 @@ POST /api/videos/play
|
|||||||
POST /api/tenant-content/assets/sign-download
|
POST /api/tenant-content/assets/sign-download
|
||||||
```
|
```
|
||||||
|
|
||||||
|
后台管理员预览:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/tenant-content/assets/sign-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 标准上传流程
|
||||||
|
|
||||||
|
后台前端上传 PDF、图片、视频或资料包时必须走下面流程:
|
||||||
|
|
||||||
|
1. 调用 `POST /api/tenant-content/assets/sign-upload` 申请短期上传 URL。
|
||||||
|
2. 前端使用返回的 `upload.url` 和 `upload.headers` 直传对象存储。
|
||||||
|
3. 调用 `PUT /api/tenant-content/assets` 登记资源台账。托管对象默认进入 `status=draft`、`uploadStatus=pending`。
|
||||||
|
4. 调用 `POST /api/tenant-content/assets/confirm-upload`,由后端读取对象元数据并比对大小、MIME、SHA-256。
|
||||||
|
5. 校验通过且 `publish=true` 时,后端将资源置为 `status=active`、`uploadStatus=verified`。
|
||||||
|
6. 学生端只能下载或预览 `active + verified` 的托管对象资源。
|
||||||
|
|
||||||
|
确认上传示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assetId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"fileSizeBytes": 4096,
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"publish": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- `local_dev` 用声明的元数据模拟对象 HEAD,方便本地和集成测试。
|
||||||
|
- `aliyun_oss` 使用 OSS 对象 HEAD 元数据。
|
||||||
|
- `tencent_cos` 使用 COS HEAD Object 预签名请求读取元数据。
|
||||||
|
- `supabase_storage` 使用 Storage list metadata 做最小存在性/大小校验。
|
||||||
|
- 如果 provider 无法返回 SHA-256,后端会记录 `checksumUnavailable=true`,生产建议上传时写入对象自定义元数据,例如 `x-oss-meta-sha256` 或 `x-cos-meta-sha256`。
|
||||||
|
|
||||||
## 安全规则
|
## 安全规则
|
||||||
|
|
||||||
- 只有租户内容维护权限用户可以申请上传签名。
|
- 只有租户内容维护权限用户可以申请上传签名。
|
||||||
- `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。
|
- `objectKey` 默认必须以当前 `tenantId/` 开头,防止跨租户覆盖或读取。
|
||||||
- 禁止 `..`、反斜杠、编码斜杠等危险 object key。
|
- 禁止 `..`、反斜杠、编码斜杠等危险 object key。
|
||||||
- 上传会校验 MIME 类型和文件大小。
|
- 上传会校验 MIME 类型和文件大小。
|
||||||
|
- 托管对象资源未确认前不能发布为 `active`,学生端不可下载。
|
||||||
|
- PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。
|
||||||
- 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
|
- 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
|
||||||
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
|
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
|
||||||
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
|
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
|
||||||
@@ -105,10 +156,10 @@ SUPABASE_STORAGE_SERVICE_KEY=
|
|||||||
- 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。
|
- 图片、PDF、视频分别设置合理的 CORS,只允许前端域名和小程序业务域名访问。
|
||||||
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
|
- 开启对象版本控制、生命周期、跨区域复制或定时备份,满足后续容灾要求。
|
||||||
- 视频资源已接入 SVIP/播放次数校验、短期签名和播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计。
|
- 视频资源已接入 SVIP/播放次数校验、短期签名和播放日志;生产阶段继续补转码、动态水印、CDN 防盗链和播放统计。
|
||||||
- 大文件上传后应由 worker 校验对象是否真实存在、大小/hash 是否匹配,再把资源状态从 `draft` 发布为 `active`。
|
- 大文件上传已经支持 API 即时确认;后续可增加 worker 做异步复检、杀毒、转码、水印和 CDN 刷新。
|
||||||
|
|
||||||
## 官方依据
|
## 官方依据
|
||||||
|
|
||||||
- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传或下载生成带过期时间的签名 URL。
|
- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传/下载生成带过期时间的签名 URL,并可通过对象 HEAD 读取元数据。
|
||||||
- 腾讯云 COS XML API V5 签名由 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段组成,可用于预签名 URL。
|
- 腾讯云 COS XML API V5 签名由 `q-sign-algorithm`、`q-ak`、`q-sign-time`、`q-key-time`、`q-header-list`、`q-url-param-list`、`q-signature` 等字段组成,可用于预签名 URL 和 HEAD Object。
|
||||||
- Supabase Storage 提供 `createSignedUploadUrl` 和 `createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS 和服务端权限控制。
|
- Supabase Storage 提供 `createSignedUploadUrl` 和 `createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS、服务端权限控制和资源台账。
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ tenant:<tenantId>:theme
|
|||||||
| 单词收藏 | `/api/learning/vocabulary/favorites` |
|
| 单词收藏 | `/api/learning/vocabulary/favorites` |
|
||||||
| 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` |
|
| 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` |
|
||||||
| 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years` |
|
| 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years` |
|
||||||
| 资料下载 | `/api/catalog/assets`、`/api/catalog/assets/download` |
|
| 资料下载/预览 | `/api/catalog/assets`、`/api/catalog/assets/preview`、`/api/catalog/assets/download` |
|
||||||
| 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` |
|
| 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` |
|
||||||
| 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` |
|
| 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` |
|
||||||
| 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` |
|
| 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` |
|
||||||
@@ -438,6 +438,57 @@ GET /api/learning/vocabulary/review-plan?unitId=<unitId>&reviewLimit=30&newLimit
|
|||||||
- 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。
|
- 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。
|
||||||
- 小程序/H5 不保存对象存储真实 key,不把播放 URL 写入本地持久缓存。
|
- 小程序/H5 不保存对象存储真实 key,不把播放 URL 写入本地持久缓存。
|
||||||
|
|
||||||
|
## 资料上传、预览和下载契约
|
||||||
|
|
||||||
|
学生端资料只读取目录、预览和下载签名,不接触对象存储真实密钥,也不自行拼接私有 bucket 地址。
|
||||||
|
|
||||||
|
学生端展示资料列表:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/catalog/assets?assetType=pdf®ionId=<regionId>&includeLocked=true
|
||||||
|
```
|
||||||
|
|
||||||
|
学生端 PDF/图片预览:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/catalog/assets/preview?assetId=<assetId>
|
||||||
|
```
|
||||||
|
|
||||||
|
学生端下载:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/catalog/assets/download?assetId=<assetId>
|
||||||
|
```
|
||||||
|
|
||||||
|
前端处理规则:
|
||||||
|
|
||||||
|
- `preview.url` 是短期 inline URL,只给预览组件使用,不持久化。
|
||||||
|
- `download.url` 是短期 attachment URL,只给下载动作使用。
|
||||||
|
- `ASSET_SVIP_REQUIRED`:提示开通对应地区/科目权益。
|
||||||
|
- `ASSET_UPLOAD_NOT_VERIFIED`:展示“资料正在处理中”,并上报前端日志。
|
||||||
|
- `ASSET_PREVIEW_NOT_SUPPORTED`:隐藏预览按钮,仅保留下载或提示不支持预览。
|
||||||
|
- `previewUrl` 字段只作为公开/托管预览提示,不代表可以绕过接口直接访问。
|
||||||
|
|
||||||
|
租户后台上传资料必须走五步:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sign-upload -> 直传对象存储 -> PUT assets 登记草稿 -> confirm-upload -> sign-preview 验收
|
||||||
|
```
|
||||||
|
|
||||||
|
后台上传确认:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"assetId": "<assetId>",
|
||||||
|
"fileSizeBytes": 4096,
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"publish": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
托管对象在确认前会保持 `status=draft`、`uploadStatus=pending`,学生端不会看到。确认失败时后端返回 `UPLOAD_VERIFICATION_FAILED`,后台必须展示失败原因并允许重新上传,不能前端强行改为已发布。
|
||||||
|
|
||||||
## 考试倒计时、签到积分和反馈
|
## 考试倒计时、签到积分和反馈
|
||||||
|
|
||||||
首页可用 `GET /api/catalog/exam-dates?regionId=<regionId>` 展示地区公开考试日期;个人中心优先用 `GET /api/profile/exam-countdowns`,后端会按学生当前 `regionId/selectedSchoolId` 返回匹配倒计时。
|
首页可用 `GET /api/catalog/exam-dates?regionId=<regionId>` 展示地区公开考试日期;个人中心优先用 `GET /api/profile/exam-countdowns`,后端会按学生当前 `regionId/selectedSchoolId` 返回匹配倒计时。
|
||||||
|
|||||||
@@ -1850,6 +1850,8 @@ async function testTenantContentAssetsAndImports() {
|
|||||||
assetType: 'pdf',
|
assetType: 'pdf',
|
||||||
storageProvider: 'local_dev',
|
storageProvider: 'local_dev',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
|
fileSizeBytes: 4096,
|
||||||
|
checksumSha256: 'a'.repeat(64),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(upload.assetDraft?.assetType, 'pdf', 'upload signer should return asset draft');
|
assert.equal(upload.assetDraft?.assetType, 'pdf', 'upload signer should return asset draft');
|
||||||
@@ -1989,11 +1991,34 @@ async function testTenantContentAssetsAndImports() {
|
|||||||
fileName: upload.assetDraft.fileName,
|
fileName: upload.assetDraft.fileName,
|
||||||
mimeType: upload.assetDraft.mimeType,
|
mimeType: upload.assetDraft.mimeType,
|
||||||
fileSizeBytes: upload.assetDraft.fileSizeBytes,
|
fileSizeBytes: upload.assetDraft.fileSizeBytes,
|
||||||
|
checksumSha256: upload.assetDraft.checksumSha256,
|
||||||
visibility: 'tenant',
|
visibility: 'tenant',
|
||||||
status: 'active',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key');
|
assert.equal(localAsset.item?.objectKey, upload.assetDraft.objectKey, 'asset upsert should keep normalized object key');
|
||||||
|
assert.equal(localAsset.item?.status, 'draft', 'managed object asset should stay draft before upload confirmation');
|
||||||
|
assert.equal(localAsset.item?.uploadStatus, 'pending', 'managed object asset should be pending before upload confirmation');
|
||||||
|
|
||||||
|
const unconfirmedDownload = await request('/api/catalog/assets/download', {
|
||||||
|
query: { assetId: localAsset.item.id },
|
||||||
|
expectStatus: 404,
|
||||||
|
});
|
||||||
|
assert.equal(unconfirmedDownload.code, 'ASSET_NOT_FOUND', 'unconfirmed draft asset should not be downloadable by students');
|
||||||
|
|
||||||
|
const confirmLocal = await request('/api/tenant-content/assets/confirm-upload', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
assetId: localAsset.item.id,
|
||||||
|
fileSizeBytes: upload.assetDraft.fileSizeBytes,
|
||||||
|
checksumSha256: upload.assetDraft.checksumSha256,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
publish: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(confirmLocal.item?.status, 'active', 'confirmed managed asset should publish when requested');
|
||||||
|
assert.equal(confirmLocal.item?.uploadStatus, 'verified', 'confirm upload should mark asset verified');
|
||||||
|
assert.equal(confirmLocal.item?.verifiedChecksumSha256, 'a'.repeat(64), 'confirm upload should persist checksum');
|
||||||
|
|
||||||
const localDownload = await request('/api/catalog/assets/download', {
|
const localDownload = await request('/api/catalog/assets/download', {
|
||||||
query: { assetId: localAsset.item.id },
|
query: { assetId: localAsset.item.id },
|
||||||
@@ -2002,6 +2027,50 @@ async function testTenantContentAssetsAndImports() {
|
|||||||
assert.equal(localDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
|
assert.equal(localDownload.download?.signatureMode, 'local-placeholder', 'local object asset should use local placeholder signer');
|
||||||
assert.ok(localDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
|
assert.ok(localDownload.download?.url?.includes(encodeURIComponent(localAsset.item.bucket)), 'object asset download should include bucket');
|
||||||
|
|
||||||
|
const localPreview = await request('/api/catalog/assets/preview', {
|
||||||
|
query: { assetId: localAsset.item.id },
|
||||||
|
});
|
||||||
|
assert.equal(localPreview.preview?.method, 'GET', 'asset preview should sign GET');
|
||||||
|
assert.equal(localPreview.preview?.signatureMode, 'local-placeholder', 'local asset preview should use local placeholder signer');
|
||||||
|
assert.ok(localPreview.preview?.url?.includes('disposition=inline'), 'asset preview should request inline disposition');
|
||||||
|
|
||||||
|
const adminPreview = await request('/api/tenant-content/assets/sign-preview', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: { assetId: localAsset.item.id },
|
||||||
|
});
|
||||||
|
assert.ok(adminPreview.preview?.url?.includes('disposition=inline'), 'admin preview should request inline disposition');
|
||||||
|
|
||||||
|
const failedAsset = await request('/api/tenant-content/assets', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
title: '集成测试校验失败资料',
|
||||||
|
assetType: 'pdf',
|
||||||
|
storageProvider: 'local_dev',
|
||||||
|
bucket: upload.assetDraft.bucket,
|
||||||
|
objectKey: `${MAIN_TENANT_ID}/pdf/failed-${Date.now()}.pdf`,
|
||||||
|
fileName: 'failed.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
fileSizeBytes: 100,
|
||||||
|
checksumSha256: 'b'.repeat(64),
|
||||||
|
visibility: 'tenant',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const failedConfirm = await request('/api/tenant-content/assets/confirm-upload', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
assetId: failedAsset.item.id,
|
||||||
|
fileSizeBytes: 101,
|
||||||
|
checksumSha256: 'b'.repeat(64),
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
publish: true,
|
||||||
|
},
|
||||||
|
expectStatus: 409,
|
||||||
|
});
|
||||||
|
assert.equal(failedConfirm.code, 'UPLOAD_VERIFICATION_FAILED', 'confirm upload should reject mismatched declared metadata');
|
||||||
|
|
||||||
const invalidObjectAsset = await request('/api/tenant-content/assets', {
|
const invalidObjectAsset = await request('/api/tenant-content/assets', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
alter table public.content_assets
|
||||||
|
add column if not exists upload_status text not null default 'not_required',
|
||||||
|
add column if not exists verified_at timestamptz,
|
||||||
|
add column if not exists verified_by uuid references public.platform_users(id) on delete set null,
|
||||||
|
add column if not exists verified_size_bytes bigint,
|
||||||
|
add column if not exists verified_checksum_sha256 text,
|
||||||
|
add column if not exists verification_details jsonb not null default '{}'::jsonb,
|
||||||
|
add column if not exists preview_object_key text,
|
||||||
|
add column if not exists preview_status text not null default 'none',
|
||||||
|
add column if not exists security_flags jsonb not null default '{}'::jsonb;
|
||||||
|
|
||||||
|
update public.content_assets
|
||||||
|
set upload_status = case
|
||||||
|
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null
|
||||||
|
then 'verified'
|
||||||
|
else 'not_required'
|
||||||
|
end,
|
||||||
|
verified_at = case
|
||||||
|
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null
|
||||||
|
then coalesce(verified_at, updated_at, created_at, now())
|
||||||
|
else verified_at
|
||||||
|
end,
|
||||||
|
verified_size_bytes = case
|
||||||
|
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null
|
||||||
|
then coalesce(verified_size_bytes, file_size_bytes)
|
||||||
|
else verified_size_bytes
|
||||||
|
end,
|
||||||
|
verified_checksum_sha256 = case
|
||||||
|
when storage_provider in ('local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos') and object_key is not null
|
||||||
|
then coalesce(verified_checksum_sha256, checksum_sha256)
|
||||||
|
else verified_checksum_sha256
|
||||||
|
end,
|
||||||
|
preview_status = case
|
||||||
|
when preview_url is not null and preview_url <> '' then 'ready'
|
||||||
|
else preview_status
|
||||||
|
end
|
||||||
|
where upload_status = 'not_required'
|
||||||
|
or verified_at is null
|
||||||
|
or preview_status = 'none';
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from pg_constraint where conname = 'content_assets_upload_status_check') then
|
||||||
|
alter table public.content_assets
|
||||||
|
add constraint content_assets_upload_status_check
|
||||||
|
check (upload_status in ('not_required', 'pending', 'verified', 'failed'));
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from pg_constraint where conname = 'content_assets_preview_status_check') then
|
||||||
|
alter table public.content_assets
|
||||||
|
add constraint content_assets_preview_status_check
|
||||||
|
check (preview_status in ('none', 'pending', 'ready', 'failed'));
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from pg_constraint where conname = 'content_assets_verified_size_check') then
|
||||||
|
alter table public.content_assets
|
||||||
|
add constraint content_assets_verified_size_check
|
||||||
|
check (verified_size_bytes is null or verified_size_bytes >= 0);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from pg_constraint where conname = 'content_assets_verified_checksum_check') then
|
||||||
|
alter table public.content_assets
|
||||||
|
add constraint content_assets_verified_checksum_check
|
||||||
|
check (verified_checksum_sha256 is null or verified_checksum_sha256 ~ '^[a-f0-9]{64}$');
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create index if not exists idx_content_assets_upload_status
|
||||||
|
on public.content_assets(tenant_id, upload_status, status, updated_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_content_assets_preview
|
||||||
|
on public.content_assets(tenant_id, preview_status)
|
||||||
|
where preview_status <> 'none';
|
||||||
Reference in New Issue
Block a user